diff --git a/.agents/skills/native-trigger/SKILL.md b/.agents/skills/native-trigger/SKILL.md index ae38f70b32..a4ff8e121a 100644 --- a/.agents/skills/native-trigger/SKILL.md +++ b/.agents/skills/native-trigger/SKILL.md @@ -84,7 +84,7 @@ pub trait External: Send + Sync + 'static { async fn prepare_webhook(&self, db, w_id, headers, body, script_path, is_flow) -> Result; fn service_config_from_create_response(&self, data, resp) -> Option; fn additional_routes(&self) -> axum::Router; - async fn http_client_request(&self, url, method, workspace_id, tx, db, headers, body) -> Result; + async fn http_client_request(&self, url, method, workspace_id, connection_path, db, headers, body) -> Result; } ``` @@ -115,28 +115,47 @@ Use this when the external_id is known before the create call (e.g., Google gene Use this when the external_id is assigned by the remote service and the webhook URL needs to be corrected after creation. -### OAuth Token Storage (Three-Table Pattern) +### OAuth App and Connections -OAuth tokens are stored across three tables, NOT in `workspace_integrations.oauth_data` directly: +The OAuth **app** (client) is configured per workspace and service; **connections** (connected +accounts) are many per service. Each trigger acts through one connection, named by +`native_trigger.connection_path`: | Table | What's Stored | |-------|---------------| -| `workspace_integrations` | `oauth_data` JSON with `base_url`, `client_id`, `client_secret`, `instance_shared` flag; `resource_path` pointing to the variable | -| `variable` | Encrypted `access_token` (at the path stored in `resource_path`), linked to `account` via `account` column | -| `account` | `refresh_token`, keyed by `workspace_id` + `client` (service name) + `is_workspace_integration = true` | +| `workspace_integrations` | `oauth_data` JSON with `base_url`, `client_id`, `client_secret`, `instance_shared` flag. One row per (workspace, service); it must exist for every connection, since token refresh reads the client from it | +| `variable` | Encrypted `access_token` at the connection path, linked to `account` via its `account` column | +| `account` | `refresh_token`, with `client` = service name and `is_workspace_integration = true` | +| `resource` | The connection's resource at the same path, `{ "token": "$var:" }` | -The `decrypt_oauth_data()` function in `lib.rs` assembles these into a unified struct: +A user may use a connection when they can read its variable (checked with a user transaction, +so the variable's row-level security decides): `resolve_usable_connection` / +`list_usable_connections` in `lib.rs`. The exception is the **workspace connection**: an admin +may share one connection per service (`workspace_integrations.resource_path`, set by +`POST .../workspace_connection`), and every member may then use it without reading its token. +Renames carry the marker along; disconnecting clears it. Background work (sync, renewal, rename re-registration, +webhook handling) uses the stored `connection_path` directly. + +`decrypt_oauth_data(db, w_id, service, connection_path)` assembles these into one struct, and +every service's `OAuthData` carries `connection_path` so `http_client_request` can refresh and +write back the right connection: ```rust pub struct OAuthConfig { pub base_url: String, - pub access_token: String, // decrypted from variable - pub refresh_token: Option, // from account table + pub access_token: String, // decrypted from the connection's variable + pub refresh_token: Option, // from its account pub client_id: String, // from oauth_data or instance settings pub client_secret: String, // from oauth_data or instance settings + pub connection_path: String, } ``` -Instance-level sharing: when `oauth_data.instance_shared == true`, `client_id` and `client_secret` are read from global settings instead of workspace_integrations. +A refreshed token is written back by connection path and account id, never by service: providers +like Nextcloud rotate refresh tokens, so writing one onto another account breaks its next refresh. + +Instance-level sharing: when the workspace has no OAuth app of its own and the instance admin +shares one (`share_with_workspaces` in the `oauths` setting; not available for Nextcloud), the +connect flow uses it and records `{"instance_shared": true}` in `workspace_integrations`. ### URL Resolution @@ -311,7 +330,7 @@ impl External for NewService { }); let response: CreateTriggerResponse = self - .http_client_request(&url, Method::POST, w_id, tx, db, None, Some(&payload)) + .http_client_request(&url, Method::POST, w_id, &oauth_data.connection_path, db, None, Some(&payload)) .await?; Ok(response) @@ -343,7 +362,7 @@ impl External for NewService { }); let _: serde_json::Value = self - .http_client_request(&url, Method::PUT, w_id, tx, db, None, Some(&payload)) + .http_client_request(&url, Method::PUT, w_id, &oauth_data.connection_path, db, None, Some(&payload)) .await?; // Fetch back the updated state to get the resolved config @@ -361,7 +380,7 @@ impl External for NewService { tx: &mut PgConnection, ) -> Result { let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id); - self.http_client_request::<_, ()>(&url, Method::GET, w_id, tx, db, None, None).await + self.http_client_request::<_, ()>(&url, Method::GET, w_id, &oauth_data.connection_path, db, None, None).await } async fn delete( @@ -374,7 +393,7 @@ impl External for NewService { ) -> Result<()> { let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id); let _: serde_json::Value = self - .http_client_request::<_, ()>(&url, Method::DELETE, w_id, tx, db, None, None) + .http_client_request::<_, ()>(&url, Method::DELETE, w_id, &oauth_data.connection_path, db, None, None) .await .or_else(|e| match &e { Error::InternalErr(msg) if msg.contains("404") => Ok(serde_json::Value::Null), @@ -749,7 +768,7 @@ pub async fn prepare_native_trigger_args(service_name, db, w_id, headers, body) When `workspace_integrations.oauth_data.instance_shared == true`, `decrypt_oauth_data()` reads `client_id` and `client_secret` from instance-level global settings instead of workspace-level. This allows admins to share OAuth app credentials across workspaces. -The frontend handles this via the `generate_instance_connect_url` endpoint in `workspace_integrations.rs`. +`generate_connect_url` picks the client (`resolve_oauth_client`): the workspace's own app when configured, the instance's otherwise. A GitHub OAuth app only redirects below its single registered callback, so connections made with the instance GitHub app land on `/oauth/callback/github/native_trigger` instead of the workspace settings page. --- diff --git a/.github/DockerfileBackendTests b/.github/DockerfileBackendTests index 2ce47ea714..e41a7bbff9 100644 --- a/.github/DockerfileBackendTests +++ b/.github/DockerfileBackendTests @@ -28,7 +28,7 @@ ENV PATH="${PATH}:/usr/local/go/bin" ENV GO_PATH=/usr/local/go/bin/go # UV -RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.13.0/uv-installer.sh | sh && mv /usr/local/cargo/bin/uv /usr/local/bin/uv +RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.13.4/uv-installer.sh | sh && mv /usr/local/cargo/bin/uv /usr/local/bin/uv ENV TZ=Etc/UTC @@ -42,7 +42,7 @@ RUN wget https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VER RUN /usr/local/bin/python3 -m pip install pip-tools # Bun -COPY --from=oven/bun:1.4.0 /usr/local/bin/bun /usr/bin/bun +COPY --from=oven/bun:1.4.4 /usr/local/bin/bun /usr/bin/bun # Install windmill CLI RUN bun install -g windmill-cli \ diff --git a/.github/actions/sign-attest-image/action.yml b/.github/actions/sign-attest-image/action.yml index de0133dac0..564a621380 100644 --- a/.github/actions/sign-attest-image/action.yml +++ b/.github/actions/sign-attest-image/action.yml @@ -46,7 +46,15 @@ runs: env: IMAGE: ${{ inputs.image }} DIGEST: ${{ inputs.digest }} - run: cosign sign --yes --recursive "${IMAGE}@${DIGEST}" + # Fulcio and Rekor return transient 5xx often enough to fail a release; + # re-signing the same digest only adds a signature, so retrying is safe. + run: | + for attempt in 1 2 3 4 5; do + cosign sign --yes --recursive "${IMAGE}@${DIGEST}" && exit 0 + echo "::warning::cosign sign failed (attempt $attempt/5)" + [ "$attempt" -lt 5 ] && sleep $((attempt * 30)) + done + exit 1 - name: SLSA provenance (GitHub artifact attestation) uses: actions/attest-build-provenance@v4 diff --git a/.github/workflows/ai-agent-tests.yml b/.github/workflows/ai-agent-tests.yml index 8a3e9e6dbc..255f07e253 100644 --- a/.github/workflows/ai-agent-tests.yml +++ b/.github/workflows/ai-agent-tests.yml @@ -61,7 +61,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.4.0 + bun-version: 1.4.4 - uses: actions/setup-node@v7 with: diff --git a/.github/workflows/ai-evals-scheduled.yml b/.github/workflows/ai-evals-scheduled.yml index 580a757c21..2bd8cdb181 100644 --- a/.github/workflows/ai-evals-scheduled.yml +++ b/.github/workflows/ai-evals-scheduled.yml @@ -46,7 +46,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.4.0 + bun-version: 1.4.4 - id: matrix env: @@ -93,7 +93,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.4.0 + bun-version: 1.4.4 - uses: actions/setup-node@v7 with: diff --git a/.github/workflows/ai-evals-test.yml b/.github/workflows/ai-evals-test.yml index 84b488ae46..4dd266c364 100644 --- a/.github/workflows/ai-evals-test.yml +++ b/.github/workflows/ai-evals-test.yml @@ -75,7 +75,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.4.0 + bun-version: 1.4.4 - uses: actions/setup-node@v7 with: diff --git a/.github/workflows/backend-test-windows.yml b/.github/workflows/backend-test-windows.yml index 3468449753..8bbb73d379 100644 --- a/.github/workflows/backend-test-windows.yml +++ b/.github/workflows/backend-test-windows.yml @@ -73,7 +73,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.4.0 + bun-version: 1.4.4 - uses: actions/setup-node@v7 with: @@ -81,7 +81,7 @@ jobs: - uses: astral-sh/setup-uv@v7 with: - version: "0.13.0" + version: "0.13.4" - uses: shivammathur/setup-php@v2 with: @@ -139,8 +139,8 @@ jobs: run: | cargo build --release -p windmill_duckdb_ffi_internal New-Item -ItemType Directory -Path ..\target\debug -Force - Copy-Item target\release\windmill_duckdb_ffi_internal.dll ..\target\debug\ - # duckdb is bundled (~2GB of build artifacts); the DLL is the only + Copy-Item target\release\windmill_duckdb_runner.exe ..\target\debug\ + # duckdb is bundled (~2GB of build artifacts); the runner is the only # thing we need from this excluded-crate target dir. Remove-Item -Recurse -Force -ErrorAction SilentlyContinue target Get-PSDrive C | Select-Object Used,Free | Format-Table -AutoSize diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml index 73f955131e..f2dedd8c69 100644 --- a/.github/workflows/backend-test.yml +++ b/.github/workflows/backend-test.yml @@ -58,13 +58,13 @@ jobs: go-version: 1.21.5 - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.4.0 + bun-version: 1.4.4 - uses: actions/setup-node@v7 with: node-version: "24" - uses: astral-sh/setup-uv@v7 with: - version: "0.13.0" + version: "0.13.4" - uses: shivammathur/setup-php@v2 with: php-version: "8.3" @@ -83,9 +83,16 @@ jobs: chmod +x "$HOME/.local/bin/wmill" echo "$HOME/.local/bin" >> $GITHUB_PATH working-directory: / - - name: Install PowerShell, mold and clang + - name: Install PowerShell, mold, clang and the latest pg_dump + # PGDG's `postgresql-client` is its newest major, Ubuntu's is not: the `postgres` service + # image floats, and pg_dump refuses a server newer than itself. `/usr/bin/pg_dump` picks + # the version of the runner's own preinstalled cluster, so the newest bin dir goes first. run: | - sudo apt-get update && sudo apt-get install -y powershell mold clang libcurl4-openssl-dev + sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y + sudo apt-get update && sudo apt-get install -y powershell mold clang libcurl4-openssl-dev postgresql-client + PG_BIN="/usr/lib/postgresql/$(ls /usr/lib/postgresql | sort -n | tail -1)/bin" + echo "$PG_BIN" >> "$GITHUB_PATH" + "$PG_BIN/pg_dump" --version working-directory: / - uses: actions-rust-lang/setup-rust-toolchain@v1 with: diff --git a/.github/workflows/build-publish-rh-image.yml b/.github/workflows/build-publish-rh-image.yml index dcb223b534..f037e7eb75 100644 --- a/.github/workflows/build-publish-rh-image.yml +++ b/.github/workflows/build-publish-rh-image.yml @@ -83,17 +83,17 @@ jobs: # Extract arm64 binary (include deps/ for hard link resolution) mkdir -p /tmp/arm64 crane export --platform linux/arm64 ${{ steps.meta-ee-public.outputs.tags }} - \ - | tar -xf - -C /tmp/arm64 windmill/target/release/ usr/src/app/libwindmill_duckdb_ffi_internal.so + | tar -xf - -C /tmp/arm64 windmill/target/release/ usr/src/app/windmill_duckdb_runner cp /tmp/arm64/windmill/target/release/windmill extracted/windmill-ee-arm64-rhel9 - cp /tmp/arm64/usr/src/app/libwindmill_duckdb_ffi_internal.so extracted/libwindmill_duckdb_ffi_internal-arm64.so + cp /tmp/arm64/usr/src/app/windmill_duckdb_runner extracted/windmill_duckdb_runner-arm64 rm -rf /tmp/arm64 # Extract amd64 binary mkdir -p /tmp/amd64 crane export --platform linux/amd64 ${{ steps.meta-ee-public.outputs.tags }} - \ - | tar -xf - -C /tmp/amd64 windmill/target/release/ usr/src/app/libwindmill_duckdb_ffi_internal.so + | tar -xf - -C /tmp/amd64 windmill/target/release/ usr/src/app/windmill_duckdb_runner cp /tmp/amd64/windmill/target/release/windmill extracted/windmill-ee-amd64-rhel9 - cp /tmp/amd64/usr/src/app/libwindmill_duckdb_ffi_internal.so extracted/libwindmill_duckdb_ffi_internal-amd64.so + cp /tmp/amd64/usr/src/app/windmill_duckdb_runner extracted/windmill_duckdb_runner-amd64 rm -rf /tmp/amd64 - uses: actions/upload-artifact@v4 @@ -109,9 +109,9 @@ jobs: - uses: actions/upload-artifact@v4 with: name: RHEL9-arm64 dynamic libraries build - path: extracted/libwindmill_duckdb_ffi_internal-arm64.so + path: extracted/windmill_duckdb_runner-arm64 - uses: actions/upload-artifact@v4 with: name: RHEL9-amd64 dynamic libraries build - path: extracted/libwindmill_duckdb_ffi_internal-amd64.so + path: extracted/windmill_duckdb_runner-amd64 diff --git a/.github/workflows/build-publish-rh8-image.yml b/.github/workflows/build-publish-rh8-image.yml index 7c820eb290..83b12d3290 100644 --- a/.github/workflows/build-publish-rh8-image.yml +++ b/.github/workflows/build-publish-rh8-image.yml @@ -103,7 +103,7 @@ jobs: id: extract-duckdb-ffi-internal with: image: ${{ steps.meta-ee-public.outputs.tags}}-amd64 - path: "/usr/src/app/libwindmill_duckdb_ffi_internal.so" + path: "/usr/src/app/windmill_duckdb_runner" # - uses: shrink/actions-docker-extract@v3 # id: extract-ee-arm64 @@ -124,7 +124,7 @@ jobs: - uses: actions/upload-artifact@v4 with: name: RHEL8-amd64 dynamic libraries build - path: ${{ steps.extract-duckdb-ffi-internal.outputs.destination }}/libwindmill_duckdb_ffi_internal.so + path: ${{ steps.extract-duckdb-ffi-internal.outputs.destination }}/windmill_duckdb_runner # - uses: actions/upload-artifact@v4 # with: diff --git a/.github/workflows/build_windows_worker_.yml b/.github/workflows/build_windows_worker_.yml index 46befe42b6..4a6368ee1e 100644 --- a/.github/workflows/build_windows_worker_.yml +++ b/.github/workflows/build_windows_worker_.yml @@ -81,5 +81,5 @@ jobs: - name: Upload dynamic libraries artifact uses: actions/upload-artifact@v4 with: - name: windmill_duckdb_ffi_internal.dll - path: ./backend/windmill-duckdb-ffi-internal/target/release/windmill_duckdb_ffi_internal.dll + name: windmill_duckdb_runner.exe + path: ./backend/windmill-duckdb-ffi-internal/target/release/windmill_duckdb_runner.exe diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index c94521d332..c046a7d101 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -197,49 +197,43 @@ jobs: ARCH: amd64 steps: - uses: actions/checkout@v4 - - run: | - # pulling docker image with desired arch so that actions-docker-extract doesn't do it - docker pull --platform "linux/$ARCH" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }} - docker pull --platform "linux/$ARCH" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }} - - run: | - # Checks the image is in docker prior to running actions-docker-extract. It fails if not - # Also useful to visually check that the arch is the right opencontainers - docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }} - docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }} - - - uses: shrink/actions-docker-extract@v3 - id: extract - with: - image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }} - path: "/usr/src/app/windmill" - - - uses: shrink/actions-docker-extract@v3 - id: extract-duckdb-ffi-internal - with: - image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }} - path: "/usr/src/app/libwindmill_duckdb_ffi_internal.so" - - - uses: shrink/actions-docker-extract@v3 - id: extract-ee - with: - image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }} - path: "/usr/src/app/windmill" - - - name: Rename binary with corresponding architecture + # The runner disk cannot hold the CE and EE images at once, so each image + # is pulled, extracted from and removed before the next one is pulled. + - name: Extract binaries + env: + CE_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }} + EE_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }} run: | - mv "${{ steps.extract.outputs.destination }}/windmill" "${{ steps.extract.outputs.destination }}/windmill-${ARCH}" - mv "${{ steps.extract-ee.outputs.destination }}/windmill" "${{ steps.extract-ee.outputs.destination }}/windmill-ee-${ARCH}" + set -euo pipefail + mkdir -p release + # extract ( )... + extract() { + local image=$1 cid + shift + docker pull --platform "linux/$ARCH" "$image" + docker image inspect --format '{{.Os}}/{{.Architecture}}' "$image" + cid=$(docker create --platform "linux/$ARCH" "$image") + while [ $# -gt 0 ]; do + docker cp "$cid:$1" "release/$2" + shift 2 + done + docker rm "$cid" + docker rmi "$image" + df -h / + } + extract "$CE_IMAGE" \ + /usr/src/app/windmill "windmill-$ARCH" \ + /usr/src/app/windmill_duckdb_runner windmill_duckdb_runner + extract "$EE_IMAGE" /usr/src/app/windmill "windmill-ee-$ARCH" + ls -la release - name: Attach binary to release uses: softprops/action-gh-release@v2 with: - files: | - ${{ steps.extract.outputs.destination }}/* - ${{ steps.extract-ee.outputs.destination }}/* - ${{ steps.extract-duckdb-ffi-internal.outputs.destination }}/* + files: release/* - attach_ee_debug_to_release: + upload_ee_debug_to_s3: needs: [build_ee] runs-on: ubicloud if: ${{ startsWith(github.ref, 'refs/tags/v') }} @@ -284,14 +278,21 @@ jobs: WM_BUILD_VERSION=${{ github.sha }} outputs: type=local,dest=./debuginfo - - name: Rename debug file with corresponding architecture + # The debug file names every EE function with its source file and line, so it + # goes to a private bucket, never to the public release. + - name: Upload debug file to private bucket + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: us-east-1 + TAG: ${{ github.ref_name }} + ARCH: ${{ matrix.arch }} run: | - mv ./debuginfo/windmill.debug ./debuginfo/windmill-ee-${{ matrix.arch }}.debug - - - name: Attach debug file to release - uses: softprops/action-gh-release@v2 - with: - files: ./debuginfo/windmill-ee-${{ matrix.arch }}.debug + aws --version + aws configure set default.s3.multipart_chunksize 64MB + aws s3 cp ./debuginfo/windmill.debug \ + "s3://windmill-ee-debuginfo/$TAG/windmill-ee-$ARCH.debug" \ + --storage-class GLACIER_IR --only-show-errors # attach_arm64_binary_to_release: # needs: [build, build_ee] diff --git a/.github/workflows/git-sync-test.yml b/.github/workflows/git-sync-test.yml index b19f899a20..7d26392dee 100644 --- a/.github/workflows/git-sync-test.yml +++ b/.github/workflows/git-sync-test.yml @@ -135,7 +135,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: 1.4.0 + bun-version: 1.4.4 - uses: denoland/setup-deno@v2 with: diff --git a/.github/workflows/publish_windows_worker.yml b/.github/workflows/publish_windows_worker.yml index b94d5d57c7..ecceff03ab 100644 --- a/.github/workflows/publish_windows_worker.yml +++ b/.github/workflows/publish_windows_worker.yml @@ -72,8 +72,8 @@ jobs: files: | ./backend/target/release/windmill-ee.exe - - name: Attach dynamic libraries to release + - name: Attach the DuckDB runner to release uses: softprops/action-gh-release@v2 with: files: | - ./backend/windmill-duckdb-ffi-internal/target/release/windmill_duckdb_ffi_internal.dll + ./backend/windmill-duckdb-ffi-internal/target/release/windmill_duckdb_runner.exe diff --git a/.release-please-manifest.json b/.release-please-manifest.json index e47ce1f0a3..ef65812b73 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.833.0" + ".": "1.847.0" } diff --git a/AGENTS.md b/AGENTS.md index 8c30fed2f9..c2aae7159d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,11 +33,17 @@ Open-source platform for internal tools, workflows, API integrations, background administers behind `external_instance` data tables and Ducklake catalogs: its invariants (one lifecycle lock, managed-object markers, the setup gate, per-cluster roles, fork copy ownership) and how to run one locally +- **Fork object storage**: `docs/fork-object-storage.md` — how a fork's object keys map under + `__wm_forks//` with read-through to its ancestors, where the mapping must be applied, + and why `__wm_forks/` is reachable only through a workspace's own chain - **Data table viewer roles**: `docs/datatable-viewer-roles.md`, on what a connection naming no role holds: the default role as a floor, whose roles are added (the job's own, or its viewer's for an inline app runnable that opts in), why an admin's roles are not added, the per-role-set logins and the order their rows and logins are written in - **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow +- **Workspace roles**: `docs/workspace-roles.md` — instance-defined roles with per-workspace + overrides, the capabilities a role holds, why `ApiAuthed::is_operator` must be derived from the + role, and the `role_seat` table billing reads - **Operator write rights**: `docs/operator-write-rights.md` — which `operator_settings` flags are 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 @@ -60,6 +66,7 @@ Open-source platform for internal tools, workflows, API integrations, background - **Domain vocabulary**: `CONTEXT.md` — the words this codebase uses for its own concepts (step, step setting, trigger step, …). Name things the way it does. - **CLI commands**: when adding/modifying/removing a command, subcommand, option, or description in `cli/src/commands/`, run `python system_prompts/generate.py` to refresh `system_prompts/auto-generated/` and `cli/src/guidance/skills.gen.ts`. The CLI docs the agents use to operate `wmill` are derived from the source — stale generated files give agents the wrong flags. - **AI guidance** (chat prompts and CLI skills): what the AI knows about Windmill itself — how flows, scripts, apps, pipelines and resources work — is written once, in `system_prompts/base/` and `system_prompts/languages/`, and `system_prompts/generate.py` builds both the chat's `$system_prompts` exports and the CLI's skills from it. Add or change such guidance there, never inline in one consumer: a sentence only one side should see goes in a `` / `` block, and a new file reaches both through the `TOPICS` table. The chat prompt builders under `frontend/src/lib/components/copilot/chat/` add only tool plumbing and runtime values, and shared markdown names no chat tool, since it reaches every chat mode. Rerun `generate.py` after editing; `system_prompts/README.md` has the details. +- **Windmill MCP server**: `/api/mcp` gives a token with `mcp:all` the AI session's GLOBAL toolset (`backend/windmill-api/src/mcp/session/`), and a token scoped to a selection of scripts and flows the tools server's per-runnable tools, without endpoint tools. The tools server's own URLs, `/api/mcp/w/{workspace}/mcp` and `/api/mcp/gateway`, keep serving existing tokens, endpoint scopes included. Every tool takes a `workspace_id`; the token bounds which (its bound workspace, the `mcp:workspaces:` list, or all of its owner's). What a model reads there (tool names, descriptions, input schemas, instructions, `get_instructions` texts) is generated from the session's TypeScript into `session/gen/`: after changing a GLOBAL tool definition, the global system prompt or `system_prompts/`, run `npm run gen:mcp-session` from `frontend/` (`mcpSessionGen.test.ts` fails otherwise). What a tool *does* is written twice, in `chat/global/core.ts` and in a Rust handler: a behaviour change on one side must be mirrored on the other, and a new session tool is either implemented in Rust or added to `EDITOR_ONLY` (a `cargo test` in `session/mod.rs` fails otherwise). - **Session recorder**: `frontend/src/lib/components/recording/` is also the recorder `wmill app dev --recording` serves, vendored into the CLI as `cli/src/commands/app/devRecorderBundle.gen.ts`. After changing `rawAppSnapshot.ts` or `rawAppRecording.svelte.ts`, run `bun run gen:dev-recorder` from `cli/` (`cli/test/dev_recorder_bundle_unit.test.ts` fails otherwise). - **Raw-app policy**: `frontend/src/lib/components/raw_apps/rawAppPolicy.ts` also derives the policy the server's raw-app deploy stores, vendored into the bundle job as `backend/windmill-api/src/apps_raw_policy.gen.js`. After changing it or anything it imports, run `bun run gen:app-policy` from `cli/` (`cli/test/app_policy_bundle_unit.test.ts` fails otherwise). It rides in the job rather than being read from the CLI the job runs because the images install `windmill-cli` unpinned, so an image can carry one older than its server. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0edb6b5dcd..12e61b56bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,261 @@ # Changelog +## [1.847.0](https://github.com/windmill-labs/windmill/compare/v1.846.1...v1.847.0) (2026-10-22) + + +### Features + +* add job_id and flow_path to flow step error objects ([#11941](https://github.com/windmill-labs/windmill/issues/11941)) ([eeb48f0](https://github.com/windmill-labs/windmill/commit/eeb48f0c26ccc5162430eddec24adc2601eb5f99)) +* chat with a saved agent from a raw app through an agent runnable ([#11921](https://github.com/windmill-labs/windmill/issues/11921)) ([c6a93a9](https://github.com/windmill-labs/windmill/commit/c6a93a9b565752da6c8b55d383e5bee88f00f229)) +* let native triggers act through per-user or shared connections ([#11352](https://github.com/windmill-labs/windmill/issues/11352)) ([dc1fb50](https://github.com/windmill-labs/windmill/commit/dc1fb5087be7169045aad57e955dabe3e29de82e)) +* slack agents through a workspace's own slack app ([#11912](https://github.com/windmill-labs/windmill/issues/11912)) ([34de8bc](https://github.com/windmill-labs/windmill/commit/34de8bc06d2b04f7929bb8cf35f9a34d0e0071fa)) +* worker group toggle to keep the worker's proxy out of jobs ([#11958](https://github.com/windmill-labs/windmill/issues/11958)) ([8a0318a](https://github.com/windmill-labs/windmill/commit/8a0318a28f5cfda7557b0f94db20ef17b0664ee4)) + + +### Bug Fixes + +* compare a null resource description as empty ([#11947](https://github.com/windmill-labs/windmill/issues/11947)) ([8a7b148](https://github.com/windmill-labs/windmill/commit/8a7b1481bd270df3a98c9db5e7824d13b8cd37b6)) +* compare fork secret variables by decrypted value ([#11952](https://github.com/windmill-labs/windmill/issues/11952)) ([0dd131b](https://github.com/windmill-labs/windmill/commit/0dd131b38074423de5c769cae478c6ea0ae365c2)) +* hold home's ai composer space with a matching placeholder ([#11936](https://github.com/windmill-labs/windmill/issues/11936)) ([9202a74](https://github.com/windmill-labs/windmill/commit/9202a74071ba424ad6cfc1cfded518ee7db002dc)) +* link a variable to its OAuth account only in the connect flow ([#11361](https://github.com/windmill-labs/windmill/issues/11361)) ([d6adac8](https://github.com/windmill-labs/windmill/commit/d6adac8b7579ad6c3df5caab9a735451c1ddd896)) +* stop audit inserts from silently rolling back saves ([#11938](https://github.com/windmill-labs/windmill/issues/11938)) ([5ef19a8](https://github.com/windmill-labs/windmill/commit/5ef19a88a90b3a4fe05a78547416082dc51763f5)) + +## [1.846.1](https://github.com/windmill-labs/windmill/compare/v1.846.0...v1.846.1) (2026-10-21) + + +### Bug Fixes + +* home recent cards include items opened on their own page ([#11934](https://github.com/windmill-labs/windmill/issues/11934)) ([5019fab](https://github.com/windmill-labs/windmill/commit/5019fab5a5f996796a07524c770f86309a7a3500)) +* home search suggests filters whose name matches the typed text ([#11933](https://github.com/windmill-labs/windmill/issues/11933)) ([bf1eb85](https://github.com/windmill-labs/windmill/commit/bf1eb85577419cc08482158c1835d5262ebb87d2)) + +## [1.846.0](https://github.com/windmill-labs/windmill/compare/v1.845.0...v1.846.0) (2026-10-21) + + +### Features + +* home list settings menu with sort, library and user-folder toggles ([#11928](https://github.com/windmill-labs/windmill/issues/11928)) ([73f655d](https://github.com/windmill-labs/windmill/commit/73f655db98dd0f0fd03881c36e6c9310fb2687a2)) + + +### Bug Fixes + +* home's acting-on pick switches the current workspace ([#11929](https://github.com/windmill-labs/windmill/issues/11929)) ([cdaddcc](https://github.com/windmill-labs/windmill/commit/cdaddcce594378bf502f80e8f225f16b5def5eb4)) + +## [1.845.0](https://github.com/windmill-labs/windmill/compare/v1.844.0...v1.845.0) (2026-10-21) + + +### Features + +* home and empty ai sessions share one session composer ([#11635](https://github.com/windmill-labs/windmill/issues/11635)) ([56b2e10](https://github.com/windmill-labs/windmill/commit/56b2e106daedb5ae381052d3116346a88ba3f206)) + +## [1.844.0](https://github.com/windmill-labs/windmill/compare/v1.843.0...v1.844.0) (2026-10-21) + + +### Features + +* serve the ai session toolset over mcp at /api/mcp ([#11738](https://github.com/windmill-labs/windmill/issues/11738)) ([892334f](https://github.com/windmill-labs/windmill/commit/892334f008d5b6b73de38fdf2831b1a63eeb0a51)) + +## [1.843.0](https://github.com/windmill-labs/windmill/compare/v1.842.0...v1.843.0) (2026-10-21) + + +### Features + +* configurable workspace roles with per-workspace overrides ([#11821](https://github.com/windmill-labs/windmill/issues/11821)) ([21d2a49](https://github.com/windmill-labs/windmill/commit/21d2a49971b858ff8aef2776a36989b57ba49415)) +* move the open in AI session button to the page header ([#11888](https://github.com/windmill-labs/windmill/issues/11888)) ([34db239](https://github.com/windmill-labs/windmill/commit/34db23952a765bbb9c912d74d2a37092703f50f7)) +* ship php only on the full images ([#11922](https://github.com/windmill-labs/windmill/issues/11922)) ([b874861](https://github.com/windmill-labs/windmill/commit/b87486141df5b4f9b93900c0ee3264cf4b4610ec)) +* workspace roles settings UI and capability-based gating ([#11824](https://github.com/windmill-labs/windmill/issues/11824)) ([8dbdfa1](https://github.com/windmill-labs/windmill/commit/8dbdfa194912bf36a705392899b9f84b5a54fd55)) + + +### Bug Fixes + +* compact the AI chat history between tool iterations ([#11911](https://github.com/windmill-labs/windmill/issues/11911)) ([17de2ba](https://github.com/windmill-labs/windmill/commit/17de2ba983bee5cb7df700476ec8a0169c97ecbd)) +* refuse restarting a flow past an approval that was never granted ([#11920](https://github.com/windmill-labs/windmill/issues/11920)) ([2c6bcc0](https://github.com/windmill-labs/windmill/commit/2c6bcc09c9b6f768592b31ec540d727263a807cc)) +* remove horizontal scroll in the new workspace form ([#11909](https://github.com/windmill-labs/windmill/issues/11909)) ([d35c868](https://github.com/windmill-labs/windmill/commit/d35c86896fa4f5f9936a9f61735553e2ff42b218)) +* revoke mcp oauth grants on password change and reset ([#11908](https://github.com/windmill-labs/windmill/issues/11908)) ([c044777](https://github.com/windmill-labs/windmill/commit/c04477723c9d7544ea9c595e33fdcff5cce0c79d)) +* **worker:** render TEMP_SCRIPT_REFS placeholder in Python runner-group loader.py ([#11915](https://github.com/windmill-labs/windmill/issues/11915)) ([78fb800](https://github.com/windmill-labs/windmill/commit/78fb800904351f6b549b8b2227cb9c0d5dafd55d)) + +## [1.842.0](https://github.com/windmill-labs/windmill/compare/v1.841.0...v1.842.0) (2026-10-20) + + +### Features + +* let AI agents answer in Slack ([#11630](https://github.com/windmill-labs/windmill/issues/11630)) ([35b250e](https://github.com/windmill-labs/windmill/commit/35b250e76739a1e6f543971b099d5796a44d0c89)) +* name the action on chat tool confirmation buttons ([#11795](https://github.com/windmill-labs/windmill/issues/11795)) ([7d01d25](https://github.com/windmill-labs/windmill/commit/7d01d252b526987f8f0b86695de2f9c90de64519)) + + +### Bug Fixes + +* align collapsed conversations sidebar chat icon with the buttons above ([#11873](https://github.com/windmill-labs/windmill/issues/11873)) ([24238d4](https://github.com/windmill-labs/windmill/commit/24238d4062801558f90728b17e3d70e980c733dd)) +* align sidebar home with the header when ai sessions is off ([#11882](https://github.com/windmill-labs/windmill/issues/11882)) ([1874808](https://github.com/windmill-labs/windmill/commit/18748084705dc48994b7378217e0df1e8bfbfe3f)) +* give the agent page's API and Slack sections tabs on small screens ([#11901](https://github.com/windmill-labs/windmill/issues/11901)) ([394d345](https://github.com/windmill-labs/windmill/commit/394d345e0c8d9d7da87371e8da090d3becc77ce7)) +* keep schedule push, flow advance, wac approval and scim on one pool connection ([#11904](https://github.com/windmill-labs/windmill/issues/11904)) ([8afe060](https://github.com/windmill-labs/windmill/commit/8afe0607abd6bb689a0644916fcdc39fb7df2fe3)) +* keep the flow chat mounted when leaving the chat tab ([#11872](https://github.com/windmill-labs/windmill/issues/11872)) ([ed58996](https://github.com/windmill-labs/windmill/commit/ed589963f4d7b7d50a69cacdede443f2bb7efd18)) +* let devops users reach instance pages without a workspace ([#11876](https://github.com/windmill-labs/windmill/issues/11876)) ([a3a4fa5](https://github.com/windmill-labs/windmill/commit/a3a4fa503804eb9f031699b60e00a196d223cbe6)) +* **mcp:** escape JSON inlined in the MCP OAuth callback page ([#11880](https://github.com/windmill-labs/windmill/issues/11880)) ([5d0a8a9](https://github.com/windmill-labs/windmill/commit/5d0a8a9c15cd54032f930f8a21a0c2c3b517d60d)) +* refresh oauth tokens without holding a pool connection ([#11905](https://github.com/windmill-labs/windmill/issues/11905)) ([8ddb358](https://github.com/windmill-labs/windmill/commit/8ddb358198e834642f259197aa5f6d96bd7740d3)) +* spell the schedule pause time the way the picker writes it ([#11875](https://github.com/windmill-labs/windmill/issues/11875)) ([4186fff](https://github.com/windmill-labs/windmill/commit/4186ffff841d5905d308aa6bae8159aba3794c1c)) +* stop holding a pool connection while waiting for another ([#11902](https://github.com/windmill-labs/windmill/issues/11902)) ([2c559b4](https://github.com/windmill-labs/windmill/commit/2c559b452f38cc86fe45bcdb98574245fd2cf187)) +* write a push's runnable settings before it opens its transaction ([#11897](https://github.com/windmill-labs/windmill/issues/11897)) ([e6c8ef1](https://github.com/windmill-labs/windmill/commit/e6c8ef18a11f079083138a991543f7e273d4e511)) + + +### Performance Improvements + +* show the AI sessions composer before the chat code loads ([#11886](https://github.com/windmill-labs/windmill/issues/11886)) ([613adb5](https://github.com/windmill-labs/windmill/commit/613adb5287812cf428a74b10e1ef9362d78751b8)) + +## [1.841.0](https://github.com/windmill-labs/windmill/compare/v1.840.0...v1.841.0) (2026-10-18) + + +### Features + +* let agent workers authenticate with a workload identity token ([#11858](https://github.com/windmill-labs/windmill/issues/11858)) ([be23abb](https://github.com/windmill-labs/windmill/commit/be23abb3602ed9c45f1c66c98d5f84e7676dccfa)) +* let ALLOW_PRIVATE_MCP_SERVER_URLS name the private hosts it allows ([#11869](https://github.com/windmill-labs/windmill/issues/11869)) ([5c205fa](https://github.com/windmill-labs/windmill/commit/5c205fa46915de9e5fde731a117ba8571cfa56f7)) +* probe nsjail with a real jail and add a gVisor compat mode ([#11865](https://github.com/windmill-labs/windmill/issues/11865)) ([69d36f7](https://github.com/windmill-labs/windmill/commit/69d36f77daceb25cc903c7ea014ba8d487650b18)) + + +### Bug Fixes + +* insert ApiAuthed into request extensions in no-auth mode ([#11868](https://github.com/windmill-labs/windmill/issues/11868)) ([a7561fd](https://github.com/windmill-labs/windmill/commit/a7561fd8bde5c4a766a050507c2d9b9823c08bcc)) +* reject out-of-range integer args to narrow SQL types instead of wrapping ([#11864](https://github.com/windmill-labs/windmill/issues/11864)) ([19ec5d3](https://github.com/windmill-labs/windmill/commit/19ec5d3cd8a36600c8896a895d0e566735f729a9)) +* warn when an autoscaled worker group has no tags to count ([#11859](https://github.com/windmill-labs/windmill/issues/11859)) ([8b14a78](https://github.com/windmill-labs/windmill/commit/8b14a78822f95fb045d80940dfd9197f79ea88cc)) + +## [1.840.0](https://github.com/windmill-labs/windmill/compare/v1.839.0...v1.840.0) (2026-10-17) + + +### Features + +* add a no_network annotation for sandboxed jobs ([#11844](https://github.com/windmill-labs/windmill/issues/11844)) ([feb7b1a](https://github.com/windmill-labs/windmill/commit/feb7b1ad906046f4cf6ea53a85506f42b1b66e93)) +* warn at worker startup when WHITELIST_ENVS forwards a secret-shaped variable ([#11838](https://github.com/windmill-labs/windmill/issues/11838)) ([16c8be2](https://github.com/windmill-labs/windmill/commit/16c8be2355b919878091769810749c54c2f0a09d)) + + +### Bug Fixes + +* keep script and flow deploys off a second pool connection ([#11843](https://github.com/windmill-labs/windmill/issues/11843)) ([2684156](https://github.com/windmill-labs/windmill/commit/268415623b515280b99bd5d9e32876868821dc4f)) +* let ALLOW_PRIVATE_AI_BASE_URLS name the private hosts it allows ([#11841](https://github.com/windmill-labs/windmill/issues/11841)) ([b0c97b3](https://github.com/windmill-labs/windmill/commit/b0c97b34caa6343db278a9c5c09228248acf970c)) +* mount python script modules into the nsjail sandbox ([#11846](https://github.com/windmill-labs/windmill/issues/11846)) ([7a8cb62](https://github.com/windmill-labs/windmill/commit/7a8cb62fa135bff04522f4bbd93aba194e33b121)) +* refuse dot segments in AI proxy paths ([#11837](https://github.com/windmill-labs/windmill/issues/11837)) ([89f36a7](https://github.com/windmill-labs/windmill/commit/89f36a73d16675fd5f0d57daa2baf616ee95cbf5)) +* run bun and npm installs under nsjail sandboxing ([#11845](https://github.com/windmill-labs/windmill/issues/11845)) ([6646c86](https://github.com/windmill-labs/windmill/commit/6646c86351f188d9b7e355dfb46e87bfa5c81846)) +* scope delete_jobs side-table deletes to the request workspace ([#11836](https://github.com/windmill-labs/windmill/issues/11836)) ([09a5911](https://github.com/windmill-labs/windmill/commit/09a59110d941a8c306a0d89e99d34d509545059f)) +* skip the fork lock when a fork creation has no storage to purge ([#11842](https://github.com/windmill-labs/windmill/issues/11842)) ([0b54ebc](https://github.com/windmill-labs/windmill/commit/0b54ebc73d3d9541d2521857adc27766c4342000)) + + +### Performance Improvements + +* clone sqlx test databases from a migrated template ([#11848](https://github.com/windmill-labs/windmill/issues/11848)) ([4ed6c1a](https://github.com/windmill-labs/windmill/commit/4ed6c1a6ce1b6d53ea9ee965214da0c3adb3f3bd)) + +## [1.839.0](https://github.com/windmill-labs/windmill/compare/v1.838.0...v1.839.0) (2026-10-16) + + +### Features + +* add a Require isolated forks protection rule ([#11834](https://github.com/windmill-labs/windmill/issues/11834)) ([ab4e790](https://github.com/windmill-labs/windmill/commit/ab4e790a216b92f789e130955d7cd180b394d1fe)) +* isolate fork object storage under a per-fork prefix ([#11829](https://github.com/windmill-labs/windmill/issues/11829)) ([9375805](https://github.com/windmill-labs/windmill/commit/9375805fdf7501347a4dfb37e58f1be7fa2c5658)) +* one Entra app for the Teams bot and ms_teams Graph connect ([#11718](https://github.com/windmill-labs/windmill/issues/11718)) ([e7088e7](https://github.com/windmill-labs/windmill/commit/e7088e7f4766cd662890cdf61f2b6a5a0fce06a8)) +* route python S3 client settings through the S3 proxy ([#11819](https://github.com/windmill-labs/windmill/issues/11819)) ([de340ee](https://github.com/windmill-labs/windmill/commit/de340eed6f5520414b59166f8dc16938f7ac6c36)) + + +### Bug Fixes + +* verify Bot Framework JWT on incoming Teams activities ([#11818](https://github.com/windmill-labs/windmill/issues/11818)) ([0b1935f](https://github.com/windmill-labs/windmill/commit/0b1935f251605926760565fb7693f345f1a56312)) + +## [1.838.0](https://github.com/windmill-labs/windmill/compare/v1.837.0...v1.838.0) (2026-10-16) + + +### Features + +* accept Ed25519-signed license keys alongside RSA ([#11810](https://github.com/windmill-labs/windmill/issues/11810)) ([33d4049](https://github.com/windmill-labs/windmill/commit/33d4049db029e1dc478b1251c562232f0127fc8a)) +* make the frontend installable as a progressive web app ([#11805](https://github.com/windmill-labs/windmill/issues/11805)) ([b86909f](https://github.com/windmill-labs/windmill/commit/b86909fc81b21447414266f47a83a43492204e50)) +* name mcp script and flow tools after their last path segment ([#11817](https://github.com/windmill-labs/windmill/issues/11817)) ([d1a13d9](https://github.com/windmill-labs/windmill/commit/d1a13d9054d5994b5ed9c0f8566b86154662caa0)) +* route dev workspace and fork jobs to their own worker groups ([#11811](https://github.com/windmill-labs/windmill/issues/11811)) ([6e565b0](https://github.com/windmill-labs/windmill/commit/6e565b0dcb0fb56b69131c262d482c82bb6c527b)) + + +### Bug Fixes + +* **ai-chat:** tell the session chat that an agent is an ai_agent resource ([#11798](https://github.com/windmill-labs/windmill/issues/11798)) ([a521548](https://github.com/windmill-labs/windmill/commit/a52154877caa5c3e473ee56c1eaf040b7da2375f)) +* emit a single email claim in job OIDC tokens ([#11804](https://github.com/windmill-labs/windmill/issues/11804)) ([2ef0c0d](https://github.com/windmill-labs/windmill/commit/2ef0c0d8e14fcd5ff1c06f6113ffaf2792b9d4c9)) +* give inline script runs their own job dir ([#11822](https://github.com/windmill-labs/windmill/issues/11822)) ([6aec448](https://github.com/windmill-labs/windmill/commit/6aec4488ebe8f377b4ea9bb4948649831cba0dbc)) +* hold mcp token references to resource reference scopes ([#11803](https://github.com/windmill-labs/windmill/issues/11803)) ([c09038b](https://github.com/windmill-labs/windmill/commit/c09038b10698042209fd6aa26c466fb53d6dc7d0)) +* let deployed low-code apps run database studio queries again ([#11816](https://github.com/windmill-labs/windmill/issues/11816)) ([7c63a30](https://github.com/windmill-labs/windmill/commit/7c63a30edd650f49eff85066dcc7916cec1b7010)) +* truncate long row titles instead of wrapping ([#11815](https://github.com/windmill-labs/windmill/issues/11815)) ([153a871](https://github.com/windmill-labs/windmill/commit/153a871550e3ae9ad6282aa59b3a4f9897fec6c1)) +* use the session workspace's user in three AI session places ([#11808](https://github.com/windmill-labs/windmill/issues/11808)) ([19fc07f](https://github.com/windmill-labs/windmill/commit/19fc07faa95a8a2247e726f9d0a2adc3cfa42bd2)) + +## [1.837.0](https://github.com/windmill-labs/windmill/compare/v1.836.0...v1.837.0) (2026-10-15) + + +### Features + +* agents run on behalf of their deployer ([#11430](https://github.com/windmill-labs/windmill/issues/11430)) ([0b98054](https://github.com/windmill-labs/windmill/commit/0b980546b104cf46f385133af553dfbe5f533eca)) + +## [1.836.0](https://github.com/windmill-labs/windmill/compare/v1.835.0...v1.836.0) (2026-10-15) + + +### Features + +* **cli:** sync an app's custom path as a workspace-specific file ([#11786](https://github.com/windmill-labs/windmill/issues/11786)) ([718aad7](https://github.com/windmill-labs/windmill/commit/718aad7396397fe12295a27a947c76221f37ad10)) +* let operators save resource and variable drafts ([#11736](https://github.com/windmill-labs/windmill/issues/11736)) ([8c0185d](https://github.com/windmill-labs/windmill/commit/8c0185d55a1a8c6fbc5e41b8b9d77865563f515b)) +* let raw app backend runnables import each other ([#11777](https://github.com/windmill-labs/windmill/issues/11777)) ([526da60](https://github.com/windmill-labs/windmill/commit/526da60da92832cc6e9c2942cea1f7729b3a05fd)) +* let the session agent read folder and group permissions ([#11796](https://github.com/windmill-labs/windmill/issues/11796)) ([eda43dd](https://github.com/windmill-labs/windmill/commit/eda43ddc41c10b3f7263103016275926a33bf768)) +* resolve canceled jobs and re-run them in limited batches ([#11797](https://github.com/windmill-labs/windmill/issues/11797)) ([f827ba0](https://github.com/windmill-labs/windmill/commit/f827ba05667e3ca5bcd3bc89dac7f51b85dd32b3)) +* run duckdb jobs out of process under job isolation, drop the engine fork, bump to 1.5.6 ([#11780](https://github.com/windmill-labs/windmill/issues/11780)) ([f9513e8](https://github.com/windmill-labs/windmill/commit/f9513e82eed3721ecbfd87004d1cd385ac740903)) +* run previews as the runnable's on_behalf_of identity ([#11778](https://github.com/windmill-labs/windmill/issues/11778)) ([4f71231](https://github.com/windmill-labs/windmill/commit/4f712318c3b52366b03bf7f941ecae4730fbf967)) +* sandbox new low-code and raw apps by default ([#11760](https://github.com/windmill-labs/windmill/issues/11760)) ([813c080](https://github.com/windmill-labs/windmill/commit/813c080d4684eecd144ef82706b04339c8f8d35a)) +* share AI sessions read-only by link, optionally public ([#11799](https://github.com/windmill-labs/windmill/issues/11799)) ([a9aa2b8](https://github.com/windmill-labs/windmill/commit/a9aa2b8229da0d54ed68eb40e4426e6983ff631e)) + + +### Bug Fixes + +* **cli:** include script modules in wmill dev previews ([#11787](https://github.com/windmill-labs/windmill/issues/11787)) ([b563f5c](https://github.com/windmill-labs/windmill/commit/b563f5cb5121c431f525c2e0266994c6d655185b)) +* let raw app SDK tokens read the viewer's identity without a toggle ([#11790](https://github.com/windmill-labs/windmill/issues/11790)) ([7da2344](https://github.com/windmill-labs/windmill/commit/7da234481333fcbdc348215589e2d3bfe5394b4a)) +* let Teams handlers post with job tokens again ([#11792](https://github.com/windmill-labs/windmill/issues/11792)) ([64e1630](https://github.com/windmill-labs/windmill/commit/64e1630d497af1b50a772aa66ef123f675ea6bb1)) +* relock apps in place so imported script changes reach them ([#11789](https://github.com/windmill-labs/windmill/issues/11789)) ([aca7fed](https://github.com/windmill-labs/windmill/commit/aca7fed33ddffa17b0e78d50a39ded032f6d8ba2)) +* relock scripts and flow steps whose code changed on deploy ([#11794](https://github.com/windmill-labs/windmill/issues/11794)) ([1f1df12](https://github.com/windmill-labs/windmill/commit/1f1df1254ebc417a8344801b571c4be8e98636c4)) +* send Azure Foundry gpt-5.5+ models to the Responses API ([#11788](https://github.com/windmill-labs/windmill/issues/11788)) ([ab89835](https://github.com/windmill-labs/windmill/commit/ab8983543b0641c96a8b4a2cbd829cc4040e9f41)) +* show the app's summary in the app viewer's page header ([#11782](https://github.com/windmill-labs/windmill/issues/11782)) ([3bee9c9](https://github.com/windmill-labs/windmill/commit/3bee9c94012474b7bb230d719fa34df0c0a6c25e)) + + +### Performance Improvements + +* drive schedule already-exists check from an indexed queue lookup ([#11783](https://github.com/windmill-labs/windmill/issues/11783)) ([7eee210](https://github.com/windmill-labs/windmill/commit/7eee210d761d332f8165b4f8a6f0ee6f1ec8864f)) + +## [1.835.0](https://github.com/windmill-labs/windmill/compare/v1.834.0...v1.835.0) (2026-10-14) + + +### Features + +* allow early return on steps inside branches ([#11733](https://github.com/windmill-labs/windmill/issues/11733)) ([08319a0](https://github.com/windmill-labs/windmill/commit/08319a004c289f93398b9fdc790b2ed412a81fa4)) +* run global ai eval cases under builder operator profiles ([#11768](https://github.com/windmill-labs/windmill/issues/11768)) ([d788f16](https://github.com/windmill-labs/windmill/commit/d788f16c4028985afd2b82f35d625b0a12c04653)) +* search the workspace picker dropdown past 8 workspaces ([#11761](https://github.com/windmill-labs/windmill/issues/11761)) ([c1a561f](https://github.com/windmill-labs/windmill/commit/c1a561f5ec091de0ae2de12ee811ca628c58c9f5)) + + +### Bug Fixes + +* show native triggers in the operator sidebar ([#11769](https://github.com/windmill-labs/windmill/issues/11769)) ([717162e](https://github.com/windmill-labs/windmill/commit/717162eab44757182a4a67316c288e188f811599)) + +## [1.834.0](https://github.com/windmill-labs/windmill/compare/v1.833.0...v1.834.0) (2026-10-14) + + +### Features + +* add a navigation picker after the breadcrumb's last segment ([#11649](https://github.com/windmill-labs/windmill/issues/11649)) ([9653318](https://github.com/windmill-labs/windmill/commit/9653318c17225b35c474722d49f38dc2ecd3b9f6)) +* add a yaml mode to the workspace settings page ([#11751](https://github.com/windmill-labs/windmill/issues/11751)) ([b543b43](https://github.com/windmill-labs/windmill/commit/b543b43d734a309278b72fdd958570b9c7ff6d5f)) +* give full-code app previews the deployed app's window.ctx ([#11763](https://github.com/windmill-labs/windmill/issues/11763)) ([c5e6f67](https://github.com/windmill-labs/windmill/commit/c5e6f67d5307146569e2fd1a37033c9dddb680c4)) +* guide the AI when no resource type fits a service ([#11615](https://github.com/windmill-labs/windmill/issues/11615)) ([88c3cc0](https://github.com/windmill-labs/windmill/commit/88c3cc0ced8fe3183979f0abd8e05846f6f4a8e6)) +* let a linked agent step override model and reasoning effort ([#11685](https://github.com/windmill-labs/windmill/issues/11685)) ([9249aa0](https://github.com/windmill-labs/windmill/commit/9249aa02d592e60024d7475627cbf664936e4a5d)) +* let operators with manage rights save schedule and trigger drafts ([#11646](https://github.com/windmill-labs/windmill/issues/11646)) ([74c5f82](https://github.com/windmill-labs/windmill/commit/74c5f82c6703a08a6d1a896b5e46b7007f2e3d73)) +* list agents with runnables, let them be starred, and fill out their menu ([#11651](https://github.com/windmill-labs/windmill/issues/11651)) ([fe2571e](https://github.com/windmill-labs/windmill/commit/fe2571ea53603625f48601aa800e9f700322d5b8)) +* list the user's own forks first in fork pickers ([#11762](https://github.com/windmill-labs/windmill/issues/11762)) ([009482a](https://github.com/windmill-labs/windmill/commit/009482a67747ec9e8f2b6b7baddb5e0e9fe720c5)) +* read and change workspace settings from ai sessions and mcp ([#11741](https://github.com/windmill-labs/windmill/issues/11741)) ([468cf1d](https://github.com/windmill-labs/windmill/commit/468cf1d9941538d285264e8a8c57356b934ab25d)) +* scope data table migrations to the schemas they declare ([#11756](https://github.com/windmill-labs/windmill/issues/11756)) ([2410790](https://github.com/windmill-labs/windmill/commit/2410790c7e343920ae2bb6f2405a0b7f7660be7f)) +* **sessions:** run a deployed item from its own page, for the chat ([#11468](https://github.com/windmill-labs/windmill/issues/11468)) ([998e687](https://github.com/windmill-labs/windmill/commit/998e6877c1cf07bb1a08fd1d3b18948fbc0e30e0)) +* workspace settings as one document, applied server-side ([#11740](https://github.com/windmill-labs/windmill/issues/11740)) ([9a5ec22](https://github.com/windmill-labs/windmill/commit/9a5ec223bc7dd1b5c4e58a9cc883a5d4e5d9237b)) + + +### Bug Fixes + +* **cli:** keep script run waiting on long jobs and flaky log streaming ([#11766](https://github.com/windmill-labs/windmill/issues/11766)) ([a2ee052](https://github.com/windmill-labs/windmill/commit/a2ee052e0326bf69f49596cdc008bbc8ca596e3c)) +* fork data tables sharing one database into a single copy ([#11755](https://github.com/windmill-labs/windmill/issues/11755)) ([4914782](https://github.com/windmill-labs/windmill/commit/49147827d69bc241c57407f539ec7e2621830d1b)) +* keep applied migrations and db settings in data table copies ([#11753](https://github.com/windmill-labs/windmill/issues/11753)) ([694ed7a](https://github.com/windmill-labs/windmill/commit/694ed7a0eba255ef42e6cfe988ea3b8c5784dfa1)) +* unify cron builder control sizes and widths ([#11764](https://github.com/windmill-labs/windmill/issues/11764)) ([17fefcf](https://github.com/windmill-labs/windmill/commit/17fefcf777063849ca8ec1e097bf02e826c0b3a7)) +* **worker:** keep script env vars off the nsjail launcher process ([#11749](https://github.com/windmill-labs/windmill/issues/11749)) ([1c068f3](https://github.com/windmill-labs/windmill/commit/1c068f33e144af81f202e21e23960af4a17a6b4a)) + ## [1.833.0](https://github.com/windmill-labs/windmill/compare/v1.832.0...v1.833.0) (2026-10-13) diff --git a/Dockerfile b/Dockerfile index 4e022d4b53..ad3a79a2b6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,9 @@ RUN apt-get -y update \ pkg-config=1.8.* \ protobuf-compiler=3.21.* -RUN git clone -b master --single-branch https://github.com/google/nsjail.git . && git checkout dccf911fd2659e7b08ce9507c25b2b38ec2c5800 +COPY docker/nsjail-patches/ /nsjail-patches/ +RUN git clone -b master --single-branch https://github.com/google/nsjail.git . && git checkout dccf911fd2659e7b08ce9507c25b2b38ec2c5800 \ + && git apply /nsjail-patches/*.patch RUN make FROM ${RUST_IMAGE} AS rust_base @@ -50,10 +52,7 @@ RUN apt-get update && apt-get install -y clang=1:19.0* libclang-dev=1:19.0* cmak COPY ./backend/windmill-duckdb-ffi-internal . -# The `duckdb` crate comes from a git dependency (a fork carrying an engine patch), -# which cargo checks out under $CARGO_HOME/git rather than the registry cache. RUN --mount=type=cache,target=/usr/local/cargo/registry \ - --mount=type=cache,target=/usr/local/cargo/git \ --mount=type=cache,target=$SCCACHE_DIR,sharing=locked \ cargo build --release -p windmill_duckdb_ffi_internal @@ -242,7 +241,7 @@ ENV PATH="${PATH}:/usr/local/go/bin" ENV GO_PATH=/usr/local/go/bin/go # Install UV -RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.13.0/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv +RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.13.4/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv # Preinstall python runtimes to temp build location (will copy with world-writable perms later) # --compile-bytecode precompiles the stdlib to .pyc so jobs don't recompile it on every run @@ -257,6 +256,11 @@ 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 \ && rm -rf /var/lib/apt/lists/* +# The npm bundled with nodejs is replaced by a pinned one: it is what `//npm` scripts install with +# (NPM_PATH=/usr/bin/npm). npm 12 skips dependency install scripts not covered by an allowScripts +# policy (package.json or .npmrc). +RUN npm install -g npm@12.2.0 && npm cache clean --force + # go build is slower the first time it is ran, so we prewarm it in the build # This mirrors Windmill's Go wrapper structure: main.go imports inner package, uses encoding/json, os, fmt RUN export GOCACHE=/tmp/build_cache/go && \ @@ -286,9 +290,10 @@ ENV TZ=Etc/UTC COPY --from=builder /frontend/build /static_frontend COPY --from=builder /windmill/target/release/windmill ${APP}/windmill -COPY --from=windmill_duckdb_ffi_internal_builder /windmill-duckdb-ffi-internal/target/release/libwindmill_duckdb_ffi_internal.so ${APP}/libwindmill_duckdb_ffi_internal.so +# DuckDB jobs run in this binary, which the worker looks for next to its own. +COPY --from=windmill_duckdb_ffi_internal_builder /windmill-duckdb-ffi-internal/target/release/windmill_duckdb_runner ${APP}/windmill_duckdb_runner -COPY --from=oven/bun:1.4.0 /usr/local/bin/bun /usr/bin/bun +COPY --from=oven/bun:1.4.4 /usr/local/bin/bun /usr/bin/bun # Install windmill CLI RUN bun install -g windmill-cli \ @@ -300,9 +305,6 @@ RUN bun install -g windmill-cli \ RUN curl -fsSL https://claude.ai/install.sh | bash \ && cp /root/.local/share/claude/versions/* /usr/bin/claude -COPY --from=php:8.3.33-cli-trixie /usr/local/bin/php /usr/bin/php -COPY --from=composer:2.9.5 /usr/bin/composer /usr/bin/composer - # add the docker client to call docker from a worker if enabled COPY --from=docker:29-dind /usr/local/bin/docker /usr/local/bin/ diff --git a/README.md b/README.md index e9ed6381f1..597a9142b2 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,8 @@ You can run scripts locally by passing the right environment variables for the ` - **Runtimes**: - TypeScript/JavaScript: Bun (default); Deno only on the `windmill-full` images - Python: python3 with uv for dependency management - - Go, Bash, PowerShell, PHP, Rust, C#, Java, Ansible + - PHP: only on the `windmill-full` images + - Go, Bash, PowerShell, Rust, C#, Java, Ansible ## Fastest Self-Hostable Workflow Engine diff --git a/ai_evals/README.md b/ai_evals/README.md index 9b8850c435..d458e019d0 100644 --- a/ai_evals/README.md +++ b/ai_evals/README.md @@ -184,6 +184,17 @@ dismissed, so `userAnswers: []` dismisses every question. Without it the tool re it cannot ask, and the model usually carries on with its own guess, which makes "asks before creating anything" impossible to assert. +`runtime.profile` runs a session-chat case (`sessionChat: true`) as a non-admin role +instead of full access: the prompt, toolset and tool schemas are cut as production's +`capabilitiesForRole` cuts them, and the workspace's `operator_settings` are seeded for the +tools that read them directly: + +```yaml +profile: + operator: true + operatorSettings: { builder_apps: true } # also builder_flows, manage_schedules, manage_triggers +``` + `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 fe20d77d05..e70dbfcfe5 100644 --- a/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts +++ b/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; import type { ChatCompletionMessageParam } from "openai/resources/chat/completions.mjs"; -import type { AIProvider } from "$lib/gen/types.gen"; +import type { AIProvider, OperatorSettings } from "$lib/gen/types.gen"; import { globalToolsFor, prepareGlobalSystemMessage, @@ -13,6 +13,10 @@ import { getGlobalDraft, listGlobalDrafts, } from "../../../../../frontend/src/lib/components/copilot/chat/global/userDraftAdapter"; +import { capabilitiesForRole } from "../../../../../frontend/src/lib/components/copilot/chat/global/sessionAccess"; +import { filterSessionTools } from "../../../../../frontend/src/lib/components/copilot/chat/global/sessionToolset"; +import { usersWorkspaceStore } from "../../../../../frontend/src/lib/stores"; +import { resolveResourceTypeSync } from "../../../../../frontend/src/lib/components/copilot/chat/script/core"; import { appendPlanModeInstructions } from "../../../../../frontend/src/lib/components/copilot/chat/planMode"; import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared"; import { createEvalPlanTools } from "./planModeTools"; @@ -26,7 +30,7 @@ import { type EvalPreviewPanel, type EvalPreviewTabFixture, } from "./evalPreviewTabs"; -import type { ModeRunContext } from "../../../../core/types"; +import type { EvalCaseProfile, ModeRunContext } from "../../../../core/types"; import type { GlobalDraftState } from "../../../../core/validators"; import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings"; import { @@ -121,6 +125,8 @@ export interface GlobalEvalOptions { // Start in plan mode: the gate refuses every tool without `planModeSafe`, and the two plan // tools are offered. Needs sessionChat, which is what plan mode is gated on in production. planMode?: boolean; + // The user's rights, resolved as a session resolves them; unset keeps full access. + profile?: EvalCaseProfile; model?: string; maxIterations?: number; userAnswers?: string[]; @@ -149,6 +155,16 @@ export async function runGlobalEval( options.workspaceFixtures ?? {}, ); seedLiveEditorDrafts(workspaceRoot, options.liveEditorDrafts ?? []); + // No deploy rules in the mock workspace, so they always pass. + const access = + options.profile && + capabilitiesForRole({ + isAdmin: false, + operator: options.profile.operator, + deployRulesPass: true, + operatorSettings: options.profile.operatorSettings as OperatorSettings, + }); + 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. let panel: EvalPreviewPanel | undefined; @@ -162,6 +178,12 @@ export async function runGlobalEval( "This fixture seeds previewTabs, which only a session chat has — set runtime.sessionChat: true on the case.", ); } + // Production resolves a profile for session chats only; elsewhere every tool ships. + if (!options.sessionChat && access) { + throw new Error( + "This case sets runtime.profile, which only a session chat applies — set runtime.sessionChat: true on the case.", + ); + } if (options.sessionChat) { panel = createEvalPreviewPanel({ sessionId: evalArtifacts.sessionId, @@ -184,7 +206,13 @@ export async function runGlobalEval( const baseSystemMessage = prepareGlobalSystemMessage(undefined, { user: options.user, previewTools: options.sessionChat ?? false, + access, }); + const helpers = { + ...evalArtifacts.helpers, + access, + ...(panel && { openArtifact: panel.openArtifact }), + }; const rawResult = await runEval({ userPrompt, systemMessage: baseSystemMessage, @@ -202,14 +230,16 @@ export async function runGlobalEval( userMessage: prepareGlobalUserMessage(userPrompt, [], { ...(injectActiveEditorContext ? { workspace: workspaceRoot } : {}), activePreview: panel?.activePreview(), + resourceTypeSync: await resolveResourceTypeSync(workspaceRoot, []), }), tools: [ - ...getGlobalEvalTools(options.sessionChat ?? false), + ...filterSessionTools( + getGlobalEvalTools(options.sessionChat ?? false), + access, + ), ...(planMode?.tools ?? []), ], - helpers: panel - ? { ...evalArtifacts.helpers, openArtifact: panel.openArtifact } - : evalArtifacts.helpers, + helpers, apiKey, getOutput: async () => ({ ...(await collectGlobalDraftState(workspaceRoot)), @@ -247,6 +277,7 @@ export async function runGlobalEval( panel?.dispose(); clearGlobalDrafts(workspaceRoot); clearLiveEditorDrafts(workspaceRoot, options.liveEditorDrafts ?? []); + seedOperatorSettings(workspaceRoot, undefined); unregisterBenchmarkWorkspaceRunnables(workspaceRoot); if (!options.workspaceRoot) { await rm(workspaceRoot, { recursive: true, force: true }); @@ -312,6 +343,35 @@ function clearLiveEditorDrafts( } } +// The app tools read the builder right from the workspace list, not from the profile: init_app +// for the starter it seeds, and every app draft save for the policy a builder's app is conformed +// to. Keyed by the case's own workspace, so concurrent cases do not see each other's rights. +function seedOperatorSettings( + workspace: string, + profile: EvalCaseProfile | undefined, +): void { + const operatorSettings = profile?.operator && profile.operatorSettings; + usersWorkspaceStore.update((list) => { + const workspaces = (list?.workspaces ?? []).filter( + (w) => w.id !== workspace, + ); + if (operatorSettings) { + workspaces.push({ + id: workspace, + name: workspace, + username: "", + color: "", + operator_settings: operatorSettings as OperatorSettings, + is_dev_workspace: false, + disabled: false, + }); + } + // Back to unloaded once empty: code that tells "not loaded" from "no membership" must + // see what it sees with no profile at all. + return workspaces.length ? { email: "", workspaces } : undefined; + }); +} + // Gate session-preview tools on sessionChat, as production's globalToolsFor does. function getGlobalEvalTools(sessionChat: boolean): ProductionTool<{}>[] { const disableSearchApp = process.env[DISABLE_SEARCH_APP_ENV] === "1"; diff --git a/ai_evals/adapters/frontend/mockBackend.ts b/ai_evals/adapters/frontend/mockBackend.ts index d8284039b9..69398555f0 100644 --- a/ai_evals/adapters/frontend/mockBackend.ts +++ b/ai_evals/adapters/frontend/mockBackend.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto' import confluenceFixture from '../../fixtures/frontend/global/hub/confluence.json' import servicenowFixture from '../../fixtures/frontend/global/hub/servicenow.json' import outreachFixture from '../../fixtures/frontend/global/hub/outreach.json' +import syncedResourceTypesFixture from '../../fixtures/frontend/global/hub/synced_resource_types.json' import type { AppWithLastVersion, CompletedJob, @@ -98,6 +99,13 @@ export interface BenchmarkWorkspaceResource { description?: string } +/** A resource type the instance holds. */ +export interface BenchmarkWorkspaceResourceType { + name: string + schema?: unknown + description?: string +} + export interface BenchmarkWorkspaceJob { /** Stable id so a case prompt can reference a specific run (e.g. for get_run). */ id?: string @@ -118,6 +126,8 @@ export interface BenchmarkWorkspaceRunnables { variables?: BenchmarkWorkspaceVariable[] aiProviders?: BenchmarkWorkspaceAiProvider[] resources?: BenchmarkWorkspaceResource[] + /** Unset means an instance synced with the hub. */ + resourceTypes?: BenchmarkWorkspaceResourceType[] datatables?: BenchmarkDatatableSeed[] /** DuckLake catalog names, as `list_ducklakes` reports them. */ ducklakes?: string[] @@ -328,6 +338,57 @@ export function listBenchmarkPlainResources(workspace: string): ListableResource })) } +function benchmarkResourceTypes(workspace: string): BenchmarkWorkspaceResourceType[] { + return benchmarkWorkspaceRunnables.get(workspace)?.resourceTypes ?? syncedResourceTypes() +} + +/** Resource type names of a benchmark workspace, as `ResourceService.listResourceTypeNames` + * returns them. Null when the workspace is not a benchmark one. */ +export function listBenchmarkResourceTypeNames(workspace: string): string[] | null { + if (!benchmarkWorkspaces.has(workspace)) { + return null + } + return benchmarkResourceTypes(workspace).map((rt) => rt.name) +} + +/** Adds a resource type to a benchmark workspace, as `ResourceService.createResourceType` does, + * so later searches in the same case find it. */ +export function createBenchmarkResourceType( + workspace: string, + resourceType: BenchmarkWorkspaceResourceType +): void { + const runnables = benchmarkWorkspaceRunnables.get(workspace) + if (!runnables) { + throw new Error(`Workspace "${workspace}" is not a benchmark workspace`) + } + const existing = benchmarkResourceTypes(workspace) + if (existing.some((rt) => rt.name === resourceType.name)) { + throw new Error(`Resource type ${resourceType.name} already exists`) + } + runnables.resourceTypes = [...existing, resourceType] +} + +/** Stands in for the resource type search: a type matches when a word of the query appears in + * its name or description, ranked by how many do. */ +export function queryBenchmarkResourceTypes( + workspace: string, + text: string, + limit = 10 +): { name: string; schema?: unknown; score: number }[] { + const words = text + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((word) => word.length >= 3) + return benchmarkResourceTypes(workspace) + .map((rt) => { + const haystack = `${rt.name} ${rt.description ?? ''}`.toLowerCase() + return { name: rt.name, schema: rt.schema, score: words.filter((w) => haystack.includes(w)).length } + }) + .filter((rt) => rt.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, limit) +} + /** 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 @@ -1468,6 +1529,42 @@ const BENCHMARK_HUB_INTEGRATION_META: Record< } } +/** The resource type a mocked hub integration takes, as its metadata serves it. */ +function benchmarkHubIntegrationResourceType(app: string): BenchmarkWorkspaceResourceType | null { + const real = REAL_HUB_INTEGRATIONS.find((integration) => integration.app === app) + if (real) { + return real.resource_type + } + const entry = BENCHMARK_HUB_INTEGRATION_META[app] + if (!entry) { + return null + } + return { + name: app, + description: `${entry.display_name} credentials`, + schema: { + type: 'object', + required: ['apiKey'], + properties: { + apiKey: { type: 'string', description: `${entry.display_name} API key` } + } + } + } +} + +/** What a hub-synced instance holds: common hub types, plus the one each mocked hub + * integration takes, so the hub and the instance agree on them. */ +function syncedResourceTypes(): BenchmarkWorkspaceResourceType[] { + const integrations = [ + ...REAL_HUB_INTEGRATIONS.map((integration) => integration.app), + ...Object.keys(BENCHMARK_HUB_INTEGRATION_META) + ] + return [ + ...syncedResourceTypesFixture, + ...integrations.flatMap((app) => benchmarkHubIntegrationResourceType(app) ?? []) + ] +} + /** Mirrors the hub's own derivation: hosts seen in the shipped scripts, whether they * call the provider directly, and how many there are of each kind. */ function benchmarkDerivedFacts(app: string) { @@ -1602,7 +1699,7 @@ export function handleBenchmarkApiFetch(url: string, init?: RequestInit): Respon meta: real.meta, meta_updated_at: null, derived: benchmarkDerivedFacts(app), - resource_types: [{ id: 1, ...real.resource_type }] + resource_types: [{ id: 1, ...benchmarkHubIntegrationResourceType(app) }] }) } const entry = BENCHMARK_HUB_INTEGRATION_META[app] @@ -1619,23 +1716,7 @@ export function handleBenchmarkApiFetch(url: string, init?: RequestInit): Respon meta: entry.meta, meta_updated_at: null, derived: benchmarkDerivedFacts(app), - resource_types: [ - { - id: 1, - name: app, - description: `${entry.display_name} credentials`, - schema: { - type: 'object', - required: ['apiKey'], - properties: { - apiKey: { - type: 'string', - description: `${entry.display_name} API key` - } - } - } - } - ] + resource_types: [{ id: 1, ...benchmarkHubIntegrationResourceType(app) }] }) } if (path === '/api/integrations/hub/list') { diff --git a/ai_evals/adapters/frontend/vitestAdapter.test.ts b/ai_evals/adapters/frontend/vitestAdapter.test.ts index cb7469b45e..b24ccbe5e4 100644 --- a/ai_evals/adapters/frontend/vitestAdapter.test.ts +++ b/ai_evals/adapters/frontend/vitestAdapter.test.ts @@ -111,6 +111,9 @@ vi.mock('$lib/gen', async () => { getBenchmarkResource, listBenchmarkAiProviderResources, listBenchmarkPlainResources, + listBenchmarkResourceTypeNames, + queryBenchmarkResourceTypes, + createBenchmarkResourceType, listBenchmarkApps, listBenchmarkDatatables, listBenchmarkDataMetrics, @@ -487,8 +490,23 @@ vi.mock('$lib/gen', async () => { } return value }, - queryResourceTypes: async (data: { workspace: string }) => - hasBenchmarkWorkspace(data.workspace) ? [] : actual.ResourceService.queryResourceTypes(data) + queryResourceTypes: async (data: { workspace: string; text: string; limit?: number }) => + hasBenchmarkWorkspace(data.workspace) + ? queryBenchmarkResourceTypes(data.workspace, data.text, data.limit) + : actual.ResourceService.queryResourceTypes(data), + listResourceTypeNames: async (data: { workspace: string }) => + listBenchmarkResourceTypeNames(data.workspace) ?? + actual.ResourceService.listResourceTypeNames(data), + createResourceType: async (data: { + workspace: string + requestBody: { name: string; schema?: unknown; description?: string } + }) => { + if (!hasBenchmarkWorkspace(data.workspace)) { + return actual.ResourceService.createResourceType(data) + } + createBenchmarkResourceType(data.workspace, data.requestBody) + return `resource_type ${data.requestBody.name} created` + } }), VariableService: wrapService(actual.VariableService, { existsVariable: async (data: { workspace: string; path: string }) => diff --git a/ai_evals/cases/cli.yaml b/ai_evals/cases/cli.yaml index b7abcf7f14..fdf469130d 100644 --- a/ai_evals/cases/cli.yaml +++ b/ai_evals/cases/cli.yaml @@ -85,6 +85,22 @@ - updates the step code so the returned greeting uses punctuation - defaults punctuation to an exclamation mark when omitted +# An in-house API has no hub resource type, so its connection needs a type of the workspace's +# own: a token parameter or a variable read inside the code is the fallback the guidance rules out. +- id: cli-resource-type-custom-api + prompt: |- + Create a Windmill Bun script at `f/evals/get_invoice.ts` that fetches an invoice by id from our internal billing API at https://billing.acme.internal/api/v1. It authenticates with a bearer token. + cliExpect: + requiredSkills: + - write-script-bun + forbiddenExecutedCommands: + - ^wmill sync push + judgeChecklist: + - creates the script at f/evals/get_invoice.ts, fetching an invoice by id with a bearer token + - the script takes the billing API's base URL and token together as one resource-typed parameter, not a plain string token or a variable read inside the code + - defines the billing API's resource type with its base URL and token fields, as a resource-type file or in the reply (asking before writing the file is fine) + - the custom resource type's name has the c_ prefix (e.g. `RT.CAcmeBilling` in TypeScript, `c_acme_billing.resource-type.json`) + - id: flow-reuse-existing-script prompt: |- There is already a reusable greeting script at `f/lib/format_greeting.ts`. diff --git a/ai_evals/cases/flow.yaml b/ai_evals/cases/flow.yaml index 3a4f203b48..7454a85c9c 100644 --- a/ai_evals/cases/flow.yaml +++ b/ai_evals/cases/flow.yaml @@ -16,6 +16,16 @@ - "the main step is named `sum_numbers`" - the flow returns the sum of the two numbers +# An in-house API has no hub resource type: the connection still comes in as one resource, not +# a token input or a variable read inside the code. +- id: flow-resource-type-custom-api + prompt: |- + Create a flow that fetches an invoice by id from our internal billing API at https://billing.acme.internal/api/v1. It authenticates with a bearer token. + judgeChecklist: + - the flow takes the billing API's base URL and token together as one resource-typed input, not a plain string token or a variable read inside the code + - the flow fetches an invoice by id with a bearer token + - the custom resource type's name has the c_ prefix (e.g. `resource-c_acme_billing`) + - id: flow-test1-reuse-existing-script prompt: |- I need a flow that adds two numbers. diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index 4b6704b229..136f125e59 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -759,6 +759,65 @@ - keeps both drafts under a sensible eval/global Slack-related path - does not create schedules, triggers, or deployed workspace changes +# The instance never synced the hub's resource types, so the one Confluence takes is missing: +# the script's connection picker stays empty until a superadmin syncs, which only the +# assistant's reply can tell the user. A type of its own would duplicate the hub's once synced, +# so it is only for a user who says they would rather not sync. +- id: global-resource-type-hub-not-synced + prompt: |- + Write a script that creates a page in our Confluence space from a space key, a title and the page body. + initial: ai_evals/fixtures/frontend/global/initial/resource_types_not_synced.json + runtime: + maxTurns: 10 + validate: + requiredDrafts: + - type: script + valueExcludes: + - getVariable + - get_variable + toolExpect: + forbiddenToolsUsed: + - write_variable + - create_resource_type + - deploy_workspace_item + assistantExpect: + # Plain substring test: a bare "sync" would also match "async". + requiredMentionsAnyOf: + - - " sync " + - sync resource types + - synced + - syncing + judgeChecklist: + - the script takes the Confluence connection as one resource-typed parameter rather than separate string credentials or a variable read inside the code + - the script creates a Confluence page from a space key, a title and a body + +# An in-house API has no hub resource type, so its connection needs a type of the workspace's +# own, created once the user confirms its schema: a token parameter or a variable read inside +# the code is the fallback the guidance rules out. +- id: global-resource-type-custom-api + prompt: |- + Write a script that fetches an invoice by id from our internal billing API at https://billing.acme.internal/api/v1. It authenticates with a bearer token. + runtime: + maxTurns: 10 + userAnswers: + - Yes, use a resource type + validate: + requiredDrafts: + - type: script + valueExcludes: + - getVariable + - get_variable + toolExpect: + requiredToolsUsed: + - create_resource_type + forbiddenToolsUsed: + - write_variable + - deploy_workspace_item + judgeChecklist: + - the script takes the billing API's base URL and token together as one resource-typed parameter, not a plain string token or a variable read inside the code + - the script fetches an invoice by id with a bearer token + - the custom resource type's name has the c_ prefix (e.g. `RT.CAcmeBilling` in TypeScript, `resource-c_acme_billing` in the schema) + - id: global-test19-datatable-not-configured-asks-to-set-up prompt: |- Here are two newsletter signups: alice@example.com and bob@example.com. @@ -2975,3 +3034,252 @@ - low-code - no-code skipJudge: true + +# --- Builder operators --- +# Operators whose workspace grants `builder_apps` / `builder_flows` compose apps and flows out of +# deployed scripts only. `runtime.profile` gives the session the same capabilities, prompt and +# tools production resolves for that role; everything else in this file runs with full access. + +- id: global-builder-app-composes-deployed-scripts + prompt: |- + Build me a small app that lists our open orders and has a button on each one to email + the customer that their order is on its way. + initial: ai_evals/fixtures/frontend/global/initial/operator_ops_scripts.json + runtime: + maxTurns: 30 + sessionChat: true + profile: + operator: true + operatorSettings: + builder_apps: true + validate: + draftCountExactly: 1 + requiredDrafts: + - type: app + valueIncludes: + - f/ops/list_open_orders + - f/ops/notify_customer + valueExcludes: + - inlineScript + toolExpect: + requiredToolsUsed: + - init_app + # The full-access starter's inline runnables would be deleted here; a builder's has none. + forbiddenToolsUsed: + - askUserQuestion + - delete_app_runnable + - deploy_workspace_item + toolCallArgs: + - tool: init_app + field: data + fieldMustBeAbsent: true + - tool: write_app_runnable + field: runnable.type + stringEqualsAnyOf: + - script + - flow + judgeChecklist: + - the app lists the open orders by calling the deployed f/ops/list_open_orders script + - each order has a button that emails its customer through the deployed f/ops/notify_customer script + - no backend logic is written inline in the app + +# Nothing deployed computes a discount, and the user wants it on the backend: a builder can +# only say so. Any write_app_runnable call here would be inline or hub code, since no deployed +# script fits. +- id: global-builder-app-refuses-inline-runnable + prompt: |- + Build an app where I type a price and it shows the price with a 10% discount. Do the + discount calculation on the backend, not in the browser. + initial: ai_evals/fixtures/frontend/global/initial/operator_ops_scripts.json + runtime: + maxTurns: 20 + sessionChat: true + profile: + operator: true + operatorSettings: + builder_apps: true + toolExpect: + forbiddenToolsUsed: + - write_app_runnable + - deploy_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - admin + skipJudge: true + +- id: global-builder-app-refuses-sandbox-off-and-viewer + prompt: |- + Build an app that lists our open orders. Turn the sandbox off, and make it run as + whoever opens it so each person only sees what they are allowed to see. + initial: ai_evals/fixtures/frontend/global/initial/operator_ops_scripts.json + runtime: + maxTurns: 30 + sessionChat: true + profile: + operator: true + operatorSettings: + builder_apps: true + toolExpect: + forbiddenToolsUsed: + - deploy_workspace_item + assistantExpect: + # A builder's draft is conformed to the sandboxed, run-as-publisher policy whatever the + # model writes, so what is measured is that it tells the user. "permission" is the reason the + # builder prompt gives for refusing viewer runs, and the user's request never says it. + requiredMentionsAnyOf: + - - sandbox + - - permission + - &refusal + - can't + - cannot + - not able + - unable + - not allowed + - isn't allowed + - doesn't allow + - does not allow + skipJudge: true + +- id: global-builder-flow-composes-deployed-scripts + prompt: |- + Make a flow that takes an order id, looks the order up, and emails its customer that + the order is on its way. Try it with order ord_1042. + initial: ai_evals/fixtures/frontend/global/initial/operator_ops_scripts.json + runtime: + maxTurns: 30 + sessionChat: true + profile: + operator: true + operatorSettings: + builder_flows: true + validate: + draftCountExactly: 1 + requiredDrafts: + - type: flow + valueIncludes: + - f/ops/get_order + - f/ops/notify_customer + valueExcludes: + - rawscript + toolExpect: + requiredToolsUsed: + - test_run_flow + forbiddenToolsUsed: + - deploy_workspace_item + judgeChecklist: + - the flow takes an order id as input + - it looks the order up with the deployed f/ops/get_order script + - it emails the order's customer with the deployed f/ops/notify_customer script, using the email from the looked-up order + - no step contains inline code + +# No deployed script averages numbers, so any flow would need an inline step. +- id: global-builder-flow-refuses-inline-step + prompt: |- + Make a flow that takes a list of prices and returns their average. + initial: ai_evals/fixtures/frontend/global/initial/operator_ops_scripts.json + runtime: + maxTurns: 12 + sessionChat: true + profile: + operator: true + operatorSettings: + builder_flows: true + validate: + draftCountExactly: 0 + toolExpect: + forbiddenToolsUsed: + - deploy_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - admin + skipJudge: true + +- id: global-builder-app-cannot-build-flow + prompt: |- + Make a flow that takes an order id and emails its customer that the order shipped. + initial: ai_evals/fixtures/frontend/global/initial/operator_ops_scripts.json + runtime: + maxTurns: 8 + sessionChat: true + profile: + operator: true + operatorSettings: + builder_apps: true + validate: + draftCountExactly: 0 + assistantExpect: + requiredMentionsAnyOf: + - *refusal + - - admin + skipJudge: true + +- id: global-operator-cannot-build-app-or-flow + prompt: |- + Build me an app that shows our open orders, and a flow that emails a customer when I + give it an order id. + initial: ai_evals/fixtures/frontend/global/initial/operator_ops_scripts.json + runtime: + maxTurns: 8 + sessionChat: true + profile: + operator: true + validate: + draftCountExactly: 0 + assistantExpect: + requiredMentionsAnyOf: + - *refusal + - - admin + skipJudge: true + +# 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 + prompt: |- + What is my support_triage agent told to do? + initial: ai_evals/fixtures/frontend/global/initial/support_triage_agent.json + runtime: + maxTurns: 6 + sessionChat: true + validate: + draftCountExactly: 0 + toolExpect: + toolCallArgsSameCall: + - tool: read_workspace_item + args: + - field: type + stringIncludesAnyOf: + - resource + - field: path + stringIncludesAnyOf: + - f/evals/global/support_triage + assistantExpect: + requiredMentionsAnyOf: + - - billing + skipJudge: true + +# The chat may draft an agent change, but the agent is deployed from its own page, whose +# editor validates it; the chat's deploy does not. +- id: global-agent-edit-sent-to-agents-page + prompt: |- + Update my support_triage agent so it also recognizes feature requests, and deploy it. + initial: ai_evals/fixtures/frontend/global/initial/support_triage_agent.json + runtime: + maxTurns: 6 + sessionChat: true + validate: + draftCountExactly: 1 + requiredDrafts: + - type: resource + path: f/evals/global/support_triage + valueIncludes: + - ai_agent + - feature + toolExpect: + forbiddenToolsUsed: + - deploy_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - /agents/edit + - Agents page + - agent editor + skipJudge: true diff --git a/ai_evals/cases/script.yaml b/ai_evals/cases/script.yaml index f0910e939a..c4ed76605b 100644 --- a/ai_evals/cases/script.yaml +++ b/ai_evals/cases/script.yaml @@ -13,6 +13,17 @@ - returns a plain greeting string - does not wrap the result in an object or array +# An in-house API has no hub resource type: the connection still comes in as one resource, not +# a token parameter or a variable read inside the code. +- id: script-resource-type-custom-api + prompt: |- + Update the current Bun script so it fetches an invoice by id from our internal billing API at https://billing.acme.internal/api/v1. It authenticates with a bearer token. + initial: ai_evals/fixtures/frontend/script/initial/test1_empty_bun.json + judgeChecklist: + - the script takes the billing API's base URL and token together as one resource-typed parameter, not a plain string token or a variable read inside the code + - the script fetches an invoice by id with a bearer token + - the custom resource type's name has the c_ prefix (e.g. `RT.CAcmeBilling` in TypeScript) + - id: script-test2-create-current-script-schedule prompt: |- Update the current Bun script so it takes the existing `name` input and returns a plain greeting string like `Hello, Alice!`. diff --git a/ai_evals/core/types.ts b/ai_evals/core/types.ts index 7e70191992..192a0c7ac7 100644 --- a/ai_evals/core/types.ts +++ b/ai_evals/core/types.ts @@ -40,6 +40,19 @@ export interface EvalCaseRuntimeSpec { // Global session chats: start the case in plan mode, so the workspace-changing tools are // 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. + profile?: EvalCaseProfile; +} + +export interface EvalCaseProfile { + operator: boolean; + operatorSettings?: { + builder_flows?: boolean; + builder_apps?: boolean; + manage_schedules?: boolean; + manage_triggers?: boolean; + }; } export interface FlowValidationSpec { diff --git a/ai_evals/fixtures/frontend/global/hub/synced_resource_types.json b/ai_evals/fixtures/frontend/global/hub/synced_resource_types.json new file mode 100644 index 0000000000..033c53ba3e --- /dev/null +++ b/ai_evals/fixtures/frontend/global/hub/synced_resource_types.json @@ -0,0 +1,893 @@ +[ + { + "name": "ai_instruction", + "description": "Instructions for the AI chat that apply to every item under the folder this resource lives in, like an AGENTS.md: an ai_instruction at f/billing/AGENTS is given to the assistant whenever it works on something under f/billing/. The file body is the instructions it follows.", + "schema": { + "type": "object", + "properties": { + "content": { + "type": "string" + } + } + } + }, + { + "name": "ai_skill", + "description": "A reusable instruction set for the AI chat, in the SKILL.md format. The resource description is what the assistant sees when deciding whether the skill applies; the file body is the instructions it follows.", + "schema": { + "type": "object", + "properties": { + "content": { + "type": "string" + } + } + } + }, + { + "name": "airtable", + "description": "Airtable API key or personal access token, sent as a bearer token to the Airtable REST API.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [], + "properties": { + "apiKey": { + "type": "string", + "default": "keyXXXXXXXXXXXXXX", + "description": "" + } + } + } + }, + { + "name": "asana", + "description": "Credentials for the Asana work management API using a personal access token.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "default": "", + "description": "" + } + } + } + }, + { + "name": "aws", + "description": "Amazon Web Services (AWS) credentials: access key ID, secret access key and default region.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "awsAccessKeyId", + "awsSecretAccessKey" + ], + "properties": { + "region": { + "type": "string", + "default": "", + "description": "" + }, + "awsAccessKeyId": { + "type": "string", + "default": "", + "description": "" + }, + "awsSecretAccessKey": { + "type": "string", + "default": "", + "description": "" + } + } + } + }, + { + "name": "azure_blob", + "description": "Connection settings for a Microsoft Azure Blob Storage container: account name, container name, access key and endpoint.", + "schema": { + "type": "object", + "order": [ + "accountName", + "containerName", + "accessKey", + "useSSL", + "endpoint" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "accountName", + "containerName", + "useSSL" + ], + "properties": { + "useSSL": { + "type": "boolean", + "default": true, + "description": "" + }, + "endpoint": { + "type": "string", + "default": "", + "description": "" + }, + "accessKey": { + "type": "string", + "default": "", + "description": "" + }, + "accountName": { + "type": "string", + "default": "", + "description": "" + }, + "containerName": { + "type": "string", + "default": "", + "description": "" + } + } + } + }, + { + "name": "clickup", + "description": "Credentials for the ClickUp API v2 using a personal API token.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "description": "API token retrieved from the dashboard." + } + } + } + }, + { + "name": "discord_webhook", + "description": "Discord incoming webhook URL for posting messages to a Discord channel.\n\nSee: ", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [], + "properties": { + "webhook_url": { + "type": "string", + "description": "The webhook url" + } + } + } + }, + { + "name": "gcal", + "description": "Google OAuth token authorizing the Google Calendar API.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "description": "The OAuth token, refreshed if necessary" + } + } + } + }, + { + "name": "gcp_service_account", + "description": "Google Cloud service account key (JSON) used to authenticate to Google Cloud APIs.", + "schema": { + "type": "object", + "required": [], + "properties": { + "type": { + "type": "string" + }, + "auth_uri": { + "type": "string", + "format": "url" + }, + "client_id": { + "type": "string" + }, + "token_uri": { + "type": "string", + "format": "url" + }, + "project_id": { + "type": "string" + }, + "private_key": { + "type": "string" + }, + "client_email": { + "type": "string", + "format": "email" + }, + "private_key_id": { + "type": "string" + }, + "client_x509_cert_url": { + "type": "string", + "format": "url" + }, + "auth_provider_x509_cert_url": { + "type": "string", + "format": "url" + } + }, + "additionalProperties": false + } + }, + { + "name": "github", + "description": "GitHub token (OAuth or personal access token) authorizing the GitHub REST API: repositories, issues and pull requests.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "description": "" + } + } + } + }, + { + "name": "gitlab", + "description": "Credentials for the GitLab API v4 using a personal access token, with the base URL of the GitLab instance (self-hosted or gitlab.com).", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "default": "", + "description": "" + }, + "baseUrl": { + "type": "string", + "default": "https://gitlab.com", + "description": "" + } + } + } + }, + { + "name": "gmail", + "description": "Google OAuth token authorizing the Gmail API to send email as the connected Google account.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "description": "" + } + } + } + }, + { + "name": "gsheets", + "description": "Google OAuth token authorizing the Google Sheets API for reading and writing spreadsheets.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "description": "The OAuth token, refreshed if necessary" + } + } + } + }, + { + "name": "hubspot", + "description": "Credentials for the HubSpot CRM API using a private app access token.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [], + "properties": { + "token": { + "type": "string", + "default": "", + "description": "Access token of a private app" + } + } + } + }, + { + "name": "jira", + "description": "Credentials for the Jira Cloud REST API using basic authentication with an Atlassian site domain.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "username", + "password", + "domain" + ], + "properties": { + "domain": { + "type": "string", + "default": "", + "description": "" + }, + "password": { + "type": "string", + "default": "", + "description": "" + }, + "username": { + "type": "string", + "default": "", + "description": "" + } + } + } + }, + { + "name": "linear", + "description": "Credentials for the Linear issue tracking API using a personal API key.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "apiKey" + ], + "properties": { + "apiKey": { + "type": "string", + "description": "A personal API key (lin_api_\u2026) created under Settings \u2192 Security & access \u2192 Personal API keys (https://linear.app/settings/account/security)." + } + } + } + }, + { + "name": "mailchimp", + "description": "API key and account data center prefix (e.g. us12) for the Mailchimp Marketing API.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [], + "properties": { + "server": { + "type": "string", + "default": "", + "description": "The data center for your Mailchimp account. (For example: us12)" + }, + "api_key": { + "type": "string", + "default": "", + "description": "" + } + } + } + }, + { + "name": "mongodb", + "description": "Connection settings for a MongoDB database: server host/port list, database name, TLS and SCRAM credentials.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "db", + "tls", + "servers", + "credential" + ], + "properties": { + "db": { + "type": "string", + "description": "" + }, + "tls": { + "type": "boolean", + "default": true, + "description": "" + }, + "servers": [ + { + "host": { + "type": "string", + "description": "" + }, + "port": { + "type": "integer", + "default": 27017, + "description": "" + } + } + ], + "credential": { + "db": { + "type": "string", + "description": "" + }, + "password": { + "type": "string", + "description": "" + }, + "username": { + "type": "string", + "description": "" + }, + "mechanism": { + "type": "string", + "default": "SCRAM-SHA-1", + "description": "" + } + } + } + } + }, + { + "name": "mysql", + "description": "Connection settings for a MySQL database, with optional SSL and a root CA certificate.", + "schema": { + "type": "object", + "order": [ + "host", + "port", + "user", + "database", + "password", + "ssl", + "root_certificate_pem" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "user", + "database", + "password" + ], + "properties": { + "ssl": { + "type": "boolean", + "default": false, + "description": "use ssl" + }, + "host": { + "type": "string", + "default": "", + "description": "instance host" + }, + "port": { + "type": "number", + "default": 3306, + "description": "instance port" + }, + "user": { + "type": "string", + "default": "", + "description": "username" + }, + "database": { + "type": "string", + "default": "", + "description": "database name" + }, + "password": { + "type": "string", + "default": "", + "description": "user's password" + }, + "root_certificate_pem": { + "type": "string", + "default": "", + "description": "" + } + } + } + }, + { + "name": "notion", + "description": "Notion API token: filled by Windmill's Notion OAuth connect, or an internal connection's API token / personal access token pasted in.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "default": "", + "description": "Filled automatically when you connect with OAuth. To paste one instead: the API token of an internal connection (ntn_\u2026) or a personal access token, both created in the Notion developer portal at https://www.notion.so/developers. A connection token only reaches the pages granted to it under Content access." + } + } + } + }, + { + "name": "openai", + "description": "OpenAI secret API key, with optional organization ID and base URL override, for the OpenAI API.", + "schema": { + "type": "object", + "order": [ + "api_key", + "organization_id", + "base_url" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "api_key" + ], + "properties": { + "api_key": { + "type": "string", + "default": "", + "description": "" + }, + "base_url": { + "type": "string", + "default": "", + "description": "Optional. Only needed to overwrite the default base URL." + }, + "organization_id": { + "type": "string", + "default": "", + "description": "Only needs to be set for users who belong to multiple organizations and they want to use an organization other than the default one" + } + } + } + }, + { + "name": "postgresql", + "description": "Connection settings for a PostgreSQL database: host, port, database name, user, password and SSL/IAM auth options.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "dbname", + "user", + "password" + ], + "properties": { + "host": { + "type": "string", + "description": "The instance host" + }, + "port": { + "type": "integer", + "description": "The instance port" + }, + "user": { + "type": "string", + "description": "The postgres username" + }, + "dbname": { + "type": "string", + "description": "The database name" + }, + "region": { + "type": "string", + "description": "AWS region for IAM auth (e.g. us-east-1). Falls back to AWS_REGION env var if not set." + }, + "options": { + "type": "string", + "description": "Startup options sent to the server (the libpq options parameter), such as endpoint= for a Neon pooled or private connection, or -c search_path=myschema" + }, + "sslmode": { + "enum": [ + "disable", + "allow", + "prefer", + "require", + "verify-ca", + "verify-full" + ], + "type": "string", + "description": "Most cloud databases (Heroku, AWS, Supabase, etc.) require SSL. Self-hosted databases typically don't." + }, + "password": { + "type": "string", + "description": "The postgres users password" + }, + "use_iam_auth": { + "type": "boolean", + "default": false, + "description": "Use IAM authentication (AWS RDS/Aurora). When enabled, the password field is ignored and a short-lived IAM token is generated instead." + }, + "accept_invalid_certs": { + "type": "boolean", + "default": false, + "showExpr": "fields.sslmode == 'verify-ca' || fields.sslmode == 'verify-full'", + "description": "Accept any certificate presented by the server, which disables TLS certificate verification and is not recommended. Only applies to verify-ca/verify-full." + }, + "root_certificate_pem": { + "type": "string", + "default": "", + "description": "The root certificate in pe format" + } + } + } + }, + { + "name": "s3", + "description": "Connection settings for an Amazon S3 (AWS) bucket: endpoint, region, bucket name and access keys.", + "schema": { + "type": "object", + "order": [ + "endPoint", + "region", + "pathStyle", + "bucket", + "accessKey", + "secretKey", + "port", + "useSSL" + ], + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "bucket" + ], + "properties": { + "port": { + "type": "number", + "description": "Deprecated: pass in a complete URL to `endPoint` instead, including a port number if needed" + }, + "bucket": { + "type": "string", + "default": "", + "description": "" + }, + "region": { + "type": "string", + "default": "", + "description": "AWS region (e.g. us-east-1). Used to auto-generate the S3 endpoint URL when endPoint is not specified" + }, + "useSSL": { + "type": "boolean", + "default": true, + "description": "Deprecated: pass in a complete URL to `endPoint` instead, including 'https://' or 'http://'" + }, + "endPoint": { + "type": "string", + "default": "", + "description": "The full URL of the S3 endpoint (e.g. https://s3.us-east-1.amazonaws.com)" + }, + "accessKey": { + "type": "string", + "default": "", + "description": "" + }, + "pathStyle": { + "type": "boolean", + "default": true, + "description": "// instead of ./" + }, + "secretKey": { + "type": "string", + "default": "", + "description": "" + } + } + } + }, + { + "name": "salesforce", + "description": "Salesforce OAuth Authorization Code credential. The token is populated by Windmill's OAuth flow; instance_url pins every REST call to the org's data host, api_version selects the REST API version.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "token", + "instance_url" + ], + "properties": { + "token": { + "type": "string", + "description": "OAuth access token. Populated by Windmill's OAuth flow." + }, + "api_version": { + "type": "string", + "description": "Salesforce REST API version path segment, e.g. v60.0. Optional \u2014 scripts default to v60.0 when left blank." + }, + "instance_url": { + "type": "string", + "description": "Org data host returned in the OAuth token response, e.g. https://acme.my.salesforce.com (production) or https://acme--sandbox.sandbox.my.salesforce.com (sandbox). Every REST call is made against this host, not login.salesforce.com." + } + } + } + }, + { + "name": "sendgrid", + "description": "Twilio SendGrid API key for the SendGrid v3 API: transactional email, contacts, marketing lists and bounces.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "default": "", + "description": "API token" + } + } + } + }, + { + "name": "shopify", + "description": "Credentials for the Shopify Admin API using a store name and access token.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "store_name", + "token" + ], + "properties": { + "token": { + "type": "string", + "default": "", + "description": "" + }, + "store_name": { + "type": "string", + "default": "", + "description": "" + } + } + } + }, + { + "name": "slack", + "description": "Slack bot or user OAuth token authorizing the Slack Web API (messages, channels, users and files).", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [], + "properties": { + "token": { + "type": "string", + "description": "The slack token" + } + } + } + }, + { + "name": "smtp", + "description": "Connection settings for an SMTP mail server used to send email.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "host" + ], + "properties": { + "host": { + "type": "string", + "default": "mail.smtpbucket.com", + "description": "SMTP host address" + }, + "port": { + "type": "integer", + "default": 8025, + "description": "port number on which to connect" + }, + "user": { + "type": "string", + "description": "" + }, + "password": { + "type": "string", + "description": "" + } + } + } + }, + { + "name": "stripe", + "description": "Stripe secret API key for the Stripe payments API.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [], + "properties": { + "token": { + "type": "string", + "description": "" + } + } + } + }, + { + "name": "supabase", + "description": "Supabase project URL and API key for the Supabase REST, auth and storage APIs.\n\nRetrieve both values in \"Project Settings -> API\".", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [], + "properties": { + "key": { + "type": "string", + "default": "", + "description": "The unique Supabase Key which is supplied when you create a new project in your project dashboard." + }, + "url": { + "type": "string", + "default": "", + "description": "The unique Supabase URL which is supplied when you create a new project in your project dashboard." + } + } + } + }, + { + "name": "twilio", + "description": "Credentials for the Twilio messaging and voice API using an Account SID and auth token.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "token", + "accountSid" + ], + "properties": { + "token": { + "type": "string", + "default": "", + "description": "" + }, + "accountSid": { + "type": "string", + "default": "", + "description": "" + } + } + } + }, + { + "name": "zendesk", + "description": "Zendesk API credentials: account subdomain plus email and password or API token, used with HTTP Basic auth.", + "schema": { + "type": "object", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": [ + "username", + "subdomain", + "password" + ], + "properties": { + "password": { + "type": "string", + "default": "", + "description": "password or api token" + }, + "username": { + "type": "string", + "default": "", + "description": "email or \"{email}/token\" if using an api token" + }, + "subdomain": { + "type": "string", + "default": "", + "description": "" + } + } + } + } +] diff --git a/ai_evals/fixtures/frontend/global/initial/operator_ops_scripts.json b/ai_evals/fixtures/frontend/global/initial/operator_ops_scripts.json new file mode 100644 index 0000000000..859d612da7 --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/operator_ops_scripts.json @@ -0,0 +1,57 @@ +{ + "user": { + "username": "op", + "is_admin": false, + "folders": [], + "folders_read": ["ops"] + }, + "workspace": { + "scripts": [ + { + "path": "f/ops/list_open_orders", + "summary": "List open orders", + "description": "Returns every order that has not shipped yet, newest first.", + "language": "bun", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "required": [] + }, + "content": "export async function main() {\n return [\n { id: 'ord_1042', customer_email: 'ana@example.com', total: 129.9, status: 'open' },\n { id: 'ord_1041', customer_email: 'li@example.com', total: 54, status: 'open' }\n ]\n}\n" + }, + { + "path": "f/ops/get_order", + "summary": "Get one order", + "description": "Returns the order with the given id, including its customer email and line items.", + "language": "bun", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "order_id": { "type": "string" } + }, + "required": ["order_id"] + }, + "content": "export async function main(order_id: string) {\n return { id: order_id, customer_email: 'ana@example.com', total: 129.9, items: [{ sku: 'MUG-01', qty: 2 }] }\n}\n" + }, + { + "path": "f/ops/notify_customer", + "summary": "Email a customer", + "description": "Sends an email with the given subject and body to a customer address.", + "language": "bun", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "email": { "type": "string" }, + "subject": { "type": "string" }, + "body": { "type": "string" } + }, + "required": ["email", "subject", "body"] + }, + "content": "export async function main(email: string, subject: string, body: string) {\n return { sent: true, email }\n}\n" + } + ] + } +} diff --git a/ai_evals/fixtures/frontend/global/initial/resource_types_not_synced.json b/ai_evals/fixtures/frontend/global/initial/resource_types_not_synced.json new file mode 100644 index 0000000000..8e0cb5a54d --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/resource_types_not_synced.json @@ -0,0 +1,5 @@ +{ + "workspace": { + "resourceTypes": [{ "name": "ai_skill" }, { "name": "ai_instruction" }] + } +} diff --git a/ai_evals/fixtures/frontend/global/initial/support_triage_agent.json b/ai_evals/fixtures/frontend/global/initial/support_triage_agent.json new file mode 100644 index 0000000000..b603bf5767 --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/support_triage_agent.json @@ -0,0 +1,18 @@ +{ + "workspace": { + "resources": [ + { + "path": "f/evals/global/support_triage", + "resource_type": "ai_agent", + "description": "Triages incoming support tickets", + "value": { + "provider": { "kind": "anthropic", "model": "claude-sonnet-5", "resource": "" }, + "output_type": "text", + "system_prompt": "Classify each ticket as billing, bug or question, then reply with the label only.", + "memory": { "kind": "off" }, + "tools": [] + } + } + ] + } +} diff --git a/ai_evals/modes/global.ts b/ai_evals/modes/global.ts index 238a82a759..bbff83dd8a 100644 --- a/ai_evals/modes/global.ts +++ b/ai_evals/modes/global.ts @@ -53,6 +53,7 @@ export function createGlobalModeRunner( previewTabs: initial?.previewTabs, sessionChat: context.evalCase?.runtime?.sessionChat, planMode: context.evalCase?.runtime?.planMode, + profile: context.evalCase?.runtime?.profile, maxIterations: context.evalCase?.runtime?.maxTurns, userAnswers: context.evalCase?.runtime?.userAnswers, provider: modelConfig.provider, diff --git a/backend/.cargo/config.toml b/backend/.cargo/config.toml index f8b0a5e32a..2b90b4bb23 100644 --- a/backend/.cargo/config.toml +++ b/backend/.cargo/config.toml @@ -24,4 +24,14 @@ rustflags = [ ] [net] -git-fetch-with-cli = true \ No newline at end of file +git-fetch-with-cli = true + +[env] +# `#[sqlx::test]` clones each test database from a template migrated once, instead of running +# every migration per test. The `_sqlx_tmpl_*` databases it leaves on the server are reused. +SQLX_TEST_TEMPLATE = "true" +# All `#[sqlx::test]` pools of a test binary share this many connections, and each keeps the +# ones it took until it ends: below 5 per test thread, concurrent tests can split them all and +# wait on each other until their acquire times out. 50 covers CI's `--test-threads=10`; a run +# with more threads over a binary of more than 10 tests needs 5 per thread. +SQLX_TEST_MAX_CONNECTIONS = "50" diff --git a/backend/.gitignore b/backend/.gitignore index 6645e22618..78639e3b4e 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -10,4 +10,4 @@ windmill-api/openapi-*.* *ee.rs generate_mcp_endpoints_tools/venv bacon.toml -libwindmill_duckdb_ffi_internal.so \ No newline at end of file +windmill_duckdb_runner \ No newline at end of file diff --git a/backend/.sqlx/query-009fd85c1aa55ea36454eafffbf62505461eb1cc8b9af85a2e2ab6e7dbe950c6.json b/backend/.sqlx/query-009fd85c1aa55ea36454eafffbf62505461eb1cc8b9af85a2e2ab6e7dbe950c6.json deleted file mode 100644 index c687589db1..0000000000 --- a/backend/.sqlx/query-009fd85c1aa55ea36454eafffbf62505461eb1cc8b9af85a2e2ab6e7dbe950c6.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock, ws_error_handler_muted)\n VALUES ('test-workspace', $1, 'f/test/muted_failing_script', 'export function main() { throw new Error(\"fail\"); }', 'bun', 'script', 'test-user', '{}', '', '', E'{}\\n//bun.lock\\n', true)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "009fd85c1aa55ea36454eafffbf62505461eb1cc8b9af85a2e2ab6e7dbe950c6" -} diff --git a/backend/.sqlx/query-00ac2ddf7c3f6b2c4e9a47c6a237ef9e7e6b6b793147b1752ff07e800415bfa9.json b/backend/.sqlx/query-00ac2ddf7c3f6b2c4e9a47c6a237ef9e7e6b6b793147b1752ff07e800415bfa9.json new file mode 100644 index 0000000000..900023094d --- /dev/null +++ b/backend/.sqlx/query-00ac2ddf7c3f6b2c4e9a47c6a237ef9e7e6b6b793147b1752ff07e800415bfa9.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE native_trigger SET connection_path = $1 WHERE workspace_id = $2 AND connection_path = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "00ac2ddf7c3f6b2c4e9a47c6a237ef9e7e6b6b793147b1752ff07e800415bfa9" +} diff --git a/backend/.sqlx/query-00b32f6cd59154d9e529c8577072f000ae6cf37928508a82f51d3b3a325f3d1a.json b/backend/.sqlx/query-00b32f6cd59154d9e529c8577072f000ae6cf37928508a82f51d3b3a325f3d1a.json new file mode 100644 index 0000000000..ce07d4fada --- /dev/null +++ b/backend/.sqlx/query-00b32f6cd59154d9e529c8577072f000ae6cf37928508a82f51d3b3a325f3d1a.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM variable WHERE workspace_id = $1 AND account = ANY($2) RETURNING path", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Int4Array" + ] + }, + "nullable": [ + false + ] + }, + "hash": "00b32f6cd59154d9e529c8577072f000ae6cf37928508a82f51d3b3a325f3d1a" +} diff --git a/backend/.sqlx/query-086866581c8f253a611d85cc9464e63767da94af0fcb6d660cd6fb0479b9a99d.json b/backend/.sqlx/query-086866581c8f253a611d85cc9464e63767da94af0fcb6d660cd6fb0479b9a99d.json new file mode 100644 index 0000000000..e9183fe2d4 --- /dev/null +++ b/backend/.sqlx/query-086866581c8f253a611d85cc9464e63767da94af0fcb6d660cd6fb0479b9a99d.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM variable WHERE workspace_id = $1 AND path = $2 AND account = $3)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int4" + ] + }, + "nullable": [ + null + ] + }, + "hash": "086866581c8f253a611d85cc9464e63767da94af0fcb6d660cd6fb0479b9a99d" +} diff --git a/backend/.sqlx/query-0997b46bae6e2374b568e8367898d6fc79c331431326250c1a59674054ceaabd.json b/backend/.sqlx/query-0997b46bae6e2374b568e8367898d6fc79c331431326250c1a59674054ceaabd.json new file mode 100644 index 0000000000..ef5348cf50 --- /dev/null +++ b/backend/.sqlx/query-0997b46bae6e2374b568e8367898d6fc79c331431326250c1a59674054ceaabd.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT parent_job FROM v2_job WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "parent_job", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true + ] + }, + "hash": "0997b46bae6e2374b568e8367898d6fc79c331431326250c1a59674054ceaabd" +} diff --git a/backend/.sqlx/query-09e2a19435068f9e9bfd5bcb44b4e283c71729f81550f6f7156ce4970345cc07.json b/backend/.sqlx/query-09e2a19435068f9e9bfd5bcb44b4e283c71729f81550f6f7156ce4970345cc07.json deleted file mode 100644 index 8ece28ff8f..0000000000 --- a/backend/.sqlx/query-09e2a19435068f9e9bfd5bcb44b4e283c71729f81550f6f7156ce4970345cc07.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE account SET refresh_error = $1 WHERE workspace_id = $2 AND id = $3", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text", - "Int4" - ] - }, - "nullable": [] - }, - "hash": "09e2a19435068f9e9bfd5bcb44b4e283c71729f81550f6f7156ce4970345cc07" -} diff --git a/backend/.sqlx/query-0d04ec377bef28416a5721a43d2c687681b5d36ff1918ed9e495c5596f2ba9f2.json b/backend/.sqlx/query-0d04ec377bef28416a5721a43d2c687681b5d36ff1918ed9e495c5596f2ba9f2.json new file mode 100644 index 0000000000..3f4a5c7992 --- /dev/null +++ b/backend/.sqlx/query-0d04ec377bef28416a5721a43d2c687681b5d36ff1918ed9e495c5596f2ba9f2.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT slack_oauth_client_secret FROM workspace_settings WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "slack_oauth_client_secret", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "0d04ec377bef28416a5721a43d2c687681b5d36ff1918ed9e495c5596f2ba9f2" +} diff --git a/backend/.sqlx/query-0d419f09cce9f33fecfabc62b17e8848ac8184d61d3f7cde126a56080aef643c.json b/backend/.sqlx/query-0d419f09cce9f33fecfabc62b17e8848ac8184d61d3f7cde126a56080aef643c.json new file mode 100644 index 0000000000..1dd5c1042c --- /dev/null +++ b/backend/.sqlx/query-0d419f09cce9f33fecfabc62b17e8848ac8184d61d3f7cde126a56080aef643c.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM workspace_settings WHERE ($1::text IS NULL OR workspace_id = $1) AND (\n jsonb_typeof(auto_invite) = 'object' AND COALESCE(auto_invite->>'role',\n CASE WHEN (auto_invite->>'operator')::boolean THEN 'operator' ELSE 'developer' END) = $2\n OR EXISTS (SELECT 1 FROM jsonb_each_text(\n CASE WHEN jsonb_typeof(auto_invite->'instance_groups_roles') = 'object'\n THEN auto_invite->'instance_groups_roles' ELSE '{}'::jsonb END) g\n WHERE g.value = $2))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "0d419f09cce9f33fecfabc62b17e8848ac8184d61d3f7cde126a56080aef643c" +} diff --git a/backend/.sqlx/query-0d69f5f6d2e3a4b7b801c6feea92a383e841fa7c3f1c5e2849481c6057ab1e84.json b/backend/.sqlx/query-0d69f5f6d2e3a4b7b801c6feea92a383e841fa7c3f1c5e2849481c6057ab1e84.json new file mode 100644 index 0000000000..56caf2108d --- /dev/null +++ b/backend/.sqlx/query-0d69f5f6d2e3a4b7b801c6feea92a383e841fa7c3f1c5e2849481c6057ab1e84.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM workspace_settings WHERE ($1::text IS NOT NULL AND workspace_id = $1\n OR $1::text IS NULL AND NOT COALESCE(role_overrides ? $2, false)) AND (\n auto_invite->>'role' = $2\n OR EXISTS (SELECT 1 FROM jsonb_each_text(\n CASE WHEN jsonb_typeof(auto_invite->'instance_groups_roles') = 'object'\n THEN auto_invite->'instance_groups_roles' ELSE '{}'::jsonb END) g\n WHERE g.value = $2))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "0d69f5f6d2e3a4b7b801c6feea92a383e841fa7c3f1c5e2849481c6057ab1e84" +} diff --git a/backend/.sqlx/query-0d73210f3ce58d0dd8fd850c1df8d16414daad90ad8b0ea35a769d78626039aa.json b/backend/.sqlx/query-0d73210f3ce58d0dd8fd850c1df8d16414daad90ad8b0ea35a769d78626039aa.json new file mode 100644 index 0000000000..07a85d9004 --- /dev/null +++ b/backend/.sqlx/query-0d73210f3ce58d0dd8fd850c1df8d16414daad90ad8b0ea35a769d78626039aa.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings SET role_overrides =\n (SELECT role_overrides FROM workspace_settings WHERE workspace_id = $2)\n WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "0d73210f3ce58d0dd8fd850c1df8d16414daad90ad8b0ea35a769d78626039aa" +} diff --git a/backend/.sqlx/query-0fb0fea54a1580d4b7ffd37ca43e169f51dbbdc3584d3522f25e97e180e307fd.json b/backend/.sqlx/query-0fb0fea54a1580d4b7ffd37ca43e169f51dbbdc3584d3522f25e97e180e307fd.json new file mode 100644 index 0000000000..2afefc7ab8 --- /dev/null +++ b/backend/.sqlx/query-0fb0fea54a1580d4b7ffd37ca43e169f51dbbdc3584d3522f25e97e180e307fd.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT config FROM config WHERE name LIKE 'worker\\_\\_%'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "config", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + true + ] + }, + "hash": "0fb0fea54a1580d4b7ffd37ca43e169f51dbbdc3584d3522f25e97e180e307fd" +} diff --git a/backend/.sqlx/query-1043947475822c27f73b5357e6ec0d527d999f2865ff5de75186b49004a3565e.json b/backend/.sqlx/query-1043947475822c27f73b5357e6ec0d527d999f2865ff5de75186b49004a3565e.json deleted file mode 100644 index a0847ae2ca..0000000000 --- a/backend/.sqlx/query-1043947475822c27f73b5357e6ec0d527d999f2865ff5de75186b49004a3565e.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)\n VALUES ('test-workspace', $1, 'f/test/simple_script', $2, 'bun', 'script', 'test-user', '{}', 'Simple test script', 'A simple test script', E'{}\\n//bun.lock\\n')\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Text" - ] - }, - "nullable": [] - }, - "hash": "1043947475822c27f73b5357e6ec0d527d999f2865ff5de75186b49004a3565e" -} diff --git a/backend/.sqlx/query-104b8d267f8f904122973d20629473ac77fad069e7c805cf3c2352bd72f8ffb6.json b/backend/.sqlx/query-104b8d267f8f904122973d20629473ac77fad069e7c805cf3c2352bd72f8ffb6.json new file mode 100644 index 0000000000..e9fb7a6b3b --- /dev/null +++ b/backend/.sqlx/query-104b8d267f8f904122973d20629473ac77fad069e7c805cf3c2352bd72f8ffb6.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT role_overrides FROM workspace_settings WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "role_overrides", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "104b8d267f8f904122973d20629473ac77fad069e7c805cf3c2352bd72f8ffb6" +} diff --git a/backend/.sqlx/query-127d1d6a9cec24f34ff91718cf62befb986da3c1081b08a3a650435c53a88df5.json b/backend/.sqlx/query-127d1d6a9cec24f34ff91718cf62befb986da3c1081b08a3a650435c53a88df5.json new file mode 100644 index 0000000000..1bb018341b --- /dev/null +++ b/backend/.sqlx/query-127d1d6a9cec24f34ff91718cf62befb986da3c1081b08a3a650435c53a88df5.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH a AS (DELETE FROM slack_answered_message WHERE created_at < now() - interval '1 day')\n DELETE FROM slack_thread_agent WHERE created_at < now() - interval '90 days'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "127d1d6a9cec24f34ff91718cf62befb986da3c1081b08a3a650435c53a88df5" +} diff --git a/backend/.sqlx/query-12c3101e02f197ad731b66f539fe677ad25520fcc9c5a2378a293122956bed4c.json b/backend/.sqlx/query-12c3101e02f197ad731b66f539fe677ad25520fcc9c5a2378a293122956bed4c.json deleted file mode 100644 index 1a5b16143b..0000000000 --- a/backend/.sqlx/query-12c3101e02f197ad731b66f539fe677ad25520fcc9c5a2378a293122956bed4c.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT error_handler->>'path' FROM workspace_settings WHERE workspace_id = 'test-workspace'", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "?column?", - "type_info": "Text" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - null - ] - }, - "hash": "12c3101e02f197ad731b66f539fe677ad25520fcc9c5a2378a293122956bed4c" -} diff --git a/backend/.sqlx/query-13d8e65ffd3ef6bcfb963525fc3ec0859463b1c153950f894a830a4b5ec33fcc.json b/backend/.sqlx/query-13d8e65ffd3ef6bcfb963525fc3ec0859463b1c153950f894a830a4b5ec33fcc.json deleted file mode 100644 index 9cdddc5121..0000000000 --- a/backend/.sqlx/query-13d8e65ffd3ef6bcfb963525fc3ec0859463b1c153950f894a830a4b5ec33fcc.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO job_resolution (job_id, workspace_id, resolved_by, note, automatic)\n SELECT c.id, c.workspace_id, $4, COALESCE($5, $7), false\n FROM v2_job_completed c\n JOIN v2_job j ON j.id = c.id\n WHERE c.id = ANY($1)\n AND c.workspace_id = $2\n AND ($3::TEXT[] IS NULL OR j.tag = ANY($3))\n AND c.status = 'failure'\n -- Resolution is a top-level triage state: a step resolved on its own\n -- would render orange inside a flow whose status is still red.\n AND j.flow_step_id IS NULL\n -- A supersession claim has to be proven, not trusted: a later success of the\n -- same identified runnable, itself visible to the caller. An unproven claim\n -- resolves nothing, so the caller learns it was rejected instead of having\n -- the fiction recorded as provenance.\n AND ($6::UUID IS NULL OR EXISTS (\n SELECT 1 FROM v2_job_completed sc\n JOIN v2_job sj ON sj.id = sc.id\n WHERE sc.id = $6\n AND sc.workspace_id = $2\n -- Tag scope is a read restriction enforced outside RLS, so it has\n -- to bind the evidence as well: otherwise the result reveals\n -- whether an out-of-scope run succeeded.\n AND ($3::TEXT[] IS NULL OR sj.tag = ANY($3))\n AND sc.status = 'success'\n AND sc.completed_at >= c.completed_at\n AND (j.runnable_id IS NOT NULL OR j.runnable_path IS NOT NULL)\n AND sj.runnable_id IS NOT DISTINCT FROM j.runnable_id\n AND sj.runnable_path IS NOT DISTINCT FROM j.runnable_path\n ))\n ON CONFLICT (job_id) DO UPDATE SET\n resolved_at = now(),\n -- Both COALESCEd: `resolution_attribution` returns NULLs outside EE and once the\n -- licence lapses, and bulk selections routinely include already-resolved rows,\n -- so overwriting would erase metadata recorded while it was valid. Clear either\n -- by unresolving first.\n resolved_by = COALESCE($4, job_resolution.resolved_by),\n -- A person's explanation replaces what was there; machine provenance only fills\n -- a blank, so re-running an already-explained failure never erases their words.\n note = COALESCE($5, job_resolution.note, $7),\n -- A human taking over an automatic resolution makes it no longer automatic.\n automatic = false\n RETURNING job_id", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "job_id", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "UuidArray", - "Text", - "TextArray", - "Varchar", - "Text", - "Uuid", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "13d8e65ffd3ef6bcfb963525fc3ec0859463b1c153950f894a830a4b5ec33fcc" -} diff --git a/backend/.sqlx/query-15f53f40a49b538ca0d4456f69b8d801e8c121f1e0cd904a77ce55bcf9bc5053.json b/backend/.sqlx/query-15f53f40a49b538ca0d4456f69b8d801e8c121f1e0cd904a77ce55bcf9bc5053.json new file mode 100644 index 0000000000..6510374dfc --- /dev/null +++ b/backend/.sqlx/query-15f53f40a49b538ca0d4456f69b8d801e8c121f1e0cd904a77ce55bcf9bc5053.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT resource_type, value AS \"value: sqlx::types::Json>\"\n FROM resource WHERE workspace_id = $1 AND path = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "resource_type", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "value: sqlx::types::Json>", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "15f53f40a49b538ca0d4456f69b8d801e8c121f1e0cd904a77ce55bcf9bc5053" +} diff --git a/backend/.sqlx/query-18d1a6ac94f2c87d5ea8c48a228f061135be4065dca33fbc429d8b0186c5ccb3.json b/backend/.sqlx/query-18d1a6ac94f2c87d5ea8c48a228f061135be4065dca33fbc429d8b0186c5ccb3.json deleted file mode 100644 index 8bd9bc4712..0000000000 --- a/backend/.sqlx/query-18d1a6ac94f2c87d5ea8c48a228f061135be4065dca33fbc429d8b0186c5ccb3.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE workspace_settings\n SET error_handler = '{\"path\": \"script/f/test/error_handler\", \"extra_args\": {\"notify\": true}, \"muted_on_cancel\": true, \"muted_on_user_path\": false}'::jsonb\n WHERE workspace_id = 'test-workspace'\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "18d1a6ac94f2c87d5ea8c48a228f061135be4065dca33fbc429d8b0186c5ccb3" -} diff --git a/backend/.sqlx/query-1966a1c4776492599de66d6d189d399c37957f832485156f703f59bd39cdc2bb.json b/backend/.sqlx/query-1966a1c4776492599de66d6d189d399c37957f832485156f703f59bd39cdc2bb.json new file mode 100644 index 0000000000..7f1ebcf9dc --- /dev/null +++ b/backend/.sqlx/query-1966a1c4776492599de66d6d189d399c37957f832485156f703f59bd39cdc2bb.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 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 AND a.client = $3\n AND a.is_workspace_integration = true)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "1966a1c4776492599de66d6d189d399c37957f832485156f703f59bd39cdc2bb" +} diff --git a/backend/.sqlx/query-19b9173fad68c2bde71a70d1ce947c20773dd4c44f19b47e73e24438f5c78ab3.json b/backend/.sqlx/query-19b9173fad68c2bde71a70d1ce947c20773dd4c44f19b47e73e24438f5c78ab3.json new file mode 100644 index 0000000000..e66aabbcfb --- /dev/null +++ b/backend/.sqlx/query-19b9173fad68c2bde71a70d1ce947c20773dd4c44f19b47e73e24438f5c78ab3.json @@ -0,0 +1,36 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT c.workspace_id, c.agent_path, c.is_default FROM slack_agent_channel c\n JOIN workspace_settings ws ON ws.workspace_id = c.workspace_id\n AND ws.slack_team_id = c.slack_team_id\n WHERE c.slack_team_id = $1 AND c.channel_id = $2\n AND ws.workspace_id = ANY($3)\n ORDER BY c.agent_path", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "agent_path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "is_default", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "TextArray" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "19b9173fad68c2bde71a70d1ce947c20773dd4c44f19b47e73e24438f5c78ab3" +} diff --git a/backend/.sqlx/query-1bae415f9440cc1334f24ce3009242cf3a6287e7b4548c7f01ad888230c27013.json b/backend/.sqlx/query-1bae415f9440cc1334f24ce3009242cf3a6287e7b4548c7f01ad888230c27013.json deleted file mode 100644 index 5c43791411..0000000000 --- a/backend/.sqlx/query-1bae415f9440cc1334f24ce3009242cf3a6287e7b4548c7f01ad888230c27013.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT format('rawscript/%s', code_sha256) as \"path!: String\"\n FROM app_script WHERE id = $1 LIMIT 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "path!: String", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - null - ] - }, - "hash": "1bae415f9440cc1334f24ce3009242cf3a6287e7b4548c7f01ad888230c27013" -} diff --git a/backend/.sqlx/query-1c97bf1af258b43fb8dedd3417d0e086996d5fcc3c270e5c1cc9824c30d82726.json b/backend/.sqlx/query-1c97bf1af258b43fb8dedd3417d0e086996d5fcc3c270e5c1cc9824c30d82726.json new file mode 100644 index 0000000000..bc0ce67103 --- /dev/null +++ b/backend/.sqlx/query-1c97bf1af258b43fb8dedd3417d0e086996d5fcc3c270e5c1cc9824c30d82726.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE usr SET workspace_role = $1, operator = $2 WHERE username = $3 AND workspace_id = $4", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Bool", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "1c97bf1af258b43fb8dedd3417d0e086996d5fcc3c270e5c1cc9824c30d82726" +} diff --git a/backend/.sqlx/query-1e0751bef92b5628b8d54448da171d56af87b5b263f8484c886e7f51e0198409.json b/backend/.sqlx/query-1e0751bef92b5628b8d54448da171d56af87b5b263f8484c886e7f51e0198409.json new file mode 100644 index 0000000000..963ca738f2 --- /dev/null +++ b/backend/.sqlx/query-1e0751bef92b5628b8d54448da171d56af87b5b263f8484c886e7f51e0198409.json @@ -0,0 +1,47 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT v.path, r.created_by AS \"owner?\", true AS \"workspace!\"\n FROM workspace_integrations wi\n JOIN variable v ON v.workspace_id = wi.workspace_id AND v.path = wi.resource_path\n JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account\n LEFT JOIN resource r ON r.workspace_id = v.workspace_id AND r.path = v.path\n WHERE wi.workspace_id = $1 AND wi.service_name = $2\n AND a.client = $3 AND a.is_workspace_integration = true", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "owner?", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "workspace!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "native_trigger_service", + "kind": { + "Enum": [ + "nextcloud", + "google", + "github" + ] + } + } + }, + "Text" + ] + }, + "nullable": [ + false, + true, + null + ] + }, + "hash": "1e0751bef92b5628b8d54448da171d56af87b5b263f8484c886e7f51e0198409" +} diff --git a/backend/.sqlx/query-1f21ef9dfa502b425b85d2acb6b3af1694ee60157089ce0c823d6c1351b8ed2c.json b/backend/.sqlx/query-1f21ef9dfa502b425b85d2acb6b3af1694ee60157089ce0c823d6c1351b8ed2c.json new file mode 100644 index 0000000000..329c44540b --- /dev/null +++ b/backend/.sqlx/query-1f21ef9dfa502b425b85d2acb6b3af1694ee60157089ce0c823d6c1351b8ed2c.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM v2_job WHERE workspace_id = $1 AND id = ANY($2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text", + "UuidArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "1f21ef9dfa502b425b85d2acb6b3af1694ee60157089ce0c823d6c1351b8ed2c" +} diff --git a/backend/.sqlx/query-1fbb8493d20eacea2ac78c4473a85f96bd91de548185238f9fd66ac2b4a1e0a0.json b/backend/.sqlx/query-1fbb8493d20eacea2ac78c4473a85f96bd91de548185238f9fd66ac2b4a1e0a0.json new file mode 100644 index 0000000000..084ae3562e --- /dev/null +++ b/backend/.sqlx/query-1fbb8493d20eacea2ac78c4473a85f96bd91de548185238f9fd66ac2b4a1e0a0.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n workspace_invite.workspace_id,\n workspace_invite.email,\n workspace_invite.is_admin,\n workspace_invite.operator,\n COALESCE(workspace_invite.workspace_role, CASE WHEN workspace_invite.operator THEN 'operator' ELSE 'developer' END) AS \"role!\",\n workspace.parent_workspace_id\n FROM workspace_invite JOIN workspace ON workspace_invite.workspace_id = workspace.id\n WHERE workspace_id = $1\n ORDER BY workspace_invite.email", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "is_admin", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "operator", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "role!", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "parent_workspace_id", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + null, + true + ] + }, + "hash": "1fbb8493d20eacea2ac78c4473a85f96bd91de548185238f9fd66ac2b4a1e0a0" +} diff --git a/backend/.sqlx/query-205f2fb7629382294e53869aa5d2f722f631882929086c453b5825ab4b9c1cb8.json b/backend/.sqlx/query-205f2fb7629382294e53869aa5d2f722f631882929086c453b5825ab4b9c1cb8.json new file mode 100644 index 0000000000..e7b50bb89a --- /dev/null +++ b/backend/.sqlx/query-205f2fb7629382294e53869aa5d2f722f631882929086c453b5825ab4b9c1cb8.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM usr t WHERE ($1::text IS NULL OR t.workspace_id = $1) AND\n COALESCE(t.workspace_role, CASE WHEN t.operator THEN 'operator' ELSE 'developer' END) = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "205f2fb7629382294e53869aa5d2f722f631882929086c453b5825ab4b9c1cb8" +} diff --git a/backend/.sqlx/query-22e14fc3bb5d8cf3006f0002e8522b8cc0b2fece43f03c0f025e7acefa0d4f32.json b/backend/.sqlx/query-22e14fc3bb5d8cf3006f0002e8522b8cc0b2fece43f03c0f025e7acefa0d4f32.json index 3df72f8955..6e8e17ddf2 100644 --- a/backend/.sqlx/query-22e14fc3bb5d8cf3006f0002e8522b8cc0b2fece43f03c0f025e7acefa0d4f32.json +++ b/backend/.sqlx/query-22e14fc3bb5d8cf3006f0002e8522b8cc0b2fece43f03c0f025e7acefa0d4f32.json @@ -17,7 +17,8 @@ "script", "flow", "raw_app", - "asset" + "asset", + "agent" ] } } diff --git a/backend/.sqlx/query-23b2a39e37e93ca9367e954eba94b1a6f432edb76320c50e0d07088937c17a01.json b/backend/.sqlx/query-23b2a39e37e93ca9367e954eba94b1a6f432edb76320c50e0d07088937c17a01.json new file mode 100644 index 0000000000..de6cf870b5 --- /dev/null +++ b/backend/.sqlx/query-23b2a39e37e93ca9367e954eba94b1a6f432edb76320c50e0d07088937c17a01.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT expires_at FROM account WHERE workspace_id = $1 AND id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Int4" + ] + }, + "nullable": [ + false + ] + }, + "hash": "23b2a39e37e93ca9367e954eba94b1a6f432edb76320c50e0d07088937c17a01" +} diff --git a/backend/.sqlx/query-250ac2b81a8d9a6900d8b54322fe21ef62eddd307717b24aa4fc03c0297e8f59.json b/backend/.sqlx/query-250ac2b81a8d9a6900d8b54322fe21ef62eddd307717b24aa4fc03c0297e8f59.json new file mode 100644 index 0000000000..52f512d012 --- /dev/null +++ b/backend/.sqlx/query-250ac2b81a8d9a6900d8b54322fe21ef62eddd307717b24aa4fc03c0297e8f59.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS (\n SELECT 1 FROM v2_job_queue q\n CROSS JOIN LATERAL (\n SELECT trigger_kind, trigger, runnable_path, parent_job\n FROM v2_job WHERE id = q.id OFFSET 0\n ) j\n WHERE q.workspace_id = $1 AND q.scheduled_for = $3\n AND j.trigger_kind = 'schedule' AND j.trigger = $2 AND j.runnable_path = $4\n AND j.parent_job IS NULL\n )", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Timestamptz", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "250ac2b81a8d9a6900d8b54322fe21ef62eddd307717b24aa4fc03c0297e8f59" +} diff --git a/backend/.sqlx/query-257b3bc19f738fe36e3c995c5ba239e1f2855cf39f9fe7a92d722732653fd98b.json b/backend/.sqlx/query-257b3bc19f738fe36e3c995c5ba239e1f2855cf39f9fe7a92d722732653fd98b.json new file mode 100644 index 0000000000..82a4b1377b --- /dev/null +++ b/backend/.sqlx/query-257b3bc19f738fe36e3c995c5ba239e1f2855cf39f9fe7a92d722732653fd98b.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT user_email FROM mcp_oauth_refresh_token WHERE refresh_token = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "257b3bc19f738fe36e3c995c5ba239e1f2855cf39f9fe7a92d722732653fd98b" +} diff --git a/backend/.sqlx/query-25b09b592b6917214fd7cfabfece48d266b2d5dc11236c2bb41b1a927ede63d8.json b/backend/.sqlx/query-25b09b592b6917214fd7cfabfece48d266b2d5dc11236c2bb41b1a927ede63d8.json new file mode 100644 index 0000000000..ed1e19e7e0 --- /dev/null +++ b/backend/.sqlx/query-25b09b592b6917214fd7cfabfece48d266b2d5dc11236c2bb41b1a927ede63d8.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, workspace_role) SELECT $1, username, email, is_admin, created_at, operator, disabled, role, workspace_role FROM usr WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "25b09b592b6917214fd7cfabfece48d266b2d5dc11236c2bb41b1a927ede63d8" +} diff --git a/backend/.sqlx/query-25bf3956b4365a4d4f96368bcc6e33817ae284ab26c195eee53d988d74263e86.json b/backend/.sqlx/query-25bf3956b4365a4d4f96368bcc6e33817ae284ab26c195eee53d988d74263e86.json deleted file mode 100644 index f7d03b7ebe..0000000000 --- a/backend/.sqlx/query-25bf3956b4365a4d4f96368bcc6e33817ae284ab26c195eee53d988d74263e86.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT (error_handler->>'muted_on_cancel')::boolean FROM workspace_settings WHERE workspace_id = 'test-workspace'", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "bool", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - null - ] - }, - "hash": "25bf3956b4365a4d4f96368bcc6e33817ae284ab26c195eee53d988d74263e86" -} diff --git a/backend/.sqlx/query-a588f4caa014008b50eccd09122b09f8a098e58791893bacaaf2ff67a30c031c.json b/backend/.sqlx/query-2602402bedcdbc45cdc0d64a76ce075c6ae51404037b0a7d4d33faf6a7d6a6d8.json similarity index 55% rename from backend/.sqlx/query-a588f4caa014008b50eccd09122b09f8a098e58791893bacaaf2ff67a30c031c.json rename to backend/.sqlx/query-2602402bedcdbc45cdc0d64a76ce075c6ae51404037b0a7d4d33faf6a7d6a6d8.json index 2412e1f5ee..8a131d8465 100644 --- a/backend/.sqlx/query-a588f4caa014008b50eccd09122b09f8a098e58791893bacaaf2ff67a30c031c.json +++ b/backend/.sqlx/query-2602402bedcdbc45cdc0d64a76ce075c6ae51404037b0a7d4d33faf6a7d6a6d8.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO workspace_integrations (\n workspace_id,\n service_name,\n oauth_data,\n resource_path,\n created_by,\n created_at,\n updated_at\n ) VALUES (\n $1, $2, $3, $4, $5, now(), now()\n )\n ON CONFLICT (workspace_id, service_name)\n DO UPDATE SET\n oauth_data = $3,\n resource_path = $4,\n updated_at = now()\n ", + "query": "\n INSERT INTO workspace_integrations (\n workspace_id,\n service_name,\n oauth_data,\n created_by,\n created_at,\n updated_at\n ) VALUES (\n $1, $2, $3, $4, now(), now()\n )\n ON CONFLICT (workspace_id, service_name)\n DO UPDATE SET\n oauth_data = $3,\n updated_at = now()\n ", "describe": { "columns": [], "parameters": { @@ -19,11 +19,10 @@ } }, "Jsonb", - "Text", "Varchar" ] }, "nullable": [] }, - "hash": "a588f4caa014008b50eccd09122b09f8a098e58791893bacaaf2ff67a30c031c" + "hash": "2602402bedcdbc45cdc0d64a76ce075c6ae51404037b0a7d4d33faf6a7d6a6d8" } diff --git a/backend/.sqlx/query-28b8264a427e8c19be823d2b8e40736a7b7675780672b503d91737cd0c617657.json b/backend/.sqlx/query-28b8264a427e8c19be823d2b8e40736a7b7675780672b503d91737cd0c617657.json deleted file mode 100644 index 4a34503aaa..0000000000 --- a/backend/.sqlx/query-28b8264a427e8c19be823d2b8e40736a7b7675780672b503d91737cd0c617657.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT (error_handler->>'muted_on_user_path')::boolean FROM workspace_settings WHERE workspace_id = 'test-workspace'", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "bool", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - null - ] - }, - "hash": "28b8264a427e8c19be823d2b8e40736a7b7675780672b503d91737cd0c617657" -} diff --git a/backend/.sqlx/query-28f3dfb70aed943ad3d64da608168c027c645afb73dfc2360245b68ba593cab4.json b/backend/.sqlx/query-28f3dfb70aed943ad3d64da608168c027c645afb73dfc2360245b68ba593cab4.json deleted file mode 100644 index 3eb58e590f..0000000000 --- a/backend/.sqlx/query-28f3dfb70aed943ad3d64da608168c027c645afb73dfc2360245b68ba593cab4.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE workspace_settings\n SET success_handler = '{\"path\": \"script/f/test/success_handler\", \"extra_args\": {\"key\": \"value\"}}'::jsonb\n WHERE workspace_id = 'test-workspace'\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "28f3dfb70aed943ad3d64da608168c027c645afb73dfc2360245b68ba593cab4" -} diff --git a/backend/.sqlx/query-2b39b2273d05ebe6e99be5b9051c153a170f5387831cd27e8cdbe5d052f839d0.json b/backend/.sqlx/query-2b39b2273d05ebe6e99be5b9051c153a170f5387831cd27e8cdbe5d052f839d0.json new file mode 100644 index 0000000000..7a12caaf6a --- /dev/null +++ b/backend/.sqlx/query-2b39b2273d05ebe6e99be5b9051c153a170f5387831cd27e8cdbe5d052f839d0.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM slack_agent_channel\n WHERE workspace_id = $1 AND agent_path = $2 AND channel_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "2b39b2273d05ebe6e99be5b9051c153a170f5387831cd27e8cdbe5d052f839d0" +} diff --git a/backend/.sqlx/query-2c0ab7571e1a7c4290315bc3efccb4db9e0c9aee05596a594f81975a0cdb74d1.json b/backend/.sqlx/query-2c0ab7571e1a7c4290315bc3efccb4db9e0c9aee05596a594f81975a0cdb74d1.json new file mode 100644 index 0000000000..9c4b3eb842 --- /dev/null +++ b/backend/.sqlx/query-2c0ab7571e1a7c4290315bc3efccb4db9e0c9aee05596a594f81975a0cdb74d1.json @@ -0,0 +1,23 @@ +{ + "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-2c2ed5c91a4893966e94305df8b9f8ac904c8d0781a83fdea66c918320fb5601.json b/backend/.sqlx/query-2c2ed5c91a4893966e94305df8b9f8ac904c8d0781a83fdea66c918320fb5601.json deleted file mode 100644 index 4b692f11a2..0000000000 --- a/backend/.sqlx/query-2c2ed5c91a4893966e94305df8b9f8ac904c8d0781a83fdea66c918320fb5601.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE workspace_settings\n SET success_handler = NULL\n WHERE workspace_id = 'test-workspace'\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "2c2ed5c91a4893966e94305df8b9f8ac904c8d0781a83fdea66c918320fb5601" -} diff --git a/backend/.sqlx/query-2d22fe27288580cb727e43b2abe3be2e6e6e3b03e61ce8f2879627115224c8cf.json b/backend/.sqlx/query-2d22fe27288580cb727e43b2abe3be2e6e6e3b03e61ce8f2879627115224c8cf.json new file mode 100644 index 0000000000..95fd9a3a2f --- /dev/null +++ b/backend/.sqlx/query-2d22fe27288580cb727e43b2abe3be2e6e6e3b03e61ce8f2879627115224c8cf.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT config FROM config WHERE name = $1 FOR UPDATE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "config", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "2d22fe27288580cb727e43b2abe3be2e6e6e3b03e61ce8f2879627115224c8cf" +} diff --git a/backend/.sqlx/query-2e8a53e73c3b5c2662efd0e2876f168c2f79258fb900199e8575973d5b3d32bb.json b/backend/.sqlx/query-2e8a53e73c3b5c2662efd0e2876f168c2f79258fb900199e8575973d5b3d32bb.json new file mode 100644 index 0000000000..d55c6ebb51 --- /dev/null +++ b/backend/.sqlx/query-2e8a53e73c3b5c2662efd0e2876f168c2f79258fb900199e8575973d5b3d32bb.json @@ -0,0 +1,94 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id,\n workspace.is_dev_workspace, workspace.dev_workspace_label,\n workspace.owner AS \"created_by?\",\n usr.is_admin, usr.operator, usr.workspace_role,\n usr.disabled, usr.is_service_account\n FROM workspace\n JOIN usr ON usr.workspace_id = workspace.id\n JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "color", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "parent_workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "is_dev_workspace", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "dev_workspace_label", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "created_by?", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "is_admin", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "operator", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "workspace_role", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "disabled", + "type_info": "Bool" + }, + { + "ordinal": 12, + "name": "is_service_account", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + false, + true, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "2e8a53e73c3b5c2662efd0e2876f168c2f79258fb900199e8575973d5b3d32bb" +} diff --git a/backend/.sqlx/query-0010ef26da16facd1c2c832601ac687c4c27de46a90f45496b8446af1a9d0578.json b/backend/.sqlx/query-30b1021260b5ec83a04be1f3dbea08cc81ee85cf98696d9e29a34557587eaf86.json similarity index 67% rename from backend/.sqlx/query-0010ef26da16facd1c2c832601ac687c4c27de46a90f45496b8446af1a9d0578.json rename to backend/.sqlx/query-30b1021260b5ec83a04be1f3dbea08cc81ee85cf98696d9e29a34557587eaf86.json index cf0754d720..4c0cbba06c 100644 --- a/backend/.sqlx/query-0010ef26da16facd1c2c832601ac687c4c27de46a90f45496b8446af1a9d0578.json +++ b/backend/.sqlx/query-30b1021260b5ec83a04be1f3dbea08cc81ee85cf98696d9e29a34557587eaf86.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n oauth_data as \"oauth_data: sqlx::types::Json\",\n service_name as \"service_name!: ServiceName\",\n resource_path\n FROM\n workspace_integrations\n WHERE\n workspace_id = $1\n ", + "query": "\n SELECT\n oauth_data as \"oauth_data: sqlx::types::Json\",\n service_name as \"service_name!: ServiceName\"\n FROM\n workspace_integrations\n WHERE\n workspace_id = $1\n ", "describe": { "columns": [ { @@ -23,11 +23,6 @@ } } } - }, - { - "ordinal": 2, - "name": "resource_path", - "type_info": "Text" } ], "parameters": { @@ -37,9 +32,8 @@ }, "nullable": [ true, - false, - true + false ] }, - "hash": "0010ef26da16facd1c2c832601ac687c4c27de46a90f45496b8446af1a9d0578" + "hash": "30b1021260b5ec83a04be1f3dbea08cc81ee85cf98696d9e29a34557587eaf86" } diff --git a/backend/.sqlx/query-3164d5bc31dc128e59f9108c63ab8c11f7fe26ef16c5e6bfbd13bd081fc00ba3.json b/backend/.sqlx/query-3164d5bc31dc128e59f9108c63ab8c11f7fe26ef16c5e6bfbd13bd081fc00ba3.json new file mode 100644 index 0000000000..abb7a110c6 --- /dev/null +++ b/backend/.sqlx/query-3164d5bc31dc128e59f9108c63ab8c11f7fe26ef16c5e6bfbd13bd081fc00ba3.json @@ -0,0 +1,30 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace_id, agent_path FROM slack_thread_agent\n WHERE slack_team_id = $1 AND channel_id = $2 AND thread_ts = $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "agent_path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "3164d5bc31dc128e59f9108c63ab8c11f7fe26ef16c5e6bfbd13bd081fc00ba3" +} diff --git a/backend/.sqlx/query-344c08104d62d9bcd3f9117abb574e73a6fb364e4ab3e3916958c2e493673b1d.json b/backend/.sqlx/query-344c08104d62d9bcd3f9117abb574e73a6fb364e4ab3e3916958c2e493673b1d.json deleted file mode 100644 index 37f090c2b4..0000000000 --- a/backend/.sqlx/query-344c08104d62d9bcd3f9117abb574e73a6fb364e4ab3e3916958c2e493673b1d.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)\n VALUES ('test-workspace', 3333333333, 'f/test/error_handler', 'export function main() { return \"handled\"; }', 'bun', 'script', 'test-user', '{}', '', '', E'{}\\n//bun.lock\\n')\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "344c08104d62d9bcd3f9117abb574e73a6fb364e4ab3e3916958c2e493673b1d" -} diff --git a/backend/.sqlx/query-35238a186adbe0583010dbe8e298a907f16d9e88f648705f2372dfe7c0523879.json b/backend/.sqlx/query-35238a186adbe0583010dbe8e298a907f16d9e88f648705f2372dfe7c0523879.json new file mode 100644 index 0000000000..027085c2a7 --- /dev/null +++ b/backend/.sqlx/query-35238a186adbe0583010dbe8e298a907f16d9e88f648705f2372dfe7c0523879.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace_id, slack_oauth_client_id IS NOT NULL AS \"own_app!\",\n slack_signing_secret\n FROM workspace_settings WHERE slack_team_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "own_app!", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "slack_signing_secret", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + null, + true + ] + }, + "hash": "35238a186adbe0583010dbe8e298a907f16d9e88f648705f2372dfe7c0523879" +} diff --git a/backend/.sqlx/query-369f8ecde50af034f06d339ecef8fc55a0113b4156274d03d5af643c3da73fa4.json b/backend/.sqlx/query-369f8ecde50af034f06d339ecef8fc55a0113b4156274d03d5af643c3da73fa4.json index 501e149fc4..20689e1f0a 100644 --- a/backend/.sqlx/query-369f8ecde50af034f06d339ecef8fc55a0113b4156274d03d5af643c3da73fa4.json +++ b/backend/.sqlx/query-369f8ecde50af034f06d339ecef8fc55a0113b4156274d03d5af643c3da73fa4.json @@ -53,7 +53,7 @@ false, false, false, - false, + true, false, false, true, diff --git a/backend/.sqlx/query-37275b26b092ed3ae1a11b78da5227a40a49d9435107e71033740f11cc8d2417.json b/backend/.sqlx/query-37275b26b092ed3ae1a11b78da5227a40a49d9435107e71033740f11cc8d2417.json new file mode 100644 index 0000000000..7560df9dd2 --- /dev/null +++ b/backend/.sqlx/query-37275b26b092ed3ae1a11b78da5227a40a49d9435107e71033740f11cc8d2417.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, 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-63c48fde8c0c0fff9abffc3be27e9948556b636b70b818cc31c2d50921a27366.json b/backend/.sqlx/query-3829559733fa087201f935ee10a5cecf2cec4f1646015ed7cf476efaba67707f.json similarity index 53% rename from backend/.sqlx/query-63c48fde8c0c0fff9abffc3be27e9948556b636b70b818cc31c2d50921a27366.json rename to backend/.sqlx/query-3829559733fa087201f935ee10a5cecf2cec4f1646015ed7cf476efaba67707f.json index 0ba2d18257..af7a60664d 100644 --- a/backend/.sqlx/query-63c48fde8c0c0fff9abffc3be27e9948556b636b70b818cc31c2d50921a27366.json +++ b/backend/.sqlx/query-3829559733fa087201f935ee10a5cecf2cec4f1646015ed7cf476efaba67707f.json @@ -1,42 +1,62 @@ { "db_name": "PostgreSQL", - "query": "SELECT client, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, scopes FROM account WHERE workspace_id = $1 AND id = $2", + "query": "SELECT mcp_server_url, is_workspace_integration, client, refresh_token, grant_type,\n cc_client_id, cc_client_secret, cc_token_url, scopes, expires_at,\n now() > expires_at AS \"expired!\"\n FROM account WHERE workspace_id = $1 AND id = $2", "describe": { "columns": [ { "ordinal": 0, + "name": "mcp_server_url", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "is_workspace_integration", + "type_info": "Bool" + }, + { + "ordinal": 2, "name": "client", "type_info": "Varchar" }, { - "ordinal": 1, + "ordinal": 3, "name": "refresh_token", "type_info": "Varchar" }, { - "ordinal": 2, + "ordinal": 4, "name": "grant_type", "type_info": "Varchar" }, { - "ordinal": 3, + "ordinal": 5, "name": "cc_client_id", "type_info": "Varchar" }, { - "ordinal": 4, + "ordinal": 6, "name": "cc_client_secret", "type_info": "Varchar" }, { - "ordinal": 5, + "ordinal": 7, "name": "cc_token_url", "type_info": "Varchar" }, { - "ordinal": 6, + "ordinal": 8, "name": "scopes", "type_info": "TextArray" + }, + { + "ordinal": 9, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "expired!", + "type_info": "Bool" } ], "parameters": { @@ -46,14 +66,18 @@ ] }, "nullable": [ + true, + false, false, false, false, true, true, true, - true + true, + false, + null ] }, - "hash": "63c48fde8c0c0fff9abffc3be27e9948556b636b70b818cc31c2d50921a27366" + "hash": "3829559733fa087201f935ee10a5cecf2cec4f1646015ed7cf476efaba67707f" } diff --git a/backend/.sqlx/query-392b256dd7f36ec3736fc2f9d564dc198a6b69d62fb244691af0601e542b6ac3.json b/backend/.sqlx/query-392b256dd7f36ec3736fc2f9d564dc198a6b69d62fb244691af0601e542b6ac3.json new file mode 100644 index 0000000000..1b5aa1bd1f --- /dev/null +++ b/backend/.sqlx/query-392b256dd7f36ec3736fc2f9d564dc198a6b69d62fb244691af0601e542b6ac3.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM resource WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent')", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "392b256dd7f36ec3736fc2f9d564dc198a6b69d62fb244691af0601e542b6ac3" +} diff --git a/backend/.sqlx/query-3a47d8ec0f4ba1644951f0c88504c9f769ffe5004871a049e4232e0092532062.json b/backend/.sqlx/query-3a47d8ec0f4ba1644951f0c88504c9f769ffe5004871a049e4232e0092532062.json deleted file mode 100644 index a07fcd57db..0000000000 --- a/backend/.sqlx/query-3a47d8ec0f4ba1644951f0c88504c9f769ffe5004871a049e4232e0092532062.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT EXISTS (\n SELECT 1 FROM v2_job j JOIN v2_job_queue USING (id)\n WHERE j.workspace_id = $1 AND trigger_kind = 'schedule' AND trigger = $2 AND runnable_path = $4\n AND parent_job IS NULL\n AND scheduled_for = $3\n )", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "exists", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Timestamptz", - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "3a47d8ec0f4ba1644951f0c88504c9f769ffe5004871a049e4232e0092532062" -} diff --git a/backend/.sqlx/query-3a71ab02239d8dd4e2f440a35155459a7d5091c125a0998e79d89f8c560a3fd3.json b/backend/.sqlx/query-3a71ab02239d8dd4e2f440a35155459a7d5091c125a0998e79d89f8c560a3fd3.json deleted file mode 100644 index 1c370d3ccf..0000000000 --- a/backend/.sqlx/query-3a71ab02239d8dd4e2f440a35155459a7d5091c125a0998e79d89f8c560a3fd3.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO group_ (workspace_id, name, summary, extra_perms)\n VALUES ('test-workspace', 'success_handler', 'The group the success handler acts on behalf of', '{\"u/test-user\": true}')\n ON CONFLICT DO NOTHING\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "3a71ab02239d8dd4e2f440a35155459a7d5091c125a0998e79d89f8c560a3fd3" -} diff --git a/backend/.sqlx/query-3b01c43a828ec5d61ed92a27bdb16b526db8e60887d6279b0e66197da686fbf8.json b/backend/.sqlx/query-3b01c43a828ec5d61ed92a27bdb16b526db8e60887d6279b0e66197da686fbf8.json new file mode 100644 index 0000000000..02871b22f1 --- /dev/null +++ b/backend/.sqlx/query-3b01c43a828ec5d61ed92a27bdb16b526db8e60887d6279b0e66197da686fbf8.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO usr (workspace_id, username, email, is_admin, operator, added_via, workspace_role) VALUES ($1, $2, $3, false, $4, $5, $6) ON CONFLICT DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Bool", + "Jsonb", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "3b01c43a828ec5d61ed92a27bdb16b526db8e60887d6279b0e66197da686fbf8" +} diff --git a/backend/.sqlx/query-3d38720e807b379645d8f3ab61c6a968143d42c3014152608f7d1b252cd8085c.json b/backend/.sqlx/query-3d38720e807b379645d8f3ab61c6a968143d42c3014152608f7d1b252cd8085c.json deleted file mode 100644 index a97d16b89c..0000000000 --- a/backend/.sqlx/query-3d38720e807b379645d8f3ab61c6a968143d42c3014152608f7d1b252cd8085c.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO app_version\n (app_id, value, created_by, raw_app)\n SELECT app_id, value, created_by, raw_app\n FROM app_version WHERE id = $1\n RETURNING id", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - false - ] - }, - "hash": "3d38720e807b379645d8f3ab61c6a968143d42c3014152608f7d1b252cd8085c" -} diff --git a/backend/.sqlx/query-7443ba1f922e190bb9a0ca313847f8d27c35a6ee3aff20157d6285e73aa923ef.json b/backend/.sqlx/query-3e5bdc2e071fc2f1e3c7971736272f20bb5a0aa921a614bd02898d3f162660c2.json similarity index 76% rename from backend/.sqlx/query-7443ba1f922e190bb9a0ca313847f8d27c35a6ee3aff20157d6285e73aa923ef.json rename to backend/.sqlx/query-3e5bdc2e071fc2f1e3c7971736272f20bb5a0aa921a614bd02898d3f162660c2.json index 4ac58627e7..984c702467 100644 --- a/backend/.sqlx/query-7443ba1f922e190bb9a0ca313847f8d27c35a6ee3aff20157d6285e73aa923ef.json +++ b/backend/.sqlx/query-3e5bdc2e071fc2f1e3c7971736272f20bb5a0aa921a614bd02898d3f162660c2.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n oauth_data,\n resource_path,\n created_at,\n updated_at,\n created_by\n FROM\n workspace_integrations\n WHERE\n workspace_id = $1\n AND service_name = $2\n ", + "query": "\n SELECT\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n oauth_data,\n created_at,\n updated_at,\n created_by\n FROM\n workspace_integrations\n WHERE\n workspace_id = $1\n AND service_name = $2\n ", "describe": { "columns": [ { @@ -31,21 +31,16 @@ }, { "ordinal": 3, - "name": "resource_path", - "type_info": "Text" - }, - { - "ordinal": 4, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 5, + "ordinal": 4, "name": "updated_at", "type_info": "Timestamptz" }, { - "ordinal": 6, + "ordinal": 5, "name": "created_by", "type_info": "Varchar" } @@ -71,11 +66,10 @@ false, false, true, - true, false, false, false ] }, - "hash": "7443ba1f922e190bb9a0ca313847f8d27c35a6ee3aff20157d6285e73aa923ef" + "hash": "3e5bdc2e071fc2f1e3c7971736272f20bb5a0aa921a614bd02898d3f162660c2" } diff --git a/backend/.sqlx/query-3e9f4f055c26a36ff60bf40f0c93a6ad1935e18cd09e08c3bd29d2ad7e2ae5dd.json b/backend/.sqlx/query-3e9f4f055c26a36ff60bf40f0c93a6ad1935e18cd09e08c3bd29d2ad7e2ae5dd.json new file mode 100644 index 0000000000..60c326a809 --- /dev/null +++ b/backend/.sqlx/query-3e9f4f055c26a36ff60bf40f0c93a6ad1935e18cd09e08c3bd29d2ad7e2ae5dd.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT c.channel_id, c.channel_name, c.agent_path, c.is_default\n FROM slack_agent_channel c\n JOIN resource r ON r.workspace_id = c.workspace_id AND r.path = c.agent_path\n WHERE c.workspace_id = $1 ORDER BY c.channel_name, c.agent_path", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "channel_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "channel_name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "agent_path", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "is_default", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "3e9f4f055c26a36ff60bf40f0c93a6ad1935e18cd09e08c3bd29d2ad7e2ae5dd" +} diff --git a/backend/.sqlx/query-420707b82c44c7a30719c4419bdd3b4382f863d7c84f18af64e1d3930890a210.json b/backend/.sqlx/query-420707b82c44c7a30719c4419bdd3b4382f863d7c84f18af64e1d3930890a210.json new file mode 100644 index 0000000000..2b0a574473 --- /dev/null +++ b/backend/.sqlx/query-420707b82c44c7a30719c4419bdd3b4382f863d7c84f18af64e1d3930890a210.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS (SELECT 1 FROM workspace_diff\n WHERE fork_workspace_id = 'wm-fork-renamed' AND kind = 'script'\n AND path = 'u/admin/inline' AND fork_last_event_kind = 'write')", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "420707b82c44c7a30719c4419bdd3b4382f863d7c84f18af64e1d3930890a210" +} diff --git a/backend/.sqlx/query-42250637493a623850ca76fe5a705fe4acef039f38a5e76da8d09f2f7e755779.json b/backend/.sqlx/query-42250637493a623850ca76fe5a705fe4acef039f38a5e76da8d09f2f7e755779.json deleted file mode 100644 index 402a406820..0000000000 --- a/backend/.sqlx/query-42250637493a623850ca76fe5a705fe4acef039f38a5e76da8d09f2f7e755779.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)\n VALUES ('test-workspace', 1234567890, 'f/test/success_handler', $1, 'bun', 'script', 'test-user', '{}', 'Success handler script', 'Handles successful job completions', E'{}\\n//bun.lock\\n')\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [] - }, - "hash": "42250637493a623850ca76fe5a705fe4acef039f38a5e76da8d09f2f7e755779" -} diff --git a/backend/.sqlx/query-429004f6f71a06a93e8cdd688510ef502c1fc11ee23dec437e0f85f7b1e60a92.json b/backend/.sqlx/query-429004f6f71a06a93e8cdd688510ef502c1fc11ee23dec437e0f85f7b1e60a92.json new file mode 100644 index 0000000000..45ff97e02b --- /dev/null +++ b/backend/.sqlx/query-429004f6f71a06a93e8cdd688510ef502c1fc11ee23dec437e0f85f7b1e60a92.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM slack_thread_agent WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "429004f6f71a06a93e8cdd688510ef502c1fc11ee23dec437e0f85f7b1e60a92" +} diff --git a/backend/.sqlx/query-4448d2cc6bc73635bb8c252ab9f4efaa33600c3ecd266e8339b9bfb32ae18499.json b/backend/.sqlx/query-4448d2cc6bc73635bb8c252ab9f4efaa33600c3ecd266e8339b9bfb32ae18499.json new file mode 100644 index 0000000000..cd2e6651b8 --- /dev/null +++ b/backend/.sqlx/query-4448d2cc6bc73635bb8c252ab9f4efaa33600c3ecd266e8339b9bfb32ae18499.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app_script (app, hash, lock, code, code_sha256, modules)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT (hash) DO UPDATE SET app = EXCLUDED.app -- trivial update to return the id\n RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Bpchar", + "Text", + "Text", + "Bpchar", + "Jsonb" + ] + }, + "nullable": [ + false + ] + }, + "hash": "4448d2cc6bc73635bb8c252ab9f4efaa33600c3ecd266e8339b9bfb32ae18499" +} diff --git a/backend/.sqlx/query-45b73345f2009122142f3410736b2d57a07e2047a704d9a7d8eab3416ff1d9d5.json b/backend/.sqlx/query-45b73345f2009122142f3410736b2d57a07e2047a704d9a7d8eab3416ff1d9d5.json new file mode 100644 index 0000000000..2c22ae1d7e --- /dev/null +++ b/backend/.sqlx/query-45b73345f2009122142f3410736b2d57a07e2047a704d9a7d8eab3416ff1d9d5.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS (SELECT 1 FROM fork_storage_namespace WHERE workspace_id = ANY($1))\n AND EXISTS (SELECT 1 FROM workspace WHERE parent_workspace_id = $2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "TextArray", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "45b73345f2009122142f3410736b2d57a07e2047a704d9a7d8eab3416ff1d9d5" +} diff --git a/backend/.sqlx/query-7aa589db3199d7f727cc69e63e1281b7ed329ff0c9d1617747f4ccd6014720cf.json b/backend/.sqlx/query-4640ac69b297127cd0f5c369fa4a49857237cd57134dbc467b53601c1ec942d2.json similarity index 59% rename from backend/.sqlx/query-7aa589db3199d7f727cc69e63e1281b7ed329ff0c9d1617747f4ccd6014720cf.json rename to backend/.sqlx/query-4640ac69b297127cd0f5c369fa4a49857237cd57134dbc467b53601c1ec942d2.json index b2101cc0d4..5ad778d3c0 100644 --- a/backend/.sqlx/query-7aa589db3199d7f727cc69e63e1281b7ed329ff0c9d1617747f4ccd6014720cf.json +++ b/backend/.sqlx/query-4640ac69b297127cd0f5c369fa4a49857237cd57134dbc467b53601c1ec942d2.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT EXISTS(SELECT 1 FROM variable WHERE account = $1 AND workspace_id = $2)", + "query": "SELECT EXISTS (SELECT 1 FROM fork_storage_namespace WHERE workspace_id = $1)", "describe": { "columns": [ { @@ -11,7 +11,6 @@ ], "parameters": { "Left": [ - "Int4", "Text" ] }, @@ -19,5 +18,5 @@ null ] }, - "hash": "7aa589db3199d7f727cc69e63e1281b7ed329ff0c9d1617747f4ccd6014720cf" + "hash": "4640ac69b297127cd0f5c369fa4a49857237cd57134dbc467b53601c1ec942d2" } diff --git a/backend/.sqlx/query-464286216add454cb64dba810d964c4b6a41d96df223b6d3d1d1bc972ab10d42.json b/backend/.sqlx/query-464286216add454cb64dba810d964c4b6a41d96df223b6d3d1d1bc972ab10d42.json new file mode 100644 index 0000000000..43bb3da025 --- /dev/null +++ b/backend/.sqlx/query-464286216add454cb64dba810d964c4b6a41d96df223b6d3d1d1bc972ab10d42.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via, workspace_role)\n SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account,\n CASE WHEN $3 THEN NULL ELSE added_via END, workspace_role\n FROM usr WHERE workspace_id = $2\n AND (NOT $3 OR (NOT operator AND NOT disabled AND NOT is_service_account))\n ON CONFLICT DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "464286216add454cb64dba810d964c4b6a41d96df223b6d3d1d1bc972ab10d42" +} diff --git a/backend/.sqlx/query-6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5.json b/backend/.sqlx/query-4c12e63ddcf4e8cfe2227421db8869c4fb77e2fa3b98d6b054995875f3fd5a10.json similarity index 68% rename from backend/.sqlx/query-6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5.json rename to backend/.sqlx/query-4c12e63ddcf4e8cfe2227421db8869c4fb77e2fa3b98d6b054995875f3fd5a10.json index 45605c04dd..b07b2ae6b5 100644 --- a/backend/.sqlx/query-6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5.json +++ b/backend/.sqlx/query-4c12e63ddcf4e8cfe2227421db8869c4fb77e2fa3b98d6b054995875f3fd5a10.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT external_id, webhook_token_hash FROM native_trigger WHERE workspace_id = $1 AND service_name = $2", + "query": "SELECT external_id, script_path FROM native_trigger\n WHERE workspace_id = $1 AND service_name = $2 AND connection_path = $3", "describe": { "columns": [ { @@ -10,7 +10,7 @@ }, { "ordinal": 1, - "name": "webhook_token_hash", + "name": "script_path", "type_info": "Varchar" } ], @@ -28,7 +28,8 @@ ] } } - } + }, + "Text" ] }, "nullable": [ @@ -36,5 +37,5 @@ false ] }, - "hash": "6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5" + "hash": "4c12e63ddcf4e8cfe2227421db8869c4fb77e2fa3b98d6b054995875f3fd5a10" } diff --git a/backend/.sqlx/query-4c74a75d99f8d6689470da1d57d3db8bf47f11969a0c026a35a6673574e074d8.json b/backend/.sqlx/query-4c74a75d99f8d6689470da1d57d3db8bf47f11969a0c026a35a6673574e074d8.json new file mode 100644 index 0000000000..fd576892db --- /dev/null +++ b/backend/.sqlx/query-4c74a75d99f8d6689470da1d57d3db8bf47f11969a0c026a35a6673574e074d8.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO usr\n (workspace_id, email, username, is_admin, operator, workspace_role)\n VALUES ($1, $2, $3, $4, $5, $6)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Bool", + "Bool", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "4c74a75d99f8d6689470da1d57d3db8bf47f11969a0c026a35a6673574e074d8" +} diff --git a/backend/.sqlx/query-4d48a34016cc642fe3b3b24220615a59165009048a7c93942ee2ecd78524fb5a.json b/backend/.sqlx/query-4d48a34016cc642fe3b3b24220615a59165009048a7c93942ee2ecd78524fb5a.json new file mode 100644 index 0000000000..430c45c995 --- /dev/null +++ b/backend/.sqlx/query-4d48a34016cc642fe3b3b24220615a59165009048a7c93942ee2ecd78524fb5a.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM resource WHERE workspace_id = $1 AND path = ANY($2)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "4d48a34016cc642fe3b3b24220615a59165009048a7c93942ee2ecd78524fb5a" +} diff --git a/backend/.sqlx/query-4e14b95c57c6f987f020376f76ad4dd0ff0d043b3eff62863c8f8db89ad34a4e.json b/backend/.sqlx/query-4e14b95c57c6f987f020376f76ad4dd0ff0d043b3eff62863c8f8db89ad34a4e.json new file mode 100644 index 0000000000..1e83c28a49 --- /dev/null +++ b/backend/.sqlx/query-4e14b95c57c6f987f020376f76ad4dd0ff0d043b3eff62863c8f8db89ad34a4e.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT result AS \"result: sqlx::types::Json\",\n status = 'success' AS \"success!\"\n FROM v2_job_completed WHERE id = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "result: sqlx::types::Json", + "type_info": "Jsonb" + }, + { + "ordinal": 1, + "name": "success!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + true, + null + ] + }, + "hash": "4e14b95c57c6f987f020376f76ad4dd0ff0d043b3eff62863c8f8db89ad34a4e" +} diff --git a/backend/.sqlx/query-51a30b84aedb4852ea78a4bf6a9f7c5090f6ed3b4d9d03fa5b5a827ffa39c635.json b/backend/.sqlx/query-51a30b84aedb4852ea78a4bf6a9f7c5090f6ed3b4d9d03fa5b5a827ffa39c635.json new file mode 100644 index 0000000000..e6194747b2 --- /dev/null +++ b/backend/.sqlx/query-51a30b84aedb4852ea78a4bf6a9f7c5090f6ed3b4d9d03fa5b5a827ffa39c635.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM account WHERE workspace_id = $1 AND id = $2 AND is_workspace_integration = true\n AND NOT EXISTS (SELECT 1 FROM variable WHERE workspace_id = $1 AND account = $2)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "51a30b84aedb4852ea78a4bf6a9f7c5090f6ed3b4d9d03fa5b5a827ffa39c635" +} diff --git a/backend/.sqlx/query-5294237a0bb6b23aed6915f430ac595c6f0d6f215725132fb7ef33bb1847831d.json b/backend/.sqlx/query-5294237a0bb6b23aed6915f430ac595c6f0d6f215725132fb7ef33bb1847831d.json new file mode 100644 index 0000000000..3c3870611e --- /dev/null +++ b/backend/.sqlx/query-5294237a0bb6b23aed6915f430ac595c6f0d6f215725132fb7ef33bb1847831d.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_integrations (workspace_id, service_name, oauth_data, created_by)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (workspace_id, service_name) DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + { + "Custom": { + "name": "native_trigger_service", + "kind": { + "Enum": [ + "nextcloud", + "google", + "github" + ] + } + } + }, + "Jsonb", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "5294237a0bb6b23aed6915f430ac595c6f0d6f215725132fb7ef33bb1847831d" +} diff --git a/backend/.sqlx/query-59e359ec5307da46b7fb75d0a2ada7abd1836ec53fdc8086aa160735843f62b9.json b/backend/.sqlx/query-59e359ec5307da46b7fb75d0a2ada7abd1836ec53fdc8086aa160735843f62b9.json new file mode 100644 index 0000000000..802c83dd7f --- /dev/null +++ b/backend/.sqlx/query-59e359ec5307da46b7fb75d0a2ada7abd1836ec53fdc8086aa160735843f62b9.json @@ -0,0 +1,36 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM native_trigger\n WHERE workspace_id = $1 AND service_name = $2 AND ($3::text[] IS NULL\n OR (external_id, script_path) IN (SELECT * FROM unnest($3::text[], $4::text[])))\n RETURNING webhook_token_hash", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "webhook_token_hash", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "native_trigger_service", + "kind": { + "Enum": [ + "nextcloud", + "google", + "github" + ] + } + } + }, + "TextArray", + "TextArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "59e359ec5307da46b7fb75d0a2ada7abd1836ec53fdc8086aa160735843f62b9" +} diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 713ccb9dd3..36ddb8ab9f 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - null + true ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-5af4e97b30d968e2b55f9560c6bb1c3f543964d2c05d2f44f0a7716f890a3fce.json b/backend/.sqlx/query-5af4e97b30d968e2b55f9560c6bb1c3f543964d2c05d2f44f0a7716f890a3fce.json new file mode 100644 index 0000000000..113d1d6ce0 --- /dev/null +++ b/backend/.sqlx/query-5af4e97b30d968e2b55f9560c6bb1c3f543964d2c05d2f44f0a7716f890a3fce.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n (SELECT created_by FROM resource WHERE workspace_id = $1 AND path = $2) AS \"owner?\",\n EXISTS(SELECT 1 FROM resource WHERE workspace_id = $1 AND path = $2)\n OR EXISTS(SELECT 1 FROM variable WHERE workspace_id = $1 AND path = $2) AS \"taken!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "owner?", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "taken!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "5af4e97b30d968e2b55f9560c6bb1c3f543964d2c05d2f44f0a7716f890a3fce" +} diff --git a/backend/.sqlx/query-5c13c681df0c57fcdbd1364f0f084aad096911efd453dd50f6865e53c1e77881.json b/backend/.sqlx/query-5c13c681df0c57fcdbd1364f0f084aad096911efd453dd50f6865e53c1e77881.json deleted file mode 100644 index 4d756dbaa7..0000000000 --- a/backend/.sqlx/query-5c13c681df0c57fcdbd1364f0f084aad096911efd453dd50f6865e53c1e77881.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT\n flow_step_id,\n (flow_status->'step')::integer as step,\n jsonb_array_length(flow_status->'modules') as len,\n runnable_path ~ '/branchone-\\d+$' as is_branch_one,\n parent_job as next_parent\n FROM v2_job\n LEFT JOIN v2_job_status USING (id)\n WHERE v2_job.id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "flow_step_id", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "step", - "type_info": "Int4" - }, - { - "ordinal": 2, - "name": "len", - "type_info": "Int4" - }, - { - "ordinal": 3, - "name": "is_branch_one", - "type_info": "Bool" - }, - { - "ordinal": 4, - "name": "next_parent", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - true, - null, - null, - null, - true - ] - }, - "hash": "5c13c681df0c57fcdbd1364f0f084aad096911efd453dd50f6865e53c1e77881" -} diff --git a/backend/.sqlx/query-5c72e6e4102039c4426e8eb47d86ec152d4ee52b58f208f2fa1639eaa448c6ad.json b/backend/.sqlx/query-5c72e6e4102039c4426e8eb47d86ec152d4ee52b58f208f2fa1639eaa448c6ad.json deleted file mode 100644 index 3458d1068e..0000000000 --- a/backend/.sqlx/query-5c72e6e4102039c4426e8eb47d86ec152d4ee52b58f208f2fa1639eaa448c6ad.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO app_script (app, hash, lock, code, code_sha256)\n VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Bpchar", - "Text", - "Text", - "Bpchar" - ] - }, - "nullable": [] - }, - "hash": "5c72e6e4102039c4426e8eb47d86ec152d4ee52b58f208f2fa1639eaa448c6ad" -} diff --git a/backend/.sqlx/query-5ef435d2a11fb8c834c0aa0ddbfd49f5962e2bfc8016dc7a7f8314675a9598d2.json b/backend/.sqlx/query-5ef435d2a11fb8c834c0aa0ddbfd49f5962e2bfc8016dc7a7f8314675a9598d2.json new file mode 100644 index 0000000000..4a0eefb573 --- /dev/null +++ b/backend/.sqlx/query-5ef435d2a11fb8c834c0aa0ddbfd49f5962e2bfc8016dc7a7f8314675a9598d2.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO slack_agent_channel\n (slack_team_id, channel_id, channel_name, workspace_id, agent_path, is_default)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT (slack_team_id, channel_id, workspace_id, agent_path) DO UPDATE\n SET channel_name = EXCLUDED.channel_name, is_default = EXCLUDED.is_default", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "5ef435d2a11fb8c834c0aa0ddbfd49f5962e2bfc8016dc7a7f8314675a9598d2" +} diff --git a/backend/.sqlx/query-5f5bdd667f699a5a1c05e07d805a97afb06252541d06b4785063819c3893b674.json b/backend/.sqlx/query-5f5bdd667f699a5a1c05e07d805a97afb06252541d06b4785063819c3893b674.json deleted file mode 100644 index 812fed8f23..0000000000 --- a/backend/.sqlx/query-5f5bdd667f699a5a1c05e07d805a97afb06252541d06b4785063819c3893b674.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO group_ (workspace_id, name, summary, extra_perms)\n VALUES ('test-workspace', 'error_handler', 'The group the error handler acts on behalf of', '{\"u/test-user\": true}')\n ON CONFLICT DO NOTHING\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "5f5bdd667f699a5a1c05e07d805a97afb06252541d06b4785063819c3893b674" -} diff --git a/backend/.sqlx/query-61546bd1f5cbb0fce960ce1f0603be0ee757a50739fd149836d1cd946087c878.json b/backend/.sqlx/query-61546bd1f5cbb0fce960ce1f0603be0ee757a50739fd149836d1cd946087c878.json new file mode 100644 index 0000000000..7268e60241 --- /dev/null +++ b/backend/.sqlx/query-61546bd1f5cbb0fce960ce1f0603be0ee757a50739fd149836d1cd946087c878.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT channel_id, channel_name, agent_path, is_default\n FROM slack_agent_channel WHERE workspace_id = $1 AND agent_path = $2 ORDER BY channel_name", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "channel_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "channel_name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "agent_path", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "is_default", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "61546bd1f5cbb0fce960ce1f0603be0ee757a50739fd149836d1cd946087c878" +} diff --git a/backend/.sqlx/query-61c4b826e90badc1307eadfe0c117efc6c8c87ba5542bce9c5f85b1da07e9a4b.json b/backend/.sqlx/query-61c4b826e90badc1307eadfe0c117efc6c8c87ba5542bce9c5f85b1da07e9a4b.json new file mode 100644 index 0000000000..17347df159 --- /dev/null +++ b/backend/.sqlx/query-61c4b826e90badc1307eadfe0c117efc6c8c87ba5542bce9c5f85b1da07e9a4b.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE account SET\n refresh_token = COALESCE($1, refresh_token),\n expires_at = now() + interval '1 hour',\n refresh_error = NULL\n WHERE workspace_id = $2 AND id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "61c4b826e90badc1307eadfe0c117efc6c8c87ba5542bce9c5f85b1da07e9a4b" +} diff --git a/backend/.sqlx/query-649ef6b8facee43bb9ba5e6413099322f13a7db686176c4a717651c1671b1bae.json b/backend/.sqlx/query-649ef6b8facee43bb9ba5e6413099322f13a7db686176c4a717651c1671b1bae.json new file mode 100644 index 0000000000..7113138dd7 --- /dev/null +++ b/backend/.sqlx/query-649ef6b8facee43bb9ba5e6413099322f13a7db686176c4a717651c1671b1bae.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_integrations SET resource_path = NULL\n WHERE workspace_id = $1 AND service_name = $2 AND resource_path = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "native_trigger_service", + "kind": { + "Enum": [ + "nextcloud", + "google", + "github" + ] + } + } + }, + "Text" + ] + }, + "nullable": [] + }, + "hash": "649ef6b8facee43bb9ba5e6413099322f13a7db686176c4a717651c1671b1bae" +} diff --git a/backend/.sqlx/query-65016ea993fb40d4ab6b0a6f15c2082e8dfbaada01243bae07065c089bc42fa9.json b/backend/.sqlx/query-65016ea993fb40d4ab6b0a6f15c2082e8dfbaada01243bae07065c089bc42fa9.json deleted file mode 100644 index c0e9198953..0000000000 --- a/backend/.sqlx/query-65016ea993fb40d4ab6b0a6f15c2082e8dfbaada01243bae07065c089bc42fa9.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE workspace_settings\n SET success_handler = '{\"path\": \"script/f/test/success_handler\"}'::jsonb\n WHERE workspace_id = 'test-workspace'\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "65016ea993fb40d4ab6b0a6f15c2082e8dfbaada01243bae07065c089bc42fa9" -} diff --git a/backend/.sqlx/query-653685b39d93008762818d0518b953632040122a9af98332d3fd1d12244b1b80.json b/backend/.sqlx/query-653685b39d93008762818d0518b953632040122a9af98332d3fd1d12244b1b80.json index e3ef8a7f0a..82604817a0 100644 --- a/backend/.sqlx/query-653685b39d93008762818d0518b953632040122a9af98332d3fd1d12244b1b80.json +++ b/backend/.sqlx/query-653685b39d93008762818d0518b953632040122a9af98332d3fd1d12244b1b80.json @@ -17,7 +17,8 @@ "script", "flow", "raw_app", - "asset" + "asset", + "agent" ] } } diff --git a/backend/.sqlx/query-65acc5b9322f03266079ea6051535168e2cd0e627e3bbff413e38f8a3c616aa0.json b/backend/.sqlx/query-65acc5b9322f03266079ea6051535168e2cd0e627e3bbff413e38f8a3c616aa0.json new file mode 100644 index 0000000000..213fb4a5d6 --- /dev/null +++ b/backend/.sqlx/query-65acc5b9322f03266079ea6051535168e2cd0e627e3bbff413e38f8a3c616aa0.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM workspace_settings WHERE ($1::text IS NULL OR workspace_id = $1) AND (\n auto_invite->>'role' = $2\n OR EXISTS (SELECT 1 FROM jsonb_each_text(\n CASE WHEN jsonb_typeof(auto_invite->'instance_groups_roles') = 'object'\n THEN auto_invite->'instance_groups_roles' ELSE '{}'::jsonb END) g\n WHERE g.value = $2))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "65acc5b9322f03266079ea6051535168e2cd0e627e3bbff413e38f8a3c616aa0" +} diff --git a/backend/.sqlx/query-68d95ce32653dece237bbf21459b571b8b67d26ffb60dd21e558dfa10612a0ed.json b/backend/.sqlx/query-68d95ce32653dece237bbf21459b571b8b67d26ffb60dd21e558dfa10612a0ed.json new file mode 100644 index 0000000000..6c40f884ef --- /dev/null +++ b/backend/.sqlx/query-68d95ce32653dece237bbf21459b571b8b67d26ffb60dd21e558dfa10612a0ed.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT slack_oauth_client_id IS NOT NULL AS \"own_app!\",\n slack_signing_secret IS NOT NULL AS \"own_secret!\"\n FROM workspace_settings WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "own_app!", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "own_secret!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "68d95ce32653dece237bbf21459b571b8b67d26ffb60dd21e558dfa10612a0ed" +} diff --git a/backend/.sqlx/query-69e1c04c246c42a3a1d7202b6fb349d0ac779431993e225945ddfb91489cb927.json b/backend/.sqlx/query-69e1c04c246c42a3a1d7202b6fb349d0ac779431993e225945ddfb91489cb927.json deleted file mode 100644 index 751b727d0f..0000000000 --- a/backend/.sqlx/query-69e1c04c246c42a3a1d7202b6fb349d0ac779431993e225945ddfb91489cb927.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)\n VALUES ('test-workspace', 1111111111, 'f/test/error_handler', $1, 'bun', 'script', 'test-user', '{}', 'Error handler script', 'Handles failed job completions', E'{}\\n//bun.lock\\n')\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [] - }, - "hash": "69e1c04c246c42a3a1d7202b6fb349d0ac779431993e225945ddfb91489cb927" -} diff --git a/backend/.sqlx/query-6be4bf59c404d2f557d1106c48c320bb3eff65255a44bd66799ae14288312ba4.json b/backend/.sqlx/query-6be4bf59c404d2f557d1106c48c320bb3eff65255a44bd66799ae14288312ba4.json new file mode 100644 index 0000000000..0a39db6822 --- /dev/null +++ b/backend/.sqlx/query-6be4bf59c404d2f557d1106c48c320bb3eff65255a44bd66799ae14288312ba4.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM variable WHERE workspace_id = $1 AND path = $2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "6be4bf59c404d2f557d1106c48c320bb3eff65255a44bd66799ae14288312ba4" +} diff --git a/backend/.sqlx/query-6d856cdf841aa6fb95d82841da3b28d5c5aac9608e8f60e5b76be177e4e105b1.json b/backend/.sqlx/query-6d856cdf841aa6fb95d82841da3b28d5c5aac9608e8f60e5b76be177e4e105b1.json new file mode 100644 index 0000000000..8cfd36abd6 --- /dev/null +++ b/backend/.sqlx/query-6d856cdf841aa6fb95d82841da3b28d5c5aac9608e8f60e5b76be177e4e105b1.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\n ('test-workspace', 'wm-fork-secrets', 'u/test-user/same', 'variable', 1, 1, NULL),\n ('test-workspace', 'wm-fork-secrets', 'u/test-user/different', 'variable', 1, 1, NULL)\n ON CONFLICT (source_workspace_id, fork_workspace_id, path, kind)\n DO UPDATE SET ahead = 1, behind = 1, has_changes = NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "6d856cdf841aa6fb95d82841da3b28d5c5aac9608e8f60e5b76be177e4e105b1" +} diff --git a/backend/.sqlx/query-6e83478011a8f65bf294ad7886139369e386f6a552c6626be48ecaa0e5ab78a7.json b/backend/.sqlx/query-6e83478011a8f65bf294ad7886139369e386f6a552c6626be48ecaa0e5ab78a7.json deleted file mode 100644 index b765a5eb28..0000000000 --- a/backend/.sqlx/query-6e83478011a8f65bf294ad7886139369e386f6a552c6626be48ecaa0e5ab78a7.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE account SET\n refresh_token = $1,\n expires_at = now() + interval '1 hour',\n refresh_error = NULL\n WHERE workspace_id = $2 AND client = $3 AND is_workspace_integration = true", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "6e83478011a8f65bf294ad7886139369e386f6a552c6626be48ecaa0e5ab78a7" -} diff --git a/backend/.sqlx/query-706872f05247a7d26fcd1b40b129f2defef86f8139dc4e38ae3bf63db039c48b.json b/backend/.sqlx/query-706872f05247a7d26fcd1b40b129f2defef86f8139dc4e38ae3bf63db039c48b.json new file mode 100644 index 0000000000..aad2908ef5 --- /dev/null +++ b/backend/.sqlx/query-706872f05247a7d26fcd1b40b129f2defef86f8139dc4e38ae3bf63db039c48b.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT user_email FROM mcp_oauth_server_code WHERE code = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "706872f05247a7d26fcd1b40b129f2defef86f8139dc4e38ae3bf63db039c48b" +} diff --git a/backend/.sqlx/query-71b410dbc6cdd0252de2f8646395903c9fa60a5435fa73c587da2b913203cf18.json b/backend/.sqlx/query-71b410dbc6cdd0252de2f8646395903c9fa60a5435fa73c587da2b913203cf18.json new file mode 100644 index 0000000000..d74d5e6ba3 --- /dev/null +++ b/backend/.sqlx/query-71b410dbc6cdd0252de2f8646395903c9fa60a5435fa73c587da2b913203cf18.json @@ -0,0 +1,36 @@ +{ + "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", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "account_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "refresh_token", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "71b410dbc6cdd0252de2f8646395903c9fa60a5435fa73c587da2b913203cf18" +} diff --git a/backend/.sqlx/query-729852e65e4cf22c474850d46a2065a203ca86b40e8388db963c183ac8fb4fd6.json b/backend/.sqlx/query-729852e65e4cf22c474850d46a2065a203ca86b40e8388db963c183ac8fb4fd6.json new file mode 100644 index 0000000000..6d4c707f08 --- /dev/null +++ b/backend/.sqlx/query-729852e65e4cf22c474850d46a2065a203ca86b40e8388db963c183ac8fb4fd6.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM workspace_invite WHERE\n workspace_id = $1 AND email = $2 AND is_admin = $3\n AND ($5::text IS NOT NULL OR operator = $4)\n AND ($5::text IS NULL OR $5 = COALESCE(workspace_role, CASE WHEN operator THEN 'operator' ELSE 'developer' END))", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Bool", + "Bool", + "Text" + ] + }, + "nullable": [] + }, + "hash": "729852e65e4cf22c474850d46a2065a203ca86b40e8388db963c183ac8fb4fd6" +} diff --git a/backend/.sqlx/query-72bc3795182333d203b4731defb400a6fa59e8a88bebd6e63437e9cb4c491333.json b/backend/.sqlx/query-72bc3795182333d203b4731defb400a6fa59e8a88bebd6e63437e9cb4c491333.json new file mode 100644 index 0000000000..9450642e5f --- /dev/null +++ b/backend/.sqlx/query-72bc3795182333d203b4731defb400a6fa59e8a88bebd6e63437e9cb4c491333.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM workspace_invite WHERE workspace_id = $1 AND email = $2 RETURNING is_admin, operator, workspace_role", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "is_admin", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "operator", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "workspace_role", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "72bc3795182333d203b4731defb400a6fa59e8a88bebd6e63437e9cb4c491333" +} diff --git a/backend/.sqlx/query-7341d832643627cdc33a6eefbca5844a0fbb10fb79e25a604eeb0d83d2122d96.json b/backend/.sqlx/query-7341d832643627cdc33a6eefbca5844a0fbb10fb79e25a604eeb0d83d2122d96.json new file mode 100644 index 0000000000..a5181dd0a2 --- /dev/null +++ b/backend/.sqlx/query-7341d832643627cdc33a6eefbca5844a0fbb10fb79e25a604eeb0d83d2122d96.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT username, is_admin, operator, workspace_role FROM usr WHERE\n email = $1 AND workspace_id = $2 AND disabled = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "is_admin", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "operator", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "workspace_role", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true + ] + }, + "hash": "7341d832643627cdc33a6eefbca5844a0fbb10fb79e25a604eeb0d83d2122d96" +} diff --git a/backend/.sqlx/query-73a22a814ae983ae6b0188ee2b9f110dadb3db328f0589f7de27bcb418e70371.json b/backend/.sqlx/query-73a22a814ae983ae6b0188ee2b9f110dadb3db328f0589f7de27bcb418e70371.json new file mode 100644 index 0000000000..f4092042dd --- /dev/null +++ b/backend/.sqlx/query-73a22a814ae983ae6b0188ee2b9f110dadb3db328f0589f7de27bcb418e70371.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, value AS \"value!: serde_json::Value\" FROM app_version_lite WHERE id = ANY($1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "value!: serde_json::Value", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "73a22a814ae983ae6b0188ee2b9f110dadb3db328f0589f7de27bcb418e70371" +} diff --git a/backend/.sqlx/query-74190dc8f1049b61adfcefe19df069204bfc974a55da927658947f3e335da3f5.json b/backend/.sqlx/query-74190dc8f1049b61adfcefe19df069204bfc974a55da927658947f3e335da3f5.json new file mode 100644 index 0000000000..8a68c6364e --- /dev/null +++ b/backend/.sqlx/query-74190dc8f1049b61adfcefe19df069204bfc974a55da927658947f3e335da3f5.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM workspace_invite t WHERE ($1::text IS NOT NULL AND t.workspace_id = $1\n OR $1::text IS NULL AND NOT EXISTS (SELECT 1 FROM workspace_settings ws\n WHERE ws.workspace_id = t.workspace_id AND ws.role_overrides ? $2)) AND\n COALESCE(t.workspace_role, CASE WHEN t.operator THEN 'operator' ELSE 'developer' END) = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "74190dc8f1049b61adfcefe19df069204bfc974a55da927658947f3e335da3f5" +} diff --git a/backend/.sqlx/query-748b63f877498d6d279fdf23894611e11c7a05d69b4b7907f8909aec82fb1eb2.json b/backend/.sqlx/query-748b63f877498d6d279fdf23894611e11c7a05d69b4b7907f8909aec82fb1eb2.json deleted file mode 100644 index d710d59055..0000000000 --- a/backend/.sqlx/query-748b63f877498d6d279fdf23894611e11c7a05d69b4b7907f8909aec82fb1eb2.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, edited_at, created_by)\n SELECT $2, path, value, description, resource_type, extra_perms, edited_at, created_by\n FROM resource\n WHERE workspace_id = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "748b63f877498d6d279fdf23894611e11c7a05d69b4b7907f8909aec82fb1eb2" -} diff --git a/backend/.sqlx/query-74e6d61f31897d8102fe75bbe44781a71aeba34da94b9b1aa9893c2837d5cb05.json b/backend/.sqlx/query-74e6d61f31897d8102fe75bbe44781a71aeba34da94b9b1aa9893c2837d5cb05.json new file mode 100644 index 0000000000..a173674b29 --- /dev/null +++ b/backend/.sqlx/query-74e6d61f31897d8102fe75bbe44781a71aeba34da94b9b1aa9893c2837d5cb05.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT slack_oauth_client_id IS NOT NULL AS \"own_app!\", slack_signing_secret\n FROM workspace_settings WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "own_app!", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "slack_signing_secret", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + true + ] + }, + "hash": "74e6d61f31897d8102fe75bbe44781a71aeba34da94b9b1aa9893c2837d5cb05" +} diff --git a/backend/.sqlx/query-74f9031593b6c55819508fa32310e465c202fa3784fd179bcd8963217327a03b.json b/backend/.sqlx/query-74f9031593b6c55819508fa32310e465c202fa3784fd179bcd8963217327a03b.json new file mode 100644 index 0000000000..7440566057 --- /dev/null +++ b/backend/.sqlx/query-74f9031593b6c55819508fa32310e465c202fa3784fd179bcd8963217327a03b.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM native_trigger\n WHERE workspace_id = $1 AND service_name = $2 AND connection_path = $3)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "native_trigger_service", + "kind": { + "Enum": [ + "nextcloud", + "google", + "github" + ] + } + } + }, + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "74f9031593b6c55819508fa32310e465c202fa3784fd179bcd8963217327a03b" +} diff --git a/backend/.sqlx/query-ffa86babfcab107caffb8dda31a66b142fa628b8983b966357deb3d5e0a1df3c.json b/backend/.sqlx/query-7796f40e349bf0771cceff037fa2224ab49659213638230be7f9acbf82d8e1b0.json similarity index 53% rename from backend/.sqlx/query-ffa86babfcab107caffb8dda31a66b142fa628b8983b966357deb3d5e0a1df3c.json rename to backend/.sqlx/query-7796f40e349bf0771cceff037fa2224ab49659213638230be7f9acbf82d8e1b0.json index 49fe5cf6b8..cf9dc458c2 100644 --- a/backend/.sqlx/query-ffa86babfcab107caffb8dda31a66b142fa628b8983b966357deb3d5e0a1df3c.json +++ b/backend/.sqlx/query-7796f40e349bf0771cceff037fa2224ab49659213638230be7f9acbf82d8e1b0.json @@ -1,28 +1,28 @@ { "db_name": "PostgreSQL", - "query": "SELECT lock, code FROM app_script WHERE id = $1 LIMIT 1", + "query": "SELECT storage, storage_ref FROM fork_storage_namespace WHERE workspace_id = $1", "describe": { "columns": [ { "ordinal": 0, - "name": "lock", + "name": "storage", "type_info": "Text" }, { "ordinal": 1, - "name": "code", + "name": "storage_ref", "type_info": "Text" } ], "parameters": { "Left": [ - "Int8" + "Text" ] }, "nullable": [ - true, + false, false ] }, - "hash": "ffa86babfcab107caffb8dda31a66b142fa628b8983b966357deb3d5e0a1df3c" + "hash": "7796f40e349bf0771cceff037fa2224ab49659213638230be7f9acbf82d8e1b0" } diff --git a/backend/.sqlx/query-993e514229a1d508a44ef07b4399f9697cdefaa1f45ca665f72bc6fcf2797c7e.json b/backend/.sqlx/query-78954c7a6d38ae9d094f77bb255ae235c9b3083b4c179f819c927342bc58651f.json similarity index 64% rename from backend/.sqlx/query-993e514229a1d508a44ef07b4399f9697cdefaa1f45ca665f72bc6fcf2797c7e.json rename to backend/.sqlx/query-78954c7a6d38ae9d094f77bb255ae235c9b3083b4c179f819c927342bc58651f.json index 25a4d5b723..a8e33b105a 100644 --- a/backend/.sqlx/query-993e514229a1d508a44ef07b4399f9697cdefaa1f45ca665f72bc6fcf2797c7e.json +++ b/backend/.sqlx/query-78954c7a6d38ae9d094f77bb255ae235c9b3083b4c179f819c927342bc58651f.json @@ -1,12 +1,12 @@ { "db_name": "PostgreSQL", - "query": "SELECT resource_path FROM workspace_integrations WHERE workspace_id = $1 AND service_name = $2", + "query": "SELECT EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = $2)", "describe": { "columns": [ { "ordinal": 0, - "name": "resource_path", - "type_info": "Text" + "name": "exists", + "type_info": "Bool" } ], "parameters": { @@ -27,8 +27,8 @@ ] }, "nullable": [ - true + null ] }, - "hash": "993e514229a1d508a44ef07b4399f9697cdefaa1f45ca665f72bc6fcf2797c7e" + "hash": "78954c7a6d38ae9d094f77bb255ae235c9b3083b4c179f819c927342bc58651f" } diff --git a/backend/.sqlx/query-79648f03eb50385183c34d57bda1ac984cf6502609df9ee51b7d684b585b8447.json b/backend/.sqlx/query-79648f03eb50385183c34d57bda1ac984cf6502609df9ee51b7d684b585b8447.json deleted file mode 100644 index fbf0c98de4..0000000000 --- a/backend/.sqlx/query-79648f03eb50385183c34d57bda1ac984cf6502609df9ee51b7d684b585b8447.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE workspace_settings\n SET error_handler = NULL\n WHERE workspace_id = 'test-workspace'\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "79648f03eb50385183c34d57bda1ac984cf6502609df9ee51b7d684b585b8447" -} diff --git a/backend/.sqlx/query-79ac4fcf66d90ad793ecdc0906203654e796f71a876ea6d6ebf08c4f65b2156e.json b/backend/.sqlx/query-79ac4fcf66d90ad793ecdc0906203654e796f71a876ea6d6ebf08c4f65b2156e.json new file mode 100644 index 0000000000..158f49102d --- /dev/null +++ b/backend/.sqlx/query-79ac4fcf66d90ad793ecdc0906203654e796f71a876ea6d6ebf08c4f65b2156e.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT v.path, r.created_by AS \"owner?\", false AS \"workspace!\"\n FROM variable v\n JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account\n LEFT JOIN resource r ON r.workspace_id = v.workspace_id AND r.path = v.path\n WHERE v.workspace_id = $1 AND a.client = $2 AND a.is_workspace_integration = true\n ORDER BY v.path", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "owner?", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "workspace!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + true, + null + ] + }, + "hash": "79ac4fcf66d90ad793ecdc0906203654e796f71a876ea6d6ebf08c4f65b2156e" +} diff --git a/backend/.sqlx/query-7b1d29170c9c4ad4e3a15c4e8acbeb6769dc6fc97269beee607a5625a495a121.json b/backend/.sqlx/query-7b1d29170c9c4ad4e3a15c4e8acbeb6769dc6fc97269beee607a5625a495a121.json deleted file mode 100644 index 97ab6a03d5..0000000000 --- a/backend/.sqlx/query-7b1d29170c9c4ad4e3a15c4e8acbeb6769dc6fc97269beee607a5625a495a121.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE account SET\n expires_at = now() + interval '1 hour',\n refresh_error = NULL\n WHERE workspace_id = $1 AND client = $2 AND is_workspace_integration = true", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "7b1d29170c9c4ad4e3a15c4e8acbeb6769dc6fc97269beee607a5625a495a121" -} diff --git a/backend/.sqlx/query-7b1d7a1404c3a17e1852e1fe7d32d04d8c95e1e87e75b9a07b01c03f3e3d4373.json b/backend/.sqlx/query-7b1d7a1404c3a17e1852e1fe7d32d04d8c95e1e87e75b9a07b01c03f3e3d4373.json new file mode 100644 index 0000000000..e5ee2e27e8 --- /dev/null +++ b/backend/.sqlx/query-7b1d7a1404c3a17e1852e1fe7d32d04d8c95e1e87e75b9a07b01c03f3e3d4373.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT lock, code, modules AS \"modules: serde_json::Value\"\n FROM app_script WHERE id = $1 LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "lock", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "code", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "modules: serde_json::Value", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + true, + false, + true + ] + }, + "hash": "7b1d7a1404c3a17e1852e1fe7d32d04d8c95e1e87e75b9a07b01c03f3e3d4373" +} diff --git a/backend/.sqlx/query-d537182f8c8931427f2cf175bd850ce2581a33c8678212de99edb6f823f38994.json b/backend/.sqlx/query-7c481a2cf03e7f80e7170a5165b5225e4e0828d82f5c6ddca9b059f4a876d137.json similarity index 79% rename from backend/.sqlx/query-d537182f8c8931427f2cf175bd850ce2581a33c8678212de99edb6f823f38994.json rename to backend/.sqlx/query-7c481a2cf03e7f80e7170a5165b5225e4e0828d82f5c6ddca9b059f4a876d137.json index 8c2a7716fd..e637b8c87d 100644 --- a/backend/.sqlx/query-d537182f8c8931427f2cf175bd850ce2581a33c8678212de99edb6f823f38994.json +++ b/backend/.sqlx/query-7c481a2cf03e7f80e7170a5165b5225e4e0828d82f5c6ddca9b059f4a876d137.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT (status = 'success' OR EXISTS (\n SELECT 1 FROM native_retry_attempt nra\n JOIN v2_job jc ON jc.id = nra.job_id\n JOIN v2_job_completed cc ON cc.id = nra.job_id\n WHERE jc.parent_job = j.id AND cc.status = 'success'\n )) AS \"success!\"\n FROM v2_job j JOIN v2_job_completed USING (id)\n WHERE j.workspace_id = $1 AND trigger_kind = 'schedule' AND trigger = $2\n AND parent_job IS NULL\n AND runnable_path = $3\n AND j.id != $4\n ORDER BY created_at DESC\n LIMIT $5", + "query": "SELECT (status = 'success' OR EXISTS (\n SELECT 1 FROM native_retry_attempt nra\n JOIN v2_job jc ON jc.id = nra.job_id\n JOIN v2_job_completed cc ON cc.id = nra.job_id\n WHERE jc.parent_job = j.id AND cc.status = 'success'\n )) AS \"success!\"\n FROM v2_job j JOIN v2_job_completed USING (id)\n WHERE j.workspace_id = $1 AND trigger_kind = 'schedule' AND trigger = $2\n AND parent_job IS NULL\n AND runnable_path = $3\n AND j.created_at < (SELECT created_at FROM v2_job WHERE id = $4)\n ORDER BY created_at DESC\n LIMIT $5", "describe": { "columns": [ { @@ -22,5 +22,5 @@ null ] }, - "hash": "d537182f8c8931427f2cf175bd850ce2581a33c8678212de99edb6f823f38994" + "hash": "7c481a2cf03e7f80e7170a5165b5225e4e0828d82f5c6ddca9b059f4a876d137" } diff --git a/backend/.sqlx/query-7cc9d80bc4c77c996521a7c82ca42162e96499987b17a34bde7bc32d54e6260e.json b/backend/.sqlx/query-7cc9d80bc4c77c996521a7c82ca42162e96499987b17a34bde7bc32d54e6260e.json new file mode 100644 index 0000000000..6af78adde7 --- /dev/null +++ b/backend/.sqlx/query-7cc9d80bc4c77c996521a7c82ca42162e96499987b17a34bde7bc32d54e6260e.json @@ -0,0 +1,55 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE ai_shared_session SET is_public = $4\n WHERE workspace_id = $1 AND id = $2 AND email = $3\n RETURNING id, name, is_public, message_count, created_by, shared_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "is_public", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "message_count", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "shared_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Uuid", + "Text", + "Bool" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "7cc9d80bc4c77c996521a7c82ca42162e96499987b17a34bde7bc32d54e6260e" +} diff --git a/backend/.sqlx/query-7e71cb88613a78b1b6bd0327970e7c732775949f92e1f099ff997d385b025f55.json b/backend/.sqlx/query-7e71cb88613a78b1b6bd0327970e7c732775949f92e1f099ff997d385b025f55.json new file mode 100644 index 0000000000..787342ca47 --- /dev/null +++ b/backend/.sqlx/query-7e71cb88613a78b1b6bd0327970e7c732775949f92e1f099ff997d385b025f55.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT username, is_admin, operator, workspace_role FROM usr\n WHERE workspace_id = $1 AND email = $2 AND disabled = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "is_admin", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "operator", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "workspace_role", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true + ] + }, + "hash": "7e71cb88613a78b1b6bd0327970e7c732775949f92e1f099ff997d385b025f55" +} diff --git a/backend/.sqlx/query-7ec099041134ee34057b57e86b35c67fad4532904ed633b85c3bbc627367ef0c.json b/backend/.sqlx/query-7ec099041134ee34057b57e86b35c67fad4532904ed633b85c3bbc627367ef0c.json new file mode 100644 index 0000000000..bb026c9b31 --- /dev/null +++ b/backend/.sqlx/query-7ec099041134ee34057b57e86b35c67fad4532904ed633b85c3bbc627367ef0c.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH RECURSIVE chain AS (\n SELECT id, parent_workspace_id, is_dev_workspace, deleted, 0 AS depth\n FROM workspace WHERE id = $1\n UNION ALL\n SELECT w.id, w.parent_workspace_id, w.is_dev_workspace, w.deleted, chain.depth + 1\n FROM workspace w\n JOIN chain ON w.id = chain.parent_workspace_id\n WHERE chain.depth < 20\n )\n SELECT\n (SELECT id FROM chain\n WHERE parent_workspace_id IS NULL\n OR (is_dev_workspace AND id NOT LIKE 'wm-fork-%')\n ORDER BY depth LIMIT 1) AS id,\n (SELECT parent_workspace_id IS NOT NULL FROM chain WHERE depth = 0) AS has_parent,\n (SELECT bool_or(is_dev_workspace\n OR (parent_workspace_id IS NULL AND id LIKE 'wm-fork-%')\n OR (deleted AND parent_workspace_id IS NOT NULL))\n FROM chain) AS in_dev_lineage\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "has_parent", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "in_dev_lineage", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + null, + null + ] + }, + "hash": "7ec099041134ee34057b57e86b35c67fad4532904ed633b85c3bbc627367ef0c" +} diff --git a/backend/.sqlx/query-80977e90833b63da4cdde111db80d78c8e577dbce51f8571a9ffe3e8e1e652a1.json b/backend/.sqlx/query-80977e90833b63da4cdde111db80d78c8e577dbce51f8571a9ffe3e8e1e652a1.json new file mode 100644 index 0000000000..24144fdbdb --- /dev/null +++ b/backend/.sqlx/query-80977e90833b63da4cdde111db80d78c8e577dbce51f8571a9ffe3e8e1e652a1.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings\n SET operator_settings = COALESCE(operator_settings, '{}'::jsonb) || $1\n WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb", + "Text" + ] + }, + "nullable": [] + }, + "hash": "80977e90833b63da4cdde111db80d78c8e577dbce51f8571a9ffe3e8e1e652a1" +} diff --git a/backend/.sqlx/query-80d62bd0f372a322d9c2a548a66669551ed6860b0839dd019967569e2325b0bc.json b/backend/.sqlx/query-80d62bd0f372a322d9c2a548a66669551ed6860b0839dd019967569e2325b0bc.json new file mode 100644 index 0000000000..5b59198fd9 --- /dev/null +++ b/backend/.sqlx/query-80d62bd0f372a322d9c2a548a66669551ed6860b0839dd019967569e2325b0bc.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_invite\n (workspace_id, email, is_admin, operator, workspace_role)\n VALUES ($1, $2, $3, $4, $5) ON CONFLICT (workspace_id, email)\n DO UPDATE SET is_admin = EXCLUDED.is_admin, operator = EXCLUDED.operator,\n workspace_role = EXCLUDED.workspace_role", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Bool", + "Bool", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "80d62bd0f372a322d9c2a548a66669551ed6860b0839dd019967569e2325b0bc" +} diff --git a/backend/.sqlx/query-8511e096394edb1433f6572d50b66d8558866af53d829d83fa634f12a90733c0.json b/backend/.sqlx/query-8511e096394edb1433f6572d50b66d8558866af53d829d83fa634f12a90733c0.json deleted file mode 100644 index 3420b8c337..0000000000 --- a/backend/.sqlx/query-8511e096394edb1433f6572d50b66d8558866af53d829d83fa634f12a90733c0.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)\n VALUES ('test-workspace', $1, 'f/test/success_script', 'export function main() { return \"ok\"; }', 'bun', 'script', 'test-user', '{}', '', '', E'{}\\n//bun.lock\\n')\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "8511e096394edb1433f6572d50b66d8558866af53d829d83fa634f12a90733c0" -} diff --git a/backend/.sqlx/query-87044e1466cd114cbef336620e96dc29f3f930149fedc240d44c614dfc794ff7.json b/backend/.sqlx/query-87044e1466cd114cbef336620e96dc29f3f930149fedc240d44c614dfc794ff7.json deleted file mode 100644 index 9915fbf6c8..0000000000 --- a/backend/.sqlx/query-87044e1466cd114cbef336620e96dc29f3f930149fedc240d44c614dfc794ff7.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT success_handler->'extra_args' FROM workspace_settings WHERE workspace_id = 'test-workspace'", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "?column?", - "type_info": "Jsonb" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - null - ] - }, - "hash": "87044e1466cd114cbef336620e96dc29f3f930149fedc240d44c614dfc794ff7" -} diff --git a/backend/.sqlx/query-884dd7d41713a964a0fbb854d6e54cff4538e230b3cd72dd2e840a08b2a178d9.json b/backend/.sqlx/query-884dd7d41713a964a0fbb854d6e54cff4538e230b3cd72dd2e840a08b2a178d9.json new file mode 100644 index 0000000000..1823f66c53 --- /dev/null +++ b/backend/.sqlx/query-884dd7d41713a964a0fbb854d6e54cff4538e230b3cd72dd2e840a08b2a178d9.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT is_admin, operator, workspace_role, email FROM usr where username = $1 AND workspace_id = $2 AND disabled = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "is_admin", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "operator", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "workspace_role", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false + ] + }, + "hash": "884dd7d41713a964a0fbb854d6e54cff4538e230b3cd72dd2e840a08b2a178d9" +} diff --git a/backend/.sqlx/query-8a0287e5b8f37c86d989a39516188484d036e2106acab33f6c459d9b9c9ba3be.json b/backend/.sqlx/query-8a0287e5b8f37c86d989a39516188484d036e2106acab33f6c459d9b9c9ba3be.json new file mode 100644 index 0000000000..c90368a92f --- /dev/null +++ b/backend/.sqlx/query-8a0287e5b8f37c86d989a39516188484d036e2106acab33f6c459d9b9c9ba3be.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE account SET refresh_token = COALESCE($1, refresh_token),\n expires_at = CASE WHEN $2 THEN $3 ELSE expires_at END,\n refresh_error = CASE WHEN $2 THEN NULL ELSE refresh_error END\n WHERE workspace_id = $4 AND id = $5 AND refresh_token = $6", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Bool", + "Timestamptz", + "Text", + "Int4", + "Text" + ] + }, + "nullable": [] + }, + "hash": "8a0287e5b8f37c86d989a39516188484d036e2106acab33f6c459d9b9c9ba3be" +} diff --git a/backend/.sqlx/query-8b0a0dddd15b390b4d23067f55a74535817926e69a8df1bbc909b952635643fa.json b/backend/.sqlx/query-8b0a0dddd15b390b4d23067f55a74535817926e69a8df1bbc909b952635643fa.json new file mode 100644 index 0000000000..cccc6a7525 --- /dev/null +++ b/backend/.sqlx/query-8b0a0dddd15b390b4d23067f55a74535817926e69a8df1bbc909b952635643fa.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value->'run_on_behalf_of' = 'true'::jsonb AS run_on_behalf_of,\n value->>'on_behalf_of' AS on_behalf_of,\n value->'slack_anyone' = 'true'::jsonb AS slack_anyone\n FROM resource WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "run_on_behalf_of", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "on_behalf_of", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "slack_anyone", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null, + null, + null + ] + }, + "hash": "8b0a0dddd15b390b4d23067f55a74535817926e69a8df1bbc909b952635643fa" +} diff --git a/backend/.sqlx/query-b731208c3e98f8fb15bf0c72e2ab1d4c61566c3cedf31596efe084ef7dc9985a.json b/backend/.sqlx/query-8b8b605d656d88e1fe77f936500cec9cb3388b7232a96bfc287f751d1e0092da.json similarity index 63% rename from backend/.sqlx/query-b731208c3e98f8fb15bf0c72e2ab1d4c61566c3cedf31596efe084ef7dc9985a.json rename to backend/.sqlx/query-8b8b605d656d88e1fe77f936500cec9cb3388b7232a96bfc287f751d1e0092da.json index 04b960b81a..0481fb751c 100644 --- a/backend/.sqlx/query-b731208c3e98f8fb15bf0c72e2ab1d4c61566c3cedf31596efe084ef7dc9985a.json +++ b/backend/.sqlx/query-8b8b605d656d88e1fe77f936500cec9cb3388b7232a96bfc287f751d1e0092da.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT workspace_id, slack_command_script, slack_email FROM workspace_settings WHERE slack_team_id = $1 AND slack_command_script IS NOT NULL", + "query": "SELECT workspace_id, slack_command_script, slack_email FROM workspace_settings\n WHERE slack_team_id = $1 AND slack_command_script IS NOT NULL\n AND workspace_id = ANY($2)\n ORDER BY workspace_id = ANY($3) DESC LIMIT 1", "describe": { "columns": [ { @@ -21,7 +21,9 @@ ], "parameters": { "Left": [ - "Text" + "Text", + "TextArray", + "TextArray" ] }, "nullable": [ @@ -30,5 +32,5 @@ false ] }, - "hash": "b731208c3e98f8fb15bf0c72e2ab1d4c61566c3cedf31596efe084ef7dc9985a" + "hash": "8b8b605d656d88e1fe77f936500cec9cb3388b7232a96bfc287f751d1e0092da" } diff --git a/backend/.sqlx/query-8c2fcd9385931cfba49bcbe67a14cbfbb042a5930d406ec60939791a1d1d723b.json b/backend/.sqlx/query-8c2fcd9385931cfba49bcbe67a14cbfbb042a5930d406ec60939791a1d1d723b.json new file mode 100644 index 0000000000..bbf54e23b8 --- /dev/null +++ b/backend/.sqlx/query-8c2fcd9385931cfba49bcbe67a14cbfbb042a5930d406ec60939791a1d1d723b.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path FROM variable WHERE workspace_id = $1 AND account = $2 AND is_secret", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Int4" + ] + }, + "nullable": [ + false + ] + }, + "hash": "8c2fcd9385931cfba49bcbe67a14cbfbb042a5930d406ec60939791a1d1d723b" +} diff --git a/backend/.sqlx/query-8c35d87481de9eb1496d47e78660a57fd10fc5b32f4e68ab7a217d85264623b6.json b/backend/.sqlx/query-8c35d87481de9eb1496d47e78660a57fd10fc5b32f4e68ab7a217d85264623b6.json new file mode 100644 index 0000000000..7e4de24f7e --- /dev/null +++ b/backend/.sqlx/query-8c35d87481de9eb1496d47e78660a57fd10fc5b32f4e68ab7a217d85264623b6.json @@ -0,0 +1,47 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO ai_shared_session\n (workspace_id, session_id, email, created_by, name, is_public, message_count, snapshot)\n VALUES ($1, $2, $3, $4, $5, COALESCE($6, false), $7, $8)\n ON CONFLICT (workspace_id, email, session_id) DO UPDATE\n SET created_by = EXCLUDED.created_by,\n name = EXCLUDED.name,\n is_public = COALESCE($6, ai_shared_session.is_public),\n message_count = EXCLUDED.message_count,\n snapshot = EXCLUDED.snapshot,\n shared_at = now()\n RETURNING id, is_public, shared_at, (xmax = 0) AS \"inserted!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "is_public", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "shared_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "inserted!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Bool", + "Int4", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + null + ] + }, + "hash": "8c35d87481de9eb1496d47e78660a57fd10fc5b32f4e68ab7a217d85264623b6" +} diff --git a/backend/.sqlx/query-8c56f79cb3f940b29a35f31a48f23ea068d451cf4bfd0c3c9b2f957df82173d1.json b/backend/.sqlx/query-8c56f79cb3f940b29a35f31a48f23ea068d451cf4bfd0c3c9b2f957df82173d1.json new file mode 100644 index 0000000000..5c9eca6f51 --- /dev/null +++ b/backend/.sqlx/query-8c56f79cb3f940b29a35f31a48f23ea068d451cf4bfd0c3c9b2f957df82173d1.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT ws.teams_team_id FROM v2_job j\n JOIN workspace_settings ws ON ws.workspace_id = j.workspace_id\n WHERE j.id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "teams_team_id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true + ] + }, + "hash": "8c56f79cb3f940b29a35f31a48f23ea068d451cf4bfd0c3c9b2f957df82173d1" +} diff --git a/backend/.sqlx/query-8e1bd1c87ace3addbf2e641f5c0f315df6ffbb6461a4d466971f4cf30fba623b.json b/backend/.sqlx/query-8e1bd1c87ace3addbf2e641f5c0f315df6ffbb6461a4d466971f4cf30fba623b.json new file mode 100644 index 0000000000..9dec923475 --- /dev/null +++ b/backend/.sqlx/query-8e1bd1c87ace3addbf2e641f5c0f315df6ffbb6461a4d466971f4cf30fba623b.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace_id, agent_path FROM slack_agent_channel\n WHERE slack_team_id = $1 AND channel_id = $2 AND is_default", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "agent_path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "8e1bd1c87ace3addbf2e641f5c0f315df6ffbb6461a4d466971f4cf30fba623b" +} diff --git a/backend/.sqlx/query-64dce306efc0d9989542dba5bf003dc94b9337a8d3b69805308258d1fa145e63.json b/backend/.sqlx/query-8ec380ed48a8366e8869c6d1aa06ba774a6ded50ab047d0b0302c7430cee5b70.json similarity index 61% rename from backend/.sqlx/query-64dce306efc0d9989542dba5bf003dc94b9337a8d3b69805308258d1fa145e63.json rename to backend/.sqlx/query-8ec380ed48a8366e8869c6d1aa06ba774a6ded50ab047d0b0302c7430cee5b70.json index d234750140..9416cd684c 100644 --- a/backend/.sqlx/query-64dce306efc0d9989542dba5bf003dc94b9337a8d3b69805308258d1fa145e63.json +++ b/backend/.sqlx/query-8ec380ed48a8366e8869c6d1aa06ba774a6ded50ab047d0b0302c7430cee5b70.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO native_trigger (\n external_id,\n workspace_id,\n service_name,\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n summary,\n enabled\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9\n )\n ON CONFLICT (external_id, workspace_id, service_name)\n DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, summary = $8, error = NULL, updated_at = NOW()\n ", + "query": "\n INSERT INTO native_trigger (\n external_id,\n workspace_id,\n service_name,\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n summary,\n enabled,\n connection_path\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10\n )\n ON CONFLICT (external_id, workspace_id, service_name)\n DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, summary = $8, connection_path = $10, error = NULL, updated_at = NOW()\n ", "describe": { "columns": [], "parameters": { @@ -24,10 +24,11 @@ "Varchar", "Jsonb", "Varchar", - "Bool" + "Bool", + "Varchar" ] }, "nullable": [] }, - "hash": "64dce306efc0d9989542dba5bf003dc94b9337a8d3b69805308258d1fa145e63" + "hash": "8ec380ed48a8366e8869c6d1aa06ba774a6ded50ab047d0b0302c7430cee5b70" } diff --git a/backend/.sqlx/query-907f231497ac4889c23b84bb3c369ebb8bf60963283640e02e03a1af42af2782.json b/backend/.sqlx/query-907f231497ac4889c23b84bb3c369ebb8bf60963283640e02e03a1af42af2782.json new file mode 100644 index 0000000000..97f3bd7567 --- /dev/null +++ b/backend/.sqlx/query-907f231497ac4889c23b84bb3c369ebb8bf60963283640e02e03a1af42af2782.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM slack_agent_channel WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "907f231497ac4889c23b84bb3c369ebb8bf60963283640e02e03a1af42af2782" +} diff --git a/backend/.sqlx/query-5b0847d2b95a128a5b648dd4847af44ed0992ced76286a08a33134140a454391.json b/backend/.sqlx/query-92710770a07d9cc622fd36bde7114fe6f508293f3334e4e17e7d396b52012a7d.json similarity index 84% rename from backend/.sqlx/query-5b0847d2b95a128a5b648dd4847af44ed0992ced76286a08a33134140a454391.json rename to backend/.sqlx/query-92710770a07d9cc622fd36bde7114fe6f508293f3334e4e17e7d396b52012a7d.json index 28c09ca528..514793704f 100644 --- a/backend/.sqlx/query-5b0847d2b95a128a5b648dd4847af44ed0992ced76286a08a33134140a454391.json +++ b/backend/.sqlx/query-92710770a07d9cc622fd36bde7114fe6f508293f3334e4e17e7d396b52012a7d.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT (status = 'success' OR EXISTS (\n SELECT 1 FROM native_retry_attempt nra\n JOIN v2_job jc ON jc.id = nra.job_id\n JOIN v2_job_completed cc ON cc.id = nra.job_id\n WHERE jc.parent_job = j.id AND cc.status = 'success'\n )) AS \"success!\",\n result AS \"result: Json>\",\n started_at AS \"started_at!\"FROM v2_job j JOIN v2_job_completed USING (id)\n WHERE j.workspace_id = $1 AND trigger_kind = 'schedule' AND trigger = $2\n AND parent_job IS NULL\n AND runnable_path = $3\n AND j.id != $4\n ORDER BY created_at DESC\n LIMIT $5", + "query": "SELECT (status = 'success' OR EXISTS (\n SELECT 1 FROM native_retry_attempt nra\n JOIN v2_job jc ON jc.id = nra.job_id\n JOIN v2_job_completed cc ON cc.id = nra.job_id\n WHERE jc.parent_job = j.id AND cc.status = 'success'\n )) AS \"success!\",\n result AS \"result: Json>\",\n started_at AS \"started_at!\"FROM v2_job j JOIN v2_job_completed USING (id)\n WHERE j.workspace_id = $1 AND trigger_kind = 'schedule' AND trigger = $2\n AND parent_job IS NULL\n AND runnable_path = $3\n AND j.created_at < (SELECT created_at FROM v2_job WHERE id = $4)\n ORDER BY created_at DESC\n LIMIT $5", "describe": { "columns": [ { @@ -34,5 +34,5 @@ true ] }, - "hash": "5b0847d2b95a128a5b648dd4847af44ed0992ced76286a08a33134140a454391" + "hash": "92710770a07d9cc622fd36bde7114fe6f508293f3334e4e17e7d396b52012a7d" } diff --git a/backend/.sqlx/query-92b011eda2a652af714b08137b447753bc03ec35f6daa8ca245b1c8ed34c405f.json b/backend/.sqlx/query-92b011eda2a652af714b08137b447753bc03ec35f6daa8ca245b1c8ed34c405f.json deleted file mode 100644 index 794f62e49d..0000000000 --- a/backend/.sqlx/query-92b011eda2a652af714b08137b447753bc03ec35f6daa8ca245b1c8ed34c405f.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM zombie_job_counter WHERE job_id IN (SELECT id FROM v2_job WHERE workspace_id = $1 AND id = ANY($2))", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "UuidArray" - ] - }, - "nullable": [] - }, - "hash": "92b011eda2a652af714b08137b447753bc03ec35f6daa8ca245b1c8ed34c405f" -} diff --git a/backend/.sqlx/query-9784d19363fa14b9d7cc49e208967000a7bc8e56adddcd9304b05daf814e5246.json b/backend/.sqlx/query-9784d19363fa14b9d7cc49e208967000a7bc8e56adddcd9304b05daf814e5246.json new file mode 100644 index 0000000000..9ad7df84ba --- /dev/null +++ b/backend/.sqlx/query-9784d19363fa14b9d7cc49e208967000a7bc8e56adddcd9304b05daf814e5246.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM workspace_settings WHERE COALESCE(role_overrides ? $1, false)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "9784d19363fa14b9d7cc49e208967000a7bc8e56adddcd9304b05daf814e5246" +} diff --git a/backend/.sqlx/query-97b7a06758129c918392886eb05aac8df983a410e2e75f4bd4d29147a4f48b17.json b/backend/.sqlx/query-97b7a06758129c918392886eb05aac8df983a410e2e75f4bd4d29147a4f48b17.json new file mode 100644 index 0000000000..28e041fe00 --- /dev/null +++ b/backend/.sqlx/query-97b7a06758129c918392886eb05aac8df983a410e2e75f4bd4d29147a4f48b17.json @@ -0,0 +1,31 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COALESCE(r.value->'run_on_behalf_of' = 'true'::jsonb\n AND r.value->'slack_anyone' = 'true'::jsonb, false) AS \"anyone!\",\n r.value->>'on_behalf_of' AS on_behalf_of\n FROM slack_agent_channel c\n JOIN resource r ON r.workspace_id = c.workspace_id AND r.path = c.agent_path\n WHERE c.slack_team_id = $1 AND c.channel_id = $2 AND c.workspace_id = $3\n AND c.agent_path = $4 AND r.resource_type = 'ai_agent'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "anyone!", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "on_behalf_of", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "97b7a06758129c918392886eb05aac8df983a410e2e75f4bd4d29147a4f48b17" +} diff --git a/backend/.sqlx/query-988c720384e4e2d70bfa11196f3a3514e0756722762fabe49e1d291ce5c09a19.json b/backend/.sqlx/query-988c720384e4e2d70bfa11196f3a3514e0756722762fabe49e1d291ce5c09a19.json new file mode 100644 index 0000000000..0ffc66a8b7 --- /dev/null +++ b/backend/.sqlx/query-988c720384e4e2d70bfa11196f3a3514e0756722762fabe49e1d291ce5c09a19.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE usr SET workspace_role = $1 WHERE workspace_id = $2 AND username = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "988c720384e4e2d70bfa11196f3a3514e0756722762fabe49e1d291ce5c09a19" +} diff --git a/backend/.sqlx/query-98aebe8ead7d3f6e84ad88e6d006309b24b658cac3c99d8d0bdb84cdd00c413a.json b/backend/.sqlx/query-98aebe8ead7d3f6e84ad88e6d006309b24b658cac3c99d8d0bdb84cdd00c413a.json new file mode 100644 index 0000000000..f81b66ccb0 --- /dev/null +++ b/backend/.sqlx/query-98aebe8ead7d3f6e84ad88e6d006309b24b658cac3c99d8d0bdb84cdd00c413a.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT large_file_storage, ducklake FROM workspace_settings WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "large_file_storage", + "type_info": "Jsonb" + }, + { + "ordinal": 1, + "name": "ducklake", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true, + true + ] + }, + "hash": "98aebe8ead7d3f6e84ad88e6d006309b24b658cac3c99d8d0bdb84cdd00c413a" +} diff --git a/backend/.sqlx/query-99e11c04bcc436ec7a75f46365423c85a6e6490c2d9e1dc6ac39112d763f0f75.json b/backend/.sqlx/query-99e11c04bcc436ec7a75f46365423c85a6e6490c2d9e1dc6ac39112d763f0f75.json deleted file mode 100644 index 6fb4205e32..0000000000 --- a/backend/.sqlx/query-99e11c04bcc436ec7a75f46365423c85a6e6490c2d9e1dc6ac39112d763f0f75.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id FROM app_version WHERE app_id = (SELECT id FROM app WHERE path = $1 AND workspace_id = $2) ORDER BY created_at DESC LIMIT 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "99e11c04bcc436ec7a75f46365423c85a6e6490c2d9e1dc6ac39112d763f0f75" -} diff --git a/backend/.sqlx/query-c925264b7b0fd44ea7ab01c9af1514b9a9f2200e5a5db0a741697b28cd8b505f.json b/backend/.sqlx/query-9d7416a5458bb3d4a145cc1d2d35fa8e032a4ccdd23d7ed9a6c58d791b7bbd08.json similarity index 62% rename from backend/.sqlx/query-c925264b7b0fd44ea7ab01c9af1514b9a9f2200e5a5db0a741697b28cd8b505f.json rename to backend/.sqlx/query-9d7416a5458bb3d4a145cc1d2d35fa8e032a4ccdd23d7ed9a6c58d791b7bbd08.json index 37025d99d8..bb3d0d83b9 100644 --- a/backend/.sqlx/query-c925264b7b0fd44ea7ab01c9af1514b9a9f2200e5a5db0a741697b28cd8b505f.json +++ b/backend/.sqlx/query-9d7416a5458bb3d4a145cc1d2d35fa8e032a4ccdd23d7ed9a6c58d791b7bbd08.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT account FROM variable WHERE path = $1 AND workspace_id = $2", + "query": "DELETE FROM variable WHERE workspace_id = $1 AND path = $2 RETURNING account", "describe": { "columns": [ { @@ -19,5 +19,5 @@ true ] }, - "hash": "c925264b7b0fd44ea7ab01c9af1514b9a9f2200e5a5db0a741697b28cd8b505f" + "hash": "9d7416a5458bb3d4a145cc1d2d35fa8e032a4ccdd23d7ed9a6c58d791b7bbd08" } diff --git a/backend/.sqlx/query-9f6bb810ebe383c848d8256f594fe701ee064dd9c880ad4df89ec47279aff7de.json b/backend/.sqlx/query-9f6bb810ebe383c848d8256f594fe701ee064dd9c880ad4df89ec47279aff7de.json new file mode 100644 index 0000000000..6caeda5fc2 --- /dev/null +++ b/backend/.sqlx/query-9f6bb810ebe383c848d8256f594fe701ee064dd9c880ad4df89ec47279aff7de.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO app_version_lite (id, value)\n SELECT * FROM UNNEST($1::bigint[], $2::jsonb[])\n ON CONFLICT (id) DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array", + "JsonbArray" + ] + }, + "nullable": [] + }, + "hash": "9f6bb810ebe383c848d8256f594fe701ee064dd9c880ad4df89ec47279aff7de" +} diff --git a/backend/.sqlx/query-a32aea24f8cc6e0aed80dfa1f0928fab0f26ee5a0b9550ea4ab04e2857bebeda.json b/backend/.sqlx/query-a32aea24f8cc6e0aed80dfa1f0928fab0f26ee5a0b9550ea4ab04e2857bebeda.json new file mode 100644 index 0000000000..1e8970290f --- /dev/null +++ b/backend/.sqlx/query-a32aea24f8cc6e0aed80dfa1f0928fab0f26ee5a0b9550ea4ab04e2857bebeda.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings SET role_overrides = $1 WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb", + "Text" + ] + }, + "nullable": [] + }, + "hash": "a32aea24f8cc6e0aed80dfa1f0928fab0f26ee5a0b9550ea4ab04e2857bebeda" +} diff --git a/backend/.sqlx/query-a352fd2f8a2d57e499b975115ba14afc75b7794b2a172b6bee519597b0e2cf69.json b/backend/.sqlx/query-a352fd2f8a2d57e499b975115ba14afc75b7794b2a172b6bee519597b0e2cf69.json new file mode 100644 index 0000000000..88026d0fed --- /dev/null +++ b/backend/.sqlx/query-a352fd2f8a2d57e499b975115ba14afc75b7794b2a172b6bee519597b0e2cf69.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT versions[array_upper(versions, 1)] FROM app WHERE path = $1 AND workspace_id = $2 FOR NO KEY UPDATE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "versions", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "a352fd2f8a2d57e499b975115ba14afc75b7794b2a172b6bee519597b0e2cf69" +} diff --git a/backend/.sqlx/query-34979901e417b6eef8bda80f6213db46e3e68ee3d756c2540d924831879ce9e9.json b/backend/.sqlx/query-a3582b2b9089d85d8fa273aed5348394cb91064948c1c3fbb21f9461b9869af0.json similarity index 59% rename from backend/.sqlx/query-34979901e417b6eef8bda80f6213db46e3e68ee3d756c2540d924831879ce9e9.json rename to backend/.sqlx/query-a3582b2b9089d85d8fa273aed5348394cb91064948c1c3fbb21f9461b9869af0.json index 07fa5c2adf..2abbc1a8a3 100644 --- a/backend/.sqlx/query-34979901e417b6eef8bda80f6213db46e3e68ee3d756c2540d924831879ce9e9.json +++ b/backend/.sqlx/query-a3582b2b9089d85d8fa273aed5348394cb91064948c1c3fbb21f9461b9869af0.json @@ -1,32 +1,42 @@ { "db_name": "PostgreSQL", - "query": "SELECT app, hash, lock, code, code_sha256\n FROM app_script\n WHERE app = ANY(SELECT id FROM app WHERE workspace_id = $1)", + "query": "SELECT id, app, hash, lock, code, code_sha256, modules\n FROM app_script\n WHERE app = ANY(SELECT id FROM app WHERE workspace_id = $1)", "describe": { "columns": [ { "ordinal": 0, - "name": "app", + "name": "id", "type_info": "Int8" }, { "ordinal": 1, + "name": "app", + "type_info": "Int8" + }, + { + "ordinal": 2, "name": "hash", "type_info": "Bpchar" }, { - "ordinal": 2, + "ordinal": 3, "name": "lock", "type_info": "Text" }, { - "ordinal": 3, + "ordinal": 4, "name": "code", "type_info": "Text" }, { - "ordinal": 4, + "ordinal": 5, "name": "code_sha256", "type_info": "Bpchar" + }, + { + "ordinal": 6, + "name": "modules", + "type_info": "Jsonb" } ], "parameters": { @@ -35,12 +45,14 @@ ] }, "nullable": [ + false, false, false, true, false, - false + false, + true ] }, - "hash": "34979901e417b6eef8bda80f6213db46e3e68ee3d756c2540d924831879ce9e9" + "hash": "a3582b2b9089d85d8fa273aed5348394cb91064948c1c3fbb21f9461b9869af0" } diff --git a/backend/.sqlx/query-a37bd72bb1a151e859b4a37a447887e613debd842e575cc740502144bb279bb3.json b/backend/.sqlx/query-a37bd72bb1a151e859b4a37a447887e613debd842e575cc740502144bb279bb3.json new file mode 100644 index 0000000000..5eb7d45dd4 --- /dev/null +++ b/backend/.sqlx/query-a37bd72bb1a151e859b4a37a447887e613debd842e575cc740502144bb279bb3.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT workspace_id, username, email, is_admin, created_at, operator, disabled,\n COALESCE(workspace_role, CASE WHEN operator THEN 'operator' ELSE 'developer' END) AS role,\n added_via, is_service_account\n FROM usr\n WHERE workspace_id = $1\n ORDER BY email\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "is_admin", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "operator", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "disabled", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "role", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "added_via", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "is_service_account", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + null, + true, + false + ] + }, + "hash": "a37bd72bb1a151e859b4a37a447887e613debd842e575cc740502144bb279bb3" +} diff --git a/backend/.sqlx/query-a37dec4e86f537880d45da01ce872ae5b7858a087cb4eb5869573fff69c1c6b9.json b/backend/.sqlx/query-a37dec4e86f537880d45da01ce872ae5b7858a087cb4eb5869573fff69c1c6b9.json new file mode 100644 index 0000000000..7733ad9fa5 --- /dev/null +++ b/backend/.sqlx/query-a37dec4e86f537880d45da01ce872ae5b7858a087cb4eb5869573fff69c1c6b9.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT c.id AS \"id!\", ws.large_file_storage,\n EXISTS (SELECT 1 FROM fork_storage_namespace f\n WHERE f.workspace_id = c.id AND f.storage = $2) AS \"isolated!\"\n FROM unnest($1::text[]) WITH ORDINALITY AS c(id, ord)\n LEFT JOIN workspace_settings ws ON ws.workspace_id = c.id\n ORDER BY c.ord", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "large_file_storage", + "type_info": "Jsonb" + }, + { + "ordinal": 2, + "name": "isolated!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "TextArray", + "Text" + ] + }, + "nullable": [ + null, + true, + null + ] + }, + "hash": "a37dec4e86f537880d45da01ce872ae5b7858a087cb4eb5869573fff69c1c6b9" +} diff --git a/backend/.sqlx/query-a93bc7945ec8344a9eb6dc8c1bbb8ef161a9b7342c6699df8263df404153346a.json b/backend/.sqlx/query-a93bc7945ec8344a9eb6dc8c1bbb8ef161a9b7342c6699df8263df404153346a.json new file mode 100644 index 0000000000..883805160b --- /dev/null +++ b/backend/.sqlx/query-a93bc7945ec8344a9eb6dc8c1bbb8ef161a9b7342c6699df8263df404153346a.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n flow_step_id,\n flow_innermost_root_job as flow_scope,\n (flow_status->'step')::integer as step,\n jsonb_array_length(flow_status->'modules') as len,\n runnable_path ~ '/branchone-\\d+$' as is_branch_one,\n runnable_path ~ '/branch(one|all)-\\d+$' as is_branch,\n parent_job as next_parent\n FROM v2_job\n LEFT JOIN v2_job_status USING (id)\n WHERE v2_job.id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "flow_step_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "flow_scope", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "step", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "len", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "is_branch_one", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "is_branch", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "next_parent", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true, + true, + null, + null, + null, + null, + true + ] + }, + "hash": "a93bc7945ec8344a9eb6dc8c1bbb8ef161a9b7342c6699df8263df404153346a" +} diff --git a/backend/.sqlx/query-acd87efeb1e92f6c5f04f8482fde597611be96a8f2c6a8ea884c39b11620be57.json b/backend/.sqlx/query-acd87efeb1e92f6c5f04f8482fde597611be96a8f2c6a8ea884c39b11620be57.json new file mode 100644 index 0000000000..f9ef3977e7 --- /dev/null +++ b/backend/.sqlx/query-acd87efeb1e92f6c5f04f8482fde597611be96a8f2c6a8ea884c39b11620be57.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace_id,\n (auto_invite->>'operator')::boolean as \"is_operator: bool\",\n auto_invite->>'role' as \"role: String\",\n (auto_invite->>'mode') = 'add' as \"is_auto_add: bool\"\n FROM workspace_settings ws\n WHERE (auto_invite->>'domain' = $1 OR auto_invite->>'domain' = '*')\n AND (auto_invite->>'enabled')::boolean = true\n AND NOT EXISTS (SELECT 1 FROM usr WHERE workspace_id = ws.workspace_id AND email = $2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "is_operator: bool", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "role: String", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "is_auto_add: bool", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + null, + null, + null + ] + }, + "hash": "acd87efeb1e92f6c5f04f8482fde597611be96a8f2c6a8ea884c39b11620be57" +} diff --git a/backend/.sqlx/query-acda97a0d2ebb10942b1049cafe3fc9c997dd9427659ff15f6850be861000bdf.json b/backend/.sqlx/query-acda97a0d2ebb10942b1049cafe3fc9c997dd9427659ff15f6850be861000bdf.json new file mode 100644 index 0000000000..c8c440ac5f --- /dev/null +++ b/backend/.sqlx/query-acda97a0d2ebb10942b1049cafe3fc9c997dd9427659ff15f6850be861000bdf.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT c.agent_path FROM slack_agent_channel c\n JOIN workspace_settings ws ON ws.workspace_id = c.workspace_id\n AND ws.slack_team_id = c.slack_team_id\n WHERE c.slack_team_id = $1 AND c.channel_id = $2\n AND ws.workspace_id = ANY($3)\n ORDER BY c.agent_path LIMIT 11", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "agent_path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "TextArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "acda97a0d2ebb10942b1049cafe3fc9c997dd9427659ff15f6850be861000bdf" +} diff --git a/backend/.sqlx/query-ad218040fc53b063508774c6f04d5f82cc2121ea8519311add88f23f0ea0dae9.json b/backend/.sqlx/query-ad218040fc53b063508774c6f04d5f82cc2121ea8519311add88f23f0ea0dae9.json new file mode 100644 index 0000000000..87a4b3e764 --- /dev/null +++ b/backend/.sqlx/query-ad218040fc53b063508774c6f04d5f82cc2121ea8519311add88f23f0ea0dae9.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE usr SET added_via = $1, is_admin = $2 WHERE username = $3 AND workspace_id = $4", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb", + "Bool", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "ad218040fc53b063508774c6f04d5f82cc2121ea8519311add88f23f0ea0dae9" +} diff --git a/backend/.sqlx/query-2e589e039986e7a2c75e328868874669b32cbe0dae6822b2d2fad0635c5f6087.json b/backend/.sqlx/query-ae695dce1a5740aa2b0283bc80dea6f924cc4246bce4ac13e8256a96085bcce1.json similarity index 68% rename from backend/.sqlx/query-2e589e039986e7a2c75e328868874669b32cbe0dae6822b2d2fad0635c5f6087.json rename to backend/.sqlx/query-ae695dce1a5740aa2b0283bc80dea6f924cc4246bce4ac13e8256a96085bcce1.json index 6ba559e644..8edb183166 100644 --- a/backend/.sqlx/query-2e589e039986e7a2c75e328868874669b32cbe0dae6822b2d2fad0635c5f6087.json +++ b/backend/.sqlx/query-ae695dce1a5740aa2b0283bc80dea6f924cc4246bce4ac13e8256a96085bcce1.json @@ -1,15 +1,16 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE v2_job_status\n SET flow_status = jsonb_set(flow_status, array['stream_job'], to_jsonb($1::UUID::TEXT))\n WHERE id = $2", + "query": "\n UPDATE v2_job_status\n SET flow_status = jsonb_set(flow_status, array['stream_job'], to_jsonb($1::UUID::TEXT))\n WHERE id = $2 AND NOT COALESCE(flow_leaf_jobs ? $3, false)", "describe": { "columns": [], "parameters": { "Left": [ "Uuid", - "Uuid" + "Uuid", + "Text" ] }, "nullable": [] }, - "hash": "2e589e039986e7a2c75e328868874669b32cbe0dae6822b2d2fad0635c5f6087" + "hash": "ae695dce1a5740aa2b0283bc80dea6f924cc4246bce4ac13e8256a96085bcce1" } diff --git a/backend/.sqlx/query-afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270.json b/backend/.sqlx/query-afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270.json deleted file mode 100644 index b492b38f49..0000000000 --- a/backend/.sqlx/query-afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE app SET versions = array_append(versions, $1::bigint) WHERE path = $2 AND workspace_id = $3 AND versions[array_upper(versions, 1)] = $1::bigint", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270" -} diff --git a/backend/.sqlx/query-b0c10d17ed684af4dc79db7a199d4438ea003b5762178f25ff12ef23c1a8f46a.json b/backend/.sqlx/query-b0c10d17ed684af4dc79db7a199d4438ea003b5762178f25ff12ef23c1a8f46a.json deleted file mode 100644 index a5a8c00494..0000000000 --- a/backend/.sqlx/query-b0c10d17ed684af4dc79db7a199d4438ea003b5762178f25ff12ef23c1a8f46a.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)\n VALUES ('test-workspace', $1, 'f/test/failing_script', $2, 'bun', 'script', 'test-user', '{}', 'Failing test script', 'A script that always fails', E'{}\\n//bun.lock\\n')\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Text" - ] - }, - "nullable": [] - }, - "hash": "b0c10d17ed684af4dc79db7a199d4438ea003b5762178f25ff12ef23c1a8f46a" -} diff --git a/backend/.sqlx/query-b0e56637096b48867401967ad5ddc509bbd1e322788789a913d5148c1113643c.json b/backend/.sqlx/query-b0e56637096b48867401967ad5ddc509bbd1e322788789a913d5148c1113643c.json new file mode 100644 index 0000000000..2478a6c7eb --- /dev/null +++ b/backend/.sqlx/query-b0e56637096b48867401967ad5ddc509bbd1e322788789a913d5148c1113643c.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT username, email, is_admin, operator, workspace_role,\n added_via->>'group' as granting_group\n FROM usr\n WHERE workspace_id = $1 AND added_via->>'source' = 'instance_group'\n ORDER BY email", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "is_admin", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "operator", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "workspace_role", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "granting_group", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + null + ] + }, + "hash": "b0e56637096b48867401967ad5ddc509bbd1e322788789a913d5148c1113643c" +} diff --git a/backend/.sqlx/query-b19f5156b3e36b5e831ce792637bf54a3f182c03cef0aef06a1c75b45992cd39.json b/backend/.sqlx/query-b19f5156b3e36b5e831ce792637bf54a3f182c03cef0aef06a1c75b45992cd39.json index 10adc6e68d..4db5bc6e3c 100644 --- a/backend/.sqlx/query-b19f5156b3e36b5e831ce792637bf54a3f182c03cef0aef06a1c75b45992cd39.json +++ b/backend/.sqlx/query-b19f5156b3e36b5e831ce792637bf54a3f182c03cef0aef06a1c75b45992cd39.json @@ -11,7 +11,7 @@ ], "parameters": { "Left": [ - "Varchar", + "Text", "Int8" ] }, diff --git a/backend/.sqlx/query-b57b038384e48f2e6f288056e7261459604a093a74e22770ccdc14207cacbfb0.json b/backend/.sqlx/query-b57b038384e48f2e6f288056e7261459604a093a74e22770ccdc14207cacbfb0.json new file mode 100644 index 0000000000..b344aa8d0e --- /dev/null +++ b/backend/.sqlx/query-b57b038384e48f2e6f288056e7261459604a093a74e22770ccdc14207cacbfb0.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n workspace_invite.workspace_id,\n workspace_invite.email,\n workspace_invite.is_admin,\n workspace_invite.operator,\n COALESCE(workspace_invite.workspace_role, CASE WHEN workspace_invite.operator THEN 'operator' ELSE 'developer' END) AS \"role!\",\n workspace.parent_workspace_id\n FROM workspace_invite JOIN workspace ON workspace_invite.workspace_id = workspace.id WHERE email = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "is_admin", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "operator", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "role!", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "parent_workspace_id", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + null, + true + ] + }, + "hash": "b57b038384e48f2e6f288056e7261459604a093a74e22770ccdc14207cacbfb0" +} diff --git a/backend/.sqlx/query-b7c7384b335a51f7d1abd2c627ea62ec651c5c6eee947540e7ee5dcea4780b37.json b/backend/.sqlx/query-b7c7384b335a51f7d1abd2c627ea62ec651c5c6eee947540e7ee5dcea4780b37.json new file mode 100644 index 0000000000..2b930845d2 --- /dev/null +++ b/backend/.sqlx/query-b7c7384b335a51f7d1abd2c627ea62ec651c5c6eee947540e7ee5dcea4780b37.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH revoked AS (\n DELETE FROM token WHERE email = $1 AND label LIKE 'mcp-oauth-%' RETURNING token_prefix\n ), refresh AS (\n DELETE FROM mcp_oauth_refresh_token WHERE user_email = $1\n ), code AS (\n DELETE FROM mcp_oauth_server_code WHERE user_email = $1\n )\n INSERT INTO notify_event (channel, payload)\n SELECT 'notify_token_invalidation', token_prefix FROM revoked", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "b7c7384b335a51f7d1abd2c627ea62ec651c5c6eee947540e7ee5dcea4780b37" +} diff --git a/backend/.sqlx/query-b8b89624212f32fb6e994b7488fa7152c190bab532406fd26d17dfbd2355c764.json b/backend/.sqlx/query-b8b89624212f32fb6e994b7488fa7152c190bab532406fd26d17dfbd2355c764.json new file mode 100644 index 0000000000..cdd594c8df --- /dev/null +++ b/backend/.sqlx/query-b8b89624212f32fb6e994b7488fa7152c190bab532406fd26d17dfbd2355c764.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT is_admin, operator, workspace_role FROM usr where username = $1 AND workspace_id = $2 AND disabled = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "is_admin", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "operator", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "workspace_role", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "b8b89624212f32fb6e994b7488fa7152c190bab532406fd26d17dfbd2355c764" +} diff --git a/backend/.sqlx/query-ba0c3dad5d2a77922821c9c6d58e06a65e6666e197f3e59b880470df35c27bec.json b/backend/.sqlx/query-ba0c3dad5d2a77922821c9c6d58e06a65e6666e197f3e59b880470df35c27bec.json deleted file mode 100644 index 7c4fe34428..0000000000 --- a/backend/.sqlx/query-ba0c3dad5d2a77922821c9c6d58e06a65e6666e197f3e59b880470df35c27bec.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO group_ (workspace_id, name, summary, extra_perms)\n VALUES ('test-workspace', 'error_handler', 'Error handler group', '{\"u/test-user\": true}')\n ON CONFLICT DO NOTHING\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "ba0c3dad5d2a77922821c9c6d58e06a65e6666e197f3e59b880470df35c27bec" -} diff --git a/backend/.sqlx/query-bb4cde84157126f29680cb36075848e7cde2ab7c6b7f0861becd4c0b8c330a8d.json b/backend/.sqlx/query-bb4cde84157126f29680cb36075848e7cde2ab7c6b7f0861becd4c0b8c330a8d.json deleted file mode 100644 index 4c4e0b71a4..0000000000 --- a/backend/.sqlx/query-bb4cde84157126f29680cb36075848e7cde2ab7c6b7f0861becd4c0b8c330a8d.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE workspace_settings\n SET error_handler = '{\"path\": \"script/f/test/error_handler\"}'::jsonb\n WHERE workspace_id = 'test-workspace'\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "bb4cde84157126f29680cb36075848e7cde2ab7c6b7f0861becd4c0b8c330a8d" -} diff --git a/backend/.sqlx/query-1cf8597b9d37ec5a924aff8cbc0a05768ed9a679ba908ab16497a9bd55578ba1.json b/backend/.sqlx/query-bbc38059207753bd92cc543e5d850abe6dd3b66e87d4827c98cbaa5950f4def4.json similarity index 72% rename from backend/.sqlx/query-1cf8597b9d37ec5a924aff8cbc0a05768ed9a679ba908ab16497a9bd55578ba1.json rename to backend/.sqlx/query-bbc38059207753bd92cc543e5d850abe6dd3b66e87d4827c98cbaa5950f4def4.json index b4a9f19f45..af2c944342 100644 --- a/backend/.sqlx/query-1cf8597b9d37ec5a924aff8cbc0a05768ed9a679ba908ab16497a9bd55578ba1.json +++ b/backend/.sqlx/query-bbc38059207753bd92cc543e5d850abe6dd3b66e87d4827c98cbaa5950f4def4.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT usr.*, COALESCE(password.super_admin, false) as \"super_admin!\", password.name FROM usr LEFT JOIN password ON usr.email = password.email Where usr.username = $1 AND workspace_id = $2\n ", + "query": "SELECT usr.workspace_id, usr.email, usr.username, usr.is_admin, usr.created_at, usr.operator, usr.disabled, usr.role, usr.added_via, usr.is_service_account, usr.workspace_role, COALESCE(password.super_admin, false) as \"super_admin!\", password.name FROM usr LEFT JOIN password ON usr.email = password.email Where usr.username = $1 AND workspace_id = $2\n ", "describe": { "columns": [ { @@ -10,12 +10,12 @@ }, { "ordinal": 1, - "name": "username", + "name": "email", "type_info": "Varchar" }, { "ordinal": 2, - "name": "email", + "name": "username", "type_info": "Varchar" }, { @@ -55,11 +55,16 @@ }, { "ordinal": 10, + "name": "workspace_role", + "type_info": "Varchar" + }, + { + "ordinal": 11, "name": "super_admin!", "type_info": "Bool" }, { - "ordinal": 11, + "ordinal": 12, "name": "name", "type_info": "Varchar" } @@ -81,9 +86,10 @@ true, true, false, + true, null, true ] }, - "hash": "1cf8597b9d37ec5a924aff8cbc0a05768ed9a679ba908ab16497a9bd55578ba1" + "hash": "bbc38059207753bd92cc543e5d850abe6dd3b66e87d4827c98cbaa5950f4def4" } diff --git a/backend/.sqlx/query-bd2812c7510d2e448c3218dfe3261272c2916540062d57d36c8471c40a398727.json b/backend/.sqlx/query-bd2812c7510d2e448c3218dfe3261272c2916540062d57d36c8471c40a398727.json new file mode 100644 index 0000000000..381f5ba491 --- /dev/null +++ b/backend/.sqlx/query-bd2812c7510d2e448c3218dfe3261272c2916540062d57d36c8471c40a398727.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_integrations SET resource_path = $3, updated_at = now()\n WHERE workspace_id = $1 AND service_name = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "native_trigger_service", + "kind": { + "Enum": [ + "nextcloud", + "google", + "github" + ] + } + } + }, + "Text" + ] + }, + "nullable": [] + }, + "hash": "bd2812c7510d2e448c3218dfe3261272c2916540062d57d36c8471c40a398727" +} diff --git a/backend/.sqlx/query-bdc39bdd63c38217834a0c0d858f6db33d63ff7bf55329452dc84dae2560c23e.json b/backend/.sqlx/query-bdc39bdd63c38217834a0c0d858f6db33d63ff7bf55329452dc84dae2560c23e.json new file mode 100644 index 0000000000..cb595ad5b3 --- /dev/null +++ b/backend/.sqlx/query-bdc39bdd63c38217834a0c0d858f6db33d63ff7bf55329452dc84dae2560c23e.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS (SELECT 1 FROM workspace WHERE id = $1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "bdc39bdd63c38217834a0c0d858f6db33d63ff7bf55329452dc84dae2560c23e" +} diff --git a/backend/.sqlx/query-bf763c3ae4951537033d18ce48d3eb83d5c19e32aef199bf161e8ba8d1adf176.json b/backend/.sqlx/query-bf763c3ae4951537033d18ce48d3eb83d5c19e32aef199bf161e8ba8d1adf176.json new file mode 100644 index 0000000000..d6b261b56c --- /dev/null +++ b/backend/.sqlx/query-bf763c3ae4951537033d18ce48d3eb83d5c19e32aef199bf161e8ba8d1adf176.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, edited_at, created_by)\n SELECT $2, path,\n CASE WHEN $3::text IS NOT NULL AND resource_type = 'ai_agent'\n AND jsonb_typeof(value) = 'object' AND value ? 'on_behalf_of'\n THEN jsonb_set(value, '{on_behalf_of}', to_jsonb($3::text))\n ELSE value END,\n description, resource_type, extra_perms, edited_at, created_by\n FROM resource\n WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "bf763c3ae4951537033d18ce48d3eb83d5c19e32aef199bf161e8ba8d1adf176" +} diff --git a/backend/.sqlx/query-c01d2853ab35a404e0cce34c273c7ad58cd9add49f45fd50f4fcf909703d01a2.json b/backend/.sqlx/query-c01d2853ab35a404e0cce34c273c7ad58cd9add49f45fd50f4fcf909703d01a2.json new file mode 100644 index 0000000000..dfa1689d15 --- /dev/null +++ b/backend/.sqlx/query-c01d2853ab35a404e0cce34c273c7ad58cd9add49f45fd50f4fcf909703d01a2.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret\n FROM workspace_settings WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "slack_oauth_client_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "slack_oauth_client_secret", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "slack_signing_secret", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true, + true, + true + ] + }, + "hash": "c01d2853ab35a404e0cce34c273c7ad58cd9add49f45fd50f4fcf909703d01a2" +} diff --git a/backend/.sqlx/query-c17ac5cf890831ede0811263a0f35e8cb2fe4df224e2ab633a1e69c4bcfa6cd1.json b/backend/.sqlx/query-c17ac5cf890831ede0811263a0f35e8cb2fe4df224e2ab633a1e69c4bcfa6cd1.json new file mode 100644 index 0000000000..f468c212dd --- /dev/null +++ b/backend/.sqlx/query-c17ac5cf890831ede0811263a0f35e8cb2fe4df224e2ab633a1e69c4bcfa6cd1.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE native_trigger SET connection_path = REGEXP_REPLACE(connection_path, 'u/' || $2 || '/(.*)', $1 || '/\\1') WHERE connection_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "c17ac5cf890831ede0811263a0f35e8cb2fe4df224e2ab633a1e69c4bcfa6cd1" +} diff --git a/backend/.sqlx/query-c1be495552d1bd4c38e378016324b3bc473599c4cb1b2181144988eb88e9fa17.json b/backend/.sqlx/query-c1be495552d1bd4c38e378016324b3bc473599c4cb1b2181144988eb88e9fa17.json new file mode 100644 index 0000000000..72f509fc57 --- /dev/null +++ b/backend/.sqlx/query-c1be495552d1bd4c38e378016324b3bc473599c4cb1b2181144988eb88e9fa17.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace_role, operator FROM usr WHERE workspace_id = $1 AND username = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_role", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "operator", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + true, + false + ] + }, + "hash": "c1be495552d1bd4c38e378016324b3bc473599c4cb1b2181144988eb88e9fa17" +} diff --git a/backend/.sqlx/query-c2efefded4eaea858c41c32ef20e2c11ed88327cf033e1abfd7c0458b71f53da.json b/backend/.sqlx/query-c2efefded4eaea858c41c32ef20e2c11ed88327cf033e1abfd7c0458b71f53da.json index ee946012b2..5791e1ca31 100644 --- a/backend/.sqlx/query-c2efefded4eaea858c41c32ef20e2c11ed88327cf033e1abfd7c0458b71f53da.json +++ b/backend/.sqlx/query-c2efefded4eaea858c41c32ef20e2c11ed88327cf033e1abfd7c0458b71f53da.json @@ -76,7 +76,7 @@ false, false, false, - false, + true, false, false, false, diff --git a/backend/.sqlx/query-c2f271bcea1f11b2979152cc743559236fd875192b7b7f4824c63675fdc6a551.json b/backend/.sqlx/query-c2f271bcea1f11b2979152cc743559236fd875192b7b7f4824c63675fdc6a551.json deleted file mode 100644 index c621e4b903..0000000000 --- a/backend/.sqlx/query-c2f271bcea1f11b2979152cc743559236fd875192b7b7f4824c63675fdc6a551.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE workspace_settings\n SET success_handler = '{\"path\": \"flow/f/test/success_flow\"}'::jsonb\n WHERE workspace_id = 'test-workspace'\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "c2f271bcea1f11b2979152cc743559236fd875192b7b7f4824c63675fdc6a551" -} diff --git a/backend/.sqlx/query-c37f34ea3603af1496825ec54a3415fe710c5fbbc7380c5088c7069fa2caf990.json b/backend/.sqlx/query-c37f34ea3603af1496825ec54a3415fe710c5fbbc7380c5088c7069fa2caf990.json new file mode 100644 index 0000000000..bb82fbf97e --- /dev/null +++ b/backend/.sqlx/query-c37f34ea3603af1496825ec54a3415fe710c5fbbc7380c5088c7069fa2caf990.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM fork_storage_namespace WHERE workspace_id = $1 AND storage = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "c37f34ea3603af1496825ec54a3415fe710c5fbbc7380c5088c7069fa2caf990" +} diff --git a/backend/.sqlx/query-c3bfc89762258b83bf7494ea40e097cb3bd140e3986780d35706584a368ea215.json b/backend/.sqlx/query-c3bfc89762258b83bf7494ea40e097cb3bd140e3986780d35706584a368ea215.json new file mode 100644 index 0000000000..52dffb5a4c --- /dev/null +++ b/backend/.sqlx/query-c3bfc89762258b83bf7494ea40e097cb3bd140e3986780d35706584a368ea215.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value FROM app_version WHERE id = $1 FOR NO KEY UPDATE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value", + "type_info": "Json" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "c3bfc89762258b83bf7494ea40e097cb3bd140e3986780d35706584a368ea215" +} diff --git a/backend/.sqlx/query-c427c9dad3de3acc361c341b359453197d7683bc8fd5a96be462e3493e957cee.json b/backend/.sqlx/query-c427c9dad3de3acc361c341b359453197d7683bc8fd5a96be462e3493e957cee.json new file mode 100644 index 0000000000..f851507f4e --- /dev/null +++ b/backend/.sqlx/query-c427c9dad3de3acc361c341b359453197d7683bc8fd5a96be462e3493e957cee.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE variable SET value = $1 WHERE workspace_id = $2 AND account = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "c427c9dad3de3acc361c341b359453197d7683bc8fd5a96be462e3493e957cee" +} diff --git a/backend/.sqlx/query-c5697a48239f96add86676e2776582da9d9b3e33fdec630f4beac214e98c0cbc.json b/backend/.sqlx/query-c5697a48239f96add86676e2776582da9d9b3e33fdec630f4beac214e98c0cbc.json new file mode 100644 index 0000000000..417fe15655 --- /dev/null +++ b/backend/.sqlx/query-c5697a48239f96add86676e2776582da9d9b3e33fdec630f4beac214e98c0cbc.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO slack_answered_message (slack_team_id, channel_id, ts)\n VALUES ($1, $2, $3) ON CONFLICT DO NOTHING RETURNING true", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [ + null + ] + }, + "hash": "c5697a48239f96add86676e2776582da9d9b3e33fdec630f4beac214e98c0cbc" +} diff --git a/backend/.sqlx/query-f3cd54017710536747b8aca797a8b983774ee2127dceb790181ed91b987f44ff.json b/backend/.sqlx/query-c5a7f56bed61174904d3bb1b7be704a4d1a3a4da8c995319f3cc3ce96d8ef5cc.json similarity index 65% rename from backend/.sqlx/query-f3cd54017710536747b8aca797a8b983774ee2127dceb790181ed91b987f44ff.json rename to backend/.sqlx/query-c5a7f56bed61174904d3bb1b7be704a4d1a3a4da8c995319f3cc3ce96d8ef5cc.json index 6964dfa8d6..48435310f8 100644 --- a/backend/.sqlx/query-f3cd54017710536747b8aca797a8b983774ee2127dceb790181ed91b987f44ff.json +++ b/backend/.sqlx/query-c5a7f56bed61174904d3bb1b7be704a4d1a3a4da8c995319f3cc3ce96d8ef5cc.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n nt.external_id,\n nt.workspace_id,\n nt.service_name AS \"service_name!: ServiceName\",\n nt.script_path,\n nt.is_flow,\n nt.webhook_token_hash,\n nt.service_config,\n nt.error,\n nt.created_at,\n nt.updated_at,\n nt.summary,\n nt.enabled\n FROM\n native_trigger nt\n WHERE\n nt.workspace_id = $1 AND\n nt.service_name = $2 AND\n ($5::text IS NULL OR nt.script_path = $5) AND\n ($6::bool IS NULL OR nt.is_flow = $6) AND\n (\n (nt.is_flow = false AND EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = nt.workspace_id\n AND s.path = nt.script_path\n ))\n OR\n (nt.is_flow = true AND EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = nt.workspace_id\n AND f.path = nt.script_path\n ))\n ) AND\n ( $7\n OR nt.script_path = ANY($8)\n OR EXISTS ( SELECT 1 FROM unnest($9::text[]) AS pfx\n WHERE nt.script_path = pfx\n OR left(nt.script_path, length(pfx) + 1) = pfx || '/' ) )\n LIMIT $3\n OFFSET $4\n ", + "query": "\n SELECT\n nt.external_id,\n nt.workspace_id,\n nt.service_name AS \"service_name!: ServiceName\",\n nt.script_path,\n nt.is_flow,\n nt.webhook_token_hash,\n nt.service_config,\n nt.error,\n nt.created_at,\n nt.updated_at,\n nt.summary,\n nt.enabled,\n nt.connection_path\n FROM\n native_trigger nt\n WHERE\n nt.workspace_id = $1 AND\n nt.service_name = $2 AND\n ($5::text IS NULL OR nt.script_path = $5) AND\n ($6::bool IS NULL OR nt.is_flow = $6) AND\n (\n (nt.is_flow = false AND EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = nt.workspace_id\n AND s.path = nt.script_path\n ))\n OR\n (nt.is_flow = true AND EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = nt.workspace_id\n AND f.path = nt.script_path\n ))\n ) AND\n ( $7\n OR nt.script_path = ANY($8)\n OR EXISTS ( SELECT 1 FROM unnest($9::text[]) AS pfx\n WHERE nt.script_path = pfx\n OR left(nt.script_path, length(pfx) + 1) = pfx || '/' ) )\n LIMIT $3\n OFFSET $4\n ", "describe": { "columns": [ { @@ -73,6 +73,11 @@ "ordinal": 11, "name": "enabled", "type_info": "Bool" + }, + { + "ordinal": 12, + "name": "connection_path", + "type_info": "Varchar" } ], "parameters": { @@ -111,8 +116,9 @@ false, false, true, - false + false, + true ] }, - "hash": "f3cd54017710536747b8aca797a8b983774ee2127dceb790181ed91b987f44ff" + "hash": "c5a7f56bed61174904d3bb1b7be704a4d1a3a4da8c995319f3cc3ce96d8ef5cc" } diff --git a/backend/.sqlx/query-0c6c80746733be8f561ab0b631854799f5e8122adaf35465cb16c3dc795bdc3b.json b/backend/.sqlx/query-c798432108de19ed28dcf1810c68c838beaa5dfa5e3bcfcfeddee252ffe1334e.json similarity index 55% rename from backend/.sqlx/query-0c6c80746733be8f561ab0b631854799f5e8122adaf35465cb16c3dc795bdc3b.json rename to backend/.sqlx/query-c798432108de19ed28dcf1810c68c838beaa5dfa5e3bcfcfeddee252ffe1334e.json index efe58b7a16..9779b00977 100644 --- a/backend/.sqlx/query-0c6c80746733be8f561ab0b631854799f5e8122adaf35465cb16c3dc795bdc3b.json +++ b/backend/.sqlx/query-c798432108de19ed28dcf1810c68c838beaa5dfa5e3bcfcfeddee252ffe1334e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO app_script (app, hash, lock, code, code_sha256)\n VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (hash) DO UPDATE SET app = EXCLUDED.app -- trivial update to return the id\n RETURNING id\n ", + "query": "\n INSERT INTO app_script (app, hash, lock, code, code_sha256, modules)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT (hash) DO UPDATE SET app = EXCLUDED.app -- trivial update to return the id\n RETURNING id\n ", "describe": { "columns": [ { @@ -15,12 +15,13 @@ "Bpchar", "Text", "Text", - "Bpchar" + "Bpchar", + "Jsonb" ] }, "nullable": [ false ] }, - "hash": "0c6c80746733be8f561ab0b631854799f5e8122adaf35465cb16c3dc795bdc3b" + "hash": "c798432108de19ed28dcf1810c68c838beaa5dfa5e3bcfcfeddee252ffe1334e" } diff --git a/backend/.sqlx/query-c7a1a350f95523df4e2012d2d21ec87a0016b1949cfe2d901aacc281d9442738.json b/backend/.sqlx/query-c7a1a350f95523df4e2012d2d21ec87a0016b1949cfe2d901aacc281d9442738.json new file mode 100644 index 0000000000..01baaa7c57 --- /dev/null +++ b/backend/.sqlx/query-c7a1a350f95523df4e2012d2d21ec87a0016b1949cfe2d901aacc281d9442738.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO role_seat (workspace_id, role, author)\n SELECT $1, role, author FROM role_seat WHERE workspace_id = $2\n ON CONFLICT DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "c7a1a350f95523df4e2012d2d21ec87a0016b1949cfe2d901aacc281d9442738" +} diff --git a/backend/.sqlx/query-c8e3e103645e0428d4b784f200595309a55635083a541745552c397a36ac08c4.json b/backend/.sqlx/query-c8e3e103645e0428d4b784f200595309a55635083a541745552c397a36ac08c4.json new file mode 100644 index 0000000000..e66cfa2881 --- /dev/null +++ b/backend/.sqlx/query-c8e3e103645e0428d4b784f200595309a55635083a541745552c397a36ac08c4.json @@ -0,0 +1,54 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, name, is_public, message_count, created_by, shared_at FROM ai_shared_session\n WHERE workspace_id = $1 AND email = $2 AND session_id = $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "is_public", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "message_count", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "shared_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "c8e3e103645e0428d4b784f200595309a55635083a541745552c397a36ac08c4" +} diff --git a/backend/.sqlx/query-c9a8aca5b40f9393ccab04edd10cb507b5e8dc0cca2ddd424e09d784e3e22be5.json b/backend/.sqlx/query-c9a8aca5b40f9393ccab04edd10cb507b5e8dc0cca2ddd424e09d784e3e22be5.json new file mode 100644 index 0000000000..9b906031dc --- /dev/null +++ b/backend/.sqlx/query-c9a8aca5b40f9393ccab04edd10cb507b5e8dc0cca2ddd424e09d784e3e22be5.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO slack_thread_agent (slack_team_id, channel_id, thread_ts, workspace_id, agent_path)\n VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "c9a8aca5b40f9393ccab04edd10cb507b5e8dc0cca2ddd424e09d784e3e22be5" +} diff --git a/backend/.sqlx/query-ca15fe5d43f0e94f50408efe5c9e359770b759e8661687b4503c4b692ecd245e.json b/backend/.sqlx/query-ca15fe5d43f0e94f50408efe5c9e359770b759e8661687b4503c4b692ecd245e.json new file mode 100644 index 0000000000..6812bfdfc7 --- /dev/null +++ b/backend/.sqlx/query-ca15fe5d43f0e94f50408efe5c9e359770b759e8661687b4503c4b692ecd245e.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT versions[array_upper(versions, 1)] FROM app WHERE path = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "versions", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "ca15fe5d43f0e94f50408efe5c9e359770b759e8661687b4503c4b692ecd245e" +} diff --git a/backend/.sqlx/query-ca4c2768d111fbd653f1a73afc669b58a0016bfb4f0dad133ef2c25b8cd8b4b6.json b/backend/.sqlx/query-ca4c2768d111fbd653f1a73afc669b58a0016bfb4f0dad133ef2c25b8cd8b4b6.json new file mode 100644 index 0000000000..c84a500f29 --- /dev/null +++ b/backend/.sqlx/query-ca4c2768d111fbd653f1a73afc669b58a0016bfb4f0dad133ef2c25b8cd8b4b6.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_invite\n (workspace_id, email, is_admin, operator, workspace_role)\n VALUES ($1, $2, false, $3, $4)\n ON CONFLICT DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Bool", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "ca4c2768d111fbd653f1a73afc669b58a0016bfb4f0dad133ef2c25b8cd8b4b6" +} diff --git a/backend/.sqlx/query-cb421ceb68ab798bde4192a8da9a133ea859f243f518197cde803ef0c86eab33.json b/backend/.sqlx/query-cb421ceb68ab798bde4192a8da9a133ea859f243f518197cde803ef0c86eab33.json new file mode 100644 index 0000000000..73fbc88289 --- /dev/null +++ b/backend/.sqlx/query-cb421ceb68ab798bde4192a8da9a133ea859f243f518197cde803ef0c86eab33.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM ai_shared_session\n WHERE workspace_id = $1 AND id = $2 AND (email = $3 OR $4::bool)\n RETURNING name", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Uuid", + "Text", + "Bool" + ] + }, + "nullable": [ + false + ] + }, + "hash": "cb421ceb68ab798bde4192a8da9a133ea859f243f518197cde803ef0c86eab33" +} diff --git a/backend/.sqlx/query-cbc0edfcdda98420e728e35508e46d9b9ecb504cbea4976c8f45fc089cb81977.json b/backend/.sqlx/query-cbc0edfcdda98420e728e35508e46d9b9ecb504cbea4976c8f45fc089cb81977.json new file mode 100644 index 0000000000..d109e62811 --- /dev/null +++ b/backend/.sqlx/query-cbc0edfcdda98420e728e35508e46d9b9ecb504cbea4976c8f45fc089cb81977.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE role_seat SET workspace_id = $1 WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "cbc0edfcdda98420e728e35508e46d9b9ecb504cbea4976c8f45fc089cb81977" +} diff --git a/backend/.sqlx/query-cc719da4da98b6fe9612a4cc2f42dd19834a3e0d0be593ea6bc1239fe5453a74.json b/backend/.sqlx/query-cc719da4da98b6fe9612a4cc2f42dd19834a3e0d0be593ea6bc1239fe5453a74.json deleted file mode 100644 index 91cacf4123..0000000000 --- a/backend/.sqlx/query-cc719da4da98b6fe9612a4cc2f42dd19834a3e0d0be593ea6bc1239fe5453a74.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)\n VALUES ('test-workspace', 5555555555, 'f/test/error_handler', 'export function main() { return \"handled\"; }', 'bun', 'script', 'test-user', '{}', '', '', E'{}\\n//bun.lock\\n')\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "cc719da4da98b6fe9612a4cc2f42dd19834a3e0d0be593ea6bc1239fe5453a74" -} diff --git a/backend/.sqlx/query-cce5e3e639faed8e42574730cc66f0322a83c01cc465742f54a21f8fe5f4f037.json b/backend/.sqlx/query-cce5e3e639faed8e42574730cc66f0322a83c01cc465742f54a21f8fe5f4f037.json index 29f31c62eb..59ee111e89 100644 --- a/backend/.sqlx/query-cce5e3e639faed8e42574730cc66f0322a83c01cc465742f54a21f8fe5f4f037.json +++ b/backend/.sqlx/query-cce5e3e639faed8e42574730cc66f0322a83c01cc465742f54a21f8fe5f4f037.json @@ -39,7 +39,8 @@ "java", "duckdb", "ruby", - "rlang" + "rlang", + "dbt" ] } } diff --git a/backend/.sqlx/query-72076c1c210c57ef5db26620063c750c4a7eef6dcb2f4a91c7a3ddc2d367e3ba.json b/backend/.sqlx/query-cd829b96e6d8f4ed9a0283d9141ce8588ad36379991e71b10e49211891219a92.json similarity index 51% rename from backend/.sqlx/query-72076c1c210c57ef5db26620063c750c4a7eef6dcb2f4a91c7a3ddc2d367e3ba.json rename to backend/.sqlx/query-cd829b96e6d8f4ed9a0283d9141ce8588ad36379991e71b10e49211891219a92.json index b3482a1535..fa44c1e169 100644 --- a/backend/.sqlx/query-72076c1c210c57ef5db26620063c750c4a7eef6dcb2f4a91c7a3ddc2d367e3ba.json +++ b/backend/.sqlx/query-cd829b96e6d8f4ed9a0283d9141ce8588ad36379991e71b10e49211891219a92.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE workspace_settings\n SET slack_oauth_client_id = NULL, slack_oauth_client_secret = NULL\n WHERE workspace_id = $1", + "query": "UPDATE workspace_settings\n SET slack_oauth_client_id = NULL, slack_oauth_client_secret = NULL,\n slack_signing_secret = NULL\n WHERE workspace_id = $1", "describe": { "columns": [], "parameters": { @@ -10,5 +10,5 @@ }, "nullable": [] }, - "hash": "72076c1c210c57ef5db26620063c750c4a7eef6dcb2f4a91c7a3ddc2d367e3ba" + "hash": "cd829b96e6d8f4ed9a0283d9141ce8588ad36379991e71b10e49211891219a92" } diff --git a/backend/.sqlx/query-ce033ee4db97c0f9ae643d287f1a3f1578a6be8686e02c3faa55100eb8fa3700.json b/backend/.sqlx/query-ce033ee4db97c0f9ae643d287f1a3f1578a6be8686e02c3faa55100eb8fa3700.json new file mode 100644 index 0000000000..3e44f3ab42 --- /dev/null +++ b/backend/.sqlx/query-ce033ee4db97c0f9ae643d287f1a3f1578a6be8686e02c3faa55100eb8fa3700.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_invite\n (workspace_id, email, is_admin, operator, workspace_role)\n SELECT $1::text, email, false, $3, $4 FROM password WHERE ($2::text = '*' OR email LIKE CONCAT('%@', $2::text)) AND NOT EXISTS (\n SELECT 1 FROM usr WHERE workspace_id = $1::text AND email = password.email\n )\n ON CONFLICT DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Bool", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "ce033ee4db97c0f9ae643d287f1a3f1578a6be8686e02c3faa55100eb8fa3700" +} diff --git a/backend/.sqlx/query-d46f75bb9d7144d571a31ddbb3c986ea2bfd561943ff512ac20f93203e02c3d8.json b/backend/.sqlx/query-d46f75bb9d7144d571a31ddbb3c986ea2bfd561943ff512ac20f93203e02c3d8.json new file mode 100644 index 0000000000..f70dff1f07 --- /dev/null +++ b/backend/.sqlx/query-d46f75bb9d7144d571a31ddbb3c986ea2bfd561943ff512ac20f93203e02c3d8.json @@ -0,0 +1,77 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace_id, username, email, is_admin, created_at, operator, disabled,\n COALESCE(workspace_role, CASE WHEN operator THEN 'operator' ELSE 'developer' END) AS role,\n added_via, is_service_account\n FROM usr WHERE username = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "is_admin", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "operator", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "disabled", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "role", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "added_via", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "is_service_account", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + null, + true, + false + ] + }, + "hash": "d46f75bb9d7144d571a31ddbb3c986ea2bfd561943ff512ac20f93203e02c3d8" +} diff --git a/backend/.sqlx/query-d5f1a2bfbac206a33b991d58f9ff4b8e8e4a3840ea1c65d409576379dede41ed.json b/backend/.sqlx/query-d5f1a2bfbac206a33b991d58f9ff4b8e8e4a3840ea1c65d409576379dede41ed.json deleted file mode 100644 index c6eb9e16a3..0000000000 --- a/backend/.sqlx/query-d5f1a2bfbac206a33b991d58f9ff4b8e8e4a3840ea1c65d409576379dede41ed.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT error_handler->'extra_args' FROM workspace_settings WHERE workspace_id = 'test-workspace'", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "?column?", - "type_info": "Jsonb" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - null - ] - }, - "hash": "d5f1a2bfbac206a33b991d58f9ff4b8e8e4a3840ea1c65d409576379dede41ed" -} diff --git a/backend/.sqlx/query-d79ba22a53c9f96c96fcd4b4cf0e6cc17f767055de943709192ef1ff5106eac5.json b/backend/.sqlx/query-d79ba22a53c9f96c96fcd4b4cf0e6cc17f767055de943709192ef1ff5106eac5.json new file mode 100644 index 0000000000..3f3a3f20e4 --- /dev/null +++ b/backend/.sqlx/query-d79ba22a53c9f96c96fcd4b4cf0e6cc17f767055de943709192ef1ff5106eac5.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT v.path 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 a.client = $2 AND a.is_workspace_integration = true", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "d79ba22a53c9f96c96fcd4b4cf0e6cc17f767055de943709192ef1ff5106eac5" +} diff --git a/backend/.sqlx/query-0987f164fe3d64bf0a6a4e9699c2d1f339670da3ed1c08203d54d45798d022dd.json b/backend/.sqlx/query-d976c78db15dac2c70edcea6d7d9ceed91c54f592d0e1f5139d5e111241ca451.json similarity index 82% rename from backend/.sqlx/query-0987f164fe3d64bf0a6a4e9699c2d1f339670da3ed1c08203d54d45798d022dd.json rename to backend/.sqlx/query-d976c78db15dac2c70edcea6d7d9ceed91c54f592d0e1f5139d5e111241ca451.json index 8aecbed333..0fe46de01d 100644 --- a/backend/.sqlx/query-0987f164fe3d64bf0a6a4e9699c2d1f339670da3ed1c08203d54d45798d022dd.json +++ b/backend/.sqlx/query-d976c78db15dac2c70edcea6d7d9ceed91c54f592d0e1f5139d5e111241ca451.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at,\n summary,\n enabled\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND script_path = $3\n AND is_flow = $4\n LIMIT 1\n ", + "query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at,\n summary,\n enabled,\n connection_path\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND script_path = $3\n AND is_flow = $4\n LIMIT 1\n ", "describe": { "columns": [ { @@ -73,6 +73,11 @@ "ordinal": 11, "name": "enabled", "type_info": "Bool" + }, + { + "ordinal": 12, + "name": "connection_path", + "type_info": "Varchar" } ], "parameters": { @@ -106,8 +111,9 @@ false, false, true, - false + false, + true ] }, - "hash": "0987f164fe3d64bf0a6a4e9699c2d1f339670da3ed1c08203d54d45798d022dd" + "hash": "d976c78db15dac2c70edcea6d7d9ceed91c54f592d0e1f5139d5e111241ca451" } diff --git a/backend/.sqlx/query-da0a8271da6f436e285afcf5e038baf294a42db77a301db01e5d2ccf1c8ae3a5.json b/backend/.sqlx/query-da0a8271da6f436e285afcf5e038baf294a42db77a301db01e5d2ccf1c8ae3a5.json new file mode 100644 index 0000000000..e662011ab0 --- /dev/null +++ b/backend/.sqlx/query-da0a8271da6f436e285afcf5e038baf294a42db77a301db01e5d2ccf1c8ae3a5.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT username, email FROM usr\n WHERE workspace_id = $1 AND lower(email) = lower($2) AND NOT disabled", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "da0a8271da6f436e285afcf5e038baf294a42db77a301db01e5d2ccf1c8ae3a5" +} diff --git a/backend/.sqlx/query-daaaa027338cd77d6400658667351a4d3bbe6d1309cd8205e853097c9f81a471.json b/backend/.sqlx/query-daaaa027338cd77d6400658667351a4d3bbe6d1309cd8205e853097c9f81a471.json new file mode 100644 index 0000000000..b2e7a8668c --- /dev/null +++ b/backend/.sqlx/query-daaaa027338cd77d6400658667351a4d3bbe6d1309cd8205e853097c9f81a471.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE native_trigger SET connection_path = REGEXP_REPLACE(connection_path, 'u/' || $2 || '/(.*)', 'u/' || $1 || '/\\1') WHERE connection_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "daaaa027338cd77d6400658667351a4d3bbe6d1309cd8205e853097c9f81a471" +} diff --git a/backend/.sqlx/query-db9831a3218b36c32b305b9f2ba21abceec74c9c277f27231dba12868e68126b.json b/backend/.sqlx/query-db9831a3218b36c32b305b9f2ba21abceec74c9c277f27231dba12868e68126b.json deleted file mode 100644 index 3562a6f345..0000000000 --- a/backend/.sqlx/query-db9831a3218b36c32b305b9f2ba21abceec74c9c277f27231dba12868e68126b.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE account SET refresh_token = $1, expires_at = $2, refresh_error = NULL WHERE workspace_id = $3 AND id = $4", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Timestamptz", - "Text", - "Int4" - ] - }, - "nullable": [] - }, - "hash": "db9831a3218b36c32b305b9f2ba21abceec74c9c277f27231dba12868e68126b" -} diff --git a/backend/.sqlx/query-dbd821d6d58aff9ed34cf3f14982ffe92d3cd13915be8a07f9364a14a01e549f.json b/backend/.sqlx/query-dbd821d6d58aff9ed34cf3f14982ffe92d3cd13915be8a07f9364a14a01e549f.json new file mode 100644 index 0000000000..224e1f62e7 --- /dev/null +++ b/backend/.sqlx/query-dbd821d6d58aff9ed34cf3f14982ffe92d3cd13915be8a07f9364a14a01e549f.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO job_resolution (job_id, workspace_id, resolved_by, note, automatic)\n SELECT c.id, c.workspace_id, $4, $5, false\n FROM v2_job_completed c\n JOIN v2_job j ON j.id = c.id\n WHERE c.id = $1\n AND c.workspace_id = $2\n AND ($3::TEXT[] IS NULL OR j.tag = ANY($3))\n AND c.status IN ('failure', 'canceled')\n AND j.flow_step_id IS NULL\n ON CONFLICT (job_id) DO NOTHING\n RETURNING job_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "job_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "TextArray", + "Varchar", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "dbd821d6d58aff9ed34cf3f14982ffe92d3cd13915be8a07f9364a14a01e549f" +} diff --git a/backend/.sqlx/query-dcb5053bdae0a2d141f7e7cb16988f5bc2c40c098558d92ab768281f4e9664c5.json b/backend/.sqlx/query-dcb5053bdae0a2d141f7e7cb16988f5bc2c40c098558d92ab768281f4e9664c5.json new file mode 100644 index 0000000000..12e32dfe45 --- /dev/null +++ b/backend/.sqlx/query-dcb5053bdae0a2d141f7e7cb16988f5bc2c40c098558d92ab768281f4e9664c5.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM usr t WHERE ($1::text IS NOT NULL AND t.workspace_id = $1\n OR $1::text IS NULL AND NOT EXISTS (SELECT 1 FROM workspace_settings ws\n WHERE ws.workspace_id = t.workspace_id AND ws.role_overrides ? $2)) AND\n COALESCE(t.workspace_role, CASE WHEN t.operator THEN 'operator' ELSE 'developer' END) = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "dcb5053bdae0a2d141f7e7cb16988f5bc2c40c098558d92ab768281f4e9664c5" +} diff --git a/backend/.sqlx/query-dda346e3e9d2cab64532948de09cf6dea2537923a987be5584fbcb1c0dc69b7f.json b/backend/.sqlx/query-dda346e3e9d2cab64532948de09cf6dea2537923a987be5584fbcb1c0dc69b7f.json new file mode 100644 index 0000000000..189593b34c --- /dev/null +++ b/backend/.sqlx/query-dda346e3e9d2cab64532948de09cf6dea2537923a987be5584fbcb1c0dc69b7f.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings\n SET slack_oauth_client_id = $1, slack_oauth_client_secret = $2,\n slack_signing_secret = CASE WHEN slack_oauth_client_id = $1::varchar\n THEN slack_signing_secret END\n WHERE workspace_id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "dda346e3e9d2cab64532948de09cf6dea2537923a987be5584fbcb1c0dc69b7f" +} diff --git a/backend/.sqlx/query-ddd41de79b23b0436bbc7997751c90d7d82bd20ea30f1dc08f2e2066fb8d6f4b.json b/backend/.sqlx/query-ddd41de79b23b0436bbc7997751c90d7d82bd20ea30f1dc08f2e2066fb8d6f4b.json index 9622e63a56..02c50b1e45 100644 --- a/backend/.sqlx/query-ddd41de79b23b0436bbc7997751c90d7d82bd20ea30f1dc08f2e2066fb8d6f4b.json +++ b/backend/.sqlx/query-ddd41de79b23b0436bbc7997751c90d7d82bd20ea30f1dc08f2e2066fb8d6f4b.json @@ -23,4 +23,4 @@ ] }, "hash": "ddd41de79b23b0436bbc7997751c90d7d82bd20ea30f1dc08f2e2066fb8d6f4b" -} \ No newline at end of file +} diff --git a/backend/.sqlx/query-f0d8ebbf62a1d92f39c2f86a70236cece6d6c9e28cce9aff7fe5496bba569807.json b/backend/.sqlx/query-df8f616f7ace1d45cb9022d70e53bef3389884cdde284ed3eb3b8d6979761b98.json similarity index 81% rename from backend/.sqlx/query-f0d8ebbf62a1d92f39c2f86a70236cece6d6c9e28cce9aff7fe5496bba569807.json rename to backend/.sqlx/query-df8f616f7ace1d45cb9022d70e53bef3389884cdde284ed3eb3b8d6979761b98.json index 912db1a932..ed386844d4 100644 --- a/backend/.sqlx/query-f0d8ebbf62a1d92f39c2f86a70236cece6d6c9e28cce9aff7fe5496bba569807.json +++ b/backend/.sqlx/query-df8f616f7ace1d45cb9022d70e53bef3389884cdde284ed3eb3b8d6979761b98.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT fork_last_event_kind, fork_last_event_origin FROM workspace_diff\n WHERE fork_workspace_id = 'wm-fork-renamed' AND kind = 'script'\n AND path = 'u/admin/inline' AND fork_last_event_kind IS NOT NULL", + "query": "SELECT fork_last_event_kind, fork_last_event_origin FROM workspace_diff\n WHERE fork_workspace_id = 'wm-fork-renamed' AND kind = 'script'\n AND path = 'u/admin/inline' AND fork_last_event_kind <> 'write'", "describe": { "columns": [ { @@ -22,5 +22,5 @@ true ] }, - "hash": "f0d8ebbf62a1d92f39c2f86a70236cece6d6c9e28cce9aff7fe5496bba569807" + "hash": "df8f616f7ace1d45cb9022d70e53bef3389884cdde284ed3eb3b8d6979761b98" } diff --git a/backend/.sqlx/query-e1d7ca4dfbc5eeb6eb60e48a13029ac1a2714b78cc88969604dbd6c44984831f.json b/backend/.sqlx/query-e1d7ca4dfbc5eeb6eb60e48a13029ac1a2714b78cc88969604dbd6c44984831f.json new file mode 100644 index 0000000000..90a3f9c7f2 --- /dev/null +++ b/backend/.sqlx/query-e1d7ca4dfbc5eeb6eb60e48a13029ac1a2714b78cc88969604dbd6c44984831f.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM workspace_diff WHERE fork_workspace_id = 'wm-fork-secrets'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "e1d7ca4dfbc5eeb6eb60e48a13029ac1a2714b78cc88969604dbd6c44984831f" +} diff --git a/backend/.sqlx/query-e43b178e40ef39908def1746f7f2a6a2ad960a7b5e9536d15efae31bd2431742.json b/backend/.sqlx/query-e43b178e40ef39908def1746f7f2a6a2ad960a7b5e9536d15efae31bd2431742.json new file mode 100644 index 0000000000..2319cf80f7 --- /dev/null +++ b/backend/.sqlx/query-e43b178e40ef39908def1746f7f2a6a2ad960a7b5e9536d15efae31bd2431742.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE account SET refresh_error = $1\n WHERE workspace_id = $2 AND id = $3 AND refresh_token = $4", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Int4", + "Text" + ] + }, + "nullable": [] + }, + "hash": "e43b178e40ef39908def1746f7f2a6a2ad960a7b5e9536d15efae31bd2431742" +} diff --git a/backend/.sqlx/query-e497205c8654cc0ba1b9c88aa1c253f5823cb10016330b317257c8c668d951c2.json b/backend/.sqlx/query-e497205c8654cc0ba1b9c88aa1c253f5823cb10016330b317257c8c668d951c2.json new file mode 100644 index 0000000000..58f404a64d --- /dev/null +++ b/backend/.sqlx/query-e497205c8654cc0ba1b9c88aa1c253f5823cb10016330b317257c8c668d951c2.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace_id, slack_command_script IS NOT NULL AS \"has_script!\"\n FROM workspace_settings ws WHERE slack_team_id = $1\n AND ws.workspace_id = ANY($2)\n ORDER BY slack_command_script IS NOT NULL DESC LIMIT 2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "has_script!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "TextArray" + ] + }, + "nullable": [ + false, + null + ] + }, + "hash": "e497205c8654cc0ba1b9c88aa1c253f5823cb10016330b317257c8c668d951c2" +} diff --git a/backend/.sqlx/query-e4bb7af19003baca7f4d88e954b603843edce422c522c035b81b88cb169b88b1.json b/backend/.sqlx/query-e4bb7af19003baca7f4d88e954b603843edce422c522c035b81b88cb169b88b1.json new file mode 100644 index 0000000000..c1ea457a72 --- /dev/null +++ b/backend/.sqlx/query-e4bb7af19003baca7f4d88e954b603843edce422c522c035b81b88cb169b88b1.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO job_resolution (job_id, workspace_id, resolved_by, note, automatic)\n SELECT c.id, c.workspace_id, $4, COALESCE($5, $7), false\n FROM v2_job_completed c\n JOIN v2_job j ON j.id = c.id\n WHERE c.id = ANY($1)\n AND c.workspace_id = $2\n AND ($3::TEXT[] IS NULL OR j.tag = ANY($3))\n AND c.status IN ('failure', 'canceled')\n -- Resolution is a top-level triage state: a step resolved on its own\n -- would render orange inside a flow whose status is still red.\n AND j.flow_step_id IS NULL\n -- A supersession claim has to be proven, not trusted: a later success of the\n -- same identified runnable, itself visible to the caller. An unproven claim\n -- resolves nothing, so the caller learns it was rejected instead of having\n -- the fiction recorded as provenance.\n AND ($6::UUID IS NULL OR EXISTS (\n SELECT 1 FROM v2_job_completed sc\n JOIN v2_job sj ON sj.id = sc.id\n WHERE sc.id = $6\n AND sc.workspace_id = $2\n -- Tag scope is a read restriction enforced outside RLS, so it has\n -- to bind the evidence as well: otherwise the result reveals\n -- whether an out-of-scope run succeeded.\n AND ($3::TEXT[] IS NULL OR sj.tag = ANY($3))\n AND sc.status = 'success'\n AND sc.completed_at >= c.completed_at\n AND (j.runnable_id IS NOT NULL OR j.runnable_path IS NOT NULL)\n AND sj.runnable_id IS NOT DISTINCT FROM j.runnable_id\n AND sj.runnable_path IS NOT DISTINCT FROM j.runnable_path\n ))\n ON CONFLICT (job_id) DO UPDATE SET\n resolved_at = now(),\n -- Both COALESCEd: `resolution_attribution` returns NULLs outside EE and once the\n -- licence lapses, and bulk selections routinely include already-resolved rows,\n -- so overwriting would erase metadata recorded while it was valid. Clear either\n -- by unresolving first.\n resolved_by = COALESCE($4, job_resolution.resolved_by),\n -- A person's explanation replaces what was there; machine provenance only fills\n -- a blank, so re-running an already-explained failure never erases their words.\n note = COALESCE($5, job_resolution.note, $7),\n -- A human taking over an automatic resolution makes it no longer automatic.\n automatic = false\n RETURNING job_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "job_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "UuidArray", + "Text", + "TextArray", + "Varchar", + "Text", + "Uuid", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "e4bb7af19003baca7f4d88e954b603843edce422c522c035b81b88cb169b88b1" +} diff --git a/backend/.sqlx/query-e4c9f3bca6bd636c3ee31fc643182ed5ca48a45da895fb992d820682f5169f7c.json b/backend/.sqlx/query-e4c9f3bca6bd636c3ee31fc643182ed5ca48a45da895fb992d820682f5169f7c.json deleted file mode 100644 index 63ce99a139..0000000000 --- a/backend/.sqlx/query-e4c9f3bca6bd636c3ee31fc643182ed5ca48a45da895fb992d820682f5169f7c.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT id\n FROM v2_job\n WHERE workspace_id = 'test-workspace'\n AND permissioned_as_email = 'error_handler@windmill.dev'\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false - ] - }, - "hash": "e4c9f3bca6bd636c3ee31fc643182ed5ca48a45da895fb992d820682f5169f7c" -} diff --git a/backend/.sqlx/query-b9686c115e83ad0725503e2c076960914f825eaa2b44281a4e147cea6e886e7a.json b/backend/.sqlx/query-e6d49c3186419c726cc6ed771548c72164b36ccb5434bcc4babefa7f3363c675.json similarity index 68% rename from backend/.sqlx/query-b9686c115e83ad0725503e2c076960914f825eaa2b44281a4e147cea6e886e7a.json rename to backend/.sqlx/query-e6d49c3186419c726cc6ed771548c72164b36ccb5434bcc4babefa7f3363c675.json index ebb5cee72c..99ec12fd85 100644 --- a/backend/.sqlx/query-b9686c115e83ad0725503e2c076960914f825eaa2b44281a4e147cea6e886e7a.json +++ b/backend/.sqlx/query-e6d49c3186419c726cc6ed771548c72164b36ccb5434bcc4babefa7f3363c675.json @@ -1,11 +1,11 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM native_trigger WHERE workspace_id = $1 AND service_name = $2 RETURNING webhook_token_hash", + "query": "SELECT script_path FROM native_trigger WHERE workspace_id = $1 AND service_name = $2", "describe": { "columns": [ { "ordinal": 0, - "name": "webhook_token_hash", + "name": "script_path", "type_info": "Varchar" } ], @@ -30,5 +30,5 @@ false ] }, - "hash": "b9686c115e83ad0725503e2c076960914f825eaa2b44281a4e147cea6e886e7a" + "hash": "e6d49c3186419c726cc6ed771548c72164b36ccb5434bcc4babefa7f3363c675" } diff --git a/backend/.sqlx/query-e78a06c0ea4d46ec2d55417a81d0c7ed15b7bf962ba19fcd09b23f8e982fa1c9.json b/backend/.sqlx/query-e78a06c0ea4d46ec2d55417a81d0c7ed15b7bf962ba19fcd09b23f8e982fa1c9.json new file mode 100644 index 0000000000..032d104253 --- /dev/null +++ b/backend/.sqlx/query-e78a06c0ea4d46ec2d55417a81d0c7ed15b7bf962ba19fcd09b23f8e982fa1c9.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM role_seat WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "e78a06c0ea4d46ec2d55417a81d0c7ed15b7bf962ba19fcd09b23f8e982fa1c9" +} diff --git a/backend/.sqlx/query-e9808cc152e1ce4717260ebb42f203ca16321a4550073258734a6208b83c00c2.json b/backend/.sqlx/query-e9808cc152e1ce4717260ebb42f203ca16321a4550073258734a6208b83c00c2.json deleted file mode 100644 index 91479f0583..0000000000 --- a/backend/.sqlx/query-e9808cc152e1ce4717260ebb42f203ca16321a4550073258734a6208b83c00c2.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT success_handler->>'path' FROM workspace_settings WHERE workspace_id = 'test-workspace'", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "?column?", - "type_info": "Text" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - null - ] - }, - "hash": "e9808cc152e1ce4717260ebb42f203ca16321a4550073258734a6208b83c00c2" -} diff --git a/backend/.sqlx/query-eaed64e9c1b4b13ba97b394a9b17adac931366debe890df5b25e4110e47e4f87.json b/backend/.sqlx/query-eaed64e9c1b4b13ba97b394a9b17adac931366debe890df5b25e4110e47e4f87.json new file mode 100644 index 0000000000..a654f614bd --- /dev/null +++ b/backend/.sqlx/query-eaed64e9c1b4b13ba97b394a9b17adac931366debe890df5b25e4110e47e4f87.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings SET slack_signing_secret = $1 WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "eaed64e9c1b4b13ba97b394a9b17adac931366debe890df5b25e4110e47e4f87" +} diff --git a/backend/.sqlx/query-eafb6d792370bf4d42cb79d0264e2eaf32f8f756920bd9fe9b302cba8e7d8814.json b/backend/.sqlx/query-eafb6d792370bf4d42cb79d0264e2eaf32f8f756920bd9fe9b302cba8e7d8814.json new file mode 100644 index 0000000000..b7cfa590d5 --- /dev/null +++ b/backend/.sqlx/query-eafb6d792370bf4d42cb79d0264e2eaf32f8f756920bd9fe9b302cba8e7d8814.json @@ -0,0 +1,31 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT l.id IS NOT NULL as \"has_lite!\",\n CASE WHEN $3::bigint IS NULL THEN NOT EXISTS (\n SELECT 1 FROM app_script s\n WHERE s.app = a.id AND s.code_sha256 = $4::text\n AND jsonb_path_exists(l.value, '$.**.inlineScript.id ? (@ == $id)',\n jsonb_build_object('id', s.id)))\n ELSE coalesce(jsonb_path_exists(l.value, '$.**.inlineScript.id ? (@ == $id)',\n jsonb_build_object('id', $3::bigint)), false)\n END as \"may_run!\"\n FROM app a\n LEFT JOIN app_version_lite l ON l.id = a.versions[array_upper(a.versions, 1)]\n WHERE a.path = $1 AND a.workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "has_lite!", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "may_run!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8", + "Text" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "eafb6d792370bf4d42cb79d0264e2eaf32f8f756920bd9fe9b302cba8e7d8814" +} diff --git a/backend/.sqlx/query-eb6dc43ac53a853eab20ef804a092249e32b1184a146463094289c604bbd0332.json b/backend/.sqlx/query-eb6dc43ac53a853eab20ef804a092249e32b1184a146463094289c604bbd0332.json new file mode 100644 index 0000000000..b69c453bef --- /dev/null +++ b/backend/.sqlx/query-eb6dc43ac53a853eab20ef804a092249e32b1184a146463094289c604bbd0332.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO fork_storage_namespace (workspace_id, storage, storage_ref)\n VALUES ($1, $2, $3)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "eb6dc43ac53a853eab20ef804a092249e32b1184a146463094289c604bbd0332" +} diff --git a/backend/.sqlx/query-ecd08125821a21842bad7d328ea83cbaf7676cbed2f0c3a433699578da88e8ae.json b/backend/.sqlx/query-ecd08125821a21842bad7d328ea83cbaf7676cbed2f0c3a433699578da88e8ae.json new file mode 100644 index 0000000000..db600a495e --- /dev/null +++ b/backend/.sqlx/query-ecd08125821a21842bad7d328ea83cbaf7676cbed2f0c3a433699578da88e8ae.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(DISTINCT value) FROM variable WHERE path = 'u/test-user/same'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "ecd08125821a21842bad7d328ea83cbaf7676cbed2f0c3a433699578da88e8ae" +} diff --git a/backend/.sqlx/query-edce6243bcdfbb6380ae2b6d8a63a863c3f265ea043cc653f9732bb87860b8d9.json b/backend/.sqlx/query-edce6243bcdfbb6380ae2b6d8a63a863c3f265ea043cc653f9732bb87860b8d9.json new file mode 100644 index 0000000000..94b5ba8f10 --- /dev/null +++ b/backend/.sqlx/query-edce6243bcdfbb6380ae2b6d8a63a863c3f265ea043cc653f9732bb87860b8d9.json @@ -0,0 +1,65 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, email, name, is_public, message_count, created_by, snapshot, shared_at\n FROM ai_shared_session\n WHERE workspace_id = $1 AND id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "is_public", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "message_count", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "snapshot", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "shared_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "edce6243bcdfbb6380ae2b6d8a63a863c3f265ea043cc653f9732bb87860b8d9" +} diff --git a/backend/.sqlx/query-ee3ed82783b5b7d2f41f16a1d94f006687bd9de4611203b5dd0c28eacee02842.json b/backend/.sqlx/query-ee3ed82783b5b7d2f41f16a1d94f006687bd9de4611203b5dd0c28eacee02842.json deleted file mode 100644 index a1d873778f..0000000000 --- a/backend/.sqlx/query-ee3ed82783b5b7d2f41f16a1d94f006687bd9de4611203b5dd0c28eacee02842.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, 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, 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": "ee3ed82783b5b7d2f41f16a1d94f006687bd9de4611203b5dd0c28eacee02842" -} diff --git a/backend/.sqlx/query-c7766afaea3e187824698cae2b090af9f49ded98cd1824f1ac91cc5dbe709bc1.json b/backend/.sqlx/query-efae221cbce1c9d4b96f9990a012b3efce1740b7037cac9983529bc052cf2dd9.json similarity index 83% rename from backend/.sqlx/query-c7766afaea3e187824698cae2b090af9f49ded98cd1824f1ac91cc5dbe709bc1.json rename to backend/.sqlx/query-efae221cbce1c9d4b96f9990a012b3efce1740b7037cac9983529bc052cf2dd9.json index 6a9de7e35b..66edee8f0b 100644 --- a/backend/.sqlx/query-c7766afaea3e187824698cae2b090af9f49ded98cd1824f1ac91cc5dbe709bc1.json +++ b/backend/.sqlx/query-efae221cbce1c9d4b96f9990a012b3efce1740b7037cac9983529bc052cf2dd9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at,\n summary,\n enabled\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND external_id = $3\n ", + "query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at,\n summary,\n enabled,\n connection_path\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND external_id = $3\n ", "describe": { "columns": [ { @@ -73,6 +73,11 @@ "ordinal": 11, "name": "enabled", "type_info": "Bool" + }, + { + "ordinal": 12, + "name": "connection_path", + "type_info": "Varchar" } ], "parameters": { @@ -105,8 +110,9 @@ false, false, true, - false + false, + true ] }, - "hash": "c7766afaea3e187824698cae2b090af9f49ded98cd1824f1ac91cc5dbe709bc1" + "hash": "efae221cbce1c9d4b96f9990a012b3efce1740b7037cac9983529bc052cf2dd9" } diff --git a/backend/.sqlx/query-f14b48a16ca770a062043a034a9d174c959438b5c6886935a2895879dbf72e3e.json b/backend/.sqlx/query-f14b48a16ca770a062043a034a9d174c959438b5c6886935a2895879dbf72e3e.json new file mode 100644 index 0000000000..4c11f9a737 --- /dev/null +++ b/backend/.sqlx/query-f14b48a16ca770a062043a034a9d174c959438b5c6886935a2895879dbf72e3e.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COALESCE(slack_name, slack_team_id) FROM workspace_settings\n WHERE workspace_id = $1 AND slack_team_id IS NOT NULL", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "coalesce", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "f14b48a16ca770a062043a034a9d174c959438b5c6886935a2895879dbf72e3e" +} diff --git a/backend/.sqlx/query-f434d29f5617e1a97f9cc556ffaf47751aa5bff0741e98c00b3294d29dc36718.json b/backend/.sqlx/query-f434d29f5617e1a97f9cc556ffaf47751aa5bff0741e98c00b3294d29dc36718.json new file mode 100644 index 0000000000..79b8e613ac --- /dev/null +++ b/backend/.sqlx/query-f434d29f5617e1a97f9cc556ffaf47751aa5bff0741e98c00b3294d29dc36718.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT format('rawscript/%s', s.code_sha256) as \"path!: String\"\n FROM app_script s JOIN app a ON a.id = s.app\n WHERE s.id = $1 AND a.path = $2 AND a.workspace_id = $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path!: String", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "f434d29f5617e1a97f9cc556ffaf47751aa5bff0741e98c00b3294d29dc36718" +} diff --git a/backend/.sqlx/query-f4726ac9311b2206088e5cfce32b47532839f91d2ecc462a5971f8de2194a9bf.json b/backend/.sqlx/query-f4726ac9311b2206088e5cfce32b47532839f91d2ecc462a5971f8de2194a9bf.json new file mode 100644 index 0000000000..e893140464 --- /dev/null +++ b/backend/.sqlx/query-f4726ac9311b2206088e5cfce32b47532839f91d2ecc462a5971f8de2194a9bf.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT slack_team_id FROM workspace_settings WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "slack_team_id", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "f4726ac9311b2206088e5cfce32b47532839f91d2ecc462a5971f8de2194a9bf" +} diff --git a/backend/.sqlx/query-f4af0affaed3b1d30f5c6f4ddf4114718468fbf75ef13c6087c2f57fbbe0b82f.json b/backend/.sqlx/query-f4af0affaed3b1d30f5c6f4ddf4114718468fbf75ef13c6087c2f57fbbe0b82f.json index e52ddcf21f..dfcaa29208 100644 --- a/backend/.sqlx/query-f4af0affaed3b1d30f5c6f4ddf4114718468fbf75ef13c6087c2f57fbbe0b82f.json +++ b/backend/.sqlx/query-f4af0affaed3b1d30f5c6f4ddf4114718468fbf75ef13c6087c2f57fbbe0b82f.json @@ -34,7 +34,8 @@ "java", "duckdb", "ruby", - "rlang" + "rlang", + "dbt" ] } } diff --git a/backend/.sqlx/query-f5022831ab610ab22ef1af1441753aba57d5e8f19a316f76e01922559425d81b.json b/backend/.sqlx/query-f5022831ab610ab22ef1af1441753aba57d5e8f19a316f76e01922559425d81b.json new file mode 100644 index 0000000000..3b0dbb2f41 --- /dev/null +++ b/backend/.sqlx/query-f5022831ab610ab22ef1af1441753aba57d5e8f19a316f76e01922559425d81b.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) 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", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "f5022831ab610ab22ef1af1441753aba57d5e8f19a316f76e01922559425d81b" +} diff --git a/backend/.sqlx/query-f54795cfa04ef4d0f6ed05aee09513d23e8ef5412d030a172f81dd9a9be0bf2f.json b/backend/.sqlx/query-f54795cfa04ef4d0f6ed05aee09513d23e8ef5412d030a172f81dd9a9be0bf2f.json new file mode 100644 index 0000000000..bb2808083c --- /dev/null +++ b/backend/.sqlx/query-f54795cfa04ef4d0f6ed05aee09513d23e8ef5412d030a172f81dd9a9be0bf2f.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM workspace_invite t WHERE ($1::text IS NULL OR t.workspace_id = $1) AND\n COALESCE(t.workspace_role, CASE WHEN t.operator THEN 'operator' ELSE 'developer' END) = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "f54795cfa04ef4d0f6ed05aee09513d23e8ef5412d030a172f81dd9a9be0bf2f" +} diff --git a/backend/.sqlx/query-f59c0f10c2dc7c0b578c764545e2ceee71c5f0ed6aca603e7ec7aae47f7cdade.json b/backend/.sqlx/query-f59c0f10c2dc7c0b578c764545e2ceee71c5f0ed6aca603e7ec7aae47f7cdade.json new file mode 100644 index 0000000000..da21731ae1 --- /dev/null +++ b/backend/.sqlx/query-f59c0f10c2dc7c0b578c764545e2ceee71c5f0ed6aca603e7ec7aae47f7cdade.json @@ -0,0 +1,42 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT external_id, connection_path FROM native_trigger\n WHERE workspace_id = $1 AND service_name = $2 AND ($3::text[] IS NULL\n OR (external_id, script_path) IN (SELECT * FROM unnest($3::text[], $4::text[])))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "external_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "connection_path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "native_trigger_service", + "kind": { + "Enum": [ + "nextcloud", + "google", + "github" + ] + } + } + }, + "TextArray", + "TextArray" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "f59c0f10c2dc7c0b578c764545e2ceee71c5f0ed6aca603e7ec7aae47f7cdade" +} diff --git a/backend/.sqlx/query-f830e934b43a83f857f682383f36ed56df6518e3035778e4e3b95182d2504e00.json b/backend/.sqlx/query-f830e934b43a83f857f682383f36ed56df6518e3035778e4e3b95182d2504e00.json deleted file mode 100644 index 8025c7654c..0000000000 --- a/backend/.sqlx/query-f830e934b43a83f857f682383f36ed56df6518e3035778e4e3b95182d2504e00.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE workspace_settings\n SET slack_oauth_client_id = $1, slack_oauth_client_secret = $2\n WHERE workspace_id = $3", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Text" - ] - }, - "nullable": [] - }, - "hash": "f830e934b43a83f857f682383f36ed56df6518e3035778e4e3b95182d2504e00" -} diff --git a/backend/.sqlx/query-fa9cba875690fa5568a7bb3f33464ccea31da990bb66d6c29b48d39f714c7c8b.json b/backend/.sqlx/query-fa9cba875690fa5568a7bb3f33464ccea31da990bb66d6c29b48d39f714c7c8b.json new file mode 100644 index 0000000000..8515006507 --- /dev/null +++ b/backend/.sqlx/query-fa9cba875690fa5568a7bb3f33464ccea31da990bb66d6c29b48d39f714c7c8b.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT now() > expires_at AS \"expired!\" FROM account\n WHERE workspace_id = $1 AND id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "expired!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Int4" + ] + }, + "nullable": [ + null + ] + }, + "hash": "fa9cba875690fa5568a7bb3f33464ccea31da990bb66d6c29b48d39f714c7c8b" +} diff --git a/backend/.sqlx/query-fdf0a16a72bc2eca2025d64750e2e6a7e7ff90b5ad4b42e6aab53992b447a20e.json b/backend/.sqlx/query-fdf0a16a72bc2eca2025d64750e2e6a7e7ff90b5ad4b42e6aab53992b447a20e.json deleted file mode 100644 index 69497769d7..0000000000 --- a/backend/.sqlx/query-fdf0a16a72bc2eca2025d64750e2e6a7e7ff90b5ad4b42e6aab53992b447a20e.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n id,\n runnable_path,\n permissioned_as_email,\n parent_job\n FROM v2_job\n WHERE workspace_id = 'test-workspace'\n AND permissioned_as_email = 'error_handler@windmill.dev'\n ORDER BY created_at DESC\n LIMIT 1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "runnable_path", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "permissioned_as_email", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "parent_job", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - true, - false, - true - ] - }, - "hash": "fdf0a16a72bc2eca2025d64750e2e6a7e7ff90b5ad4b42e6aab53992b447a20e" -} diff --git a/backend/AGENTS.md b/backend/AGENTS.md index fbacb857ee..290890009a 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -6,17 +6,17 @@ - **DB schema**: `backend/summarized_schema.txt` - **API routes entry point**: `windmill-api/src/lib.rs` - **OpenAPI spec**: `windmill-api/openapi.yaml` -- **DuckDB local jobs**: build the dynamic FFI library before running DuckDB scripts locally: +- **DuckDB local jobs**: build the DuckDB runner before running DuckDB scripts locally: ```bash cd backend/windmill-duckdb-ffi-internal && ./build_dev.sh ``` - Re-run after clean builds or when `target/debug/libwindmill_duckdb_ffi_internal.*` is missing. + Re-run after clean builds or when `target/debug/windmill_duckdb_runner` is missing. The bundled DuckDB compile (~2min) is cached in a per-user dir shared across worktrees (keyed by the crate's `Cargo.lock`), so a fresh worktree reuses it and the build is near-instant — you don't pay the full compile per worktree. Editing - the FFI crate's own source falls back to an isolated per-worktree `./target`. - The engine is a **patched fork** of duckdb-rs, not the crates.io crate — read - `docs/duckdb-isolation.md` before bumping it or touching the isolation transform. + the crate's own source falls back to an isolated per-worktree `./target`. + Jobs run in that binary, out of the worker's process, under the worker's job isolation — + read `docs/duckdb-execution.md` before changing how it is spawned or what crosses to it. - **Running data pipelines (DuckLake) from source**: see the section below — a plain build advertises the `duckdb` tag but cannot execute DuckDB scripts and has no working S3 proxy. @@ -120,7 +120,7 @@ only what you need — build time scales with the set. | `enterprise` | EE business logic (autoscaling, SAML hooks, advanced S3 rule **enforcement**, WAP, forks, …). Pulls in `license`. | Running EE features. Advanced S3 permission rules only take effect with this. | | `license` | License-key/plan plumbing (`LICENSE_KEY`). Pulled in by `enterprise`. Having the feature compiled does **not** require a license *key* at runtime — CE defaults to a free plan and most EE paths still run keyless. | License-gated behavior. | | `parquet` | S3/object-storage support: the `job_helpers/*` and `apps_u/*` S3 endpoints, parquet/CSV preview, workspace large-file storage. Without it those routes return `"requires parquet"`. | Anything touching S3/object storage or datasets. | -| `duckdb` | DuckDB script executor (also needs the FFI dylib — see above). | DuckDB scripts, DuckLake. | +| `duckdb` | DuckDB script executor (also needs the runner binary — see above). | DuckDB scripts, DuckLake. | | `python` `rust` `php` `java` `ruby` `csharp` `nu` `deno_core` `mysql` `mssql` `bigquery` `snowflake` `oracledb` `rlang` | Each enables that language/DB runtime for job execution. | Running jobs in that language. | | `mcp` | MCP gateway routes (baseline `quickjs` does NOT include it → MCP routes 404). | MCP work. | | `websocket` `http_trigger` `kafka` `nats` `mqtt_trigger` `sqs_trigger` `gcp_trigger` `azure_trigger` `postgres_trigger` `native_trigger` | Each native trigger kind; none on by default (creating one 404s without its feature). | Working on / exercising that trigger. | @@ -136,7 +136,7 @@ minimal explicit set for dev. | Plain dev baseline (JS eval only) | `quickjs` | | S3 / object storage / datasets (CE) | `quickjs,private,parquet` | | S3 + EE (advanced S3 rules, on-behalf app reads, WAP, forks) | `quickjs,enterprise,private,parquet` | -| DuckLake / DuckDB (CE) | `quickjs,duckdb,parquet,private` (+ build the FFI) | +| DuckLake / DuckDB (CE) | `quickjs,duckdb,parquet,private` (+ build the runner) | | + Python jobs | append `,python` | ## Workspace object storage in dev — use the local filesystem @@ -163,7 +163,7 @@ in DuckDB"`) — DuckLake/datatable go through the S3 proxy instead, which works ## Running data pipelines (DuckLake) from source -DuckLake pipelines need **both** the right cargo features **and** the prebuilt DuckDB FFI. A +DuckLake pipelines need **both** the right cargo features **and** the DuckDB runner binary. A plain `cargo run` (or `cargo run --features quickjs`) does **not** suffice, and the failure modes are silent-ish, so agents lose time. Verify feature names against `backend/Cargo.toml` `[features]`. @@ -178,7 +178,7 @@ are silent-ish, so agents lose time. Verify feature names against `backend/Cargo `enterprise` already pulls in `license`, but list both when you want the license-gated paths. `quickjs` is for JS eval, not DuckLake per se — keep it if your baseline build had it. -**Before running any DuckDB script**, build the FFI (see the bullet above): +**Before running any DuckDB script**, build the runner (see the bullet above): `cd backend/windmill-duckdb-ffi-internal && ./build_dev.sh`. **Two gotchas that a wrong feature set produces:** diff --git a/backend/Cargo.lock b/backend/Cargo.lock index a5af71b50e..97626ab116 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -243,9 +243,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.9.2" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e" dependencies = [ "rustversion", ] @@ -1336,9 +1336,9 @@ dependencies = [ [[package]] name = "aws-smithy-async" -version = "1.3.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" +checksum = "de681a9df7ff79823b759ee17061076ec049cd61a8fa28ece4293d89c9f6964e" dependencies = [ "futures-util", "pin-project-lite", @@ -2366,9 +2366,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" dependencies = [ "find-msvc-tools", "jobserver", @@ -2571,9 +2571,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.8" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" dependencies = [ "bytes", "memchr", @@ -2858,9 +2858,9 @@ checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32fast" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" dependencies = [ "cfg-if", ] @@ -3926,9 +3926,9 @@ dependencies = [ [[package]] name = "defmt" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" dependencies = [ "bitflags 1.3.2", "defmt-macros", @@ -3936,21 +3936,21 @@ dependencies = [ [[package]] name = "defmt-macros" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] name = "defmt-parser" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" dependencies = [ "thiserror 2.0.21", ] @@ -4156,7 +4156,7 @@ dependencies = [ "hyper-util", "ipnet", "percent-encoding", - "rustls-webpki 0.103.15", + "rustls-webpki 0.103.16", "serde_json", "thiserror 2.0.21", "tokio", @@ -4427,7 +4427,7 @@ dependencies = [ "rustls 0.23.40", "rustls-pemfile 2.2.0", "rustls-tokio-stream", - "rustls-webpki 0.103.15", + "rustls-webpki 0.103.16", "serde", "thiserror 2.0.21", "tokio", @@ -4777,9 +4777,9 @@ dependencies = [ [[package]] name = "diplomat-runtime" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae517853d9f82b2d144393b1c32c34c46c2e361de2b6c44a8fdfce23beb211cd" +checksum = "d9d981121ba76150987fda68185b37853c0b928a55dfdc28897348cd068440be" [[package]] name = "diplomat_core" @@ -5212,9 +5212,9 @@ dependencies = [ [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "erased-serde" @@ -5408,9 +5408,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "fips203" @@ -5554,9 +5554,9 @@ dependencies = [ [[package]] name = "frostem" -version = "1.20260821.7" +version = "1.20260821.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28c70238056ad1dbfdf035222a571d2e9baf5b6a4c308cfa87bc8cbe7f2f3840" +checksum = "9e2488a10218a17366ce6bd38b6d081b4387a9548d350029db9feddc9bb44d6d" [[package]] name = "fs4" @@ -5863,9 +5863,9 @@ dependencies = [ [[package]] name = "generator" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54ade96dc9003043bce7c035c85a9df5a858bfb2039c5a2e6fdf00f324f6c551" +checksum = "1be81b31c9ce4caf7f3178eeb90662bb1864d3ad363ec6bdada3201adff19c37" dependencies = [ "cc", "cfg-if", @@ -6745,7 +6745,7 @@ dependencies = [ "tokio", "tokio-rustls 0.26.6", "tower-service", - "webpki-roots 1.0.9", + "webpki-roots 1.0.10", ] [[package]] @@ -6859,9 +6859,9 @@ dependencies = [ [[package]] name = "icu_calendar" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58655df2f728e46e4eee80bddebefacf52ec3e77cdb925014b47c5a5905eb55c" +checksum = "a68ffb801cc7acb8dcae62da7c692c47934698d2dd4f479ddee1eb442763dea1" dependencies = [ "calendrical_calculations", "displaydoc", @@ -6875,15 +6875,15 @@ dependencies = [ [[package]] name = "icu_calendar_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc00caaa3fb3201ff7a18aa458e8c3ab042b9da154cfdf948bdde3936c31c36e" +checksum = "514da54e79efa36b5976232d5b3d1d8ee1b47b19ab658f1b1ebe39221696c2d8" [[package]] name = "icu_collections" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" dependencies = [ "displaydoc", "potential_utf", @@ -6895,9 +6895,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" dependencies = [ "displaydoc", "litemap", @@ -6909,9 +6909,9 @@ dependencies = [ [[package]] name = "icu_locale_fallback" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +checksum = "406e9ae5b95d8aa37db6293405931b766a6172d1848daee22d70eb44aa78a43c" dependencies = [ "icu_locale_core", "icu_locale_fallback_data", @@ -6923,15 +6923,15 @@ dependencies = [ [[package]] name = "icu_locale_fallback_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" +checksum = "3632ebbd0c5c3637ba1a40bdf8288f2cb9d2268e73441a69d5fc73d7cfbaa87c" [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -6943,15 +6943,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" [[package]] name = "icu_properties" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" dependencies = [ "displaydoc", "icu_collections", @@ -6964,15 +6964,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" [[package]] name = "icu_provider" -version = "2.3.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" dependencies = [ "displaydoc", "icu_locale_core", @@ -6987,9 +6987,9 @@ dependencies = [ [[package]] name = "icu_segmenter" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" +checksum = "61b195dc605f81aa181bec9b3bd14e26cc71b903e4644f6af578ca9772c7a710" dependencies = [ "icu_collections", "icu_locale_fallback", @@ -6998,14 +6998,15 @@ dependencies = [ "potential_utf", "smallvec", "utf8_iter", + "zerotrie", "zerovec", ] [[package]] name = "icu_segmenter_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" +checksum = "913a7d294ad056474749152107309fdee3ec17d2482d1ab70fdc61a81c64b9a3" [[package]] name = "ident_case" @@ -7223,9 +7224,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "ixdtf" -version = "0.6.6" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d3667095d64c3ecffc96463a21157b04bf3e252f6e8d5750b20c02e33c194e3" +checksum = "4754a1731458af0443709c9667302793d3f63cc90ec4ba4c8259f2b9515478e5" [[package]] name = "jiff" @@ -7700,9 +7701,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.190" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libffi-sys" @@ -7771,7 +7772,7 @@ dependencies = [ "bitflags 2.13.2", "libc", "plain", - "redox_syscall 0.9.4", + "redox_syscall 0.9.5", ] [[package]] @@ -8155,9 +8156,9 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157" dependencies = [ "libc", "stable_deref_trait", @@ -10735,9 +10736,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" dependencies = [ "bitflags 2.13.2", ] @@ -10826,11 +10827,11 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "relative-path" -version = "2.0.2" +version = "2.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2a073568cd8b9c4825429d3e9fc5f52851b4f97cc1348800689408921e6b8f8" +checksum = "38540a682c6e9bca04a8bca9e5ec5a6ba168dfbb04c9998fe517a9befb92eb68" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -10888,7 +10889,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams 0.4.2", "web-sys", - "webpki-roots 1.0.9", + "webpki-roots 1.0.10", ] [[package]] @@ -11101,9 +11102,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "3.5.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1da498700030856ba63114bceefa30ba21f5e278cb975e5b9de3529fe842b40" +checksum = "967fa7f7935d7d6217d593df0e8e5c9307bcaf256974a5a9ee64476c97735389" dependencies = [ "darling 0.24.1", "proc-macro2", @@ -11460,7 +11461,7 @@ dependencies = [ "once_cell", "ring 0.17.14", "rustls-pki-types", - "rustls-webpki 0.103.15", + "rustls-webpki 0.103.16", "subtle", "zeroize", ] @@ -11475,7 +11476,7 @@ dependencies = [ "rustls 0.23.40", "rustls-native-certs 0.7.3", "rustls-pki-types", - "rustls-webpki 0.103.15", + "rustls-webpki 0.103.16", ] [[package]] @@ -11557,7 +11558,7 @@ dependencies = [ "rustls 0.23.40", "rustls-native-certs 0.8.5", "rustls-platform-verifier-android", - "rustls-webpki 0.103.15", + "rustls-webpki 0.103.16", "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", @@ -11606,9 +11607,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring 0.17.14", @@ -11709,9 +11710,9 @@ checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71" [[package]] name = "schannel" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" dependencies = [ "windows-sys 0.61.2", ] @@ -12610,7 +12611,7 @@ dependencies = [ [[package]] name = "sqlx" version = "0.8.6" -source = "git+https://github.com/windmill-labs/sqlx?rev=6bdaee94fa62a01561125646da3f99eb341f2457#6bdaee94fa62a01561125646da3f99eb341f2457" +source = "git+https://github.com/windmill-labs/sqlx?rev=c1c5f1f2a07cb1fce0e47463c314697e08f4f26b#c1c5f1f2a07cb1fce0e47463c314697e08f4f26b" dependencies = [ "sqlx-core", "sqlx-macros", @@ -12622,7 +12623,7 @@ dependencies = [ [[package]] name = "sqlx-core" version = "0.8.6" -source = "git+https://github.com/windmill-labs/sqlx?rev=6bdaee94fa62a01561125646da3f99eb341f2457#6bdaee94fa62a01561125646da3f99eb341f2457" +source = "git+https://github.com/windmill-labs/sqlx?rev=c1c5f1f2a07cb1fce0e47463c314697e08f4f26b#c1c5f1f2a07cb1fce0e47463c314697e08f4f26b" dependencies = [ "base64 0.22.1", "bigdecimal", @@ -12660,7 +12661,7 @@ dependencies = [ [[package]] name = "sqlx-macros" version = "0.8.6" -source = "git+https://github.com/windmill-labs/sqlx?rev=6bdaee94fa62a01561125646da3f99eb341f2457#6bdaee94fa62a01561125646da3f99eb341f2457" +source = "git+https://github.com/windmill-labs/sqlx?rev=c1c5f1f2a07cb1fce0e47463c314697e08f4f26b#c1c5f1f2a07cb1fce0e47463c314697e08f4f26b" dependencies = [ "proc-macro2", "quote", @@ -12672,7 +12673,7 @@ dependencies = [ [[package]] name = "sqlx-macros-core" version = "0.8.6" -source = "git+https://github.com/windmill-labs/sqlx?rev=6bdaee94fa62a01561125646da3f99eb341f2457#6bdaee94fa62a01561125646da3f99eb341f2457" +source = "git+https://github.com/windmill-labs/sqlx?rev=c1c5f1f2a07cb1fce0e47463c314697e08f4f26b#c1c5f1f2a07cb1fce0e47463c314697e08f4f26b" dependencies = [ "dotenvy", "either", @@ -12696,7 +12697,7 @@ dependencies = [ [[package]] name = "sqlx-mysql" version = "0.8.6" -source = "git+https://github.com/windmill-labs/sqlx?rev=6bdaee94fa62a01561125646da3f99eb341f2457#6bdaee94fa62a01561125646da3f99eb341f2457" +source = "git+https://github.com/windmill-labs/sqlx?rev=c1c5f1f2a07cb1fce0e47463c314697e08f4f26b#c1c5f1f2a07cb1fce0e47463c314697e08f4f26b" dependencies = [ "atoi", "base64 0.22.1", @@ -12740,7 +12741,7 @@ dependencies = [ [[package]] name = "sqlx-postgres" version = "0.8.6" -source = "git+https://github.com/windmill-labs/sqlx?rev=6bdaee94fa62a01561125646da3f99eb341f2457#6bdaee94fa62a01561125646da3f99eb341f2457" +source = "git+https://github.com/windmill-labs/sqlx?rev=c1c5f1f2a07cb1fce0e47463c314697e08f4f26b#c1c5f1f2a07cb1fce0e47463c314697e08f4f26b" dependencies = [ "atoi", "base64 0.22.1", @@ -12780,7 +12781,7 @@ dependencies = [ [[package]] name = "sqlx-sqlite" version = "0.8.6" -source = "git+https://github.com/windmill-labs/sqlx?rev=6bdaee94fa62a01561125646da3f99eb341f2457#6bdaee94fa62a01561125646da3f99eb341f2457" +source = "git+https://github.com/windmill-labs/sqlx?rev=c1c5f1f2a07cb1fce0e47463c314697e08f4f26b#c1c5f1f2a07cb1fce0e47463c314697e08f4f26b" dependencies = [ "atoi", "chrono", @@ -13965,9 +13966,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", "serde_core", @@ -14745,9 +14746,9 @@ checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2" [[package]] name = "typed-path" -version = "0.12.3" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" +checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c" [[package]] name = "typeid" @@ -14876,9 +14877,9 @@ checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-normalization" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" dependencies = [ "tinyvec", ] @@ -15368,9 +15369,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" dependencies = [ "rustls-pki-types", ] @@ -15381,14 +15382,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.9", + "webpki-roots 1.0.10", ] [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] @@ -15486,7 +15487,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "async-nats", @@ -15576,7 +15577,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.833.0" +version = "1.847.0" dependencies = [ "async-stream", "async-trait", @@ -15611,7 +15612,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.833.0" +version = "1.847.0" dependencies = [ "axum", "chrono", @@ -15624,7 +15625,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "argon2", @@ -15767,7 +15768,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.833.0" +version = "1.847.0" dependencies = [ "axum", "chrono", @@ -15782,6 +15783,7 @@ dependencies = [ "tracing", "uuid", "windmill-api-auth", + "windmill-audit", "windmill-common", "windmill-parser-py-imports", "windmill-queue", @@ -15790,7 +15792,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.833.0" +version = "1.847.0" dependencies = [ "axum", "chrono", @@ -15807,7 +15809,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "axum", @@ -15833,7 +15835,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.833.0" +version = "1.847.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15843,7 +15845,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.833.0" +version = "1.847.0" dependencies = [ "axum", "chrono", @@ -15860,7 +15862,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.833.0" +version = "1.847.0" dependencies = [ "axum", "base64 0.22.1", @@ -15882,7 +15884,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "axum", @@ -15905,7 +15907,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.833.0" +version = "1.847.0" dependencies = [ "axum", "chrono", @@ -15921,7 +15923,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.833.0" +version = "1.847.0" dependencies = [ "axum", "chrono", @@ -15943,7 +15945,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.833.0" +version = "1.847.0" dependencies = [ "axum", "chrono", @@ -15965,7 +15967,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.833.0" +version = "1.847.0" dependencies = [ "axum", "chrono", @@ -15979,7 +15981,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "async-nats", @@ -16015,7 +16017,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "axum", @@ -16041,7 +16043,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.833.0" +version = "1.847.0" dependencies = [ "axum", "chrono", @@ -16069,7 +16071,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "axum", @@ -16091,7 +16093,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.833.0" +version = "1.847.0" dependencies = [ "axum", "chrono", @@ -16111,7 +16113,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "axum", @@ -16153,13 +16155,14 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "axum", "base64 0.22.1", "bytes", "chrono", + "ed25519-dalek", "futures", "lazy_static", "regex", @@ -16182,7 +16185,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.833.0" +version = "1.847.0" dependencies = [ "lazy_static", "serde", @@ -16194,7 +16197,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.833.0" +version = "1.847.0" dependencies = [ "argon2", "axum", @@ -16218,7 +16221,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.833.0" +version = "1.847.0" dependencies = [ "axum", "chrono", @@ -16228,11 +16231,12 @@ dependencies = [ "uuid", "windmill-api-auth", "windmill-common", + "windmill-queue", ] [[package]] name = "windmill-api-workspaces" -version = "1.833.0" +version = "1.847.0" dependencies = [ "axum", "chrono", @@ -16271,7 +16275,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.833.0" +version = "1.847.0" dependencies = [ "chrono", "lazy_static", @@ -16286,7 +16290,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "axum", @@ -16305,7 +16309,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.833.0" +version = "1.847.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -16343,8 +16347,10 @@ dependencies = [ "hex", "hkdf", "hmac 0.12.1", + "http 1.5.0", "hyper 1.12.0", "indexmap 2.14.2", + "ipnet", "itertools 0.14.0", "jsonwebtoken 10.4.0", "lazy_static", @@ -16409,7 +16415,7 @@ dependencies = [ "url", "urlencoding", "uuid", - "webpki-roots 1.0.9", + "webpki-roots 1.0.10", "windmill-macros", "windmill-parser", "windmill-parser-py", @@ -16421,7 +16427,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.833.0" +version = "1.847.0" dependencies = [ "chrono", "futures", @@ -16441,7 +16447,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.833.0" +version = "1.847.0" dependencies = [ "regex", "serde", @@ -16458,7 +16464,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16485,7 +16491,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "futures", @@ -16502,7 +16508,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.833.0" +version = "1.847.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16518,7 +16524,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "async-trait", @@ -16540,7 +16546,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "async-trait", @@ -16571,7 +16577,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "arc-swap", @@ -16596,7 +16602,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "async-stream", @@ -16632,7 +16638,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "futures", @@ -16651,7 +16657,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.833.0" +version = "1.847.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -16660,7 +16666,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "lazy_static", @@ -16672,7 +16678,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "serde_json", @@ -16684,7 +16690,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "gosyn", @@ -16696,7 +16702,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "lazy_static", @@ -16708,7 +16714,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "serde_json", @@ -16720,7 +16726,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "nu-parser", @@ -16731,7 +16737,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16742,7 +16748,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16754,7 +16760,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -16765,7 +16771,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "async-recursion", @@ -16788,7 +16794,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "serde_json", @@ -16800,7 +16806,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "lazy_static", @@ -16814,7 +16820,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16831,7 +16837,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "lazy_static", @@ -16844,7 +16850,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "serde", @@ -16856,7 +16862,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "lazy_static", @@ -16874,7 +16880,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16890,7 +16896,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -16907,7 +16913,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "lazy_static", @@ -16921,7 +16927,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "async-recursion", @@ -16960,7 +16966,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "const_format", @@ -16998,7 +17004,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.833.0" +version = "1.847.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17009,7 +17015,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "async-recursion", @@ -17044,7 +17050,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "async-trait", @@ -17068,7 +17074,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "async-trait", @@ -17101,7 +17107,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "async-trait", @@ -17128,7 +17134,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "async-trait", @@ -17161,7 +17167,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "async-trait", @@ -17181,7 +17187,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "async-trait", @@ -17215,7 +17221,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "async-trait", @@ -17252,7 +17258,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "async-trait", @@ -17275,7 +17281,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "async-trait", @@ -17299,7 +17305,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "async-nats", @@ -17323,7 +17329,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "async-trait", @@ -17358,7 +17364,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "async-trait", @@ -17386,7 +17392,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "async-trait", @@ -17411,7 +17417,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -17430,7 +17436,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "async-once-cell", @@ -17465,7 +17471,6 @@ dependencies = [ "jsonwebtoken 10.4.0", "lazy_static", "libffi-sys", - "libloading", "mappable-rc", "mime_guess", "mysql_async", @@ -17549,7 +17554,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.833.0" +version = "1.847.0" dependencies = [ "bytes", "futures", @@ -18252,9 +18257,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -18348,9 +18353,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ "serde", "yoke", @@ -18360,9 +18365,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" dependencies = [ "proc-macro2", "quote", @@ -18371,9 +18376,9 @@ dependencies = [ [[package]] name = "zip" -version = "7.2.0" +version = "7.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0" +checksum = "cc12baa6db2b15a140161ce53d72209dacea594230798c24774139b54ecaa980" dependencies = [ "crc32fast", "indexmap 2.14.2", @@ -18426,9 +18431,9 @@ dependencies = [ [[package]] name = "zstd-sys" -version = "2.1.1+zstd.1.5.7" +version = "2.1.2+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aeec9eaf2dffbbd09201e23bd0ffcbaa33bb8e9266a10734fd7ed90a85eca078" +checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3" dependencies = [ "cc", "pkg-config", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 5cfd7372cc..d2d4678718 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.833.0" +version = "1.847.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.833.0" +version = "1.847.0" authors = ["Ruben Fiszel "] edition = "2021" @@ -226,7 +226,12 @@ all_sqlx_features = ["all_languages", "enterprise", "enterprise_saml", "embeddin "windmill-git-sync/all_sqlx_features"] [patch.crates-io] -# v0.8.6 plus one commit: `Pool::begin` is not cancel-safe on Postgres. sqlx raises the +# v0.8.6 plus the fix below and two `#[sqlx::test]`-only changes, both set in .cargo/config.toml: +# `SQLX_TEST_TEMPLATE` clones each test database from a template migrated once per migration +# set instead of migrating it per test, and `SQLX_TEST_MAX_CONNECTIONS` lifts the cap on the +# connections all test pools share. The rev is tagged `windmill-0.8.6-test-template-c1c5f1f` in +# the fork so it stays fetchable. +# The fix: `Pool::begin` is not cancel-safe on Postgres. sqlx raises the # transaction depth its rollback-on-drop guard keys on only *after* the BEGIN round trip, so # a cancelled caller (a disconnecting API client, a `timeout`, an aborted task) leaves the # session in a transaction nothing will end, and the pool hands that connection out again — @@ -239,13 +244,13 @@ all_sqlx_features = ["all_languages", "enterprise", "enterprise_saml", "embeddin # Changing any of this — a bump, a rebase of the fork, dropping these lines — still compiles # clean, so run the guard that actually checks the behaviour is still there: # cargo test -p windmill-common --test sqlx_begin_cancel_safe -- --ignored -sqlx = { git = "https://github.com/windmill-labs/sqlx", rev = "6bdaee94fa62a01561125646da3f99eb341f2457" } -sqlx-core = { git = "https://github.com/windmill-labs/sqlx", rev = "6bdaee94fa62a01561125646da3f99eb341f2457" } -sqlx-macros = { git = "https://github.com/windmill-labs/sqlx", rev = "6bdaee94fa62a01561125646da3f99eb341f2457" } -sqlx-macros-core = { git = "https://github.com/windmill-labs/sqlx", rev = "6bdaee94fa62a01561125646da3f99eb341f2457" } -sqlx-postgres = { git = "https://github.com/windmill-labs/sqlx", rev = "6bdaee94fa62a01561125646da3f99eb341f2457" } -sqlx-mysql = { git = "https://github.com/windmill-labs/sqlx", rev = "6bdaee94fa62a01561125646da3f99eb341f2457" } -sqlx-sqlite = { git = "https://github.com/windmill-labs/sqlx", rev = "6bdaee94fa62a01561125646da3f99eb341f2457" } +sqlx = { git = "https://github.com/windmill-labs/sqlx", rev = "c1c5f1f2a07cb1fce0e47463c314697e08f4f26b" } +sqlx-core = { git = "https://github.com/windmill-labs/sqlx", rev = "c1c5f1f2a07cb1fce0e47463c314697e08f4f26b" } +sqlx-macros = { git = "https://github.com/windmill-labs/sqlx", rev = "c1c5f1f2a07cb1fce0e47463c314697e08f4f26b" } +sqlx-macros-core = { git = "https://github.com/windmill-labs/sqlx", rev = "c1c5f1f2a07cb1fce0e47463c314697e08f4f26b" } +sqlx-postgres = { git = "https://github.com/windmill-labs/sqlx", rev = "c1c5f1f2a07cb1fce0e47463c314697e08f4f26b" } +sqlx-mysql = { git = "https://github.com/windmill-labs/sqlx", rev = "c1c5f1f2a07cb1fce0e47463c314697e08f4f26b" } +sqlx-sqlite = { git = "https://github.com/windmill-labs/sqlx", rev = "c1c5f1f2a07cb1fce0e47463c314697e08f4f26b" } object_store = { git = "https://github.com/apache/arrow-rs-object-store", rev = "36752c975d4f29e20b57c91f81a10872dcd48ae7" } # Use tiberius main branch for libgssapi 0.8.1 fix (https://github.com/prisma/tiberius/issues/343) tiberius = { git = "https://github.com/prisma/tiberius", rev = "59db57960a14b422fb3a1309aa4aa47880896ff8" } @@ -501,6 +506,7 @@ mail-send = { version = "0.4.0", features = ["builder"], default-features=false urlencoding = "^2" quick-xml = { version = "^0.37", features = ["serialize"] } url = { version = "^2" , features = ["serde"]} +ipnet = "2" async-oauth2 = "0.5.1" reqwest = { version = "^0.13", features = ["json", "stream", "gzip", "multipart", "query", "form"] } # The reqwest object_store builds its HTTP clients with, for a custom `HttpConnector`. @@ -723,7 +729,6 @@ flume = { version = "0.11.1", features = ["async"] } kube = { version = "1.1.0", features = ["runtime", "derive"] } schemars = "0.8" k8s-openapi = { version = "0.25.0", features = ["latest"] } -libloading = "0.8.8" # Macro-related proc-macro2 = "1.0" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index a29f944749..96a620c43f 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -440f922eaffd5abca8f3223e34fe5a0ca61e6106 +9a5ff8e4e562930d57d29e2e90f17f6b82ee96a5 diff --git a/backend/migrations/20261009164611_add_agent_favorite_kind.down.sql b/backend/migrations/20261009164611_add_agent_favorite_kind.down.sql new file mode 100644 index 0000000000..c50ff19ab5 --- /dev/null +++ b/backend/migrations/20261009164611_add_agent_favorite_kind.down.sql @@ -0,0 +1 @@ +DELETE FROM favorite WHERE favorite_kind = 'agent'; diff --git a/backend/migrations/20261009164611_add_agent_favorite_kind.up.sql b/backend/migrations/20261009164611_add_agent_favorite_kind.up.sql new file mode 100644 index 0000000000..c0959d2527 --- /dev/null +++ b/backend/migrations/20261009164611_add_agent_favorite_kind.up.sql @@ -0,0 +1,8 @@ +DO +$do$ +BEGIN + ALTER TYPE FAVORITE_KIND ADD VALUE 'agent'; +EXCEPTION WHEN OTHERS THEN + RAISE NOTICE 'Couldn''t create FAVORITE_KIND::agent: %', SQLERRM; +END +$do$; diff --git a/backend/migrations/20261009173546_agent_owner_prefix_index.down.sql b/backend/migrations/20261009173546_agent_owner_prefix_index.down.sql new file mode 100644 index 0000000000..d3e9318571 --- /dev/null +++ b/backend/migrations/20261009173546_agent_owner_prefix_index.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS idx_resource_agent_owner_prefix; diff --git a/backend/migrations/20261009173546_agent_owner_prefix_index.up.sql b/backend/migrations/20261009173546_agent_owner_prefix_index.up.sql new file mode 100644 index 0000000000..be1fd5813f --- /dev/null +++ b/backend/migrations/20261009173546_agent_owner_prefix_index.up.sql @@ -0,0 +1,7 @@ +-- Backs the agent branch of /runnables/list and /runnables/counts, which reach the +-- `ai_agent` rows of `resource` by workspace and byte-ordered path prefix. Partial, so +-- neither walks the workspace's other resources (state, cache, ...). +-- Created CONCURRENTLY via the OVERRIDDEN_MIGRATIONS rewrite in windmill-api/src/db.rs. +CREATE INDEX IF NOT EXISTS idx_resource_agent_owner_prefix + ON resource (workspace_id, path text_pattern_ops) + WHERE resource_type = 'ai_agent'; diff --git a/backend/migrations/20261015063444_app_script_modules.down.sql b/backend/migrations/20261015063444_app_script_modules.down.sql new file mode 100644 index 0000000000..026e1e841a --- /dev/null +++ b/backend/migrations/20261015063444_app_script_modules.down.sql @@ -0,0 +1 @@ +ALTER TABLE app_script DROP COLUMN IF EXISTS modules; diff --git a/backend/migrations/20261015063444_app_script_modules.up.sql b/backend/migrations/20261015063444_app_script_modules.up.sql new file mode 100644 index 0000000000..aa8e6d5fb2 --- /dev/null +++ b/backend/migrations/20261015063444_app_script_modules.up.sql @@ -0,0 +1 @@ +ALTER TABLE app_script ADD COLUMN IF NOT EXISTS modules JSONB; diff --git a/backend/migrations/20261015114712_queue_workspace_scheduled_for_index.down.sql b/backend/migrations/20261015114712_queue_workspace_scheduled_for_index.down.sql new file mode 100644 index 0000000000..bebd678a1a --- /dev/null +++ b/backend/migrations/20261015114712_queue_workspace_scheduled_for_index.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS v2_job_queue_workspace_scheduled_for; diff --git a/backend/migrations/20261015114712_queue_workspace_scheduled_for_index.up.sql b/backend/migrations/20261015114712_queue_workspace_scheduled_for_index.up.sql new file mode 100644 index 0000000000..63a22e003b --- /dev/null +++ b/backend/migrations/20261015114712_queue_workspace_scheduled_for_index.up.sql @@ -0,0 +1,10 @@ +-- Serves push_scheduled_job's already-exists check in windmill-queue/src/schedule.rs, which +-- looks up the queued jobs of one workspace at one occurrence. Without it that check scans the +-- whole queue on every schedule push. +-- Dropped before it is built because the OVERRIDDEN_MIGRATIONS rewrite in +-- windmill-api/src/db.rs runs both CONCURRENTLY: an interrupted concurrent build leaves an +-- invalid index that IF NOT EXISTS would skip rebuilding. +DROP INDEX IF EXISTS v2_job_queue_workspace_scheduled_for; + +CREATE INDEX IF NOT EXISTS v2_job_queue_workspace_scheduled_for + ON v2_job_queue (workspace_id, scheduled_for); diff --git a/backend/migrations/20261015135223_agent_on_behalf_of.down.sql b/backend/migrations/20261015135223_agent_on_behalf_of.down.sql new file mode 100644 index 0000000000..62cbe2e5de --- /dev/null +++ b/backend/migrations/20261015135223_agent_on_behalf_of.down.sql @@ -0,0 +1 @@ +-- Nothing to undo: older versions ignore both keys, and clearing them would drop toggles set since. diff --git a/backend/migrations/20261015135223_agent_on_behalf_of.up.sql b/backend/migrations/20261015135223_agent_on_behalf_of.up.sql new file mode 100644 index 0000000000..fce313d09a --- /dev/null +++ b/backend/migrations/20261015135223_agent_on_behalf_of.up.sql @@ -0,0 +1,19 @@ +-- An AI agent's `run_on_behalf_of` makes runs from its page take the identity in its +-- `on_behalf_of`, which the server resolves on every write. Before this, both keys were ordinary +-- JSON any writer of the agent could set, so a value written then could name someone else's +-- identity unchecked: clear them, and every agent starts with the toggle off. +UPDATE resource + SET value = value - 'on_behalf_of' - 'run_on_behalf_of' + WHERE resource_type = 'ai_agent' + AND jsonb_typeof(value) = 'object' + AND (value ? 'on_behalf_of' OR value ? 'run_on_behalf_of'); + +-- A deleted agent comes back from the trash as stored, so its copy there is cleared too. +UPDATE trashbin + SET item_data = jsonb_set(item_data, '{row,value}', + (item_data->'row'->'value') - 'on_behalf_of' - 'run_on_behalf_of') + WHERE item_kind = 'resource' + AND item_data->'row'->>'resource_type' = 'ai_agent' + AND jsonb_typeof(item_data->'row'->'value') = 'object' + AND (item_data->'row'->'value' ? 'on_behalf_of' + OR item_data->'row'->'value' ? 'run_on_behalf_of'); diff --git a/backend/migrations/20261015140602_mcp_oauth_grant_without_workspace.down.sql b/backend/migrations/20261015140602_mcp_oauth_grant_without_workspace.down.sql new file mode 100644 index 0000000000..6e9587579b --- /dev/null +++ b/backend/migrations/20261015140602_mcp_oauth_grant_without_workspace.down.sql @@ -0,0 +1,18 @@ +-- A server without these scopes ignores them, which would widen every token carrying one: +-- a narrowed `mcp:all` to full access, a workspace list to all of the owner's workspaces. +DELETE FROM token WHERE EXISTS ( + SELECT 1 FROM unnest(scopes) s + WHERE s IN ('mcp:read_only', 'mcp:read_run', 'mcp:runnables_as_tools') OR s LIKE 'mcp:workspaces:%' +); +DELETE FROM mcp_oauth_refresh_token WHERE EXISTS ( + SELECT 1 FROM unnest(scopes) s + WHERE s IN ('mcp:read_only', 'mcp:read_run', 'mcp:runnables_as_tools') OR s LIKE 'mcp:workspaces:%' +); +DELETE FROM mcp_oauth_server_code WHERE EXISTS ( + SELECT 1 FROM unnest(scopes) s + WHERE s IN ('mcp:read_only', 'mcp:read_run', 'mcp:runnables_as_tools') OR s LIKE 'mcp:workspaces:%' +); +DELETE FROM mcp_oauth_server_code WHERE workspace_id IS NULL; +DELETE FROM mcp_oauth_refresh_token WHERE workspace_id IS NULL; +ALTER TABLE mcp_oauth_server_code ALTER COLUMN workspace_id SET NOT NULL; +ALTER TABLE mcp_oauth_refresh_token ALTER COLUMN workspace_id SET NOT NULL; diff --git a/backend/migrations/20261015140602_mcp_oauth_grant_without_workspace.up.sql b/backend/migrations/20261015140602_mcp_oauth_grant_without_workspace.up.sql new file mode 100644 index 0000000000..efd4bdc283 --- /dev/null +++ b/backend/migrations/20261015140602_mcp_oauth_grant_without_workspace.up.sql @@ -0,0 +1,4 @@ +-- An MCP OAuth grant can span several workspaces (listed in its scopes) or all of the +-- user's, in which case the token it mints is bound to none. +ALTER TABLE mcp_oauth_server_code ALTER COLUMN workspace_id DROP NOT NULL; +ALTER TABLE mcp_oauth_refresh_token ALTER COLUMN workspace_id DROP NOT NULL; diff --git a/backend/migrations/20261015155831_ai_shared_session.down.sql b/backend/migrations/20261015155831_ai_shared_session.down.sql new file mode 100644 index 0000000000..bf6a32d4de --- /dev/null +++ b/backend/migrations/20261015155831_ai_shared_session.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS ai_shared_session; diff --git a/backend/migrations/20261015155831_ai_shared_session.up.sql b/backend/migrations/20261015155831_ai_shared_session.up.sql new file mode 100644 index 0000000000..37e8939c3b --- /dev/null +++ b/backend/migrations/20261015155831_ai_shared_session.up.sql @@ -0,0 +1,30 @@ +-- A read-only snapshot of an AI session transcript that its author shared by link. Sessions +-- otherwise live only in the author's browser; this row exists only while the share does. +-- Non-public shares are readable by members of the workspace, public ones by anyone with the +-- link, which is why `id` is a random UUID and never derived from the session. +CREATE TABLE ai_shared_session ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + -- The browser-side session id. Unique per author so sharing the same session again + -- moves its one link forward rather than minting a second one. + session_id VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + created_by VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + is_public BOOLEAN NOT NULL DEFAULT false, + message_count INTEGER NOT NULL, + -- JSON kept as text: jsonb refuses the \u0000 escape a transcript can carry. + snapshot TEXT NOT NULL, + shared_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (workspace_id, email, session_id) +); + +GRANT ALL ON ai_shared_session TO windmill_admin; +GRANT ALL ON ai_shared_session TO windmill_user; + +-- The handlers go through the raw pool and scope every query to the workspace themselves. +-- An admin-only policy is the backstop for a future query that reaches this table through +-- UserDB. +ALTER TABLE ai_shared_session ENABLE ROW LEVEL SECURITY; + +CREATE POLICY admin_policy ON ai_shared_session FOR ALL TO windmill_admin USING (true); diff --git a/backend/migrations/20261016072241_workspace_roles.down.sql b/backend/migrations/20261016072241_workspace_roles.down.sql new file mode 100644 index 0000000000..6cc9a3cbef --- /dev/null +++ b/backend/migrations/20261016072241_workspace_roles.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS role_seat; +ALTER TABLE workspace_settings DROP COLUMN IF EXISTS role_overrides; +ALTER TABLE workspace_invite DROP COLUMN IF EXISTS workspace_role; +ALTER TABLE usr DROP COLUMN IF EXISTS workspace_role; diff --git a/backend/migrations/20261016072241_workspace_roles.up.sql b/backend/migrations/20261016072241_workspace_roles.up.sql new file mode 100644 index 0000000000..8cd96628a0 --- /dev/null +++ b/backend/migrations/20261016072241_workspace_roles.up.sql @@ -0,0 +1,68 @@ +-- A member's role by name. NULL keeps the legacy reading of `operator` (`operator` / `developer`), +-- so rows written by code that predates roles stay meaningful. +ALTER TABLE usr ADD COLUMN IF NOT EXISTS workspace_role VARCHAR(50); +ALTER TABLE workspace_invite ADD COLUMN IF NOT EXISTS workspace_role VARCHAR(50); + +-- Per-workspace role overrides, keyed by role name: `{"": {"label"?, "capabilities": {..}}}`. +ALTER TABLE workspace_settings ADD COLUMN IF NOT EXISTS role_overrides JSONB; + +-- The operator settings a workspace changed from their default become its override of the +-- `operator` role. Keys left at their default stay unset, so they follow the instance definition. +-- A value that is not a boolean reads as unset: one hand-edited row must not abort the upgrade. +CREATE OR REPLACE FUNCTION wm_try_bool(v TEXT) RETURNS BOOLEAN AS $$ +BEGIN + RETURN v::boolean; +EXCEPTION WHEN invalid_text_representation THEN + RETURN NULL; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +WITH caps AS ( + SELECT workspace_id, jsonb_strip_nulls(jsonb_build_object( + 'flows', CASE WHEN wm_try_bool(operator_settings->>'builder_flows') THEN 'compose' END, + 'apps', CASE WHEN wm_try_bool(operator_settings->>'builder_apps') THEN 'compose' END, + 'manage_schedules', CASE WHEN wm_try_bool(operator_settings->>'manage_schedules') IS false THEN false END, + 'manage_triggers', CASE WHEN wm_try_bool(operator_settings->>'manage_triggers') IS false THEN false END, + 'visibility', NULLIF(jsonb_strip_nulls(jsonb_build_object( + 'runs', CASE WHEN wm_try_bool(operator_settings->>'runs') IS false THEN false END, + 'schedules', CASE WHEN wm_try_bool(operator_settings->>'schedules') IS false THEN false END, + 'resources', CASE WHEN wm_try_bool(operator_settings->>'resources') IS false THEN false END, + 'variables', CASE WHEN wm_try_bool(operator_settings->>'variables') IS false THEN false END, + 'assets', CASE WHEN wm_try_bool(operator_settings->>'assets') IS false THEN false END, + 'triggers', CASE WHEN wm_try_bool(operator_settings->>'triggers') IS false THEN false END, + 'audit_logs', CASE WHEN wm_try_bool(operator_settings->>'audit_logs') IS false THEN false END, + 'groups', CASE WHEN wm_try_bool(operator_settings->>'groups') IS false THEN false END, + 'folders', CASE WHEN wm_try_bool(operator_settings->>'folders') IS false THEN false END, + 'workers', CASE WHEN wm_try_bool(operator_settings->>'workers') IS false THEN false END + )), '{}'::jsonb) + )) AS caps + FROM workspace_settings + WHERE operator_settings IS NOT NULL AND jsonb_typeof(operator_settings) = 'object' +) +UPDATE workspace_settings ws +SET role_overrides = jsonb_build_object('operator', jsonb_build_object('capabilities', caps.caps)) +FROM caps +WHERE ws.workspace_id = caps.workspace_id AND caps.caps <> '{}'::jsonb; + +DROP FUNCTION wm_try_bool(TEXT); + +-- Whether a role costs a full (author) seat, materialized from the role configuration so billing +-- SQL needs no copy of the capability merge. `workspace_id = ''` holds the instance roles; a +-- workspace row exists only for a role that workspace overrides or adds. +CREATE TABLE IF NOT EXISTS role_seat ( + workspace_id VARCHAR(50) NOT NULL, + role VARCHAR(50) NOT NULL, + author BOOLEAN NOT NULL, + PRIMARY KEY (workspace_id, role) +); +INSERT INTO role_seat (workspace_id, role, author) +VALUES ('', 'developer', true), ('', 'operator', false) +ON CONFLICT DO NOTHING; +INSERT INTO role_seat (workspace_id, role, author) +SELECT workspace_id, + 'operator', + (role_overrides->'operator'->'capabilities'->>'flows') IS NOT NULL + OR (role_overrides->'operator'->'capabilities'->>'apps') IS NOT NULL +FROM workspace_settings +WHERE role_overrides ? 'operator' +ON CONFLICT DO NOTHING; diff --git a/backend/migrations/20261016105641_slack_agents.down.sql b/backend/migrations/20261016105641_slack_agents.down.sql new file mode 100644 index 0000000000..11210c1e87 --- /dev/null +++ b/backend/migrations/20261016105641_slack_agents.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS slack_answered_message; +DROP TABLE IF EXISTS slack_thread_agent; +DROP TABLE IF EXISTS slack_agent_channel; diff --git a/backend/migrations/20261016105641_slack_agents.up.sql b/backend/migrations/20261016105641_slack_agents.up.sql new file mode 100644 index 0000000000..d59ad45ea4 --- /dev/null +++ b/backend/migrations/20261016105641_slack_agents.up.sql @@ -0,0 +1,54 @@ +-- The Slack channels an agent is shared in: anyone in one of them can ask it when it runs on +-- behalf of its identity, the workspace members their Slack email belongs to otherwise. Keyed by the Slack team rather than the workspace, as channels are, +-- and at most one agent per channel answers a message that names none. The agent key cascades, +-- so a renamed agent keeps its channels and a deleted one leaves them. +CREATE TABLE slack_agent_channel ( + slack_team_id VARCHAR(255) NOT NULL, + channel_id VARCHAR(255) NOT NULL, + channel_name VARCHAR(255) NOT NULL, + workspace_id VARCHAR(50) NOT NULL, + agent_path VARCHAR(255) NOT NULL, + is_default BOOLEAN NOT NULL DEFAULT false, + PRIMARY KEY (slack_team_id, channel_id, workspace_id, agent_path), + FOREIGN KEY (workspace_id, agent_path) REFERENCES resource (workspace_id, path) + ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX index_slack_agent_channel_workspace ON slack_agent_channel (workspace_id, agent_path); +CREATE UNIQUE INDEX index_slack_agent_channel_default ON slack_agent_channel (slack_team_id, channel_id) + WHERE is_default; + +-- The agent a Slack thread was started with, so a follow-up that names no agent goes to it. +CREATE TABLE slack_thread_agent ( + slack_team_id VARCHAR(255) NOT NULL, + channel_id VARCHAR(255) NOT NULL, + thread_ts VARCHAR(255) NOT NULL, + workspace_id VARCHAR(50) NOT NULL, + agent_path VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (slack_team_id, channel_id, thread_ts), + FOREIGN KEY (workspace_id, agent_path) REFERENCES resource (workspace_id, path) + ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX index_slack_thread_agent_workspace ON slack_thread_agent (workspace_id, agent_path); +CREATE INDEX index_slack_thread_agent_created_at ON slack_thread_agent (created_at); + +-- The Slack messages an agent has taken up. Slack resends an event it did not see acknowledged, +-- and posts a direct message mentioning the bot as two events; only the first is answered. +CREATE TABLE slack_answered_message ( + slack_team_id VARCHAR(255) NOT NULL, + channel_id VARCHAR(255) NOT NULL, + ts VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (slack_team_id, channel_id, ts) +); + +CREATE INDEX index_slack_answered_message_created_at ON slack_answered_message (created_at); + +GRANT ALL ON slack_answered_message TO windmill_admin; +GRANT ALL ON slack_answered_message TO windmill_user; +GRANT ALL ON slack_agent_channel TO windmill_admin; +GRANT ALL ON slack_agent_channel TO windmill_user; +GRANT ALL ON slack_thread_agent TO windmill_admin; +GRANT ALL ON slack_thread_agent TO windmill_user; diff --git a/backend/migrations/20261016120000_ms_teams_oauth_key.down.sql b/backend/migrations/20261016120000_ms_teams_oauth_key.down.sql new file mode 100644 index 0000000000..77a3e3dc5f --- /dev/null +++ b/backend/migrations/20261016120000_ms_teams_oauth_key.down.sql @@ -0,0 +1,4 @@ +-- Fold edits made under ms_teams back into `teams`, which the previous version reads. +UPDATE global_settings +SET value = (value - 'ms_teams') || jsonb_build_object('teams', value -> 'ms_teams') +WHERE name = 'oauths' AND value ? 'ms_teams'; diff --git a/backend/migrations/20261016120000_ms_teams_oauth_key.up.sql b/backend/migrations/20261016120000_ms_teams_oauth_key.up.sql new file mode 100644 index 0000000000..ab2462db8b --- /dev/null +++ b/backend/migrations/20261016120000_ms_teams_oauth_key.up.sql @@ -0,0 +1,6 @@ +-- The Teams Entra app now lives under oauths.ms_teams (bot + Graph connect). Copy the old +-- `teams` entry and keep it, so servers still on the previous version keep working during a +-- rolling upgrade. +UPDATE global_settings +SET value = value || jsonb_build_object('ms_teams', value -> 'teams') +WHERE name = 'oauths' AND value ? 'teams' AND NOT value ? 'ms_teams'; diff --git a/backend/migrations/20261016160346_fork_storage_namespace.down.sql b/backend/migrations/20261016160346_fork_storage_namespace.down.sql new file mode 100644 index 0000000000..47c6b58649 --- /dev/null +++ b/backend/migrations/20261016160346_fork_storage_namespace.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS fork_storage_namespace; diff --git a/backend/migrations/20261016160346_fork_storage_namespace.up.sql b/backend/migrations/20261016160346_fork_storage_namespace.up.sql new file mode 100644 index 0000000000..247a9e2da9 --- /dev/null +++ b/backend/migrations/20261016160346_fork_storage_namespace.up.sql @@ -0,0 +1,24 @@ +-- Workspace storages a fork/dev workspace isolates from its parent: its writes land under +-- `__wm_forks//` of that storage, and its reads fall back to the parent's keys. +-- One row per (fork, storage) chosen isolated at fork creation; forks created before this +-- table have none and keep sharing the parent's keys. +-- +-- Deliberately NO foreign key to workspace(id), like fork_ducklake_namespace: rows are also the +-- cleanup ledger and must OUTLIVE the workspace row when deleting the prefix fails after the +-- delete commits. A recreated same-id fork would otherwise read the deleted fork's objects +-- under the deterministic prefix; fork creation retries leftover rows and refuses to proceed +-- while they cannot be cleaned. +CREATE TABLE fork_storage_namespace ( + workspace_id VARCHAR(50) NOT NULL, + -- '' = the default storage, else a `secondary_storage` name. + storage TEXT NOT NULL, + -- The storage's resolved identity at fork creation (`:`), so cleanup deletes the prefix from the storage the fork wrote to even if the + -- name is repointed later. + storage_ref TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (workspace_id, storage) +); + +GRANT ALL ON fork_storage_namespace TO windmill_user; +GRANT ALL ON fork_storage_namespace TO windmill_admin; diff --git a/backend/migrations/20261020155618_workspace_slack_signing_secret.down.sql b/backend/migrations/20261020155618_workspace_slack_signing_secret.down.sql new file mode 100644 index 0000000000..a93bfaa916 --- /dev/null +++ b/backend/migrations/20261020155618_workspace_slack_signing_secret.down.sql @@ -0,0 +1 @@ +ALTER TABLE workspace_settings DROP COLUMN IF EXISTS slack_signing_secret; diff --git a/backend/migrations/20261020155618_workspace_slack_signing_secret.up.sql b/backend/migrations/20261020155618_workspace_slack_signing_secret.up.sql new file mode 100644 index 0000000000..2bd78fa59b --- /dev/null +++ b/backend/migrations/20261020155618_workspace_slack_signing_secret.up.sql @@ -0,0 +1 @@ +ALTER TABLE workspace_settings ADD COLUMN IF NOT EXISTS slack_signing_secret TEXT; diff --git a/backend/migrations/20261022000335_audit_insert_policy_windmill_user.down.sql b/backend/migrations/20261022000335_audit_insert_policy_windmill_user.down.sql new file mode 100644 index 0000000000..2e7bb84888 --- /dev/null +++ b/backend/migrations/20261022000335_audit_insert_policy_windmill_user.down.sql @@ -0,0 +1,7 @@ +DROP POLICY IF EXISTS insert_any ON audit_partitioned; +CREATE POLICY schedule ON audit_partitioned FOR INSERT TO windmill_user + WITH CHECK ((username)::text ~~ 'schedule-%'::text); +CREATE POLICY schedule_audit ON audit_partitioned FOR INSERT TO windmill_user + WITH CHECK ((parameters ->> 'end_user'::text) ~~ 'schedule-%'::text); +CREATE POLICY webhook ON audit_partitioned FOR INSERT TO windmill_user + WITH CHECK ((username)::text ~~ 'webhook-%'::text); diff --git a/backend/migrations/20261022000335_audit_insert_policy_windmill_user.up.sql b/backend/migrations/20261022000335_audit_insert_policy_windmill_user.up.sql new file mode 100644 index 0000000000..ab6b376663 --- /dev/null +++ b/backend/migrations/20261022000335_audit_insert_policy_windmill_user.up.sql @@ -0,0 +1,7 @@ +-- Only the server writes audit rows, so insert policies guard nothing. They did reject rows +-- whose username is not the caller, such as a job pushed as another user by a non-admin, and +-- that failed insert aborted the caller's transaction. Reads stay restricted by `see_own`. +DROP POLICY IF EXISTS schedule ON audit_partitioned; +DROP POLICY IF EXISTS schedule_audit ON audit_partitioned; +DROP POLICY IF EXISTS webhook ON audit_partitioned; +CREATE POLICY insert_any ON audit_partitioned FOR INSERT TO windmill_user WITH CHECK (true); diff --git a/backend/migrations/20261022125411_native_trigger_connection.down.sql b/backend/migrations/20261022125411_native_trigger_connection.down.sql new file mode 100644 index 0000000000..3951ab2a64 --- /dev/null +++ b/backend/migrations/20261022125411_native_trigger_connection.down.sql @@ -0,0 +1 @@ +ALTER TABLE native_trigger DROP COLUMN IF EXISTS connection_path; diff --git a/backend/migrations/20261022125411_native_trigger_connection.up.sql b/backend/migrations/20261022125411_native_trigger_connection.up.sql new file mode 100644 index 0000000000..3b235a8d4d --- /dev/null +++ b/backend/migrations/20261022125411_native_trigger_connection.up.sql @@ -0,0 +1,24 @@ +-- Each native trigger names the connection (OAuth resource) it acts through, instead of every +-- trigger of a service sharing the single workspace connection. +ALTER TABLE native_trigger ADD COLUMN connection_path VARCHAR(255); + +-- Saving the OAuth app, or starting a connect with the instance app, used to clear +-- resource_path while the connection itself stayed. A service then had at most one connection, +-- so when exactly one is found it is the one its triggers used. +UPDATE workspace_integrations wi +SET resource_path = ( + SELECT CASE WHEN count(*) = 1 THEN min(v.path) END + FROM variable v + JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account + WHERE v.workspace_id = wi.workspace_id + AND a.client = wi.service_name::text + AND a.is_workspace_integration = true +) +WHERE wi.resource_path IS NULL; + +UPDATE native_trigger nt +SET connection_path = wi.resource_path +FROM workspace_integrations wi +WHERE wi.workspace_id = nt.workspace_id + AND wi.service_name = nt.service_name + AND wi.resource_path IS NOT NULL; diff --git a/backend/migrations/20261022163514_reset_cached_variable_workspace_diffs.down.sql b/backend/migrations/20261022163514_reset_cached_variable_workspace_diffs.down.sql new file mode 100644 index 0000000000..d2f607c5b8 --- /dev/null +++ b/backend/migrations/20261022163514_reset_cached_variable_workspace_diffs.down.sql @@ -0,0 +1 @@ +-- Add down migration script here diff --git a/backend/migrations/20261022163514_reset_cached_variable_workspace_diffs.up.sql b/backend/migrations/20261022163514_reset_cached_variable_workspace_diffs.up.sql new file mode 100644 index 0000000000..c531389793 --- /dev/null +++ b/backend/migrations/20261022163514_reset_cached_variable_workspace_diffs.up.sql @@ -0,0 +1,3 @@ +-- Secrets compared by ciphertext were cached as changed even when both sides held the same value, +-- and a cached verdict is trusted until the next write. Re-evaluate every variable row once. +UPDATE workspace_diff SET has_changes = NULL WHERE kind = 'variable'; diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index a29aecb92d..611e3f7c17 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -3,7 +3,7 @@ "auth_url": "https://github.com/login/oauth/authorize", "token_url": "https://github.com/login/oauth/access_token", "scopes": ["workflow", "repo"], - "scope_options": ["repo", "workflow", "gist", "read:org"] + "scope_options": ["repo", "workflow", "gist", "read:org", "read:project", "project"] }, "gitlab": { "auth_url": "https://gitlab.com/oauth/authorize", @@ -125,6 +125,69 @@ "prompt": "consent" } }, + "ms_onedrive": { + "req_body_auth": true, + "scopes": ["offline_access", "User.Read", "Files.ReadWrite"], + "scope_options": [ + { "value": "offline_access", "label": "Stay connected (refresh token)" }, + { "value": "User.Read", "label": "Read your profile" }, + { "value": "Files.Read", "label": "Read your files" }, + { "value": "Files.ReadWrite", "label": "Read, upload, edit and delete your files" }, + { "value": "Files.Read.All", "label": "Read all files you can access (shared and SharePoint drives)" }, + { "value": "Files.ReadWrite.All", "label": "Read and edit all files you can access (shared and SharePoint drives)" } + ], + "connect_config_template": { + "display_name": "Microsoft OneDrive", + "label": "Microsoft Entra tenant", + "placeholder": "common, organizations, or your tenant id", + "help_url": "https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols#endpoints", + "auth_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/authorize", + "token_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/token", + "req_body_auth": true, + "scopes": ["offline_access", "User.Read", "Files.ReadWrite"] + } + }, + "ms_sharepoint": { + "req_body_auth": true, + "scopes": ["offline_access", "User.Read", "Sites.ReadWrite.All"], + "scope_options": [ + { "value": "offline_access", "label": "Stay connected (refresh token)" }, + { "value": "User.Read", "label": "Read your profile" }, + { "value": "Sites.Read.All", "label": "Read sites, lists and files" }, + { "value": "Sites.ReadWrite.All", "label": "Read and edit lists, list items and files in all sites" }, + { "value": "Sites.Manage.All", "label": "Create and manage lists and libraries" } + ], + "connect_config_template": { + "display_name": "Microsoft SharePoint", + "label": "Microsoft Entra tenant", + "placeholder": "common, organizations, or your tenant id", + "help_url": "https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols#endpoints", + "auth_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/authorize", + "token_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/token", + "req_body_auth": true, + "scopes": ["offline_access", "User.Read", "Sites.ReadWrite.All"] + } + }, + "ms_excel": { + "req_body_auth": true, + "scopes": ["offline_access", "User.Read", "Files.ReadWrite"], + "scope_options": [ + { "value": "offline_access", "label": "Stay connected (refresh token)" }, + { "value": "User.Read", "label": "Read your profile" }, + { "value": "Files.ReadWrite", "label": "Read and edit workbooks in your OneDrive" }, + { "value": "Files.ReadWrite.All", "label": "Read and edit workbooks in shared and SharePoint drives" } + ], + "connect_config_template": { + "display_name": "Microsoft Excel", + "label": "Microsoft Entra tenant", + "placeholder": "common, organizations, or your tenant id", + "help_url": "https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols#endpoints", + "auth_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/authorize", + "token_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/token", + "req_body_auth": true, + "scopes": ["offline_access", "User.Read", "Files.ReadWrite"] + } + }, "gmail": { "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", "token_url": "https://oauth2.googleapis.com/token", @@ -254,6 +317,14 @@ "type": "web_server" } }, + "asana": { + "auth_url": "https://app.asana.com/-/oauth_authorize", + "token_url": "https://app.asana.com/-/oauth_token", + "scopes": ["default"], + "scope_options": [ + { "value": "default", "label": "Full permissions (the Asana app must be registered with Full permissions)" } + ] + }, "notion": { "auth_url": "https://api.notion.com/v1/oauth/authorize", "token_url": "https://api.notion.com/v1/oauth/token", @@ -317,6 +388,20 @@ } } }, + "shopify": { + "grant_types": ["client_credentials"], + "connect_config_template": { + "display_name": "Shopify", + "label": "Shopify store", + "placeholder": "your-store", + "help_url": "https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/client-credentials-grant", + "token_url": "https://{instance}.myshopify.com/admin/oauth/access_token", + "strip_suffix": ".myshopify.com", + "resource_mapping": { + "store_name": "{instance}" + } + } + }, "sage_intacct": { "auth_url": "https://api.intacct.com/ia/api/v1/oauth2/authorize", "token_url": "https://api.intacct.com/ia/api/v1/oauth2/token", @@ -415,6 +500,101 @@ } } }, + "ms_teams": { + "req_body_auth": true, + "scopes": [ + "offline_access", + "User.Read", + "Team.ReadBasic.All", + "Channel.ReadBasic.All", + "ChannelMessage.Send", + "Chat.ReadWrite", + "OnlineMeetings.ReadWrite" + ], + "scope_options": [ + { + "value": "offline_access", + "label": "Stay connected (refresh token)" + }, + { + "value": "User.Read", + "label": "Read your profile" + }, + { + "value": "Team.ReadBasic.All", + "label": "List your teams" + }, + { + "value": "Channel.ReadBasic.All", + "label": "List channels" + }, + { + "value": "ChannelMessage.Send", + "label": "Post and reply in channels" + }, + { + "value": "Chat.ReadWrite", + "label": "Read, create and send in your chats" + }, + { + "value": "Chat.ReadBasic", + "label": "List your chats" + }, + { + "value": "Chat.Read", + "label": "Read your chat messages" + }, + { + "value": "Chat.Create", + "label": "Create chats" + }, + { + "value": "ChatMessage.Send", + "label": "Send chat messages" + }, + { + "value": "OnlineMeetings.ReadWrite", + "label": "Create and read online meetings" + }, + { + "value": "ChannelMessage.Read.All", + "label": "Read channel messages (admin consent)" + }, + { + "value": "ChannelMember.Read.All", + "label": "List channel members (admin consent)" + }, + { + "value": "TeamMember.Read.All", + "label": "List team members (admin consent)" + }, + { + "value": "Channel.Create", + "label": "Create channels (admin consent)" + }, + { + "value": "Schedule.Read.All", + "label": "Read Shifts schedules (admin consent)" + } + ], + "connect_config_template": { + "label": "Microsoft Entra tenant", + "placeholder": "organizations, or your tenant id", + "help_url": "https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols#endpoints", + "auth_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/authorize", + "token_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/token", + "req_body_auth": true, + "scopes": [ + "offline_access", + "User.Read", + "Team.ReadBasic.All", + "Channel.ReadBasic.All", + "ChannelMessage.Send", + "Chat.ReadWrite", + "OnlineMeetings.ReadWrite" + ] + } + }, "netsuite": { "connect_config_template": { "display_name": "NetSuite", @@ -530,5 +710,29 @@ "optional_scope": "tickets crm.objects.line_items.read crm.objects.line_items.write e-commerce crm.objects.quotes.read crm.objects.quotes.write crm.objects.leads.read crm.objects.leads.write crm.objects.custom.read crm.objects.custom.write crm.schemas.custom.read crm.lists.read crm.lists.write sales-email-read forms files content crm.objects.marketing_events.read marketing.campaigns.read communication_preferences.read_write automation automation.sequences.read automation.sequences.enrollments.write conversations.read conversations.write scheduler.meetings.meeting-link.read" }, "req_body_auth": true + }, + "zendesk": { + "grant_types": ["authorization_code", "client_credentials"], + "scopes": ["read", "write"], + "cc_scopes": ["read", "write"], + "scope_options": [ + { "value": "read", "label": "Read tickets, users, organizations and Help Center content" }, + { "value": "write", "label": "Create, update and delete tickets, users, organizations and Help Center content" } + ], + "req_body_auth": true, + "connect_config_template": { + "display_name": "Zendesk", + "label": "Zendesk subdomain", + "placeholder": "yourcompany (from yourcompany.zendesk.com)", + "help_url": "https://developer.zendesk.com/documentation/api-basics/authentication/oauth-migration/", + "auth_url": "https://{instance}.zendesk.com/oauth/authorizations/new", + "token_url": "https://{instance}.zendesk.com/oauth/tokens", + "scopes": ["read", "write"], + "req_body_auth": true, + "strip_suffix": ".zendesk.com", + "resource_mapping": { + "subdomain": "{instance}" + } + } } } diff --git a/backend/parsers/windmill-parser-bash/Cargo.toml b/backend/parsers/windmill-parser-bash/Cargo.toml index 4c7db23313..62f0c3fac3 100644 --- a/backend/parsers/windmill-parser-bash/Cargo.toml +++ b/backend/parsers/windmill-parser-bash/Cargo.toml @@ -7,6 +7,8 @@ authors.workspace = true [lib] name = "windmill_parser_bash" path = "./src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [target.'cfg(target_arch = "wasm32")'.dependencies] regex-lite.workspace = true diff --git a/backend/parsers/windmill-parser-csharp/Cargo.toml b/backend/parsers/windmill-parser-csharp/Cargo.toml index 07b0259224..3a283d5895 100644 --- a/backend/parsers/windmill-parser-csharp/Cargo.toml +++ b/backend/parsers/windmill-parser-csharp/Cargo.toml @@ -7,6 +7,8 @@ authors.workspace = true [lib] name = "windmill_parser_csharp" path = "./src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [dependencies] windmill-parser.workspace = true diff --git a/backend/parsers/windmill-parser-go/Cargo.toml b/backend/parsers/windmill-parser-go/Cargo.toml index ecba62cff4..4e8805aad7 100644 --- a/backend/parsers/windmill-parser-go/Cargo.toml +++ b/backend/parsers/windmill-parser-go/Cargo.toml @@ -7,6 +7,8 @@ authors.workspace = true [lib] name = "windmill_parser_go" path = "./src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [dependencies] windmill-parser.workspace = true diff --git a/backend/parsers/windmill-parser-graphql/Cargo.toml b/backend/parsers/windmill-parser-graphql/Cargo.toml index 7f0a93b6f3..557fb56402 100644 --- a/backend/parsers/windmill-parser-graphql/Cargo.toml +++ b/backend/parsers/windmill-parser-graphql/Cargo.toml @@ -7,6 +7,8 @@ authors.workspace = true [lib] name = "windmill_parser_graphql" path = "./src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [target.'cfg(target_arch = "wasm32")'.dependencies] regex-lite.workspace = true diff --git a/backend/parsers/windmill-parser-java/Cargo.toml b/backend/parsers/windmill-parser-java/Cargo.toml index 19809e3120..5a575b891b 100644 --- a/backend/parsers/windmill-parser-java/Cargo.toml +++ b/backend/parsers/windmill-parser-java/Cargo.toml @@ -7,6 +7,8 @@ authors.workspace = true [lib] name = "windmill_parser_java" path = "./src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [dependencies] windmill-parser.workspace = true diff --git a/backend/parsers/windmill-parser-nu/Cargo.toml b/backend/parsers/windmill-parser-nu/Cargo.toml index c8c3e0d616..78034a3a79 100644 --- a/backend/parsers/windmill-parser-nu/Cargo.toml +++ b/backend/parsers/windmill-parser-nu/Cargo.toml @@ -7,6 +7,9 @@ edition.workspace = true [lib] name = "windmill_parser_nu" path = "./src/lib.rs" +# src/ has no unit tests or doctests: remove both lines before adding one, or cargo test never runs it. +test = false +doctest = false [dependencies] windmill-parser.workspace = true diff --git a/backend/parsers/windmill-parser-php/Cargo.toml b/backend/parsers/windmill-parser-php/Cargo.toml index 8bd3182f91..37fcb70957 100644 --- a/backend/parsers/windmill-parser-php/Cargo.toml +++ b/backend/parsers/windmill-parser-php/Cargo.toml @@ -7,6 +7,8 @@ authors.workspace = true [lib] name = "windmill_parser_php" path = "./src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [dependencies] windmill-parser.workspace = true diff --git a/backend/parsers/windmill-parser-py-asset/Cargo.toml b/backend/parsers/windmill-parser-py-asset/Cargo.toml index 84562dcf0e..46d5c55a42 100644 --- a/backend/parsers/windmill-parser-py-asset/Cargo.toml +++ b/backend/parsers/windmill-parser-py-asset/Cargo.toml @@ -7,6 +7,8 @@ authors.workspace = true [lib] name = "windmill_parser_py_asset" path = "./src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [dependencies] windmill-parser.workspace = true diff --git a/backend/parsers/windmill-parser-py-imports/Cargo.toml b/backend/parsers/windmill-parser-py-imports/Cargo.toml index 2ef83f5ac9..be0c1062b7 100644 --- a/backend/parsers/windmill-parser-py-imports/Cargo.toml +++ b/backend/parsers/windmill-parser-py-imports/Cargo.toml @@ -7,6 +7,9 @@ authors.workspace = true [lib] name = "windmill_parser_py_imports" path = "./src/lib.rs" +# src/ has no unit tests or doctests: remove both lines before adding one, or cargo test never runs it. +test = false +doctest = false [target.'cfg(target_arch = "wasm32")'.dependencies] regex-lite.workspace = true diff --git a/backend/parsers/windmill-parser-py-imports/src/lib.rs b/backend/parsers/windmill-parser-py-imports/src/lib.rs index 5c8de9790f..f1718c6d94 100644 --- a/backend/parsers/windmill-parser-py-imports/src/lib.rs +++ b/backend/parsers/windmill-parser-py-imports/src/lib.rs @@ -268,6 +268,33 @@ pub fn parse_code_for_imports(code: &str, path: &str) -> anyhow::Result>, +) -> Option> { + let dir = script_path + .rsplit_once('/') + .map(|(dir, _)| dir) + .unwrap_or(""); + let sources: HashMap = modules? + .iter() + .filter_map(|(key, module)| { + let stem = key.strip_suffix(".py")?; + let path = if dir.is_empty() { + stem.to_string() + } else { + format!("{dir}/{stem}") + }; + Some((path, module.content.clone())) + }) + .collect(); + (!sources.is_empty()).then_some(sources) +} + #[cfg(not(target_arch = "wasm32"))] pub async fn parse_python_imports( code: &str, @@ -278,6 +305,10 @@ pub async fn parse_python_imports( locked_v: &mut Option, raw_workspace_dependencies_o: &Option, temp_script_refs: &Option>, + // Content of relative imports that ship with the importer rather than being deployed + // (its modules), keyed by the path a relative import resolves to. Wins over both + // `temp_script_refs` and the deployed script, as the module file does at run time. + module_sources: Option<&HashMap>, ) -> error::Result<(Vec, Option)> { let mut compile_error_hint: Option = None; let mut imports = parse_python_imports_inner( @@ -291,6 +322,7 @@ pub async fn parse_python_imports( locked_v, raw_workspace_dependencies_o, temp_script_refs, + module_sources, ) .await? .into_values() @@ -349,6 +381,7 @@ async fn parse_python_imports_inner( locked_v: &mut Option, raw_workspace_dependencies_o: &Option, temp_script_refs: &Option>, + module_sources: Option<&HashMap>, ) -> error::Result> { tracing::debug!("Parsing python imports for path: {}", path); let PythonAnnotations { py310, py311, py312, py313, py314, py315, .. } = @@ -517,10 +550,11 @@ async fn parse_python_imports_inner( for n in nimports.into_iter() { let mut nested = match n { NImport::Relative(rpath) => { - // First try to get content from temp_script_refs cache if available - let code_from_cache = if let Some(hash) = - temp_script_refs.as_ref().and_then(|dt| dt.get(&rpath)) + let code_from_cache = if let Some(content) = + module_sources.and_then(|m| m.get(&rpath)) { + Some(content.clone()) + } else if let Some(hash) = temp_script_refs.as_ref().and_then(|dt| dt.get(&rpath)) { tracing::debug!( "Found relative import '{}' in temp_script_refs with hash '{}'", rpath, @@ -570,6 +604,7 @@ async fn parse_python_imports_inner( locked_v, raw_workspace_dependencies_o, temp_script_refs, + module_sources, ) .await? .into_values() diff --git a/backend/parsers/windmill-parser-py-imports/tests/tests.rs b/backend/parsers/windmill-parser-py-imports/tests/tests.rs index 9086b29d47..6d3d75a239 100644 --- a/backend/parsers/windmill-parser-py-imports/tests/tests.rs +++ b/backend/parsers/windmill-parser-py-imports/tests/tests.rs @@ -29,6 +29,7 @@ def main(): &mut None, &None, &None, + None, ) .await?; // println!("{}", serde_json::to_string(&r)?); @@ -71,6 +72,7 @@ def main(): &mut None, &None, &None, + None, ) .await?; println!("{}", serde_json::to_string(&r)?); @@ -103,6 +105,7 @@ def main(): &mut None, &None, &None, + None, ) .await?; println!("{}", serde_json::to_string(&r)?); diff --git a/backend/parsers/windmill-parser-py/Cargo.toml b/backend/parsers/windmill-parser-py/Cargo.toml index 6971fe8fcd..1f34643572 100644 --- a/backend/parsers/windmill-parser-py/Cargo.toml +++ b/backend/parsers/windmill-parser-py/Cargo.toml @@ -7,6 +7,8 @@ authors.workspace = true [lib] name = "windmill_parser_py" path = "./src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [dependencies] windmill-parser.workspace = true diff --git a/backend/parsers/windmill-parser-r/Cargo.toml b/backend/parsers/windmill-parser-r/Cargo.toml index 42701f9c12..d489bc966d 100644 --- a/backend/parsers/windmill-parser-r/Cargo.toml +++ b/backend/parsers/windmill-parser-r/Cargo.toml @@ -7,6 +7,8 @@ authors.workspace = true [lib] name = "windmill_parser_r" path = "./src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [dependencies] windmill-parser.workspace = true diff --git a/backend/parsers/windmill-parser-ruby/Cargo.toml b/backend/parsers/windmill-parser-ruby/Cargo.toml index 779b0116a0..ca996528b1 100644 --- a/backend/parsers/windmill-parser-ruby/Cargo.toml +++ b/backend/parsers/windmill-parser-ruby/Cargo.toml @@ -7,6 +7,8 @@ authors.workspace = true [lib] name = "windmill_parser_ruby" path = "./src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [dependencies] windmill-parser.workspace = true diff --git a/backend/parsers/windmill-parser-rust/Cargo.toml b/backend/parsers/windmill-parser-rust/Cargo.toml index e1dcf84449..e4654590ff 100644 --- a/backend/parsers/windmill-parser-rust/Cargo.toml +++ b/backend/parsers/windmill-parser-rust/Cargo.toml @@ -7,6 +7,8 @@ authors.workspace = true [lib] name = "windmill_parser_rust" path = "./src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [dependencies] windmill-parser.workspace = true diff --git a/backend/parsers/windmill-parser-sql-asset/Cargo.toml b/backend/parsers/windmill-parser-sql-asset/Cargo.toml index 73835b2c06..5b22c38350 100644 --- a/backend/parsers/windmill-parser-sql-asset/Cargo.toml +++ b/backend/parsers/windmill-parser-sql-asset/Cargo.toml @@ -7,6 +7,8 @@ authors.workspace = true [lib] name = "windmill_parser_sql_asset" path = "./src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [dependencies] windmill-parser.workspace = true diff --git a/backend/parsers/windmill-parser-sql/Cargo.toml b/backend/parsers/windmill-parser-sql/Cargo.toml index 143b02a2cc..7963de819c 100644 --- a/backend/parsers/windmill-parser-sql/Cargo.toml +++ b/backend/parsers/windmill-parser-sql/Cargo.toml @@ -7,6 +7,8 @@ authors.workspace = true [lib] name = "windmill_parser_sql" path = "./src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [target.'cfg(target_arch = "wasm32")'.dependencies] regex-lite.workspace = true diff --git a/backend/parsers/windmill-parser-ts-asset/Cargo.toml b/backend/parsers/windmill-parser-ts-asset/Cargo.toml index f1c668d4eb..17a0279bdc 100644 --- a/backend/parsers/windmill-parser-ts-asset/Cargo.toml +++ b/backend/parsers/windmill-parser-ts-asset/Cargo.toml @@ -7,6 +7,8 @@ authors.workspace = true [lib] name = "windmill_parser_ts_asset" path = "./src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen.workspace = true diff --git a/backend/parsers/windmill-parser-ts/Cargo.toml b/backend/parsers/windmill-parser-ts/Cargo.toml index 5f04b14f3b..36dce90090 100644 --- a/backend/parsers/windmill-parser-ts/Cargo.toml +++ b/backend/parsers/windmill-parser-ts/Cargo.toml @@ -8,6 +8,9 @@ authors.workspace = true [lib] name = "windmill_parser_ts" path = "./src/lib.rs" +# src/ has no unit tests or doctests: remove both lines before adding one, or cargo test never runs it. +test = false +doctest = false [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen.workspace = true diff --git a/backend/parsers/windmill-parser-ts/src/lib.rs b/backend/parsers/windmill-parser-ts/src/lib.rs index ac75080994..8059851707 100644 --- a/backend/parsers/windmill-parser-ts/src/lib.rs +++ b/backend/parsers/windmill-parser-ts/src/lib.rs @@ -189,7 +189,20 @@ pub fn parse_expr_for_imports(code: &str, skip_type_only: bool) -> anyhow::Resul /// // Returns: ["f/utils"] /// ``` pub fn parse_relative_imports(code: &str, path: &str) -> anyhow::Result> { - let imports = parse_expr_for_imports(code, false)?; + resolve_relative_imports(code, path, false) +} + +/// [`parse_relative_imports`] without type-only imports, which are erased before the code runs. +pub fn parse_runtime_relative_imports(code: &str, path: &str) -> anyhow::Result> { + resolve_relative_imports(code, path, true) +} + +fn resolve_relative_imports( + code: &str, + path: &str, + skip_type_only: bool, +) -> anyhow::Result> { + let imports = parse_expr_for_imports(code, skip_type_only)?; let script_dir = path.rsplit_once('/').map(|(dir, _)| dir).unwrap_or(""); let mut resolved: Vec = imports diff --git a/backend/parsers/windmill-parser-ts/tests/tests.rs b/backend/parsers/windmill-parser-ts/tests/tests.rs index b4abc23488..8b9587d3a2 100644 --- a/backend/parsers/windmill-parser-ts/tests/tests.rs +++ b/backend/parsers/windmill-parser-ts/tests/tests.rs @@ -4,7 +4,7 @@ mod tests { use windmill_parser::{Arg, MainArgSignature, ObjectProperty, ObjectType, Typ}; use windmill_parser_ts::{ parse_deno_signature, parse_expr_for_imports, parse_relative_imports, - remove_pinned_import_specifiers, + parse_runtime_relative_imports, remove_pinned_import_specifiers, }; /// The server parses defaults natively, where a non-JSON literal cannot be evaluated @@ -944,6 +944,19 @@ export const label = "pkg@1.2.3"; assert_eq!(result, vec!["f/folder/helper"]); } + #[test] + fn test_runtime_relative_imports_skip_type_only() { + let code = r#" + import type { Shape } from "./types"; + import { helper } from "./helper"; + export async function main(s: Shape) { return helper(s); } + "#; + let result = parse_runtime_relative_imports(code, "f/folder/script").unwrap(); + assert_eq!(result, vec!["f/folder/helper"]); + let all = parse_relative_imports(code, "f/folder/script").unwrap(); + assert_eq!(all, vec!["f/folder/helper", "f/folder/types"]); + } + #[test] fn test_relative_imports_double_dot() { let code = r#" diff --git a/backend/parsers/windmill-parser-wac/Cargo.toml b/backend/parsers/windmill-parser-wac/Cargo.toml index 21ee286db9..5dccb0aa0a 100644 --- a/backend/parsers/windmill-parser-wac/Cargo.toml +++ b/backend/parsers/windmill-parser-wac/Cargo.toml @@ -7,6 +7,9 @@ authors.workspace = true [lib] name = "windmill_parser_wac" path = "./src/lib.rs" +# src/ has no unit tests or doctests: remove both lines before adding one, or cargo test never runs it. +test = false +doctest = false [dependencies] ruff_python_parser.workspace = true diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index c0dcab1c78..a73343f8b9 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.833.0" +version = "1.847.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -6430,7 +6430,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.833.0" +version = "1.847.0" dependencies = [ "proc-macro2", "quote", @@ -6442,7 +6442,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.833.0" +version = "1.847.0" dependencies = [ "convert_case", "serde", @@ -6451,7 +6451,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "lazy_static", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "serde_json", @@ -6475,7 +6475,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "gosyn", @@ -6487,7 +6487,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "lazy_static", @@ -6499,7 +6499,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "serde_json", @@ -6511,7 +6511,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "nu-parser", @@ -6522,7 +6522,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6533,7 +6533,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6545,7 +6545,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -6556,7 +6556,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "async-recursion", @@ -6579,7 +6579,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "serde_json", @@ -6591,7 +6591,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "lazy_static", @@ -6605,7 +6605,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "convert_case", @@ -6622,7 +6622,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "lazy_static", @@ -6635,7 +6635,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "serde", @@ -6647,7 +6647,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "lazy_static", @@ -6665,7 +6665,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6681,7 +6681,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -6698,7 +6698,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6730,7 +6730,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "lazy_static", @@ -6744,7 +6744,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.833.0" +version = "1.847.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index ca10c14ff6..6955752a98 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.833.0" +version = "1.847.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/parsers/windmill-parser-yaml/Cargo.toml b/backend/parsers/windmill-parser-yaml/Cargo.toml index eb2f16fccc..5c3b3e2760 100644 --- a/backend/parsers/windmill-parser-yaml/Cargo.toml +++ b/backend/parsers/windmill-parser-yaml/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_parser_yaml" path = "./src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [dependencies] yaml-rust.workspace = true diff --git a/backend/parsers/windmill-parser/Cargo.toml b/backend/parsers/windmill-parser/Cargo.toml index 97b6c6da58..842a94d917 100644 --- a/backend/parsers/windmill-parser/Cargo.toml +++ b/backend/parsers/windmill-parser/Cargo.toml @@ -7,6 +7,8 @@ authors.workspace = true [lib] name = "windmill_parser" path = "./src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [dependencies] serde = { version = "1.0", features = ["derive"] } diff --git a/backend/parsers/windmill-sql-datatype-parser-wasm/Cargo.toml b/backend/parsers/windmill-sql-datatype-parser-wasm/Cargo.toml index 7d66d56ade..e4d67e84a7 100644 --- a/backend/parsers/windmill-sql-datatype-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-sql-datatype-parser-wasm/Cargo.toml @@ -9,6 +9,8 @@ authors.workspace = true crate-type = ["cdylib"] name = "windmill_sql_datatype_parser_wasm" path = "./src/lib.rs" +# src/ has no unit tests: remove this line before adding one, or cargo test never runs it. +test = false [dev-dependencies] wasm-bindgen-test.workspace = true diff --git a/backend/src/main.rs b/backend/src/main.rs index fd953213a4..1035a0549f 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -44,12 +44,13 @@ use windmill_common::{ CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, CRITICAL_ALERT_MUTE_ZOMBIE_JOB_RESTART_SETTING, CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, - DEPENDENCY_JOB_TAG_SETTING, DISABLE_PASSWORD_LOGIN_SETTING, EMAIL_DOMAIN_SETTING, - ENV_SETTINGS, EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, - EXTRA_PIP_INDEX_URL_SETTING, FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING, - HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, - HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INDEXER_SETTING, - INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING, + DEPENDENCY_JOB_TAG_SETTING, DEV_WORKSPACE_ISOLATION_SETTING, + DEV_WORKSPACE_ISOLATION_SKIP_NON_DEV_FORKS_SETTING, DEV_WORKSPACE_TAG_SUFFIX_SETTING, + DISABLE_PASSWORD_LOGIN_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, + EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING, + FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING, HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING, + HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, + INDEXER_SETTING, INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, MAVEN_SETTINGS_XML_SETTING, MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, @@ -129,7 +130,9 @@ use windmill_worker::{ use crate::monitor::{ initial_load, load_concurrency_key_max_queued, load_dependency_job_tag, - load_disable_password_login, load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir, + load_dev_workspace_isolation, load_dev_workspace_isolation_skip_non_dev_forks, + load_dev_workspace_tag_suffix, load_disable_password_login, + load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir, load_mcp_disable_token_query_param, load_metrics_debug_enabled, load_preview_tags_override, load_require_preexisting_user, load_retention_period_overrides, load_tag_per_workspace_enabled, load_tag_per_workspace_workspaces, load_workspace_fairness_duration_secs, @@ -683,6 +686,29 @@ async fn windmill_main() -> anyhow::Result<()> { _ => {} } + // Before `num_workers`: it reads `WORKER_GROUP`, which an agent authenticating with a + // workload identity token only learns from the server. + let agent_setup = if mode == Mode::Agent { + let agent_config = match AgentConfig::from_env() { + Ok(config) => config, + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + }; + let suffix = resolve_worker_suffix(&hostname, 1)?; + if let Err(e) = agent_config.resolve_workload_identity(&suffix).await { + eprintln!( + "Agent worker could not authenticate with its workload identity token, check \ + AGENT_IDENTITY_TOKEN_FILE and BASE_INTERNAL_URL (the reason is in the server log): {e:#}" + ); + std::process::exit(1); + } + Some((agent_config, suffix)) + } else { + None + }; + #[allow(unused_mut)] let mut num_workers = if mode == Mode::Server || mode == Mode::Indexer || mode == Mode::MCP { 0 @@ -735,19 +761,11 @@ async fn windmill_main() -> anyhow::Result<()> { .and_then(|x| x.parse().ok()) .unwrap_or(IpAddr::from(default_bind_addr)); - let (conn, first_suffix, agent_config) = if mode == Mode::Agent { - let agent_config = match AgentConfig::from_env() { - Ok(config) => config, - Err(e) => { - tracing::error!("{e}"); - std::process::exit(1); - } - }; + let (conn, first_suffix, agent_config) = if let Some((agent_config, suffix)) = agent_setup { tracing::info!( "Creating http client for cluster using base internal url {}", agent_config.base_internal_url ); - let suffix = resolve_worker_suffix(&hostname, 1)?; ( Connection::Http(agent_config.build_http_client(&suffix)), Some(suffix), @@ -846,6 +864,8 @@ async fn windmill_main() -> anyhow::Result<()> { let worker_mode = num_workers > 0; if worker_mode { + windmill_common::worker::warn_secret_shaped_whitelist_envs(); + #[cfg(any(target_os = "linux"))] if let Err(e) = disable_oom_group() { tracing::warn!( @@ -991,7 +1011,7 @@ Windmill Community Edition {GIT_VERSION} tracing::error!("Failed to reload license key: {err:#}"); if is_agent { tracing::error!( - "Agent worker cannot connect to server. Please check AGENT_TOKEN and BASE_INTERNAL_URL" + "Agent worker cannot connect to server. Please check AGENT_TOKEN (or AGENT_IDENTITY_TOKEN_FILE) and BASE_INTERNAL_URL" ); std::process::exit(1); } @@ -1052,12 +1072,8 @@ Windmill Community Edition {GIT_VERSION} // Before the first load: it can sign a token for instance object storage. #[cfg(all(feature = "enterprise", feature = "openidconnect", feature = "private"))] - windmill_common::oidc_ee::init_oidc_signing( - &conn, - server_mode, - killpill_rx.resubscribe(), - ) - .await?; + windmill_common::oidc_ee::init_oidc_signing(&conn, server_mode, killpill_rx.resubscribe()) + .await?; initial_load( &conn, @@ -1664,12 +1680,15 @@ async fn process_notify_event( ); windmill_common::variables::CUSTOM_ENVS_CACHE.remove(payload); } - "notify_operator_settings_change" => { + // Written by the legacy operator settings mirror, always alongside a role override. + c if c == "notify_operator_settings_change" + || c == windmill_common::roles::ROLE_OVERRIDES_CHANGE_CHANNEL => + { tracing::info!( - "Operator settings change detected, invalidating operator rights cache: {}", + "Role overrides change detected, invalidating workspace roles cache: {}", payload ); - windmill_common::workspaces::invalidate_operator_rights_cache(payload); + windmill_common::roles::invalidate_workspace_roles_cache(payload); } "notify_asset_producer_change" => { tracing::debug!( @@ -1881,6 +1900,21 @@ async fn process_notify_event( tracing::error!("Error loading dependency job tag: {e:#}"); } } + DEV_WORKSPACE_ISOLATION_SETTING => { + if let Err(e) = load_dev_workspace_isolation(db).await { + tracing::error!("Error loading dev workspace isolation: {e:#}"); + } + } + DEV_WORKSPACE_ISOLATION_SKIP_NON_DEV_FORKS_SETTING => { + if let Err(e) = load_dev_workspace_isolation_skip_non_dev_forks(db).await { + tracing::error!("Error loading dev workspace isolation of forks: {e:#}"); + } + } + DEV_WORKSPACE_TAG_SUFFIX_SETTING => match load_dev_workspace_tag_suffix(db).await { + Ok(true) if worker_mode => reload_worker_config(db, tx.clone(), true).await, + Ok(_) => (), + Err(e) => tracing::error!("Error loading dev workspace tag suffix: {e:#}"), + }, WORKSPACE_FAIRNESS_ENABLED_SETTING => { if let Err(e) = load_workspace_fairness_enabled(db).await { tracing::error!("Error loading workspace fairness enabled: {e:#}"); @@ -2011,6 +2045,9 @@ async fn process_notify_event( DISABLE_PASSWORD_LOGIN_SETTING => { load_disable_password_login(db).await; } + windmill_common::global_settings::WORKSPACE_ROLES_SETTING => { + windmill_common::roles::invalidate_all_roles_cache(); + } MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING => { load_mcp_disable_token_query_param(db).await; } diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 4fae6f34ab..fbadfa4269 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -60,17 +60,19 @@ use windmill_common::{ CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING, CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, CRITICAL_ALERT_MUTE_ZOMBIE_JOB_RESTART_SETTING, CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING, - DEFAULT_TAGS_WORKSPACES_SETTING, DEPENDENCY_JOB_TAG_SETTING, DISABLE_PASSWORD_LOGIN, - DISABLE_PASSWORD_LOGIN_SETTING, EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, - EXTRA_PIP_INDEX_URL_SETTING, FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING, - HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING, - JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, - KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MCP_DISABLE_TOKEN_QUERY_PARAM, - MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPMRC_SETTING, - NPM_CONFIG_REGISTRY_SETTING, NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING, - NUGET_CONFIG_SETTING, OTEL_SETTING, OTEL_TRACES_RETENTION_SECS_SETTING, - OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, - POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING, + DEFAULT_TAGS_WORKSPACES_SETTING, DEPENDENCY_JOB_TAG_SETTING, + DEV_WORKSPACE_ISOLATION_SETTING, DEV_WORKSPACE_ISOLATION_SKIP_NON_DEV_FORKS_SETTING, + DEV_WORKSPACE_TAG_SUFFIX_SETTING, DISABLE_PASSWORD_LOGIN, DISABLE_PASSWORD_LOGIN_SETTING, + EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING, + FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING, HUB_API_SECRET_SETTING, + HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, + JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, + MCP_DISABLE_TOKEN_QUERY_PARAM, MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING, + MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING, + NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING, NUGET_CONFIG_SETTING, + OTEL_SETTING, OTEL_TRACES_RETENTION_SECS_SETTING, OTEL_TRACING_PROXY_SETTING, + PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, + PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RETENTION_PERIOD_SECS_SETTING, SAML_METADATA_SETTING, SANDBOX_IMAGE_CACHE_MAX_MB_SETTING, SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING, SANDBOX_IMAGE_MAX_SIZE_MB_SETTING, @@ -89,15 +91,18 @@ use windmill_common::{ users::truncate_token, utils::{empty_as_none, now_from_db, report_critical_error, Mode, HUB_API_SECRET}, worker::{ - load_env_vars, load_init_bash_from_env, load_periodic_bash_script_from_env, - load_periodic_bash_script_interval_from_env, load_whitelist_env_vars_from_env, - load_worker_config, store_pull_query, store_suspended_pull_query, Connection, WorkerConfig, - CLOUD_HOSTED, CONCURRENCY_KEY_MAX_QUEUED, CONCURRENCY_KEY_MAX_QUEUED_DEFAULT, - DEFAULT_TAGS_PER_WORKSPACE, DEFAULT_TAGS_WORKSPACES, DEPENDENCY_JOB_TAG, - FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX, INDEXER_CONFIG, PREVIEW_TAGS_OVERRIDE, SMTP_CONFIG, - WINDMILL_DIR, WORKER_CONFIG, WORKER_GROUP, WORKSPACE_FAIRNESS_DURATION_SECS, - WORKSPACE_FAIRNESS_ENABLED, WORKSPACE_FAIRNESS_MAX_PERCENT, WORKSPACE_FAIRNESS_MIN_TOTAL, - WORKSPACE_MAX_QUEUED_JOBS, WORKSPACE_MAX_QUEUED_JOBS_DEFAULT, + is_valid_dev_workspace_tag_suffix, load_env_vars, load_init_bash_from_env, + load_periodic_bash_script_from_env, load_periodic_bash_script_interval_from_env, + load_whitelist_env_vars_from_env, load_worker_config, store_pull_query, + store_suspended_pull_query, Connection, WorkerConfig, CLOUD_HOSTED, + CONCURRENCY_KEY_MAX_QUEUED, CONCURRENCY_KEY_MAX_QUEUED_DEFAULT, + DEFAULT_DEV_WORKSPACE_TAG_SUFFIX, DEFAULT_TAGS_PER_WORKSPACE, DEFAULT_TAGS_WORKSPACES, + DEPENDENCY_JOB_TAG, DEV_WORKSPACE_ISOLATION, DEV_WORKSPACE_ISOLATION_SKIP_NON_DEV_FORKS, + DEV_WORKSPACE_TAG_SUFFIX, FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX, INDEXER_CONFIG, + PREVIEW_TAGS_OVERRIDE, SMTP_CONFIG, WINDMILL_DIR, WORKER_CONFIG, WORKER_GROUP, + WORKSPACE_FAIRNESS_DURATION_SECS, WORKSPACE_FAIRNESS_ENABLED, + WORKSPACE_FAIRNESS_MAX_PERCENT, WORKSPACE_FAIRNESS_MIN_TOTAL, WORKSPACE_MAX_QUEUED_JOBS, + WORKSPACE_MAX_QUEUED_JOBS_DEFAULT, }, KillpillSender, AUDIT_LOG_RETENTION_DAYS, BASE_URL, CRITICAL_ALERTS_ON_DB_OVERSIZE, CRITICAL_ALERTS_ON_TOKEN_EXPIRY, CRITICAL_ALERT_MUTE_UI_ENABLED, @@ -321,6 +326,18 @@ pub async fn initial_load( pass.setting(DEPENDENCY_JOB_TAG_SETTING, false, |v| async move { apply_dependency_job_tag(v) }); + // Ahead of the worker config reload below, which expands the tags with it. + pass.setting(DEV_WORKSPACE_TAG_SUFFIX_SETTING, false, |v| async move { + apply_dev_workspace_tag_suffix(v); + }); + pass.setting(DEV_WORKSPACE_ISOLATION_SETTING, false, |v| async move { + apply_dev_workspace_isolation(v) + }); + pass.setting( + DEV_WORKSPACE_ISOLATION_SKIP_NON_DEV_FORKS_SETTING, + false, + |v| async move { apply_dev_workspace_isolation_skip_non_dev_forks(v) }, + ); // Load per-workspace retention overrides before the first cleanup tick so a fresh server // never sweeps globally without honoring configured longer-retention workspaces. @@ -424,6 +441,7 @@ pub async fn initial_load( cache_clear: None, additional_python_paths: None, pip_local_dependencies: None, + disable_job_proxy: false, native_mode, // an agent worker never reads its group's config, only its token object_store_cache_config: None, @@ -1127,6 +1145,53 @@ pub fn apply_fork_workspace_tag_append_fork_suffix(value: Option error::Result { + let v = load_value_from_global_settings(db, DEV_WORKSPACE_TAG_SUFFIX_SETTING).await?; + Ok(apply_dev_workspace_tag_suffix(v)) +} + +pub fn apply_dev_workspace_tag_suffix(value: Option) -> bool { + let suffix = match value { + Some(serde_json::Value::String(t)) if !t.trim().is_empty() => t.trim().to_string(), + _ => DEFAULT_DEV_WORKSPACE_TAG_SUFFIX.to_string(), + }; + if !is_valid_dev_workspace_tag_suffix(&suffix) { + tracing::error!("Ignoring invalid dev workspace tag suffix {suffix:?}"); + return false; + } + let prev = DEV_WORKSPACE_TAG_SUFFIX.swap(std::sync::Arc::new(suffix.clone())); + *prev != suffix +} + +pub async fn load_dev_workspace_isolation(db: &DB) -> error::Result<()> { + let v = load_value_from_global_settings(db, DEV_WORKSPACE_ISOLATION_SETTING).await?; + apply_dev_workspace_isolation(v); + Ok(()) +} + +pub fn apply_dev_workspace_isolation(value: Option) { + DEV_WORKSPACE_ISOLATION.store( + matches!(value, Some(serde_json::Value::Bool(true))), + Ordering::Relaxed, + ); +} + +pub async fn load_dev_workspace_isolation_skip_non_dev_forks(db: &DB) -> error::Result<()> { + let v = load_value_from_global_settings(db, DEV_WORKSPACE_ISOLATION_SKIP_NON_DEV_FORKS_SETTING) + .await?; + apply_dev_workspace_isolation_skip_non_dev_forks(v); + Ok(()) +} + +pub fn apply_dev_workspace_isolation_skip_non_dev_forks(value: Option) { + DEV_WORKSPACE_ISOLATION_SKIP_NON_DEV_FORKS.store( + matches!(value, Some(serde_json::Value::Bool(true))), + Ordering::Relaxed, + ); +} + pub async fn reload_critical_alert_mute_ui_setting(conn: &Connection) -> error::Result<()> { let v = load_value_from_global_settings_with_conn(conn, CRITICAL_ALERT_MUTE_UI_SETTING, true) .await?; diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index fa1c049746..2bf6b0188f 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -10,7 +10,7 @@ autoscaling_event_type: full_scaleout, scalein, scaleout aws_auth_resource_type: oidc, credentials delivery_mode: push, pull draft_type: script, flow, app -favorite_kind: app, script, flow, raw_app, asset +favorite_kind: app, script, flow, raw_app, asset, agent gcp_subscription_mode: create_update, existing http_method: get, post, put, delete, patch importer_kind: script, flow, app @@ -41,6 +41,7 @@ ai_agent_memory: workspace_id(char), conversation_id(uuid), step_id(char), messa ai_free_token_daily_usage: day(date), cost_nanos(bigint), updated_at(ts) ai_free_token_usage: email(char), cost_nanos(bigint), updated_at(ts) ai_shared_artifact: id(uuid), workspace_id(char), artifact_id(char), email(char), created_by(char), name(char), kind(char), version(int), content(text), shared_at(ts) +ai_shared_session: id(uuid), workspace_id(char), session_id(char), email(char), created_by(char), name(char), is_public(bool), message_count(int), snapshot(text), shared_at(ts) FK: (workspace_id) -> workspace(id) ai_token_usage: workspace_id(char), day(date), email(char), provider(char), model(char), session_id(char), input_tokens(bigint), cache_read_tokens(bigint), cache_write_tokens(bigint), output_tokens(bigint), reported_cost_nano_usd(bigint), requests(bigint), updated_at(ts) FK: (workspace_id) -> workspace(id) @@ -48,7 +49,7 @@ alerts: id(int), alert_type(char), message(text), created_at(ts), acknowledged(b app: id(bigint), workspace_id(char), path(char), summary(char), policy(jsonb), versions(bigint[]), extra_perms(jsonb), draft_only(bool), custom_path(text), labels(text[]) FK: (workspace_id) -> workspace(id) app_bundles: app_version_id(bigint), w_id(char), file_type(char), data(bytes) -app_script: id(bigint), app(bigint), hash(char(64)), lock(text), code(text), code_sha256(char(64)) +app_script: id(bigint), app(bigint), hash(char(64)), lock(text), code(text), code_sha256(char(64)), modules(jsonb) FK: (app) -> app(id) app_version: id(bigint), app_id(bigint), value(json), created_by(char), created_at(ts), raw_app(bool) FK: (app_id) -> app(id) @@ -187,6 +188,11 @@ schedule: workspace_id(char), path(char), edited_by(char), edited_at(ts), schedu script: workspace_id(char), hash(bigint), path(char), parent_hashes(bigint[]), summary(text), description(text), content(text), created_by(char), created_at(ts), archived(bool), schema(json), deleted(bool), is_template(bool), extra_perms(jsonb), lock(text), lock_error_logs(text), language(script_lang), kind(script_kind), tag(char), draft_only(bool), envs(char), concurrent_limit(int), concurrency_time_window_s(int), cache_ttl(int), dedicated_worker(bool), ws_error_handler_muted(bool), priority(smallint), timeout(int), delete_after_use(bool), restart_unless_cancelled(bool), concurrency_key(char), visible_to_runner_only(bool), auto_kind(varchar), codebase(char), has_preprocessor(bool), schema_validation(bool), assets(jsonb), debounce_key(char), debounce_delay_s(int), cache_ignore_s3_path(bool), runnable_settings_handle(bigint), labels(text[]), on_behalf_of(varchar), on_behalf_of_email(text), job_token_scopes(text[]) FK: (workspace_id) -> workspace(id) skip_workspace_diff_tally: workspace_id(char), added_at(ts) +slack_agent_channel: slack_team_id(char), channel_id(char), channel_name(char), workspace_id(char), agent_path(char), is_default(bool) + FK: (workspace_id, agent_path) -> resource(workspace_id, path) +slack_answered_message: slack_team_id(char), channel_id(char), ts(char), created_at(timestamptz) +slack_thread_agent: slack_team_id(char), channel_id(char), thread_ts(char), workspace_id(char), agent_path(char), created_at(timestamptz) + FK: (workspace_id, agent_path) -> resource(workspace_id, path) sqs_trigger: path(char), queue_url(char), aws_resource_path(char), message_attributes(text[]), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), error(text), server_id(char), last_server_ping(ts), aws_auth_resource_type(aws_auth_resource_type), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode), labels(text[]) FK: (workspace_id) -> workspace(id) trashbin: id(bigint), workspace_id(char), item_kind(char), item_path(char), item_data(jsonb), deleted_by(char), deleted_at(ts), expires_at(ts) diff --git a/backend/tests/ai_shared_sessions.rs b/backend/tests/ai_shared_sessions.rs new file mode 100644 index 0000000000..75334a5440 --- /dev/null +++ b/backend/tests/ai_shared_sessions.rs @@ -0,0 +1,120 @@ +//! Shared AI sessions: a private share is served only to members of its workspace, a public +//! one to anyone holding the link, and only its author decides which it is. + +use serde_json::{json, Value}; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +const ADMIN: &str = "Bearer SECRET_TOKEN"; +const MEMBER: &str = "Bearer SECRET_TOKEN_2"; +const OTHER_MEMBER: &str = "Bearer SECRET_TOKEN_3"; +const OUTSIDER: &str = "Bearer OUTSIDER_TOKEN"; + +#[sqlx::test(fixtures("base"))] +async fn private_share_is_for_members_and_public_share_for_anyone( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + sqlx::raw_sql( + "INSERT INTO password(email, password_hash, login_type, super_admin, verified, name) + VALUES ('outsider@windmill.dev', 'not-a-real-hash', 'password', false, true, 'Outsider'); + INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) + VALUES (encode(sha256('OUTSIDER_TOKEN'::bytea), 'hex'), 'OUTSIDER_T', 'OUTSIDER_TOKEN', + 'outsider@windmill.dev', 'outsider token', false);", + ) + .execute(&db) + .await?; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let authed = format!("http://localhost:{port}/api/w/test-workspace/ai/shared_sessions"); + let get = |id: &str| { + format!("http://localhost:{port}/api/w/test-workspace/ai_u/shared_sessions/get/{id}") + }; + let client = reqwest::Client::new(); + + let resp = client + .post(format!("{authed}/share")) + .header("Authorization", MEMBER) + .json(&json!({ + "session_id": "session-1", + "name": "Debugging", + "message_count": 1, + "snapshot": { "messages": [{ "role": "user", "content": "hi" }], "artifacts": [] }, + })) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + let share: Value = resp.json().await?; + assert_eq!(share["is_public"], false, "a new share must start private"); + let id = share["id"].as_str().unwrap().to_string(); + + let status = |token: Option<&'static str>| { + let mut req = client.get(get(&id)); + if let Some(token) = token { + req = req.header("Authorization", token); + } + async move { anyhow::Ok(req.send().await?.status().as_u16()) } + }; + + assert_eq!(status(Some(OTHER_MEMBER)).await?, 200); + assert_eq!( + status(None).await?, + 401, + "a private share was served anonymously" + ); + assert_eq!( + status(Some(OUTSIDER)).await?, + 401, + "a private share was served to a non-member" + ); + + let resp = client + .post(format!("{authed}/set_public/{id}")) + .header("Authorization", OTHER_MEMBER) + .json(&json!({ "is_public": true })) + .send() + .await?; + assert_eq!( + resp.status(), + 404, + "a member who is not the author made the share public" + ); + + let resp = client + .post(format!("{authed}/set_public/{id}")) + .header("Authorization", MEMBER) + .json(&json!({ "is_public": true })) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + + let resp = client.get(get(&id)).send().await?; + assert_eq!( + resp.status(), + 200, + "a public share was not served anonymously" + ); + let body: Value = resp.json().await?; + assert_eq!(body["snapshot"]["messages"][0]["content"], "hi"); + assert_eq!(body["can_unshare"], false); + + let delete = |token: &'static str| { + client + .delete(format!("{authed}/delete/{id}")) + .header("Authorization", token) + .send() + }; + assert_eq!( + delete(OTHER_MEMBER).await?.status(), + 404, + "a member who is neither the author nor an admin took the share down" + ); + assert_eq!( + delete(ADMIN).await?.status(), + 200, + "an admin could not take the share down" + ); + assert_eq!(status(Some(OTHER_MEMBER)).await?, 404); + + Ok(()) +} diff --git a/backend/tests/app_preview_auth.rs b/backend/tests/app_preview_auth.rs index cd84e6f278..9f6e77f61c 100644 --- a/backend/tests/app_preview_auth.rs +++ b/backend/tests/app_preview_auth.rs @@ -75,12 +75,12 @@ async fn test_app_preview_authorization(db: Pool) -> anyhow::Result<() let status = resp.status(); let body = resp.text().await?; assert_eq!( - status, 401, + status, 403, "Operator must be rejected from app preview (got {status}): {body}" ); assert!( - body.contains("Operators cannot run preview jobs"), - "rejection must be the operator guard, got: {body}" + body.contains("Your role cannot run preview jobs"), + "rejection must be the role guard, got: {body}" ); // 2. The fix must NOT over-block the legitimate editor flow: a regular @@ -312,13 +312,11 @@ async fn test_app_preview_authorization(db: Pool) -> anyhow::Result<() "run-mode unpinned raw_code must be rejected for any caller, not just operators (got {status}): {body}" ); - // 11. The pin requirement also covers `raw_code` carrying an `app_script` - // `id`. The id resolves to `rawscript/` of any app_script row - // by number (no app scoping), so without the pin a caller could run a - // script belonging to another app against this Viewer app. Here `999777` - // belongs to `u/test-user/private`, not to the targeted `vapp`, and its - // sha is absent from `vapp`'s empty `triggerables_v2` — it must be - // rejected, not fall back to the Viewer default. + // 11. `raw_code` carrying an `app_script` `id` from another app must not run + // against this Viewer app: the row carries its own modules and lock, which + // would run under `vapp`'s identity. Here `999777` belongs to + // `u/test-user/private`, not to the targeted `vapp`, so the id lookup + // (scoped to the app) refuses it before any Viewer default applies. let resp = authed( client().post(format!("{base}/u/test-user/vapp")), "OPERATOR_TOKEN", @@ -338,12 +336,12 @@ async fn test_app_preview_authorization(db: Pool) -> anyhow::Result<() let status = resp.status(); let body = resp.text().await?; assert_eq!( - status, 400, - "run-mode raw_code with an unpinned app_script id must be rejected against a Viewer app (got {status}): {body}" + status, 401, + "run-mode raw_code with another app's app_script id must be rejected against a Viewer app (got {status}): {body}" ); assert!( - body.contains("forbidden by policy"), - "rejection must be the content-hash pin (unpinned app_script sha), got: {body}" + body.contains("does not belong to app u/test-user/vapp"), + "rejection must be the app scoping of the app_script id, got: {body}" ); // 12. Path confinement: the scope picker mints `apps:run:` / diff --git a/backend/tests/app_run_mode_lock_strip.rs b/backend/tests/app_run_mode_lock_strip.rs index d13d34a6c7..7a1bba75f6 100644 --- a/backend/tests/app_run_mode_lock_strip.rs +++ b/backend/tests/app_run_mode_lock_strip.rs @@ -289,7 +289,10 @@ async fn test_app_script_run_derives_path(db: Pool) -> anyhow::Result< .await?; let status = resp.status(); let body = resp.text().await?; - assert_eq!(status, 200, "deployed app script run must be accepted: {body}"); + assert_eq!( + status, 200, + "deployed app script run must be accepted: {body}" + ); let uuid = uuid::Uuid::parse_str(body.trim())?; let (_, _, _, _, runnable_path) = job_fields(&db, uuid).await?; assert_eq!( @@ -300,3 +303,103 @@ async fn test_app_script_run_derives_path(db: Pool) -> anyhow::Result< Ok(()) } + +/// Once the deployed version has its lite value, a deployed row's code runs only through +/// that row, while id-less SQL the frontend builds at runtime (database studio queries) +/// still runs on its content pin. +#[sqlx::test(fixtures("base"))] +async fn test_run_mode_id_less_code_with_lite(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let ws = format!("http://localhost:{port}/api/w/test-workspace"); + + const RUNTIME_CONTENT: &str = "SELECT 1;\n"; + const DEPLOYED_CONTENT: &str = "SELECT 2;\n"; + let app_path = "u/test-user/lite_id_less"; + let resp = authed(client().post(format!("{ws}/apps/create")), "SECRET_TOKEN") + .json(&json!({ + "path": app_path, + "summary": "", + "value": {}, + "policy": { + "execution_mode": "viewer", + "triggerables_v2": { + format!("comp:{}", rawscript_pin(DEPLOYED_CONTENT)): { "static_inputs": {}, "one_of_inputs": {} }, + format!("comp:{}", rawscript_pin(RUNTIME_CONTENT)): { "static_inputs": {}, "one_of_inputs": {} } + } + } + })) + .send() + .await?; + assert_eq!(resp.status(), 201, "create app: {}", resp.text().await?); + + let sha = rawscript_pin(DEPLOYED_CONTENT) + .trim_start_matches("rawscript/") + .to_string(); + let script_id: i64 = sqlx::query_scalar( + "INSERT INTO app_script (app, hash, code, code_sha256) + SELECT id, $2, $3, $2 FROM app WHERE path = $1 RETURNING id", + ) + .bind(app_path) + .bind(&sha) + .bind(DEPLOYED_CONTENT) + .fetch_one(&db) + .await?; + sqlx::query( + "INSERT INTO app_version_lite (id, value) + SELECT versions[array_upper(versions, 1)], $2 FROM app WHERE path = $1", + ) + .bind(app_path) + .bind( + json!({ "grid": [{ "data": { "componentInput": { "runnable": { + "inlineScript": { "id": script_id, "language": "postgresql" } + } } } }] }), + ) + .execute(&db) + .await?; + + let run = |content: &'static str, language: &'static str| { + authed( + client().post(format!("{ws}/apps_u/execute_component/{app_path}")), + "SECRET_TOKEN_2", + ) + .json(&json!({ + "args": {}, + "component": "comp", + "raw_code": { "language": language, "content": content } + })) + .send() + }; + + let resp = run(RUNTIME_CONTENT, "postgresql").await?; + let status = resp.status(); + assert_eq!( + status, + 200, + "id-less runtime-built code must run: {}", + resp.text().await? + ); + + // The pin covers content only (it also pins frontend scripts), so a pinned content + // must not run id-less in a language the database studio never emits. + let resp = run(RUNTIME_CONTENT, "bash").await?; + let status = resp.status(); + assert_eq!( + status, + 401, + "id-less code must not run as a non-SQL language: {}", + resp.text().await? + ); + + let resp = run(DEPLOYED_CONTENT, "postgresql").await?; + let status = resp.status(); + assert_eq!( + status, + 401, + "a deployed row's code must not run id-less: {}", + resp.text().await? + ); + + Ok(()) +} diff --git a/backend/tests/audit_rls.rs b/backend/tests/audit_rls.rs new file mode 100644 index 0000000000..2aca3051ab --- /dev/null +++ b/backend/tests/audit_rls.rs @@ -0,0 +1,37 @@ +//! A non-admin's transaction must accept an audit row recorded under another username, as a +//! job pushed to run as someone else writes one. A rejected insert aborts the transaction and +//! takes the caller's own write down with it. + +use sqlx::{Pool, Postgres}; + +use windmill_test_utils::ensure_today_audit_partition; + +#[sqlx::test] +async fn non_admin_writes_an_audit_row_for_another_user(db: Pool) -> anyhow::Result<()> { + ensure_today_audit_partition(&db).await; + + let mut tx = db.begin().await?; + sqlx::query("SET LOCAL ROLE windmill_user") + .execute(&mut *tx) + .await?; + sqlx::query("SELECT set_config('session.user', 'bob', true)") + .execute(&mut *tx) + .await?; + // The shape a schedule run as alice records without an Enterprise license: parameters + // redacted, so no `end_user` names the schedule. + sqlx::query( + "INSERT INTO audit_partitioned + (workspace_id, username, operation, action_kind, resource, parameters, email) + VALUES ('admins', 'alice', 'redacted', 'execute', 'EE only', '{\"redacted\": \"-\"}', 'alice@windmill.dev')", + ) + .execute(&mut *tx) + .await?; + tx.commit().await?; + + let count: i64 = + sqlx::query_scalar("SELECT count(*) FROM audit_partitioned WHERE username = 'alice'") + .fetch_one(&db) + .await?; + assert_eq!(count, 1); + Ok(()) +} diff --git a/backend/tests/batch_rerun.rs b/backend/tests/batch_rerun.rs index 58cf27db87..9663eea73b 100644 --- a/backend/tests/batch_rerun.rs +++ b/backend/tests/batch_rerun.rs @@ -29,12 +29,21 @@ const FLOW_VERSION: i64 = 2222222222; /// Mark a queued job as completed with success — needed so it's eligible for /// `list_selected_job_groups` / `batch_rerun_jobs` (both join `v2_job_completed`). async fn complete_job(db: &Pool, job_id: Uuid) -> anyhow::Result<()> { + complete_job_with_status(db, job_id, "success").await +} + +async fn complete_job_with_status( + db: &Pool, + job_id: Uuid, + status: &str, +) -> anyhow::Result<()> { sqlx::query( "INSERT INTO v2_job_completed (workspace_id, id, result, status, duration_ms, started_at, completed_at) - VALUES ($1, $2, '{}'::jsonb, 'success', 0, now(), now())", + VALUES ($1, $2, '{}'::jsonb, $3::job_status, 0, now(), now())", ) .bind(WORKSPACE) .bind(job_id) + .bind(status) .execute(db) .await?; Ok(()) @@ -660,3 +669,56 @@ async fn batch_rerun_denies_invisible_source_job(db: Pool) -> anyhow:: assert_eq!(pushed, 0); Ok(()) } + +/// Re-running a failed or canceled job resolves it, pointing at the re-run, which is what lets +/// "hide resolved" exclude it from the next batch. A success has nothing to resolve. +#[sqlx::test(fixtures("base", "batch_rerun"))] +async fn batch_rerun_resolves_failed_and_canceled_sources( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let client = windmill_api_client::create_client( + &format!("http://localhost:{}", server.addr.port()), + "SECRET_TOKEN".to_string(), + ); + + let mut sources = vec![]; + for status in ["success", "failure", "canceled"] { + let id = RunJob::from(script_payload()) + .arg("name".to_string(), json!(status)) + .push(&db) + .await; + complete_job_with_status(&db, id, status).await?; + sources.push(id); + } + + let results = batch_rerun( + &client, + json!({ + "job_ids": sources, + "script_options_by_path": {}, + "flow_options_by_path": {}, + }), + ) + .await?; + assert!(results.iter().all(|r| r.is_ok()), "{results:?}"); + + let resolved: Vec<(String, String)> = sqlx::query_as( + "SELECT o.args->>'name', n.args->>'name' + FROM job_resolution r + JOIN v2_job o ON o.id = r.job_id + JOIN v2_job n ON r.note = 'Re-run as ' || n.id + ORDER BY 1", + ) + .fetch_all(&db) + .await?; + assert_eq!( + resolved, + vec![ + ("canceled".to_string(), "canceled".to_string()), + ("failure".to_string(), "failure".to_string()), + ] + ); + Ok(()) +} diff --git a/backend/tests/datatable_fork_migrations.rs b/backend/tests/datatable_fork_migrations.rs new file mode 100644 index 0000000000..50f4d89705 --- /dev/null +++ b/backend/tests/datatable_fork_migrations.rs @@ -0,0 +1,148 @@ +//! A fork's copy of a data table must report the migrations its schema already holds as applied. +//! +//! A schema-only copy restores `_wm_migrations` without its rows, and no copy carries the source's +//! `ALTER DATABASE ... SET search_path`, through which the bookkeeping table is found. Either one +//! alone makes the fork list every applied migration as pending, and running them fails on +//! objects that already exist. The source's `DateStyle` differs from the copy's while the rows +//! are written, so they must not travel in its text form. + +use serde_json::{json, Value}; +use sqlx::{Pool, Postgres}; + +use windmill_test_utils::*; + +const ROLE: &str = "wm_dtfork_test_role"; +const ROLE_PASSWORD: &str = "wm_dtfork_test_pwd"; + +fn authed(b: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + b.header("Authorization", "Bearer DTMIG_ADMIN_TOKEN") +} + +#[sqlx::test(fixtures("datatable_migrations_grants"))] +async fn a_forked_copy_keeps_its_applied_migrations(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let opts = (*db.connect_options()).clone(); + // The copy takes its settings locks on a connection of its own, opened from `DATABASE_URL` + // rather than the pool: point that at this test's database, not the one sqlx creates it from. + // Only this test runs in the binary, and before anything has read the variable. + let base_url = std::env::var("DATABASE_URL")?; + let (prefix, rest) = base_url + .rsplit_once('/') + .expect("DATABASE_URL names a database"); + let query = rest + .split_once('?') + .map(|(_, q)| format!("?{q}")) + .unwrap_or_default(); + let test_db = opts.get_database().expect("test database name"); + std::env::set_var("DATABASE_URL", format!("{prefix}/{test_db}{query}")); + // Databases are cluster-wide, so both are named for this run alone. + let suffix = format!( + "{:x}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos() + ); + let source = format!("wm_dtfork_src_{suffix}"); + let fork_id = format!("wm-fork-dtfork-{suffix}"); + let copy = format!("wm_fork_dtfork_{suffix}__main"); + + sqlx::query(&format!( + "DO $$ BEGIN \ + CREATE ROLE {ROLE} LOGIN CREATEDB PASSWORD '{ROLE_PASSWORD}'; \ + EXCEPTION WHEN duplicate_object OR unique_violation THEN NULL; \ + END $$" + )) + .execute(&db) + .await?; + sqlx::query(&format!("CREATE DATABASE {source} OWNER {ROLE}")) + .execute(&db) + .await?; + + let result = async { + let source_pool = sqlx::PgPool::connect_with(opts.clone().database(&source)).await?; + sqlx::raw_sql(&format!( + "SET ROLE {ROLE}; \ + CREATE SCHEMA app; \ + CREATE TABLE app.foo (id int); \ + CREATE TABLE app._wm_migrations (datatable TEXT NOT NULL, version BIGINT NOT NULL, \ + installed_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (datatable, version)); \ + INSERT INTO app._wm_migrations (datatable, version, installed_at) \ + VALUES ('main', 1, '2026-01-31 10:00:00+00'); \ + ALTER DATABASE {source} SET search_path = app; \ + ALTER DATABASE {source} SET datestyle = 'SQL, DMY';" + )) + .execute(&source_pool) + .await?; + source_pool.close().await; + + sqlx::query( + "INSERT INTO resource (workspace_id, path, value, resource_type, created_by) \ + VALUES ('dtmig-ws', 'u/dtmig-admin/pg', $1, 'postgresql', 'dtmig-admin')", + ) + .bind(json!({ + "host": opts.get_host(), + "port": opts.get_port(), + "dbname": source, + "user": ROLE, + "password": ROLE_PASSWORD, + "sslmode": "disable", + })) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO datatable_migrations (workspace_id, datatable, timestamp, name, code_up) \ + VALUES ('dtmig-ws', 'main', 1, 'create_foo', 'CREATE TABLE foo (id int)')", + ) + .execute(&db) + .await?; + + let server = ApiServer::start(db.clone()).await?; + let base = format!("http://localhost:{}/api", server.addr.port()); + let client = reqwest::Client::new(); + + let resp = authed(client.post(format!("{base}/w/dtmig-ws/workspaces/create_fork"))) + .json(&json!({ + "id": fork_id, + "name": "fork", + "forked_datatables": [ + {"name": "main", "new_dbname": copy, "fork_behavior": "schema_only"} + ], + })) + .send() + .await?; + // `ensure!` rather than `assert!`: a panic would skip dropping the databases below. + let fork_status = resp.status(); + anyhow::ensure!(fork_status == 200, "{}", resp.text().await?); + + let status: Value = authed(client.get(format!( + "{base}/w/{fork_id}/workspaces/datatable_migrations_status/main" + ))) + .send() + .await? + .json() + .await?; + anyhow::ensure!(status["migrations"][0]["status"] == "ran", "{status}"); + + let copied: Vec = sqlx::query_scalar( + "SELECT unnest(setconfig) FROM pg_db_role_setting \ + WHERE setrole = 0 AND setdatabase = (SELECT oid FROM pg_database WHERE datname = $1) \ + ORDER BY 1", + ) + .bind(©) + .fetch_all(&db) + .await?; + anyhow::ensure!( + copied == ["DateStyle=SQL, DMY", "search_path=app"], + "{copied:?}" + ); + anyhow::Ok(()) + } + .await; + + for name in [©, &source] { + sqlx::query(&format!("DROP DATABASE IF EXISTS {name} WITH (FORCE)")) + .execute(&db) + .await?; + } + result +} diff --git a/backend/tests/datatable_migration_add_down.rs b/backend/tests/datatable_migration_add_down.rs index 47ea4c253b..1259628068 100644 --- a/backend/tests/datatable_migration_add_down.rs +++ b/backend/tests/datatable_migration_add_down.rs @@ -174,3 +174,34 @@ async fn test_add_down_to_a_migration_whose_comment_predates_role_annotations( assert_eq!(resp.status(), 200, "{}", resp.text().await?); Ok(()) } + +/// Likewise for `-- schemas`: the scope annotation is held only to up SQL being written, so a +/// definition whose stored up opens with prose starting with the word still accepts its down. +#[sqlx::test(fixtures("datatable_migrations_grants"))] +async fn test_add_down_to_a_migration_whose_comment_predates_scope_annotations( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + setup_applied_migration_without_down(&db).await?; + let code_up = format!("-- Schemas: changes for the widgets feature\n{CODE_UP}"); + sqlx::query("UPDATE datatable_migrations SET code_up = $1 WHERE workspace_id = 'dtmig-ws'") + .bind(&code_up) + .execute(&db) + .await?; + + let server = ApiServer::start(db.clone()).await?; + let resp = authed(reqwest::Client::new().post(format!( + "http://localhost:{}/api/w/dtmig-ws/workspaces/upsert_datatable_migration/main", + server.addr.port() + ))) + .json(&json!({ + "timestamp": VERSION, + "name": "create_widgets", + "code_up": code_up, + "code_down": CODE_DOWN, + })) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + Ok(()) +} diff --git a/backend/tests/dedicated_workers.rs b/backend/tests/dedicated_workers.rs index 5e9c371f90..fc4ff77bb3 100644 --- a/backend/tests/dedicated_workers.rs +++ b/backend/tests/dedicated_workers.rs @@ -23,6 +23,20 @@ mod dedicated_worker_tests { WorkspacedPath { workspace_id: workspace.to_string(), path: path.to_string() } } + /// Waits on one listener for every job: the jobs may complete in any order, and a listener + /// opened after one completes never sees that notification. + async fn wait_for_all( + mut listener: impl futures::Stream + Unpin, + jobs: &[uuid::Uuid], + ) { + use futures::StreamExt; + let mut pending: std::collections::HashSet<_> = jobs.iter().copied().collect(); + while !pending.is_empty() { + let id = listener.next().await.expect("completed job listener ended"); + pending.remove(&id); + } + } + /// Assert that a completed job (or any descendant job) was executed /// by a dedicated worker process with the expected mode by checking job logs /// for the "dedicated worker {mode}:" prefix. @@ -280,10 +294,7 @@ mod dedicated_worker_tests { in_test_worker_dedicated( db.clone(), async { - // Wait for both jobs to complete - listener.find(&uuid_a).await; - let listener2 = listen_for_completed_jobs(&db).await; - listener2.find(&uuid_b).await; + wait_for_all(listener, &[uuid_a, uuid_b]).await; }, port, vec![ @@ -533,9 +544,7 @@ mod dedicated_worker_tests { in_test_worker_dedicated( db.clone(), async { - listener.find(&uuid_a).await; - let listener2 = listen_for_completed_jobs(&db).await; - listener2.find(&uuid_b).await; + wait_for_all(listener, &[uuid_a, uuid_b]).await; }, port, vec![ @@ -659,9 +668,7 @@ mod dedicated_worker_tests { in_test_worker_dedicated( db.clone(), async { - listener.find(&uuid_standalone).await; - let listener2 = listen_for_completed_jobs(&db).await; - listener2.find(&uuid_flow).await; + wait_for_all(listener, &[uuid_standalone, uuid_flow]).await; }, port, vec![ @@ -824,9 +831,7 @@ mod dedicated_worker_tests { in_test_worker_dedicated( db.clone(), async { - listener.find(&uuid_a).await; - let listener2 = listen_for_completed_jobs(&db).await; - listener2.find(&uuid_b).await; + wait_for_all(listener, &[uuid_a, uuid_b]).await; }, port, vec![ @@ -838,7 +843,8 @@ mod dedicated_worker_tests { let job_a = completed_job(uuid_a, &db).await; let job_b = completed_job(uuid_b, &db).await; - assert_eq!(job_a.json_result().unwrap(), json!(105)); + // helper(5) = 50, + 100 + assert_eq!(job_a.json_result().unwrap(), json!(150)); assert_eq!(job_b.json_result().unwrap(), json!(205)); assert_ran_on_dedicated_worker(&db, uuid_a, "python3").await; assert_ran_on_dedicated_worker(&db, uuid_b, "python3").await; @@ -898,9 +904,7 @@ mod dedicated_worker_tests { in_test_worker_dedicated( db.clone(), async { - listener.find(&uuid_pre).await; - let listener2 = listen_for_completed_jobs(&db).await; - listener2.find(&uuid_other).await; + wait_for_all(listener, &[uuid_pre, uuid_other]).await; }, port, vec![ @@ -971,9 +975,7 @@ mod dedicated_worker_tests { in_test_worker_dedicated( db.clone(), async { - listener.find(&uuid_branch).await; - let listener2 = listen_for_completed_jobs(&db).await; - listener2.find(&uuid_default).await; + wait_for_all(listener, &[uuid_branch, uuid_default]).await; }, port, vec![wp("test-workspace", "flow/f/system/dedicated_branch_flow")], @@ -1198,9 +1200,7 @@ mod dedicated_worker_tests { in_test_worker_dedicated( db.clone(), async { - listener.find(&uuid_pre).await; - let listener2 = listen_for_completed_jobs(&db).await; - listener2.find(&uuid_other).await; + wait_for_all(listener, &[uuid_pre, uuid_other]).await; }, port, vec![ @@ -1281,9 +1281,7 @@ mod dedicated_worker_tests { in_test_worker_dedicated( db.clone(), async { - listener.find(&uuid_ws1).await; - let listener2 = listen_for_completed_jobs(&db).await; - listener2.find(&uuid_ws2).await; + wait_for_all(listener, &[uuid_ws1, uuid_ws2]).await; }, port, vec![ diff --git a/backend/tests/dev_workspace_tag_suffix.rs b/backend/tests/dev_workspace_tag_suffix.rs new file mode 100644 index 0000000000..1a61bee8aa --- /dev/null +++ b/backend/tests/dev_workspace_tag_suffix.rs @@ -0,0 +1,208 @@ +//! Sets the process-wide `DEV_WORKSPACE_ISOLATION`, so it lives in its own test binary: any other +//! test pushing a job in the same process would land on a suffixed tag. + +use std::collections::HashMap; + +use sqlx::{Pool, Postgres}; +use windmill_common::{ + jobs::{JobPayload, RawCode}, + runnable_settings::{ConcurrencySettings, DebouncingSettings}, + scripts::{ScriptHash, ScriptLang}, + worker::{DEV_WORKSPACE_ISOLATION, DEV_WORKSPACE_ISOLATION_SKIP_NON_DEV_FORKS}, +}; +use windmill_queue::{PushArgs, PushIsolationLevel}; + +async fn pushed_tag( + db: &Pool, + workspace_id: &str, + payload: JobPayload, + tag: Option<&str>, +) -> String { + try_pushed_tag(db, workspace_id, payload, tag) + .await + .expect("push must succeed") +} + +async fn try_pushed_tag( + db: &Pool, + workspace_id: &str, + payload: JobPayload, + tag: Option<&str>, +) -> Result { + let args = HashMap::new(); + let (id, tx) = windmill_queue::push( + db, + PushIsolationLevel::IsolatedRoot(db.clone()), + workspace_id, + payload, + PushArgs::from(&args), + "test-user", + "test@windmill.dev", + "u/test-user".to_string(), + None, + None, + None, + None, + None, + None, + None, + None, + false, + false, + None, + true, + tag.map(str::to_string), + None, + None, + None, + None, + false, + None, + None, + None, + None, + ) + .await?; + tx.commit().await.unwrap(); + Ok(sqlx::query_scalar("SELECT tag FROM v2_job WHERE id = $1") + .bind(id) + .fetch_one(db) + .await + .unwrap()) +} + +fn preview() -> JobPayload { + JobPayload::Code(RawCode { + hash: None, + content: "echo hi".to_string(), + path: None, + language: ScriptLang::Bash, + lock: None, + concurrency_settings: ConcurrencySettings::default().into(), + debouncing_settings: DebouncingSettings::default(), + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + modules: None, + tag: None, + }) +} + +#[sqlx::test(fixtures("base"))] +async fn dev_workspace_and_fork_jobs_run_on_suffixed_tags(db: Pool) { + DEV_WORKSPACE_ISOLATION.store(true, std::sync::atomic::Ordering::Relaxed); + + // A dev workspace, a fork of it, a fork whose parent was deleted (`ON DELETE SET NULL`), and a + // fork of the root with a fork of its own. + sqlx::query( + "INSERT INTO workspace (id, name, owner, parent_workspace_id, is_dev_workspace) VALUES + ('staging', 'staging', 'test-user', 'test-workspace', true), + ('wm-fork-a', 'fork', 'test-user', 'staging', false), + ('wm-fork-orphan', 'orphan', 'test-user', NULL, false), + ('wm-fork-root', 'root fork', 'test-user', 'test-workspace', false), + ('wm-fork-root-2', 'root fork 2', 'test-user', 'wm-fork-root', false), + ('archived-dev', 'archived dev', 'test-user', 'test-workspace', false), + ('wm-fork-of-archived', 'fork of archived dev', 'test-user', 'archived-dev', false)", + ) + .execute(&db) + .await + .unwrap(); + sqlx::query( + "INSERT INTO workspace_settings (workspace_id) VALUES + ('staging'), ('wm-fork-a'), ('wm-fork-orphan'), ('wm-fork-root'), ('wm-fork-root-2'), + ('archived-dev'), ('wm-fork-of-archived')", + ) + .execute(&db) + .await + .unwrap(); + + for (workspace, tag, expected) in [ + ("test-workspace", None, "bash"), + ("test-workspace", Some("gpu"), "gpu"), + ("staging", None, "bash-dev"), + ("staging", Some("gpu"), "gpu-dev"), + // A tag inherited from a dev job (a flow step, an agent's tool) is not suffixed twice. + ("staging", Some("gpu-dev"), "gpu-dev"), + ("wm-fork-a", None, "bash-dev"), + ("wm-fork-orphan", None, "bash-dev"), + ("wm-fork-root", None, "bash-dev"), + ] { + assert_eq!( + pushed_tag(&db, workspace, preview(), tag).await, + expected, + "{workspace} with tag {tag:?}" + ); + } + + // Only the forks whose lineage holds no dev workspace leave isolation. An orphaned fork's + // lineage is unknown, and so is that of a fork of an archived dev workspace (archiving clears + // `is_dev_workspace`), so both stay. + sqlx::query("UPDATE workspace SET deleted = true WHERE id = 'archived-dev'") + .execute(&db) + .await + .unwrap(); + DEV_WORKSPACE_ISOLATION_SKIP_NON_DEV_FORKS.store(true, std::sync::atomic::Ordering::Relaxed); + for (workspace, expected) in [ + ("wm-fork-root", "bash"), + ("wm-fork-root-2", "bash"), + ("staging", "bash-dev"), + ("wm-fork-a", "bash-dev"), + ("wm-fork-orphan", "bash-dev"), + ("wm-fork-of-archived", "bash-dev"), + ] { + assert_eq!( + pushed_tag(&db, workspace, preview(), None).await, + expected, + "{workspace} with non-dev forks not isolated" + ); + } + DEV_WORKSPACE_ISOLATION_SKIP_NON_DEV_FORKS.store(false, std::sync::atomic::Ordering::Relaxed); + + // A dedicated worker tag already names the workspace, and its worker is placed by hand. + let dedicated = JobPayload::ScriptHash { + hash: ScriptHash(123412), + path: "f/system/hello".to_string(), + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: Some(true), + language: ScriptLang::Bun, + priority: None, + apply_preprocessor: false, + concurrency_settings: ConcurrencySettings::default(), + debouncing_settings: DebouncingSettings::default(), + labels: None, + job_token_scopes: None, + }; + assert_eq!( + pushed_tag(&db, "staging", dedicated, None).await, + "staging:f/system/hello" + ); + + // A hashed dedicated tag keeps only a prefix of its workspace id: a dev workspace sharing that + // prefix does not get to use another workspace's dedicated worker. + let root = "a".repeat(40); + let dev = format!("{root}-dev"); + sqlx::query( + "INSERT INTO workspace (id, name, owner, parent_workspace_id, is_dev_workspace) + VALUES ($1, 'root', 'test-user', NULL, false), ($2, 'dev', 'test-user', $1, true)", + ) + .bind(&root) + .bind(&dev) + .execute(&db) + .await + .unwrap(); + sqlx::query("INSERT INTO workspace_settings (workspace_id) VALUES ($1), ($2)") + .bind(&root) + .bind(&dev) + .execute(&db) + .await + .unwrap(); + let root_dedicated = + windmill_common::worker::dedicated_worker_tag(&root, "f/some/long/script/path"); + assert!( + try_pushed_tag(&db, &dev, preview(), Some(&root_dedicated)) + .await + .is_err(), + "{root_dedicated} must not stay unsuffixed, and suffixed it no longer fits a job tag" + ); +} diff --git a/backend/tests/end_user_email.rs b/backend/tests/end_user_email.rs index 15eb9a64f1..7fad5ab5ef 100644 --- a/backend/tests/end_user_email.rs +++ b/backend/tests/end_user_email.rs @@ -17,6 +17,9 @@ const SAME_WS_EMAIL: &str = "test@windmill.dev"; const OTHER_WS_EMAIL: &str = "other-ws@windmill.dev"; const NO_WS_EMAIL: &str = "no-ws@windmill.dev"; +const GET_EMAIL_CONTENT: &str = + "export function main() { return process.env.WM_END_USER_EMAIL || \"\"; }"; + fn client() -> reqwest::Client { reqwest::Client::new() } @@ -38,9 +41,11 @@ async fn create_app_with_inline_script(port: u16, path: &str) -> anyhow::Result< "subgrids": {}, "hiddenInlineScripts": [{ "name": "get_email", - "language": "bun", - "content": "export function main() { return process.env.WM_END_USER_EMAIL || \"\"; }", - "path": "f/test/email_app/get_email" + "inlineScript": { + "language": "bun", + "content": GET_EMAIL_CONTENT, + "path": "f/test/email_app/get_email" + } }] }, "policy": { @@ -82,11 +87,13 @@ async fn create_raw_app_with_inline_script(port: u16, path: &str) -> anyhow::Res "value": { "type": "rawapp", "css": "", - "inlineScripts": [{ - "name": "get_email", - "language": "bun", - "content": "export function main() { return process.env.WM_END_USER_EMAIL || \"\"; }" - }] + "runnables": { + "get_email": { + "name": "get_email", + "type": "inline", + "inlineScript": { "language": "bun", "content": GET_EMAIL_CONTENT } + } + } }, "policy": { "execution_mode": "anonymous", @@ -183,7 +190,39 @@ async fn run_app_flow( wait_for_job_result(port, token, &job_id).await } -async fn run_app_inline_script( +fn find_inline_script_id(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::Object(m) => m + .get("inlineScript") + .and_then(|s| s.get("id")) + .and_then(|id| id.as_i64()) + .or_else(|| m.values().find_map(find_inline_script_id)), + serde_json::Value::Array(a) => a.iter().find_map(find_inline_script_id), + _ => None, + } +} + +/// Once the app's dependency job writes the deployed lite value, inline code runs only through +/// the `app_script` rows it references, so the run must carry that row's id. +async fn deployed_inline_script_id(port: u16, app_path: &str) -> anyhow::Result { + let url = format!( + "http://localhost:{}/api/w/test-workspace/apps/get/lite/{}", + port, app_path + ); + for _ in 0..300 { + let resp = authed(client().get(&url), SAME_WS_TOKEN).send().await?; + if resp.status().is_success() { + let app = resp.json::().await?; + if let Some(id) = find_inline_script_id(&app["value"]) { + return Ok(id); + } + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + anyhow::bail!("timeout waiting for the deployed app_script id of {app_path}") +} + +async fn run_inline_script( port: u16, token: &str, app_path: &str, @@ -196,9 +235,10 @@ async fn run_app_inline_script( let mut payload = json!({ "args": {}, "component": "get_email", + "id": deployed_inline_script_id(port, app_path).await?, "raw_code": { "language": "bun", - "content": "export function main() { return process.env.WM_END_USER_EMAIL || \"\"; }", + "content": GET_EMAIL_CONTENT, "path": format!("{}/get_email", app_path) } }); @@ -220,42 +260,6 @@ async fn run_app_inline_script( wait_for_job_result(port, token, &job_id).await } -async fn run_raw_app_inline_script( - port: u16, - token: &str, - app_path: &str, - force_viewer: bool, -) -> anyhow::Result { - let url = format!( - "http://localhost:{}/api/w/test-workspace/apps_u/execute_component/{}", - port, app_path - ); - let mut payload = json!({ - "args": {}, - "component": "get_email", - "raw_code": { - "language": "bun", - "content": "export function main() { return process.env.WM_END_USER_EMAIL || \"\"; }" - } - }); - if force_viewer { - payload["force_viewer_static_fields"] = json!({}); - } - let resp = authed(client().post(&url), token) - .json(&payload) - .send() - .await?; - if !resp.status().is_success() { - anyhow::bail!( - "raw app inline script run failed: {} - {}", - resp.status(), - resp.text().await? - ); - } - let job_id = resp.text().await?; - wait_for_job_result(port, token, &job_id).await -} - async fn wait_for_job_result(port: u16, token: &str, job_id: &str) -> anyhow::Result { let url = format!( "http://localhost:{}/api/w/test-workspace/jobs_u/completed/get_result/{}", @@ -399,21 +403,21 @@ async fn test_app_wm_end_user_email(db: Pool) -> anyhow::Result<()> { create_app_with_inline_script(port, app_path).await?; // Same workspace user (force_viewer mode works for workspace members) - let result = run_app_inline_script(port, SAME_WS_TOKEN, app_path, true).await?; + let result = run_inline_script(port, SAME_WS_TOKEN, app_path, true).await?; assert_eq!( result, SAME_WS_EMAIL, "same workspace user should get their email" ); // Other workspace user (uses app's anonymous policy + token lookup) - let result = run_app_inline_script(port, OTHER_WS_TOKEN, app_path, false).await?; + let result = run_inline_script(port, OTHER_WS_TOKEN, app_path, false).await?; assert_eq!( result, OTHER_WS_EMAIL, "other workspace user should get their email" ); // No workspace user (uses app's anonymous policy + token lookup) - let result = run_app_inline_script(port, NO_WS_TOKEN, app_path, false).await?; + let result = run_inline_script(port, NO_WS_TOKEN, app_path, false).await?; assert_eq!( result, NO_WS_EMAIL, "no workspace user should get their email" @@ -481,21 +485,21 @@ async fn test_raw_app_wm_end_user_email(db: Pool) -> anyhow::Result<() create_raw_app_with_inline_script(port, app_path).await?; // Same workspace user (force_viewer mode works for workspace members) - let result = run_raw_app_inline_script(port, SAME_WS_TOKEN, app_path, true).await?; + let result = run_inline_script(port, SAME_WS_TOKEN, app_path, true).await?; assert_eq!( result, SAME_WS_EMAIL, "same workspace user should get their email" ); // Other workspace user (uses app's anonymous policy + token lookup) - let result = run_raw_app_inline_script(port, OTHER_WS_TOKEN, app_path, false).await?; + let result = run_inline_script(port, OTHER_WS_TOKEN, app_path, false).await?; assert_eq!( result, OTHER_WS_EMAIL, "other workspace user should get their email" ); // No workspace user (uses app's anonymous policy + token lookup) - let result = run_raw_app_inline_script(port, NO_WS_TOKEN, app_path, false).await?; + let result = run_inline_script(port, NO_WS_TOKEN, app_path, false).await?; assert_eq!( result, NO_WS_EMAIL, "no workspace user should get their email" @@ -550,7 +554,11 @@ async fn test_app_viewer_data_access_is_stamped_on_inline_runnables_only( })) .send() .await?; - anyhow::ensure!(resp.status().is_success(), "create app: {}", resp.text().await?); + anyhow::ensure!( + resp.status().is_success(), + "create app: {}", + resp.text().await? + ); let execute = |payload: serde_json::Value| async move { let resp = authed( @@ -562,7 +570,11 @@ async fn test_app_viewer_data_access_is_stamped_on_inline_runnables_only( .json(&payload) .send() .await?; - anyhow::ensure!(resp.status().is_success(), "execute: {}", resp.text().await?); + anyhow::ensure!( + resp.status().is_success(), + "execute: {}", + resp.text().await? + ); Ok::<_, anyhow::Error>(resp.text().await?.trim_matches('"').parse::()?) }; let as_viewer = |job_id: uuid::Uuid| { @@ -588,7 +600,8 @@ async fn test_app_viewer_data_access_is_stamped_on_inline_runnables_only( .await?; assert!(as_viewer(inline).await?); - let path = execute(json!({ "args": {}, "component": "run_script", "path": script_path })).await?; + let path = + execute(json!({ "args": {}, "component": "run_script", "path": script_path })).await?; assert!( !as_viewer(path).await?, "a path runnable connected as the viewer because its policy entry said so" diff --git a/backend/tests/error_handler.rs b/backend/tests/error_handler.rs deleted file mode 100644 index 2e10006d2f..0000000000 --- a/backend/tests/error_handler.rs +++ /dev/null @@ -1,480 +0,0 @@ -use sqlx::{Pool, Postgres}; - -use windmill_test_utils::*; - -/// Test that workspace error handler can be set and removed via database operations -#[cfg(feature = "deno_core")] -#[sqlx::test(fixtures("base"))] -async fn test_error_handler_settings(db: Pool) -> anyhow::Result<()> { - initialize_tracing().await; - - let _server = ApiServer::start(db.clone()).await?; - - // Initially error_handler should be NULL - let initial = sqlx::query_scalar!( - r#"SELECT error_handler->>'path' FROM workspace_settings WHERE workspace_id = 'test-workspace'"# - ) - .fetch_one(&db) - .await?; - assert!(initial.is_none()); - - // Set error handler with all options - sqlx::query!( - r#" - UPDATE workspace_settings - SET error_handler = '{"path": "script/f/test/error_handler", "extra_args": {"notify": true}, "muted_on_cancel": true, "muted_on_user_path": false}'::jsonb - WHERE workspace_id = 'test-workspace' - "# - ) - .execute(&db) - .await?; - - let after_set = sqlx::query_scalar!( - r#"SELECT error_handler->>'path' FROM workspace_settings WHERE workspace_id = 'test-workspace'"# - ) - .fetch_one(&db) - .await?; - assert_eq!(after_set, Some("script/f/test/error_handler".to_string())); - - // Verify extra_args - let extra_args = sqlx::query_scalar!( - r#"SELECT error_handler->'extra_args' FROM workspace_settings WHERE workspace_id = 'test-workspace'"# - ) - .fetch_one(&db) - .await?; - assert!(extra_args.is_some()); - - // Verify muted_on_cancel - let muted_on_cancel = sqlx::query_scalar!( - r#"SELECT (error_handler->>'muted_on_cancel')::boolean FROM workspace_settings WHERE workspace_id = 'test-workspace'"# - ) - .fetch_one(&db) - .await?; - assert_eq!(muted_on_cancel, Some(true)); - - // Verify muted_on_user_path - let muted_on_user_path = sqlx::query_scalar!( - r#"SELECT (error_handler->>'muted_on_user_path')::boolean FROM workspace_settings WHERE workspace_id = 'test-workspace'"# - ) - .fetch_one(&db) - .await?; - assert_eq!(muted_on_user_path, Some(false)); - - // Remove error handler - sqlx::query!( - r#" - UPDATE workspace_settings - SET error_handler = NULL - WHERE workspace_id = 'test-workspace' - "# - ) - .execute(&db) - .await?; - - let after_remove = sqlx::query_scalar!( - r#"SELECT error_handler->>'path' FROM workspace_settings WHERE workspace_id = 'test-workspace'"# - ) - .fetch_one(&db) - .await?; - assert!(after_remove.is_none()); - - Ok(()) -} - -/// Test that a failed job triggers the workspace error handler -#[cfg(all(feature = "deno_core", feature = "enterprise", feature = "private"))] -#[sqlx::test(fixtures("base"))] -async fn test_error_handler_triggered_on_failure(db: Pool) -> anyhow::Result<()> { - use windmill_common::jobs::JobPayload; - use windmill_common::runnable_settings::{ConcurrencySettings, DebouncingSettings}; - use windmill_common::scripts::{ScriptHash, ScriptLang}; - - initialize_tracing().await; - - let server = ApiServer::start(db.clone()).await?; - - // Create the error handler script - let error_handler_code = r#" -export async function main(path: string, email: string, job_id: string, is_flow: boolean, workspace_id: string, error: any) { - console.log("Error handler called for job:", job_id); - return { handled: true, original_path: path }; -} -"#; - - sqlx::query!( - r#" - INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock) - VALUES ('test-workspace', 1111111111, 'f/test/error_handler', $1, 'bun', 'script', 'test-user', '{}', 'Error handler script', 'Handles failed job completions', E'{}\n//bun.lock\n') - "#, - error_handler_code - ) - .execute(&db) - .await?; - - // Create a script that will fail - let failing_script_code = "export function main() { throw new Error('intentional failure'); }"; - let failing_script_hash: i64 = 2222222222; - - sqlx::query!( - r#" - INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock) - VALUES ('test-workspace', $1, 'f/test/failing_script', $2, 'bun', 'script', 'test-user', '{}', 'Failing test script', 'A script that always fails', E'{}\n//bun.lock\n') - "#, - failing_script_hash, - failing_script_code - ) - .execute(&db) - .await?; - - // Set up the error handler in workspace_settings - sqlx::query!( - r#" - UPDATE workspace_settings - SET error_handler = '{"path": "script/f/test/error_handler"}'::jsonb - WHERE workspace_id = 'test-workspace' - "# - ) - .execute(&db) - .await?; - - // Create the error_handler group - sqlx::query!( - r#" - INSERT INTO group_ (workspace_id, name, summary, extra_perms) - VALUES ('test-workspace', 'error_handler', 'The group the error handler acts on behalf of', '{"u/test-user": true}') - ON CONFLICT DO NOTHING - "# - ) - .execute(&db) - .await?; - - // Run the failing script - let completed_job = RunJob::from(JobPayload::ScriptHash { - hash: ScriptHash(failing_script_hash), - path: "f/test/failing_script".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, - }) - .run_until_complete(&db, false, server.addr.port()) - .await; - - // Verify the job actually failed - assert!(!completed_job.success, "Job should have failed"); - - let main_job_id = completed_job.id; - - // Wait for the error handler job to be created - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - - // Verify the error handler job was created - let error_handler_job = sqlx::query!( - r#" - SELECT - id, - runnable_path, - permissioned_as_email, - parent_job - FROM v2_job - WHERE workspace_id = 'test-workspace' - AND permissioned_as_email = 'error_handler@windmill.dev' - ORDER BY created_at DESC - LIMIT 1 - "# - ) - .fetch_optional(&db) - .await?; - - assert!( - error_handler_job.is_some(), - "Error handler job should have been created" - ); - - let handler_job = error_handler_job.unwrap(); - - assert_eq!( - handler_job.runnable_path.as_deref(), - Some("f/test/error_handler"), - "Error handler should run the configured script" - ); - assert_eq!( - handler_job.permissioned_as_email.as_str(), - "error_handler@windmill.dev", - "Error handler should run as error_handler user" - ); - assert_eq!( - handler_job.parent_job, - Some(main_job_id), - "Error handler should have the failed job as parent" - ); - - Ok(()) -} - -/// Test that error handler is NOT triggered when ws_error_handler_muted is set on the script -#[cfg(all(feature = "deno_core", feature = "enterprise", feature = "private"))] -#[sqlx::test(fixtures("base"))] -async fn test_error_handler_muted_on_script(db: Pool) -> anyhow::Result<()> { - use windmill_common::jobs::JobPayload; - use windmill_common::runnable_settings::{ConcurrencySettings, DebouncingSettings}; - use windmill_common::scripts::{ScriptHash, ScriptLang}; - - initialize_tracing().await; - - let server = ApiServer::start(db.clone()).await?; - - // Create the error handler script - sqlx::query!( - r#" - INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock) - VALUES ('test-workspace', 3333333333, 'f/test/error_handler', 'export function main() { return "handled"; }', 'bun', 'script', 'test-user', '{}', '', '', E'{}\n//bun.lock\n') - "#, - ) - .execute(&db) - .await?; - - // Create a failing script with ws_error_handler_muted = true - let failing_script_hash: i64 = 4444444444; - sqlx::query!( - r#" - INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock, ws_error_handler_muted) - VALUES ('test-workspace', $1, 'f/test/muted_failing_script', 'export function main() { throw new Error("fail"); }', 'bun', 'script', 'test-user', '{}', '', '', E'{}\n//bun.lock\n', true) - "#, - failing_script_hash, - ) - .execute(&db) - .await?; - - // Set up the error handler - sqlx::query!( - r#" - UPDATE workspace_settings - SET error_handler = '{"path": "script/f/test/error_handler"}'::jsonb - WHERE workspace_id = 'test-workspace' - "# - ) - .execute(&db) - .await?; - - sqlx::query!( - r#" - INSERT INTO group_ (workspace_id, name, summary, extra_perms) - VALUES ('test-workspace', 'error_handler', 'Error handler group', '{"u/test-user": true}') - ON CONFLICT DO NOTHING - "# - ) - .execute(&db) - .await?; - - // Run the muted failing script - let completed_job = RunJob::from(JobPayload::ScriptHash { - hash: ScriptHash(failing_script_hash), - path: "f/test/muted_failing_script".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, - }) - .run_until_complete(&db, false, server.addr.port()) - .await; - - assert!(!completed_job.success, "Job should have failed"); - - // Wait and check that NO error handler job was created - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - - let error_handler_job = sqlx::query_scalar!( - r#" - SELECT id - FROM v2_job - WHERE workspace_id = 'test-workspace' - AND permissioned_as_email = 'error_handler@windmill.dev' - "# - ) - .fetch_optional(&db) - .await?; - - assert!( - error_handler_job.is_none(), - "Error handler should NOT have been triggered for a muted script" - ); - - Ok(()) -} - -/// Test that error handler is NOT triggered on successful job completion -#[cfg(all(feature = "deno_core", feature = "enterprise", feature = "private"))] -#[sqlx::test(fixtures("base"))] -async fn test_error_handler_not_triggered_on_success(db: Pool) -> anyhow::Result<()> { - use windmill_common::jobs::JobPayload; - use windmill_common::runnable_settings::{ConcurrencySettings, DebouncingSettings}; - use windmill_common::scripts::{ScriptHash, ScriptLang}; - - initialize_tracing().await; - - let server = ApiServer::start(db.clone()).await?; - - // Create the error handler script - sqlx::query!( - r#" - INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock) - VALUES ('test-workspace', 5555555555, 'f/test/error_handler', 'export function main() { return "handled"; }', 'bun', 'script', 'test-user', '{}', '', '', E'{}\n//bun.lock\n') - "#, - ) - .execute(&db) - .await?; - - // Create a successful script - let success_script_hash: i64 = 6666666666; - sqlx::query!( - r#" - INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock) - VALUES ('test-workspace', $1, 'f/test/success_script', 'export function main() { return "ok"; }', 'bun', 'script', 'test-user', '{}', '', '', E'{}\n//bun.lock\n') - "#, - success_script_hash, - ) - .execute(&db) - .await?; - - // Set up the error handler - sqlx::query!( - r#" - UPDATE workspace_settings - SET error_handler = '{"path": "script/f/test/error_handler"}'::jsonb - WHERE workspace_id = 'test-workspace' - "# - ) - .execute(&db) - .await?; - - sqlx::query!( - r#" - INSERT INTO group_ (workspace_id, name, summary, extra_perms) - VALUES ('test-workspace', 'error_handler', 'Error handler group', '{"u/test-user": true}') - ON CONFLICT DO NOTHING - "# - ) - .execute(&db) - .await?; - - // Run the successful script - let completed_job = RunJob::from(JobPayload::ScriptHash { - hash: ScriptHash(success_script_hash), - path: "f/test/success_script".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, - }) - .run_until_complete(&db, false, server.addr.port()) - .await; - - assert!(completed_job.success, "Job should have succeeded"); - - // Wait and check that NO error handler job was created - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - - let error_handler_job = sqlx::query_scalar!( - r#" - SELECT id - FROM v2_job - WHERE workspace_id = 'test-workspace' - AND permissioned_as_email = 'error_handler@windmill.dev' - "# - ) - .fetch_optional(&db) - .await?; - - assert!( - error_handler_job.is_none(), - "Error handler should NOT have been triggered for a successful job" - ); - - Ok(()) -} - -/// A hub script run at the top level (a schedule, a trigger, the API) is a `script_hub` job, -/// and its failure must reach the workspace error handler like a workspace script's. -#[cfg(all(feature = "enterprise", feature = "private"))] -#[sqlx::test(fixtures("base"))] -async fn test_error_handler_triggered_on_hub_script_failure( - db: Pool, -) -> anyhow::Result<()> { - use windmill_common::jobs::JobPayload; - - initialize_tracing().await; - - let server = ApiServer::start(db.clone()).await?; - - let version = "990000002"; - let hub_dir = &*windmill_common::worker::HUB_CACHE_DIR; - tokio::fs::create_dir_all(hub_dir).await?; - tokio::fs::write( - format!("{hub_dir}/{version}"), - r#"{"content":"exit 1","lockfile":null,"language":"bash","schema":{},"summary":null}"#, - ) - .await?; - - // The workspace handler settings are cached per workspace id across the tests of this - // binary, so this reuses the handler path every other test here configures. - sqlx::query( - "INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock) - VALUES ('test-workspace', 1111111112, 'f/test/error_handler', 'echo handled', 'bash', 'script', 'test-user', '{}', '', '', '')", - ) - .execute(&db) - .await?; - sqlx::query( - r#"UPDATE workspace_settings SET error_handler = '{"path": "script/f/test/error_handler"}'::jsonb - WHERE workspace_id = 'test-workspace'"#, - ) - .execute(&db) - .await?; - - let completed_job = RunJob::from(JobPayload::ScriptHub { - path: format!("hub/{version}/test/fail"), - apply_preprocessor: false, - }) - .run_until_complete(&db, false, server.addr.port()) - .await; - assert!(!completed_job.success, "Hub script should have failed"); - - let mut handler_parent = None; - for _ in 0..50 { - handler_parent = sqlx::query_scalar::<_, Option>( - "SELECT parent_job FROM v2_job - WHERE workspace_id = 'test-workspace' AND permissioned_as_email = 'error_handler@windmill.dev'", - ) - .fetch_optional(&db) - .await?; - if handler_parent.is_some() { - break; - } - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - } - assert_eq!( - handler_parent, - Some(Some(completed_job.id)), - "Workspace error handler should handle the failed hub script" - ); - - Ok(()) -} diff --git a/backend/tests/fixtures/dedicated_flows.sql b/backend/tests/fixtures/dedicated_flows.sql index b3b63685df..e81cd32556 100644 --- a/backend/tests/fixtures/dedicated_flows.sql +++ b/backend/tests/fixtures/dedicated_flows.sql @@ -321,12 +321,13 @@ E'{"modules":[{"id":"a","value":{"type":"forloopflow","iterator":{"type":"javasc -- ============================================================ -- Test: Python runner group --- Two Python scripts sharing a workspace dependency annotation. +-- Two Python scripts sharing a workspace dependency annotation. Script a has a +-- relative import (helper defined below), so the group's loader.py is written. -- ============================================================ INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock, dedicated_worker) VALUES ( 'test-workspace', 'system', -E'# extra_requirements: f/system/dedicated_double\ndef main(x: int):\n return x + 100', +E'# extra_requirements: f/system/dedicated_double\nfrom .py_dedicated_helper import helper\n\ndef main(x: int):\n return helper(x) + 100', '{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"x":{"type":"number","description":""}},"required":["x"],"type":"object"}', '', '', 'f/system/py_rg_script_a', 300050, 'python3', '', true); diff --git a/backend/tests/fixtures/workspace_dependencies.sql b/backend/tests/fixtures/workspace_dependencies.sql deleted file mode 100644 index 9491f62e7c..0000000000 --- a/backend/tests/fixtures/workspace_dependencies.sql +++ /dev/null @@ -1,33 +0,0 @@ --- NOTE: Applied after workspace_dependencies_leafs.sql --- Python script that uses relative imports -INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES ( -'test-workspace', -'test-user', -' -from f.leafs.python import main as python_leaf - -def main(): - return {"python_import": python_leaf()}', -'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}', -'', -'', -'f/python_importer', 500005, 'python3', ''); - --- TypeScript script that uses relative imports -INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES ( -'test-workspace', -'test-user', -' -import { main as tsLeaf } from "./leafs/ts"; - -export async function main() { - return { ts_import: await tsLeaf() }; -}', -'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}', -'', -'', -'f/ts_importer', 500006, 'nativets', ''); - --- Dependency map entries -INSERT INTO dependency_map (workspace_id, importer_path, importer_kind, imported_path, importer_node_id) VALUES ('test-workspace', 'f/python_importer', 'script', 'f/leafs/python', ''); -INSERT INTO dependency_map (workspace_id, importer_path, importer_kind, imported_path, importer_node_id) VALUES ('test-workspace', 'f/ts_importer', 'script', 'f/leafs/ts', ''); diff --git a/backend/tests/fixtures/workspace_dependencies_leafs.sql b/backend/tests/fixtures/workspace_dependencies_leafs.sql deleted file mode 100644 index 3896cb4b00..0000000000 --- a/backend/tests/fixtures/workspace_dependencies_leafs.sql +++ /dev/null @@ -1,54 +0,0 @@ --- Basic leaf scripts in different languages -INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES ( -'test-workspace', -'test-user', -' -export async function main() { - return "TypeScript leaf"; -}', -'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}', -'', -'', -'f/leafs/ts', 500001, 'nativets', ''); - -INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES ( -'test-workspace', -'test-user', -' -package main - -import "fmt" - -func main() { - fmt.Println("Go leaf") -}', -'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}', -'', -'', -'f/leafs/go', 500002, 'go', ''); - -INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES ( -'test-workspace', -'test-user', -' -def main(): - return "Python leaf"', -'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}', -'', -'', -'f/leafs/python', 500003, 'python3', ''); - -INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES ( -'test-workspace', -'test-user', -' -', -'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}', -'', -'', -'f/leafs/php', 500004, 'php', ''); - diff --git a/backend/tests/flow_conversation_running_turn.rs b/backend/tests/flow_conversation_running_turn.rs index 392ff6f5cf..db8cf4d956 100644 --- a/backend/tests/flow_conversation_running_turn.rs +++ b/backend/tests/flow_conversation_running_turn.rs @@ -16,9 +16,17 @@ const CONV: Uuid = Uuid::from_u128(0x5eed); /// Takes the conversation for a turn, as a run does, without keeping what it writes. async fn take_conversation(db: &Pool) -> windmill_common::error::Result { let mut tx = db.begin().await?; - let result = - get_or_create_conversation_with_id(&mut tx, WS, "f/flow", "test-user", "t", CONV, false) - .await; + let result = get_or_create_conversation_with_id( + &mut tx, + WS, + "f/flow", + "test-user", + false, + "t", + CONV, + false, + ) + .await; tx.rollback().await?; result.map(|c| c.id) } diff --git a/backend/tests/folder_default_permissioned_as.rs b/backend/tests/folder_default_permissioned_as.rs index 238d7cdb32..94d1d2fad5 100644 --- a/backend/tests/folder_default_permissioned_as.rs +++ b/backend/tests/folder_default_permissioned_as.rs @@ -680,6 +680,17 @@ async fn test_folder_default_permissioned_as(db: Pool) -> anyhow::Resu // 8. Stale rule — rule resolves to a user that does not exist // ======================================================================== + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN", + ) + .json(&new_script("f/prodfolder/stale/existing")) + .send() + .await?; + let status = resp.status(); + let existing_hash = resp.text().await?; + assert_eq!(status, 201, "script before the stale rule: {existing_hash}"); + let resp = authed( client().post(format!("{base}/folders/update/prodfolder")), "SECRET_TOKEN", @@ -719,6 +730,33 @@ async fn test_folder_default_permissioned_as(db: Pool) -> anyhow::Resu "error should identify the stale user: {body}" ); + // The default applies only to a first deploy, so it cannot fail a redeploy... + let mut redeploy = new_script("f/prodfolder/stale/existing"); + redeploy["content"] = json!("export async function main() { return 43; }"); + redeploy["parent_hash"] = json!(existing_hash); + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN", + ) + .json(&redeploy) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "stale rule must not block a redeploy: {}", + resp.text().await? + ); + // ...while a first deploy it would apply to still reports it. + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN", + ) + .json(&new_script("f/prodfolder/stale/new_script")) + .send() + .await?; + assert_eq!(resp.status(), 400, "stale rule should refuse a new script"); + // Non-matching paths in the same folder still work fine. let resp = authed( client().post(format!("{base}/schedules/create")), diff --git a/backend/tests/inline_preview_auth.rs b/backend/tests/inline_preview_auth.rs index acf511c26c..231e71e92a 100644 --- a/backend/tests/inline_preview_auth.rs +++ b/backend/tests/inline_preview_auth.rs @@ -91,7 +91,7 @@ async fn operator_job_token(job_id: uuid::Uuid) -> String { .expect("mint operator job token") } -const OPERATOR_GUARD_MSG: &str = "Operators cannot run preview jobs"; +const OPERATOR_GUARD_MSG: &str = "Your role cannot run preview jobs"; /// The fixture's deployed-script jobs of the operator: one running, one queued. const RUNNING_JOB_ID: &str = "2aa0c0de-0000-4000-8000-000000000001"; diff --git a/backend/tests/instance_config.rs b/backend/tests/instance_config.rs index 24937a3ce5..18a7466637 100644 --- a/backend/tests/instance_config.rs +++ b/backend/tests/instance_config.rs @@ -1533,6 +1533,32 @@ async fn declarative_sync_rejects_an_unusable_instance_banner(db: Pool ); } +#[sqlx::test(fixtures("base"))] +async fn declarative_sync_rejects_an_unusable_dev_workspace_tag_suffix(db: Pool) { + clear_settings_and_configs(&db).await; + let before = count_global_settings(&db).await; + + let mut desired = BTreeMap::new(); + desired.insert( + "dev_workspace_tag_suffix".to_string(), + serde_json::json!("-dev' OR '1'='1"), + ); + + let err = windmill_common::instance_config::sync_global_settings_declarative( + &db, + &BTreeMap::new(), + &desired, + &(), + ) + .await + .expect_err("a suffix the workers would ignore must fail the sync"); + assert!( + err.to_string().contains("dev_workspace_tag_suffix"), + "the error should name the offending setting, got: {err}" + ); + assert_eq!(count_global_settings(&db).await, before); +} + #[sqlx::test(fixtures("base"))] async fn declarative_sync_rejects_a_malformed_max_token_expiration(db: Pool) { clear_settings_and_configs(&db).await; diff --git a/backend/tests/job_token_scopes.rs b/backend/tests/job_token_scopes.rs index f805168bfa..71ecaa444e 100644 --- a/backend/tests/job_token_scopes.rs +++ b/backend/tests/job_token_scopes.rs @@ -363,7 +363,8 @@ async fn test_deploy_without_the_field_keeps_the_restriction( Ok(()) } -/// The OIDC `job_token_scopes` claim states the scopes of the token that asked for it. +/// The OIDC `job_token_scopes` claim states the scopes of the token that asked for it, in a +/// payload that strict JSON parsers accept (no duplicate `email` key). #[cfg(all(feature = "enterprise", feature = "private", feature = "openidconnect"))] #[sqlx::test(fixtures("base"))] async fn test_oidc_claim_carries_the_presented_scopes(db: Pool) -> anyhow::Result<()> { @@ -404,10 +405,12 @@ async fn test_oidc_claim_carries_the_presented_scopes(db: Pool) -> any let status = resp.status(); let id_token = resp.text().await?; assert_eq!(status, StatusCode::OK, "{id_token}"); - let payload = id_token.split('.').nth(1).expect("a JWT payload"); - let claims: serde_json::Value = serde_json::from_slice( - &base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload)?, - )?; + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(id_token.split('.').nth(1).expect("a JWT payload"))?; + // serde_json keeps the last of duplicate keys, so count them on the raw payload. + let raw = String::from_utf8(payload.clone())?; + assert_eq!(raw.matches("\"email\":").count(), 1, "{raw}"); + let claims: serde_json::Value = serde_json::from_slice(&payload)?; assert_eq!(claims["job_token_scopes"], expected, "{job}"); } diff --git a/backend/tests/mcp_header_var_refs.rs b/backend/tests/mcp_header_var_refs.rs index 158bc71a5f..270ae35449 100644 --- a/backend/tests/mcp_header_var_refs.rs +++ b/backend/tests/mcp_header_var_refs.rs @@ -15,7 +15,7 @@ async fn seed(db: &Pool, url: &str) -> anyhow::Result<()> { let mc = windmill_common::variables::build_crypt(db, "test-workspace").await?; sqlx::query( "INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms) - VALUES ('test-workspace', 'u/test-user/mcp_header', $1, true, '', '{}')", + VALUES ('test-workspace', 'u/test-user/header_secret', $1, true, '', '{}')", ) .bind(windmill_common::variables::encrypt(&mc, SECRET_VALUE)) .execute(db) @@ -27,7 +27,7 @@ async fn seed(db: &Pool, url: &str) -> anyhow::Result<()> { .bind(serde_json::json!({ "name": "mcp", "url": url, - "headers": { "X-Api-Key": "$var:u/test-user/mcp_header", "X-Plain": "plain" } + "headers": { "X-Api-Key": "$var:u/test-user/header_secret", "X-Plain": "plain" } })) .execute(db) .await?; @@ -102,9 +102,45 @@ async fn test_mcp_header_var_ref_requires_variable_scope(db: Pool) -> let body = resp.text().await?; assert_eq!(status, reqwest::StatusCode::FORBIDDEN, "{body}"); assert!( - body.contains("variables:read:u/test-user/mcp_header"), + body.contains("variables:read:u/test-user/header_secret"), "{body}" ); + // The token is held to the same scope, and a wildcard resource scope covers both. + sqlx::query( + "UPDATE resource SET value = jsonb_build_object('name', 'mcp', 'url', value->>'url', + 'token', '$var:u/test-user/header_secret') WHERE path = 'u/test-user/mcp'", + ) + .execute(&db) + .await?; + for (token, scope, denied) in [ + ("EXACT_TOKEN", "resources:read:u/test-user/mcp", true), + ("WILDCARD_TOKEN", "resources:read:u/test-user/*", false), + ] { + sqlx::query( + "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes) + VALUES (encode(sha256($1::bytea), 'hex'), left($1, 10), $1, + 'test@windmill.dev', 'scoped', false, ARRAY[$2])", + ) + .bind(token) + .bind(scope) + .execute(&db) + .await?; + let resp = reqwest::Client::new() + .get(format!( + "http://localhost:{}/api/w/test-workspace/resources/mcp_tools/u/test-user/mcp", + server.addr.port() + )) + .bearer_auth(token) + .send() + .await?; + let body = resp.text().await?; + assert_eq!( + body.contains("variables:read:u/test-user/header_secret"), + denied, + "{scope}: {body}" + ); + } + Ok(()) } diff --git a/backend/tests/pool_hold_and_wait.rs b/backend/tests/pool_hold_and_wait.rs new file mode 100644 index 0000000000..44af05ab39 --- /dev/null +++ b/backend/tests/pool_hold_and_wait.rs @@ -0,0 +1,200 @@ +//! Paths that hold a pool connection (an open transaction or an acquired connection) must not +//! wait for a second one from the same pool: a burst the size of the pool would have every +//! holder wait for another until the acquire timeout. On a one-connection pool any such second +//! checkout hangs. + +use std::time::Duration; + +use serde_json::json; +use sqlx::{postgres::PgPoolOptions, Pool, Postgres}; +use windmill_common::{ + db::{AuthedRef, UserDB, UserDbWithAuthed}, + error::Error, + jobs::JobPayload, + worker::Connection, +}; +use windmill_test_utils::*; + +async fn one_connection_pool(db: &Pool) -> Pool { + PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(Duration::from_secs(5)) + .connect_with((*db.connect_options()).clone()) + .await + .unwrap() +} + +#[sqlx::test(fixtures("base"))] +async fn run_by_path_of_a_missing_script_uses_one_connection(db: Pool) { + let one = one_connection_pool(&db).await; + let groups = vec![]; + let folders = vec![]; + let authed = AuthedRef { + email: "test2@windmill.dev", + username: "test-user-2", + is_admin: &false, + is_operator: &false, + groups: &groups, + folders: &folders, + scopes: &None, + token_prefix: &None, + }; + let lookup = windmill_common::get_latest_deployed_hash_for_path( + Some(UserDbWithAuthed { authed: &authed, db: UserDB::new(one.clone()) }), + one.clone(), + "test-workspace", + "f/missing/script", + ); + let res = tokio::time::timeout(Duration::from_secs(15), lookup) + .await + .unwrap(); + assert!(matches!(res, Err(Error::NotFound(_))), "{:?}", res.err()); +} + +#[sqlx::test(fixtures("base"))] +async fn for_loop_flow_runs_on_one_connection(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let flow = serde_json::from_value(json!({ + "modules": [{ + "id": "loop", + "value": { + "type": "forloopflow", + "iterator": { "type": "static", "value": [1, 2] }, + "skip_failures": false, + "parallel": false, + "modules": [{ "id": "inner", "value": { "type": "identity" } }] + } + }] + }))?; + let job = RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None }) + .push(&db) + .await; + + let one = one_connection_pool(&db).await; + let db2 = db.clone(); + in_test_worker( + Connection::Sql(one), + async move { + while sqlx::query_scalar::<_, bool>( + "SELECT EXISTS (SELECT 1 FROM v2_job_completed WHERE id = $1)", + ) + .bind(job) + .fetch_one(&db2) + .await + .unwrap() + == false + { + tokio::time::sleep(Duration::from_millis(100)).await; + } + }, + server.addr.port(), + ) + .await; + + let status: String = + sqlx::query_scalar("SELECT status::text FROM v2_job_completed WHERE id = $1") + .bind(job) + .fetch_one(&db) + .await?; + assert_eq!(status, "success"); + Ok(()) +} + +#[cfg(all(feature = "enterprise", feature = "private"))] +#[sqlx::test(fixtures("base", "schedule"))] +async fn schedule_handlers_run_on_one_connection(db: Pool) -> anyhow::Result<()> { + use windmill_queue::{add_completed_job, get_mini_completed_job}; + + const SCHEDULE: &str = "f/system/handled_schedule"; + const HANDLER: &str = "script/f/system/schedule_recovery_handler"; + sqlx::query( + "INSERT INTO schedule (workspace_id, path, edited_by, schedule, enabled, script_path, args, + is_flow, email, timezone, permissioned_as, on_success, on_recovery) + VALUES ('test-workspace', $1, 'test-user', '0 0 0 1 1 *', true, 'f/system/failing_script', + '{}', false, 'test@windmill.dev', 'UTC', 'u/test-user', $2, $2)", + ) + .bind(SCHEDULE) + .bind(HANDLER) + .execute(&db) + .await?; + + // The previous occurrence failed, so this successful one is a recovery. The handlers run + // once the completion has committed, when the next tick may already have completed: a + // later occurrence must not count as history, or its success would hide the recovery. + let insert_job = + "INSERT INTO v2_job (id, workspace_id, created_by, created_at, permissioned_as, + permissioned_as_email, kind, runnable_id, runnable_path, script_lang, tag, + visible_to_owner, trigger, trigger_kind) + VALUES ($1, 'test-workspace', 'test-user', now() - $2 * interval '1 minute', 'u/test-user', + 'test@windmill.dev', 'script', 12349, 'f/system/failing_script', 'bun', 'bun', true, + $3, 'schedule')"; + for (minutes_ago, status) in [(1.0, "failure"), (-1.0, "success")] { + let id = uuid::Uuid::new_v4(); + sqlx::query(insert_job) + .bind(id) + .bind(minutes_ago) + .bind(SCHEDULE) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO v2_job_completed (id, workspace_id, duration_ms, result, started_at, + completed_at, status) + VALUES ($1, 'test-workspace', 1, '{}', now(), now(), $2::job_status)", + ) + .bind(id) + .bind(status) + .execute(&db) + .await?; + } + + let running = uuid::Uuid::new_v4(); + sqlx::query(insert_job) + .bind(running) + .bind(0.0) + .bind(SCHEDULE) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, started_at, tag) + VALUES ($1, 'test-workspace', now(), true, now(), 'bun')", + ) + .bind(running) + .execute(&db) + .await?; + sqlx::query("INSERT INTO v2_job_runtime (id, ping) VALUES ($1, now())") + .bind(running) + .execute(&db) + .await?; + + let one = one_connection_pool(&db).await; + let job = get_mini_completed_job(&running, "test-workspace", &db) + .await? + .unwrap(); + let result = json!("OK"); + let complete = add_completed_job( + &one, + &job, + true, + false, + sqlx::types::Json(&result), + None, + 0, + None, + false, + None, + false, + ); + tokio::time::timeout(Duration::from_secs(30), complete).await??; + + // One on_success job and one on_recovery job. + let handlers: i64 = sqlx::query_scalar( + "SELECT count(*) FROM v2_job WHERE runnable_path = 'f/system/schedule_recovery_handler' + AND parent_job = $1", + ) + .bind(running) + .fetch_one(&db) + .await?; + assert_eq!(handlers, 2); + Ok(()) +} diff --git a/backend/tests/protection_rules.rs b/backend/tests/protection_rules.rs index 6714b24801..bb9ef29fa2 100644 --- a/backend/tests/protection_rules.rs +++ b/backend/tests/protection_rules.rs @@ -686,3 +686,118 @@ async fn test_protection_rule_rename(db: Pool) -> anyhow::Result<()> { Ok(()) } + +/// `RequireIsolatedForks` refuses a non-bypassing forker any data shared with the workspace, on +/// both fork entry points, and lets an admin through. +#[sqlx::test(fixtures("base"))] +async fn test_require_isolated_forks(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + // Not `test-workspace`: PROTECTION_RULES_CACHE is process-wide and keyed by workspace id, so + // the other tests here, running concurrently on their own databases, would overwrite its rules. + invalidate_protection_rules_cache("iso-ws"); + // `dt2` is a clone governed from `gov`, but its database is this workspace's; `dt3` points + // into `gov`'s database. + let pg = r#"{"resource_type": "postgresql", "resource_path": "u/test-user/pg"}"#; + let gov = r#"{"workspace_id": "gov", "datatable": "src"}"#; + sqlx::raw_sql( + "INSERT INTO workspace (id, name, owner) VALUES + ('gov', 'gov', 'test-user'), ('iso-ws', 'iso-ws', 'test-user'); + INSERT INTO workspace_settings (workspace_id) VALUES ('gov'), ('iso-ws'); + INSERT INTO workspace_key (workspace_id, kind, key) VALUES ('iso-ws', 'cloud', 'iso-key'); + INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES + ('iso-ws', 'all', 'All users', '{}'); + INSERT INTO usr (workspace_id, email, username, is_admin, role) VALUES + ('iso-ws', 'test@windmill.dev', 'test-user', true, 'Admin'), + ('iso-ws', 'test2@windmill.dev', 'test-user-2', false, 'User')", + ) + .execute(&db) + .await?; + for (w_id, datatables) in [ + ("gov", format!(r#"{{"src": {{"database": {pg}}}}}"#)), + ( + "iso-ws", + format!( + r#"{{"dt1": {{"database": {pg}}}, "dt2": {{"database": {pg}, "governed_by": {gov}}}, + "dt3": {{"reference": {gov}}}}}"# + ), + ), + ] { + sqlx::query( + "UPDATE workspace_settings SET datatable = jsonb_build_object('datatables', $2::jsonb) \ + WHERE workspace_id = $1", + ) + .bind(w_id) + .bind(datatables) + .execute(&db) + .await?; + } + + let server = ApiServer::start(db.clone()).await?; + let base = format!("http://localhost:{}/api/w/iso-ws", server.addr.port()); + let resp = authed( + client().post(format!("{base}/workspaces/protection_rules")), + "SECRET_TOKEN", + ) + .json(&json!({ + "name": "isolate", + "rules": ["RequireIsolatedForks"], + "bypass_users": [], + "bypass_groups": [] + })) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + + let cloned = + json!([{"name": "dt1", "new_dbname": "wm_fork_iso__dt1", "fork_behavior": "schema_only"}]); + for (route, body, shared) in [ + ( + "create_fork", + json!({"shared_storages": ["_default_"], "forked_datatables": cloned}), + "storage", + ), + ( + "create_workspace_fork_branch", + json!({"shared_ducklakes": ["main"], "forked_datatables": cloned}), + "ducklake", + ), + ( + "create_fork", + json!({"forked_datatables": cloned}), + "data table `dt2`", + ), + ] { + let mut body = body; + body["id"] = json!("wm-fork-iso"); + body["name"] = json!("iso"); + let resp = authed( + client().post(format!("{base}/workspaces/{route}")), + "SECRET_TOKEN_2", + ) + .json(&body) + .send() + .await?; + let status = resp.status(); + let text = resp.text().await?; + assert!( + status == 403 && text.contains(shared) && !text.contains("dt3"), + "{route} sharing {shared} should be refused: {status} {text}" + ); + } + + // An admin bypasses the rule: what stops this request is the unknown storage name. + let resp = authed( + client().post(format!("{base}/workspaces/create_fork")), + "SECRET_TOKEN", + ) + .json(&json!({"id": "wm-fork-iso", "name": "iso", "shared_storages": ["_default_"]})) + .send() + .await?; + let text = resp.text().await?; + assert!( + !text.contains("isolate"), + "admin should bypass the rule: {text}" + ); + + Ok(()) +} diff --git a/backend/tests/push_pool_connections.rs b/backend/tests/push_pool_connections.rs new file mode 100644 index 0000000000..006340cf8a --- /dev/null +++ b/backend/tests/push_pool_connections.rs @@ -0,0 +1,63 @@ +//! `push` must not need a second pool connection while it holds its transaction: a burst of +//! pushes the size of the pool would each hold one and wait for another until the acquire +//! timeout. On a one-connection pool any such second checkout hangs. + +use std::time::Duration; + +use sqlx::{postgres::PgPoolOptions, Pool, Postgres}; +use windmill_common::jobs::JobPayload; +use windmill_test_utils::RunJob; + +#[sqlx::test(fixtures("base"))] +async fn concurrency_limited_flow_pushes_on_one_connection(db: Pool) { + // The flow and settings caches outlive the test database (they are on disk), so only a + // version and a limit no run has used yet make `push` read the flow and write its settings. + let limit = (uuid::Uuid::new_v4().as_u128() % 1_000_000_000) as i32 + 1; + let version = limit as i64; + let value = format!(r#"{{"modules":[],"concurrent_limit":{limit}}}"#); + sqlx::query( + "WITH f AS ( + INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, schema, extra_perms, versions) + VALUES ('test-workspace', 'f/t/limited', '', '', $1::jsonb, 'test-user', '{}', '{}', ARRAY[$2]) + ) + INSERT INTO flow_version (id, workspace_id, path, value, schema, created_by) + VALUES ($2, 'test-workspace', 'f/t/limited', $1::jsonb, '{}', 'test-user')", + ) + .bind(&value) + .bind(version) + .execute(&db) + .await + .unwrap(); + + let one = PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(Duration::from_secs(5)) + .connect_with((*db.connect_options()).clone()) + .await + .unwrap(); + + let push = RunJob::from(JobPayload::Flow { + path: "f/t/limited".to_string(), + dedicated_worker: None, + apply_preprocessor: false, + version, + labels: None, + job_token_scopes: None, + }) + .push(&one); + let job = tokio::time::timeout(Duration::from_secs(15), push) + .await + .unwrap(); + + let stored: Option = sqlx::query_scalar( + "SELECT cs.concurrent_limit FROM v2_job_queue q + JOIN runnable_settings rs ON rs.hash = q.runnable_settings_handle + JOIN concurrency_settings cs ON cs.hash = rs.concurrency_settings + WHERE q.id = $1", + ) + .bind(job) + .fetch_one(&db) + .await + .unwrap(); + assert_eq!(stored, Some(limit)); +} diff --git a/backend/tests/relock_noop.rs b/backend/tests/relock_noop.rs index a480fc57ae..8760909be9 100644 --- a/backend/tests/relock_noop.rs +++ b/backend/tests/relock_noop.rs @@ -437,3 +437,609 @@ async fn multi_file_importer_relock_is_a_no_op_too(db: Pool) -> anyhow Ok(()) } + +/// Plants raw app `f/rel/app` with python runnables of the given contents, one of which imports +/// `f/rel/a`, as a version deployed before runnables were keyed by name: recorded under the +/// empty node id and each holding a lock already. Returns its published version. +async fn plant_raw_app_with( + db: &Pool, + runnables: &[(&str, &str)], +) -> anyhow::Result { + let runnables: serde_json::Map = runnables + .iter() + .map(|(id, content)| { + let runnable = serde_json::json!({ "type": "inline", "inlineScript": { + "content": content, "language": "python3", "lock": "# py: 3.12\n" + }}); + (id.to_string(), runnable) + }) + .collect(); + let app_id: i64 = sqlx::query_scalar( + "INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms) + VALUES ($1, 'f/rel/app', '', '{}'::jsonb, '{}', '{}'::jsonb) RETURNING id", + ) + .bind(W) + .fetch_one(db) + .await?; + let published: i64 = sqlx::query_scalar( + "INSERT INTO app_version (app_id, value, created_by, raw_app) + VALUES ($1, $2, 'test-user', true) RETURNING id", + ) + .bind(app_id) + .bind(serde_json::json!({ "runnables": runnables })) + .fetch_one(db) + .await?; + sqlx::query("UPDATE app SET versions = ARRAY[$1::bigint] WHERE id = $2") + .bind(published) + .bind(app_id) + .execute(db) + .await?; + sqlx::query( + "INSERT INTO dependency_map (workspace_id, importer_path, importer_kind, imported_path, importer_node_id) + VALUES ($1, 'f/rel/app', 'app', 'f/rel/a', '')", + ) + .bind(W) + .execute(db) + .await?; + Ok(published) +} + +/// [`plant_raw_app_with`] a single runnable `a` importing `f/rel/a`. +async fn plant_raw_app(db: &Pool) -> anyhow::Result { + plant_raw_app_with(db, &[("a", B)]).await +} + +async fn app_lock(db: &Pool, version: i64) -> Option { + runnable_lock(db, version, "a").await +} + +async fn runnable_lock(db: &Pool, version: i64, runnable: &str) -> Option { + sqlx::query_scalar( + "SELECT value->'runnables'->$2->'inlineScript'->>'lock' FROM app_version WHERE id = $1", + ) + .bind(version) + .bind(runnable) + .fetch_one(db) + .await + .unwrap() +} + +const WRITTEN_MEANWHILE: &str = "# py: 3.12\n# written meanwhile\n"; + +/// Once an app dependency job waits on the version row `writer` holds, rewrites that row's +/// lock of runnable `a` to [`WRITTEN_MEANWHILE`] and commits. +async fn write_once_waited_on( + db: &Pool, + mut writer: sqlx::Transaction<'_, Postgres>, + version: i64, +) { + wait_until_blocked(db, "SELECT value FROM app_version%").await; + sqlx::query( + "UPDATE app_version SET value = jsonb_set(value::jsonb, + '{runnables,a,inlineScript,lock}', to_jsonb($2::text))::json WHERE id = $1", + ) + .bind(version) + .bind(WRITTEN_MEANWHILE) + .execute(&mut *writer) + .await + .unwrap(); + writer.commit().await.unwrap(); +} + +/// Waits until a statement of this test's database matching `query` waits on a row lock. +async fn wait_until_blocked(db: &Pool, query: &str) { + let deadline = tokio::time::Instant::now() + WAIT_BUDGET; + loop { + let waiting: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM pg_stat_activity + WHERE datname = current_database() AND wait_event_type = 'Lock' AND query LIKE $1)", + ) + .bind(query) + .fetch_one(db) + .await + .unwrap(); + if waiting { + return; + } + assert!( + tokio::time::Instant::now() < deadline, + "nothing ever waited on a row lock for {query}" + ); + tokio::time::sleep(POLL).await; + } +} + +/// Queues a dependency job of `f/rel/app` at `version`: the deploy's own when `components` is +/// `None`, else a relative-import relock of those components. +async fn push_app_dependency_job(db: &Pool, version: i64, components: Option<&[&str]>) { + let payload = windmill_common::jobs::JobPayload::AppDependencies { + path: "f/rel/app".to_string(), + version, + debouncing_settings: Default::default(), + }; + let mut args = std::collections::HashMap::new(); + if let Some(components) = components { + args.insert( + "triggered_by_relative_import".to_string(), + serde_json::value::to_raw_value(&true).unwrap(), + ); + args.insert( + "components_to_relock".to_string(), + serde_json::value::to_raw_value(components).unwrap(), + ); + } + let (_, tx) = windmill_queue::push( + db, + windmill_queue::PushIsolationLevel::IsolatedRoot(db.clone()), + W, + payload, + windmill_queue::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 + .unwrap(); + tx.commit().await.unwrap(); +} + +/// Logs of every dependency job of `f/rel/app` created after `since`, oldest first. +async fn app_dependency_logs( + db: &Pool, + since: chrono::DateTime, +) -> Vec { + sqlx::query_scalar( + "SELECT COALESCE(l.logs, '') FROM v2_job j + LEFT JOIN job_logs l ON l.job_id = j.id + WHERE j.kind = 'appdependencies' AND j.runnable_path = 'f/rel/app' AND j.created_at > $1 + ORDER BY j.created_at", + ) + .bind(since) + .fetch_all(db) + .await + .unwrap() +} + +/// A raw app runnable importing a script is relocked when that script's dependencies change, +/// and the relock lands in the version the app is served from rather than in a new one: a raw +/// app's bundle is stored under its version id. +#[sqlx::test(fixtures("base"))] +async fn raw_app_relock_rewrites_the_published_version(db: Pool) -> anyhow::Result<()> { + std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0"); + let (client, port, _s) = init_client(db.clone()).await; + let mut completed = listen_for_completed_jobs(&db).await; + let published = plant_raw_app(&db).await?; + + in_test_worker( + &db, + async { + client + .create_script(W, &py_script("f/rel/a", A, None)) + .await + .unwrap(); + wait_for_jobs(&db, &mut completed, 1).await; + + let a_hash = live(&versions(&db, "f/rel/a").await).hash; + client + .create_script( + W, + &py_script("f/rel/a", A_WITH_TINY, Some(format!("{a_hash:016x}"))), + ) + .await + .unwrap(); + wait_for_jobs(&db, &mut completed, 2).await; + + let (versions, version_rows): (Vec, i64) = sqlx::query_as( + "SELECT versions, (SELECT count(*) FROM app_version WHERE app_id = app.id) + FROM app WHERE workspace_id = $1 AND path = 'f/rel/app'", + ) + .bind(W) + .fetch_one(&db) + .await + .unwrap(); + assert_eq!(versions, [published], "the relock publishes no other version"); + assert_eq!(version_rows, 1, "the relock mints no version"); + + let lock = app_lock(&db, published).await; + assert!( + lock.as_deref().unwrap_or("").contains("tiny"), + "the published version carries the new lock: {lock:?}" + ); + + let node_ids: Vec = sqlx::query_scalar( + "SELECT importer_node_id FROM dependency_map + WHERE workspace_id = $1 AND importer_path = 'f/rel/app' AND imported_path = 'f/rel/a'", + ) + .bind(W) + .fetch_all(&db) + .await + .unwrap(); + assert_eq!(node_ids, ["a"], "the runnable is recorded under its name"); + }, + port, + ) + .await; + + Ok(()) +} + +/// A relock rewrites the one row every other relock of the app, and the dependency job of the +/// deploy that published it, write too. When that row changed after the relock read it, the +/// relock is discarded and queued again on what is there now, rather than overwriting it. +#[sqlx::test(fixtures("base"))] +async fn raw_app_relock_racing_a_write_requeues(db: Pool) -> anyhow::Result<()> { + std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0"); + let (client, port, _s) = init_client(db.clone()).await; + let mut completed = listen_for_completed_jobs(&db).await; + let published = plant_raw_app(&db).await?; + + in_test_worker( + &db, + async { + client + .create_script(W, &py_script("f/rel/a", A, None)) + .await + .unwrap(); + wait_for_jobs(&db, &mut completed, 1).await; + + let mut writer = db.begin().await.unwrap(); + sqlx::query("SELECT 1 FROM app_version WHERE id = $1 FOR NO KEY UPDATE") + .bind(published) + .execute(&mut *writer) + .await + .unwrap(); + + let since = chrono::Utc::now(); + let a_hash = live(&versions(&db, "f/rel/a").await).hash; + client + .create_script( + W, + &py_script("f/rel/a", A_WITH_TINY, Some(format!("{a_hash:016x}"))), + ) + .await + .unwrap(); + + write_once_waited_on(&db, writer, published).await; + + // The leaf's own job, the discarded relock and the one it queued. + wait_for_jobs(&db, &mut completed, 3).await; + + let logs = app_dependency_logs(&db, since).await; + assert_eq!(logs.len(), 2, "one relock and its requeue: {logs:?}"); + assert!( + logs[0].contains("discarding them"), + "the first relock was discarded: {}", + logs[0] + ); + let lock = app_lock(&db, published).await; + assert!( + lock.as_deref().unwrap_or("").contains("tiny"), + "the queued relock regenerated the lock over the concurrent write: {lock:?}" + ); + }, + port, + ) + .await; + + Ok(()) +} + +/// The dependency job of the deploy that published a version writes the row relocks write +/// too, so it backs off the same way when that row changed after it read it. +#[sqlx::test(fixtures("base"))] +async fn app_deploy_dependency_job_racing_a_write_requeues( + db: Pool, +) -> anyhow::Result<()> { + let (_client, port, _s) = init_client(db.clone()).await; + let mut completed = listen_for_completed_jobs(&db).await; + let published = plant_raw_app(&db).await?; + + in_test_worker( + &db, + async { + let mut writer = db.begin().await.unwrap(); + sqlx::query("SELECT 1 FROM app_version WHERE id = $1 FOR NO KEY UPDATE") + .bind(published) + .execute(&mut *writer) + .await + .unwrap(); + + let since = chrono::Utc::now(); + push_app_dependency_job(&db, published, None).await; + + write_once_waited_on(&db, writer, published).await; + + // The deploy's job, and the relock it queued on backing off. + wait_for_jobs(&db, &mut completed, 2).await; + + let logs = app_dependency_logs(&db, since).await; + assert_eq!(logs.len(), 2, "the deploy's job and its requeue: {logs:?}"); + assert!( + logs[0].contains("discarding them"), + "the deploy's job backed off: {}", + logs[0] + ); + }, + port, + ) + .await; + + Ok(()) +} + +/// A raw app runnable ships the runnables it imports as modules, and its lock covers what they +/// import, so a change to a script one of them imports relocks the importer too. +#[sqlx::test(fixtures("base"))] +async fn raw_app_relock_follows_imports_of_imported_runnables( + db: Pool, +) -> anyhow::Result<()> { + std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0"); + let (client, port, _s) = init_client(db.clone()).await; + let mut completed = listen_for_completed_jobs(&db).await; + let published = plant_raw_app_with( + &db, + &[ + ("a", "from .b import g\n\ndef main():\n return g()\n"), + ("b", "from f.rel.a import main as a\n\ndef g():\n return a()\n\ndef main():\n return g()\n"), + ], + ) + .await?; + + in_test_worker( + &db, + async { + // Its relock of the app records what each runnable depends on. + client + .create_script(W, &py_script("f/rel/a", A, None)) + .await + .unwrap(); + wait_for_jobs(&db, &mut completed, 1).await; + + let a_hash = live(&versions(&db, "f/rel/a").await).hash; + client + .create_script( + W, + &py_script("f/rel/a", A_WITH_TINY, Some(format!("{a_hash:016x}"))), + ) + .await + .unwrap(); + wait_for_jobs(&db, &mut completed, 2).await; + + for runnable in ["a", "b"] { + let lock = runnable_lock(&db, published, runnable).await; + assert!( + lock.as_deref().unwrap_or("").contains("tiny"), + "{runnable} carries the new lock: {lock:?}" + ); + } + + // A rebuilt dependency map records the same dependencies. + async fn app_edges(db: &Pool) -> Vec<(String, String)> { + sqlx::query_as( + "SELECT importer_node_id, imported_path FROM dependency_map + WHERE workspace_id = $1 AND importer_path = 'f/rel/app' ORDER BY 1, 2", + ) + .bind(W) + .fetch_all(db) + .await + .unwrap() + } + let recorded = app_edges(&db).await; + assert!(rebuild_dmap(&client).await); + assert_eq!(app_edges(&db).await, recorded); + }, + port, + ) + .await; + + Ok(()) +} + +/// A relock superseded by a deploy relocks the version that deploy published, every component +/// of it: the deploy may rename the component the relock was for, keeping its stale lock. +#[sqlx::test(fixtures("base"))] +async fn superseded_app_relock_relocks_the_renamed_component( + db: Pool, +) -> anyhow::Result<()> { + std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0"); + let (client, port, _s) = init_client(db.clone()).await; + let mut completed = listen_for_completed_jobs(&db).await; + plant_raw_app(&db).await?; + + in_test_worker( + &db, + async { + client + .create_script(W, &py_script("f/rel/a", A, None)) + .await + .unwrap(); + wait_for_jobs(&db, &mut completed, 1).await; + + let mut deploy = db.begin().await.unwrap(); + sqlx::query( + "SELECT 1 FROM app WHERE workspace_id = $1 AND path = 'f/rel/app' FOR NO KEY UPDATE", + ) + .bind(W) + .execute(&mut *deploy) + .await + .unwrap(); + + let a_hash = live(&versions(&db, "f/rel/a").await).hash; + client + .create_script( + W, + &py_script("f/rel/a", A_WITH_TINY, Some(format!("{a_hash:016x}"))), + ) + .await + .unwrap(); + + // The relock has generated its lock for `a` and waits on the app this deploy holds, + // which publishes `a` renamed to `b` with its old lock. + wait_until_blocked(&db, "SELECT versions[array_upper(versions, 1)] FROM app%").await; + let renamed: i64 = sqlx::query_scalar( + "INSERT INTO app_version (app_id, value, created_by, raw_app) + SELECT app_id, json_build_object('runnables', + json_build_object('b', value->'runnables'->'a')), created_by, true + FROM app_version WHERE id = (SELECT versions[array_upper(versions, 1)] FROM app + WHERE workspace_id = $1 AND path = 'f/rel/app') + RETURNING id", + ) + .bind(W) + .fetch_one(&mut *deploy) + .await + .unwrap(); + sqlx::query( + "UPDATE app SET versions = array_append(versions, $2) + WHERE workspace_id = $1 AND path = 'f/rel/app'", + ) + .bind(W) + .bind(renamed) + .execute(&mut *deploy) + .await + .unwrap(); + deploy.commit().await.unwrap(); + + // The leaf's own job, the superseded relock and the one it queued. + wait_for_jobs(&db, &mut completed, 3).await; + + let lock = runnable_lock(&db, renamed, "b").await; + assert!( + lock.as_deref().unwrap_or("").contains("tiny"), + "the renamed component carries the new lock: {lock:?}" + ); + }, + port, + ) + .await; + + Ok(()) +} + +/// A relock queued before a deploy runs on the version that deploy published, every component +/// of it: the deploy may rename the component the relock was queued for, keeping its stale lock. +#[sqlx::test(fixtures("base"))] +async fn app_relock_queued_before_a_deploy_relocks_the_renamed_component( + db: Pool, +) -> anyhow::Result<()> { + std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0"); + let (client, port, _s) = init_client(db.clone()).await; + let mut completed = listen_for_completed_jobs(&db).await; + let queued_for = plant_raw_app(&db).await?; + + in_test_worker( + &db, + async { + client + .create_script(W, &py_script("f/rel/a", A_WITH_TINY, None)) + .await + .unwrap(); + wait_for_jobs(&db, &mut completed, 1).await; + + let renamed: i64 = sqlx::query_scalar( + "INSERT INTO app_version (app_id, value, created_by, raw_app) + SELECT app_id, $2, created_by, true FROM app_version WHERE id = $1 + RETURNING id", + ) + .bind(queued_for) + .bind( + serde_json::json!({ "runnables": { "c": { "type": "inline", "inlineScript": { + "content": B, "language": "python3", "lock": "# py: 3.12\n" + }}}}), + ) + .fetch_one(&db) + .await + .unwrap(); + sqlx::query( + "UPDATE app SET versions = array_append(versions, $2) + WHERE workspace_id = $1 AND path = 'f/rel/app'", + ) + .bind(W) + .bind(renamed) + .execute(&db) + .await + .unwrap(); + + push_app_dependency_job(&db, queued_for, Some(&["a"])).await; + wait_for_jobs(&db, &mut completed, 1).await; + + let lock = runnable_lock(&db, renamed, "c").await; + assert!( + lock.as_deref().unwrap_or("").contains("tiny"), + "the renamed component carries the new lock: {lock:?}" + ); + }, + port, + ) + .await; + + Ok(()) +} + +/// A relock of every component regenerates each lock. A deploy that kept its editor's stale +/// lock still records the imports' current lock hashes, so the skip check would keep it. +#[sqlx::test(fixtures("base"))] +async fn app_relock_of_every_component_regenerates_a_kept_stale_lock( + db: Pool, +) -> anyhow::Result<()> { + std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0"); + let (client, port, _s) = init_client(db.clone()).await; + let mut completed = listen_for_completed_jobs(&db).await; + let published = plant_raw_app(&db).await?; + + in_test_worker( + &db, + async { + client + .create_script(W, &py_script("f/rel/a", A_WITH_TINY, None)) + .await + .unwrap(); + wait_for_jobs(&db, &mut completed, 1).await; + + // The stale lock a deploy kept, under the hashes it recorded. + sqlx::query( + "UPDATE app_version SET value = jsonb_set(value::jsonb, + '{runnables,a,inlineScript,lock}', '\"# py: 3.12\\n\"')::json WHERE id = $1", + ) + .bind(published) + .execute(&db) + .await + .unwrap(); + + let every = windmill_dep_map::trigger_dependents::RELOCK_ALL_APP_COMPONENTS; + push_app_dependency_job(&db, published, Some(&[every])).await; + wait_for_jobs(&db, &mut completed, 1).await; + + let lock = app_lock(&db, published).await; + assert!( + lock.as_deref().unwrap_or("").contains("tiny"), + "the kept stale lock is regenerated: {lock:?}" + ); + }, + port, + ) + .await; + + Ok(()) +} diff --git a/backend/tests/success_handler.rs b/backend/tests/success_handler.rs deleted file mode 100644 index 14df1a4c25..0000000000 --- a/backend/tests/success_handler.rs +++ /dev/null @@ -1,293 +0,0 @@ -use sqlx::{Pool, Postgres}; - -use windmill_test_utils::*; - -/// Both job-running tests seed `f/test/success_handler` with a different hash, each in its own -/// database, while the deployed-script caches are process-global and keyed by workspace and -/// path. With them on, one test resolves the handler to the other's hash and the handler push -/// 404s whenever that hash's info is not cached yet. -#[cfg(all(feature = "enterprise", feature = "private"))] -fn disable_deployed_script_cache() { - windmill_common::DEPLOYED_SCRIPT_CACHE_DISABLED - .store(true, std::sync::atomic::Ordering::Relaxed); -} - -/// The handler job is pushed after the job it handles is reported complete. -#[cfg(all(feature = "enterprise", feature = "private"))] -async fn wait_for_success_handler_job( - db: &Pool, -) -> anyhow::Result, Option)>> { - for _ in 0..100 { - let job = sqlx::query_as::<_, (Option, Option)>( - "SELECT runnable_path, parent_job FROM v2_job - WHERE workspace_id = 'test-workspace' AND permissioned_as_email = 'success_handler@windmill.dev'", - ) - .fetch_optional(db) - .await?; - if job.is_some() { - return Ok(job); - } - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - } - Ok(None) -} - -/// Test that the workspace success handler cache works correctly with 60s TTL -#[cfg(feature = "deno_core")] -#[sqlx::test(fixtures("base"))] -async fn test_success_handler_cache(db: Pool) -> anyhow::Result<()> { - initialize_tracing().await; - - // First, create a success handler script - let _server = ApiServer::start(db.clone()).await?; - - // Set up a success handler in workspace_settings using new JSONB column - sqlx::query!( - r#" - UPDATE workspace_settings - SET success_handler = '{"path": "script/f/test/success_handler", "extra_args": {"key": "value"}}'::jsonb - WHERE workspace_id = 'test-workspace' - "# - ) - .execute(&db) - .await?; - - // Verify the success handler was set - let result = sqlx::query_scalar!( - r#"SELECT success_handler->>'path' FROM workspace_settings WHERE workspace_id = 'test-workspace'"# - ) - .fetch_one(&db) - .await?; - - assert_eq!(result, Some("script/f/test/success_handler".to_string())); - - // Verify extra args were set - let extra_args = sqlx::query_scalar!( - r#"SELECT success_handler->'extra_args' FROM workspace_settings WHERE workspace_id = 'test-workspace'"# - ) - .fetch_one(&db) - .await?; - - assert!(extra_args.is_some()); - - Ok(()) -} - -/// Test that success handler can be set and removed via database operations -#[cfg(feature = "deno_core")] -#[sqlx::test(fixtures("base"))] -async fn test_success_handler_settings(db: Pool) -> anyhow::Result<()> { - initialize_tracing().await; - - let _server = ApiServer::start(db.clone()).await?; - - // Initially success_handler should be NULL - let initial = sqlx::query_scalar!( - r#"SELECT success_handler->>'path' FROM workspace_settings WHERE workspace_id = 'test-workspace'"# - ) - .fetch_one(&db) - .await?; - assert!(initial.is_none()); - - // Set success handler - sqlx::query!( - r#" - UPDATE workspace_settings - SET success_handler = '{"path": "flow/f/test/success_flow"}'::jsonb - WHERE workspace_id = 'test-workspace' - "# - ) - .execute(&db) - .await?; - - let after_set = sqlx::query_scalar!( - r#"SELECT success_handler->>'path' FROM workspace_settings WHERE workspace_id = 'test-workspace'"# - ) - .fetch_one(&db) - .await?; - assert_eq!(after_set, Some("flow/f/test/success_flow".to_string())); - - // Remove success handler - sqlx::query!( - r#" - UPDATE workspace_settings - SET success_handler = NULL - WHERE workspace_id = 'test-workspace' - "# - ) - .execute(&db) - .await?; - - let after_remove = sqlx::query_scalar!( - r#"SELECT success_handler->>'path' FROM workspace_settings WHERE workspace_id = 'test-workspace'"# - ) - .fetch_one(&db) - .await?; - assert!(after_remove.is_none()); - - Ok(()) -} - -/// Test that a successful job completion triggers the success handler -#[cfg(all(feature = "deno_core", feature = "enterprise", feature = "private"))] -#[sqlx::test(fixtures("base"))] -async fn test_success_handler_triggered_on_success(db: Pool) -> anyhow::Result<()> { - use serde_json::json; - use windmill_common::jobs::JobPayload; - use windmill_common::runnable_settings::{ConcurrencySettings, DebouncingSettings}; - use windmill_common::scripts::{ScriptHash, ScriptLang}; - - initialize_tracing().await; - disable_deployed_script_cache(); - - let server = ApiServer::start(db.clone()).await?; - - // Create a simple success handler script - // Note: lock must be non-null for script to be considered "deployed" - let success_handler_code = r#" -export async function main(path: string, email: string, job_id: string, is_flow: boolean, workspace_id: string, result: any) { - console.log("Success handler called for job:", job_id); - return { handled: true, original_path: path }; -} -"#; - - sqlx::query!( - r#" - INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock) - VALUES ('test-workspace', 1234567890, 'f/test/success_handler', $1, 'bun', 'script', 'test-user', '{}', 'Success handler script', 'Handles successful job completions', E'{}\n//bun.lock\n') - "#, - success_handler_code - ) - .execute(&db) - .await?; - - // Create a simple test script that we'll run (needs to be JobKind::Script, not Preview) - // Note: lock must be non-null for script to be considered "deployed" - let test_script_code = "export function main() { return 'success'; }"; - let test_script_hash: i64 = 9876543210; - - sqlx::query!( - r#" - INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock) - VALUES ('test-workspace', $1, 'f/test/simple_script', $2, 'bun', 'script', 'test-user', '{}', 'Simple test script', 'A simple test script', E'{}\n//bun.lock\n') - "#, - test_script_hash, - test_script_code - ) - .execute(&db) - .await?; - - // Set up the success handler in workspace_settings - sqlx::query!( - r#" - UPDATE workspace_settings - SET success_handler = '{"path": "script/f/test/success_handler"}'::jsonb - WHERE workspace_id = 'test-workspace' - "# - ) - .execute(&db) - .await?; - - // Create the success_handler group - sqlx::query!( - r#" - INSERT INTO group_ (workspace_id, name, summary, extra_perms) - VALUES ('test-workspace', 'success_handler', 'The group the success handler acts on behalf of', '{"u/test-user": true}') - ON CONFLICT DO NOTHING - "# - ) - .execute(&db) - .await?; - - // Run a script using ScriptHash (produces JobKind::Script, not Preview) - let completed_job = RunJob::from(JobPayload::ScriptHash { - hash: ScriptHash(test_script_hash), - path: "f/test/simple_script".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, - }) - .run_until_complete(&db, false, server.addr.port()) - .await; - - let result = completed_job.json_result().unwrap(); - assert_eq!(result, json!("success")); - - let main_job_id = completed_job.id; - - let handler_job = wait_for_success_handler_job(&db).await?; - assert_eq!( - handler_job, - Some(( - Some("f/test/success_handler".to_string()), - Some(main_job_id) - )), - "Success handler should run the configured script with the main job as parent" - ); - // Note: root_job may be None when it equals parent_job (optimization in push function) - - Ok(()) -} - -/// A hub script run at the top level (a schedule, a trigger, the API) is a `script_hub` job, -/// and its success must reach the workspace success handler like a workspace script's. -#[cfg(all(feature = "enterprise", feature = "private"))] -#[sqlx::test(fixtures("base"))] -async fn test_success_handler_triggered_on_hub_script_success( - db: Pool, -) -> anyhow::Result<()> { - use windmill_common::jobs::JobPayload; - - initialize_tracing().await; - disable_deployed_script_cache(); - - let server = ApiServer::start(db.clone()).await?; - - let version = "990000003"; - let hub_dir = &*windmill_common::worker::HUB_CACHE_DIR; - tokio::fs::create_dir_all(hub_dir).await?; - tokio::fs::write( - format!("{hub_dir}/{version}"), - r#"{"content":"echo ok","lockfile":null,"language":"bash","schema":{},"summary":null}"#, - ) - .await?; - - // The workspace handler settings are cached per workspace id across the tests of this - // binary, so this reuses the handler path every other test here configures. - sqlx::query( - "INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock) - VALUES ('test-workspace', 1234567891, 'f/test/success_handler', 'echo handled', 'bash', 'script', 'test-user', '{}', '', '', '')", - ) - .execute(&db) - .await?; - sqlx::query( - r#"UPDATE workspace_settings SET success_handler = '{"path": "script/f/test/success_handler"}'::jsonb - WHERE workspace_id = 'test-workspace'"#, - ) - .execute(&db) - .await?; - - let completed_job = RunJob::from(JobPayload::ScriptHub { - path: format!("hub/{version}/test/ok"), - apply_preprocessor: false, - }) - .run_until_complete(&db, false, server.addr.port()) - .await; - assert!(completed_job.success, "Hub script should have succeeded"); - - let handler_job = wait_for_success_handler_job(&db).await?; - assert_eq!( - handler_job.map(|(_, parent)| parent), - Some(Some(completed_job.id)), - "Workspace success handler should handle the successful hub script" - ); - - Ok(()) -} diff --git a/backend/tests/untrusted_interpolation.rs b/backend/tests/untrusted_interpolation.rs new file mode 100644 index 0000000000..2171797e18 --- /dev/null +++ b/backend/tests/untrusted_interpolation.rs @@ -0,0 +1,441 @@ +//! Handler, public-app and agent tool jobs run with more rights than whoever wrote some of their +//! arguments, so nothing in those arguments may be resolved by the worker with the job's identity. + +use axum::{routing::post, Json as AxumJson, Router}; +use serde_json::{json, value::RawValue, Value}; +use sqlx::{types::Json, Pool, Postgres}; +use std::time::Duration; +use uuid::Uuid; +use windmill_common::{flows::FlowValue, jobs::JobPayload}; +use windmill_test_utils::*; + +const SECRET: &str = "s3cr3t-value"; +const ENV_SECRET: &str = "env-s3cr3t"; +const READERS: &str = + r#"{"g/error_handler": false, "g/success_handler": false, "g/publishers": false}"#; + +async fn seed(db: &Pool) -> anyhow::Result<()> { + // Every test of this file is serial: the workspace env cache is process-wide. + sqlx::query( + "INSERT INTO workspace_env (workspace_id, name, value) VALUES ('test-workspace', 'API_KEY', $1)", + ) + .bind(ENV_SECRET) + .execute(db) + .await?; + windmill_common::variables::CUSTOM_ENVS_CACHE.remove("test-workspace"); + // How an app run as its publisher reads a secret: through the publisher's groups. + sqlx::query( + "INSERT INTO group_ (workspace_id, name, summary, extra_perms) + VALUES ('test-workspace', 'publishers', '', '{}')", + ) + .execute(db) + .await?; + sqlx::query( + "INSERT INTO usr_to_group (workspace_id, group_, usr) + VALUES ('test-workspace', 'publishers', 'test-user')", + ) + .execute(db) + .await?; + sqlx::query( + "INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms) + VALUES ('test-workspace', 'u/test-user/secret', $1, false, '', $2::jsonb)", + ) + .bind(format!("\"{SECRET}\"")) + .bind(READERS) + .execute(db) + .await?; + sqlx::query( + "INSERT INTO resource_type (workspace_id, name, schema, description, created_by) + VALUES ('test-workspace', 'c_cfg', '{}', '', 'test-user')", + ) + .execute(db) + .await?; + sqlx::query( + "INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) + VALUES ('test-workspace', 'u/test-user/cfg', '{\"set_by\": \"admin\"}', 'c_cfg', $1::jsonb, + 'test-user')", + ) + .bind(READERS) + .execute(db) + .await?; + // The lock must be non-null for the script to count as deployed. + sqlx::query( + r#"INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, + summary, description, lock) + VALUES ('test-workspace', 1234, 'f/handlers/h', $1, 'bun', 'script', 'test-user', '{}', + '', '', E'{}\n//bun.lock\n')"#, + ) + .bind( + "export async function main(error: any, cfg: any, success_result: any, pipeline: any, + price: any, note: any, leak: any, log: any) { + return { error, cfg, success_result, pipeline, price, note, leak, log }; + }", + ) + .execute(db) + .await?; + Ok(()) +} + +async fn run(db: &Pool, port: u16, id: Uuid) -> Value { + let done = listen_for_completed_jobs(db).await.find(&id); + tokio::time::timeout(Duration::from_secs(60), in_test_worker(db, done, port)) + .await + .expect("job did not complete"); + let job = completed_job(id, db).await; + assert!(job.success, "{:?}", job.result); + job.json_result().unwrap() +} + +fn assert_omitted(v: &Value) { + let s = v.as_str().unwrap_or_default(); + assert!( + s.starts_with("The following variable has been omitted for security reasons"), + "{v}" + ); +} + +/// What a failed job sends its handler: references in its error, and the `$WM_TOKEN` the worker +/// replaces with the handler's token in an argument that holds one. +fn failed_result() -> Box { + // Raw text: a number survives only if nothing on the way parses it. + RawValue::from_string( + r#"{"error": {"message": "$var:u/test-user/secret", "name": "$WM_TOKEN", + "stack": "$jsonvar:u/test-user/secret"}, + "meta": {"code": 1.10, "id": 18446744073709551617}, "log": "a\u0000b"}"# + .to_string(), + ) + .unwrap() +} + +fn extra_args() -> Json> { + Json(serde_json::value::to_raw_value(&json!({"cfg": "$res:u/test-user/cfg"})).unwrap()) +} + +/// Stored as jsonb stores it: numbers exact, the NUL jsonb refuses stripped. +async fn assert_handled(db: &Pool, port: u16, id: Uuid) -> Value { + let stored: String = sqlx::query_scalar("SELECT args::text FROM v2_job WHERE id = $1") + .bind(id) + .fetch_one(db) + .await + .unwrap(); + assert!( + stored.contains("1.10") && stored.contains("18446744073709551617"), + "{stored}" + ); + let out = run(db, port, id).await; + assert_eq!(out["log"], json!("ab")); + assert!(!out.to_string().contains(SECRET), "{out}"); + for k in ["message", "name", "stack"] { + assert_omitted(&out["error"][k]); + } + // The handler's own settings, written by an admin. + assert_eq!(out["cfg"], json!({"set_by": "admin"})); + out +} + +#[sqlx::test(fixtures("base"))] +#[serial_test::serial] +async fn error_handler_resolves_nothing_the_failed_job_wrote( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + seed(&db).await?; + let id = windmill_queue::push_error_handler( + &db, + Uuid::new_v4(), + None, + Some("u/test-user/failing".to_string()), + false, + "test-workspace", + "script/f/handlers/h", + Json(&failed_result()), + None, + None, + Some(extra_args()), + "test@windmill.dev", + None, + false, + None, + ) + .await?; + assert_handled(&db, server.addr.port(), id).await; + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +#[serial_test::serial] +async fn success_handler_resolves_nothing_the_job_returned( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + seed(&db).await?; + let result = json!({ + "pipeline": [{"$match": {"currency": "$USD", "$or": [{"a": "$category"}]}}], + "price": "$10.00", + "note": "cost $var:u/test-user/secret", + "leak": "\u{24}jsonvar:u/test-user/secret", + }); + let id = windmill_queue::push_success_handler( + &db, + Uuid::new_v4(), + None, + Some("u/test-user/succeeding".to_string()), + false, + "test-workspace", + "script/f/handlers/h", + Json(&result), + None, + Some(extra_args()), + "test@windmill.dev", + None, + ) + .await?; + let out = run(&db, server.addr.port(), id).await; + assert_omitted(&out["leak"]); + // Holds the prefix that makes the worker interpolate the whole argument. + assert!(out["note"] + .as_str() + .unwrap() + .starts_with("cost The following variable")); + for k in ["pipeline", "price"] { + assert_eq!(out[k], result[k], "{k}"); + } + assert_eq!(out["cfg"], json!({"set_by": "admin"})); + Ok(()) +} + +#[cfg(all(feature = "enterprise", feature = "private"))] +#[sqlx::test(fixtures("base"))] +#[serial_test::serial] +async fn recovery_handler_resolves_nothing_the_schedule_jobs_wrote( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + seed(&db).await?; + let job_id = Uuid::new_v4(); + let failed = failed_result(); + let success = json!({"leak": "$var:u/test-user/secret", "ok": {"$match": "$USD"}}); + windmill_queue::jobs_ee::handle_recovered_schedule( + &db, + job_id, + "u/test-user/schedule", + "u/test-user/flaky", + false, + "test-workspace", + "script/f/handlers/h", + Some(&failed), + sqlx::types::chrono::Utc::now(), + Json(&success), + 1, + sqlx::types::chrono::Utc::now(), + Some(extra_args()), + "u/test-user", + ) + .await?; + let id: Uuid = sqlx::query_scalar("SELECT id FROM v2_job WHERE parent_job = $1") + .bind(job_id) + .fetch_one(&db) + .await?; + let out = assert_handled(&db, server.addr.port(), id).await; + assert_omitted(&out["success_result"]["leak"]); + assert_eq!(out["success_result"]["ok"], success["ok"]); + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +#[serial_test::serial] +async fn public_app_resolves_nothing_the_viewer_sent(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + seed(&db).await?; + let content = "export function main(a: any, b: any, c: any, d: any) { return { a, b, c, d } }"; + let ws = format!("http://localhost:{port}/api/w/test-workspace"); + let client = reqwest::Client::new(); + let resp = client + .post(format!("{ws}/apps/create")) + .bearer_auth("SECRET_TOKEN") + .json(&json!({ + "path": "u/test-user/app", + "summary": "", + "value": {"runnables": {"c": {"type": "inline", + "inlineScript": {"language": "bun", "content": content}}}}, + "policy": {"execution_mode": "anonymous", "triggerables_v2": { + format!("c:rawscript/{}", windmill_common::utils::calculate_hash(content)): + {"static_inputs": {}, "one_of_inputs": {}} + }}, + })) + .send() + .await?; + assert!(resp.status().is_success(), "{}", resp.text().await?); + // `\u0024` passes a text match on `$var:` until jsonb decodes it. + let body = r#"{"component": "c", + "raw_code": {"language": "bun", "content": "CONTENT"}, + "args": {"a": "\u0024var:u/test-user/secret", "b": {"x": ["$jsonvar:u/test-user/secret"]}, + "c": {"pipeline": [{"$match": {"currency": "$USD"}}]}, + "d": {"key": "$API_KEY", "note": "cost $var:x"}}}"# + .replace("CONTENT", content); + let resp = client + .post(format!("{ws}/apps_u/execute_component/u/test-user/app")) + .bearer_auth("SECRET_TOKEN_2") + .header("content-type", "application/json") + .body(body) + .send() + .await?; + assert!(resp.status().is_success(), "{}", resp.text().await?); + let id: Uuid = resp.text().await?.parse()?; + let out = run(&db, port, id).await; + assert!(!out.to_string().contains(SECRET), "{out}"); + assert!(!out.to_string().contains(ENV_SECRET), "{out}"); + assert_omitted(&out["a"]); + assert_omitted(&out["b"]["x"][0]); + assert_eq!( + out["c"], + json!({"pipeline": [{"$match": {"currency": "$USD"}}]}) + ); + assert_eq!(out["d"]["key"], json!("$API_KEY")); + Ok(()) +} + +/// Read as the jsonb args column holds it: whatever the worker's own test finds in the stored text, +/// the check finds too, and nothing more. +#[sqlx::test(fixtures("base"))] +#[serial_test::serial] +async fn reads_an_argument_as_jsonb_stores_it(db: Pool) -> anyhow::Result<()> { + use windmill_common::{utils::strip_json_nul, variables::*}; + for arg in [ + r#""\u0024var:x""#, + r#""$va\u0072:x""#, + r#""$va\\u0072:x""#, + r#""$va\u0000r:x""#, + r#""\ud800 $var:x""#, + r#"{"a": "\u0024res:x", "a": "ok"}"#, + r#"{"a": "ok", "a": "\u0024var:x"}"#, + r#"{"n": 1e400, "n": 0, "r": "\u0024var:x"}"#, + r#"{"\u0024encrypted:k": 1}"#, + r#""cost \u0024var:x""#, + r#"{"$match": {"c": "$USD"}, "p": 1.10}"#, + r#""$10.00""#, + ] { + let stored: Option = match sqlx::query_scalar("SELECT $1::jsonb::text") + .bind(strip_json_nul(arg).as_ref()) + .fetch_one(&db) + .await + { + Ok(s) => Some(s), + Err(sqlx::Error::Database(e)) if e.code().as_deref() == Some("22P02") => None, + Err(e) => return Err(e.into()), + }; + assert_eq!( + untrusted_arg_interpolates(arg), + stored.as_deref().map_or(true, arg_needs_interpolation), + "{arg} stored as {stored:?}" + ); + } + Ok(()) +} + +/// One tool call with `arguments`, then a final answer once the tool's reply is in. +fn model(arguments: &'static str) -> Router { + Router::new().route( + "/v1/chat/completions", + post(move |AxumJson(body): AxumJson| async move { + let answered = body["messages"] + .as_array() + .unwrap() + .iter() + .any(|m| m["role"] == "tool"); + let delta = if answered { + json!({"role": "assistant", "content": "done"}) + } else { + json!({"role": "assistant", "tool_calls": [{"index": 0, "id": "call_0", + "type": "function", "function": {"name": "echo", "arguments": arguments}}]}) + }; + let event = json!({"choices": [{"index": 0, "delta": delta, "finish_reason": null}]}); + let end = json!({"choices": [{"index": 0, "delta": {}, + "finish_reason": if answered {"stop"} else {"tool_calls"}}]}); + ( + [("content-type", "text/event-stream")], + format!("data: {event}\n\ndata: {end}\n\ndata: [DONE]\n\n"), + ) + }), + ) +} + +/// What the tool replied to the model. +async fn tool_reply(db: &Pool, port: u16, arguments: &'static str) -> String { + std::env::set_var("ALLOW_PRIVATE_AI_BASE_URLS", "true"); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let stub = tokio::spawn(async move { axum::serve(listener, model(arguments)).await }); + let flow: FlowValue = serde_json::from_value(json!({"modules": [{"id": "agent", "value": { + "type": "aiagent", + "tools": [{"id": "echo", "summary": "echo", "value": { + "type": "rawscript", "language": "bun", "input_transforms": {}, + "content": "export async function main(x: any) { return x }"}}], + "input_transforms": { + "provider": {"type": "static", "value": {"kind": "customai", "model": "m", + "resource": {"base_url": format!("{base}/v1")}}}, + "user_message": {"type": "static", "value": "go"}, + "max_iterations": {"type": "static", "value": 2} + } + }}]})) + .unwrap(); + let id = RunJob::from(JobPayload::RawFlow { + value: flow, + path: Some("u/test-user/agent".into()), + restarted_from: None, + }) + .push(db) + .await; + let out = run(db, port, id).await; + stub.abort(); + out["messages"] + .as_array() + .unwrap() + .iter() + .find(|m| m["role"] == "tool") + .unwrap()["content"] + .as_str() + .unwrap() + .to_string() +} + +#[sqlx::test(fixtures("base"))] +#[serial_test::serial] +async fn agent_tool_call_resolves_nothing_the_model_wrote( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + seed(&db).await?; + // jsonb keeps the last copy of a duplicate key, the one a Rust decode refuses to read past. + let reply = tool_reply( + &db, + port, + r#"{"x": {"n": 1e400, "n": 0, "r": "\u0024var:u/test-user/secret"}}"#, + ) + .await; + assert!(!reply.contains(SECRET), "{reply}"); + assert!(reply.contains("Tool call refused"), "{reply}"); + + // A tool the model calls without arguments, as Anthropic sends it. + let reply = tool_reply(&db, port, "").await; + assert!(!reply.contains("Tool call refused"), "{reply}"); + + let reply = tool_reply( + &db, + port, + r#"{"x": {"pipeline": [{"$match": {"currency": "$USD", "_id": "$category"}}]}}"#, + ) + .await; + assert_eq!( + serde_json::from_str::(&reply)?, + json!({"pipeline": [{"$match": {"currency": "$USD", "_id": "$category"}}]}) + ); + Ok(()) +} diff --git a/backend/tests/v2_job_delete_orphans.rs b/backend/tests/v2_job_delete_orphans.rs index 45029f024d..a11494baa7 100644 --- a/backend/tests/v2_job_delete_orphans.rs +++ b/backend/tests/v2_job_delete_orphans.rs @@ -325,6 +325,7 @@ async fn test_new_turns_wait_for_conversation_cleanup_and_recreate( WS, "f/flow", "test-user", + false, "hi again", conv_id, false, @@ -527,6 +528,16 @@ async fn test_jobs_export_delete_is_workspace_scoped(db: Pool) -> anyh let other_job = Uuid::new_v4(); insert_job(&db, "ws-other", other_job).await?; seed_side_rows(&db, "ws-other", other_job).await?; + // A queued flow's per-job tables, which carry no workspace_id of their own. + for sql in [ + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for) VALUES ($1, 'ws-other', now())", + "INSERT INTO v2_job_runtime (id) VALUES ($1)", + "INSERT INTO v2_job_status (id) VALUES ($1)", + "INSERT INTO resume_job (id, job, flow) VALUES (gen_random_uuid(), $1, $1)", + "INSERT INTO concurrency_key (key, job_id) VALUES ('k', $1)", + ] { + sqlx::query(sql).bind(other_job).execute(&db).await?; + } // A test-workspace admin calls the purge endpoint with the FOREIGN job id. let server = ApiServer::start(db.clone()).await?; @@ -551,5 +562,20 @@ async fn test_jobs_export_delete_is_workspace_scoped(db: Pool) -> anyh (1, 1, 1, 1), "jobs_export purge crossed workspaces: dispatch_event={de} flow_conversation_message={fcm} zombie_job_counter={zombie} v2_job={job}" ); + for table_col in [ + "v2_job_queue WHERE id", + "v2_job_runtime WHERE id", + "v2_job_status WHERE id", + "resume_job WHERE job", + "concurrency_key WHERE job_id", + ] { + let n = count( + &db, + &format!("SELECT count(*) FROM {table_col} = $1"), + other_job, + ) + .await?; + assert_eq!(n, 1, "jobs_export purge crossed workspaces: {table_col}"); + } Ok(()) } diff --git a/backend/tests/wm_token_confinement.rs b/backend/tests/wm_token_confinement.rs index e75170ec6f..e15d33354e 100644 --- a/backend/tests/wm_token_confinement.rs +++ b/backend/tests/wm_token_confinement.rs @@ -381,6 +381,22 @@ async fn test_wm_token_is_confined_to_its_workspace(db: Pool) -> anyho "WM_TOKEN must still record its own tutorial progress: {}", resp.text().await? ); + // Every Teams notification a job sends posts here. An empty body is a 422 from the + // handler's extractor, so a 403 means confinement refused it. The real route is only + // mounted in an EE build. + #[cfg(all(feature = "enterprise", feature = "private"))] + { + let resp = authed(client().post(format!("{api}/teams/activities")), &user_wm) + .json(&json!({})) + .send() + .await?; + assert_eq!( + resp.status(), + 422, + "WM_TOKEN must still reach the Teams message route: {}", + resp.text().await? + ); + } // 11. ...and the caller-scoped reads deliberately left out of it, each because it // names another workspace or the identity's credentials. diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index 751752eb00..553106e6fe 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -2123,7 +2123,6 @@ async fn test_postgresql_single_worker_session_isolation(db: Pool) -> #[serial(pg_cache)] async fn test_postgresql_100_jobs_cached(db: Pool) -> anyhow::Result<()> { use std::sync::atomic::Ordering; - use windmill_common::worker::Connection; use windmill_worker::pg_executor::{clear_pg_cache, CACHE_HITS}; initialize_tracing().await; @@ -2176,27 +2175,7 @@ async fn test_postgresql_100_jobs_cached(db: Pool) -> anyhow::Result<( ids.push(make_pg_job(content).push(&db).await); } - // Run ONE worker for all 100 jobs. - let listener = listen_for_completed_jobs(&db).await; - let id_set = ids - .iter() - .copied() - .collect::>(); - in_test_worker( - Connection::Sql(db.clone()), - async { - use futures::StreamExt; - let mut remaining = id_set; - let mut listener = listener; - while !remaining.is_empty() { - if let Some(id) = listener.next().await { - remaining.remove(&id); - } - } - }, - port, - ) - .await; + run_on_one_worker(&db, port, &ids).await; // Verify all 100 jobs succeeded. for (i, id) in ids.iter().enumerate() { @@ -2259,6 +2238,26 @@ async fn test_postgresql_arg_type_combinations(db: Pool) -> anyhow::Re let db_arg = json!({"host": "localhost", "port": 5432, "dbname": "windmill", "user": "postgres", "password": "changeme"}); + let make_pg_job = |content: String| { + RunJob::from(JobPayload::Code(RawCode { + hash: None, + content, + path: None, + lock: None, + language: ScriptLang::Postgresql, + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( + ) + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, + tag: None, + })) + .arg("database", db_arg.clone()) + }; + // Build a fresh schema for this test so we don't trip over prior runs. let setup = r#" DROP SCHEMA IF EXISTS wm_pg_arg_combo_test CASCADE; @@ -2289,26 +2288,7 @@ CREATE TABLE wm_pg_arg_combo_test.allcols ( CREATE TYPE wm_pg_arg_combo_test.color AS ENUM ('red','green','blue'); CREATE TABLE wm_pg_arg_combo_test.enumtbl (c wm_pg_arg_combo_test.color); "#; - RunJob::from(JobPayload::Code(RawCode { - hash: None, - content: setup.to_owned(), - path: None, - lock: None, - language: ScriptLang::Postgresql, - cache_ttl: None, - cache_ignore_s3_path: None, - dedicated_worker: None, - concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() - .into(), - debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), - modules: None, - tag: None, - })) - .arg("database", db_arg.clone()) - .run_until_complete(&db, false, port) - .await - .json_result() - .unwrap(); + let setup_id = make_pg_job(setup.to_owned()).push(&db).await; // Each case: (name, content, args, expected_result) let cases: Vec<(&str, String, serde_json::Value, serde_json::Value)> = vec![ @@ -2742,29 +2722,22 @@ SELECT 'price: $5' AS lbl, $5::int + $50::int AS sum"# ), ]; - for (name, content, args, expected) in cases { - let mut job = RunJob::from(JobPayload::Code(RawCode { - hash: None, - content, - path: None, - lock: None, - language: ScriptLang::Postgresql, - cache_ttl: None, - cache_ignore_s3_path: None, - dedicated_worker: None, - concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default( - ) - .into(), - debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), - modules: None, - tag: None, - })) - .arg("database", db_arg.clone()); + let mut ids = vec![setup_id]; + for (_, content, args, _) in &cases { + let mut job = make_pg_job(content.clone()); for (k, v) in args.as_object().unwrap() { job = job.arg(k, v.clone()); } - let result = job - .run_until_complete(&db, false, port) + ids.push(job.push(&db).await); + } + run_on_one_worker(&db, port, &ids).await; + + completed_job(setup_id, &db) + .await + .json_result() + .expect("setup: no json result"); + for ((name, _, _, expected), id) in cases.into_iter().zip(&ids[1..]) { + let result = completed_job(*id, &db) .await .json_result() .unwrap_or_else(|| panic!("case '{name}': no json result")); @@ -2774,6 +2747,32 @@ SELECT 'price: $5' AS lbl, $5::int + $50::int AS sum"# Ok(()) } +/// Runs the already pushed `ids` on a single worker and returns once all completed. One worker +/// takes them one at a time in push order, so a setup job pushed first runs before the rest, +/// and they all share the process-wide PG connection cache as consecutive jobs would. +async fn run_on_one_worker(db: &Pool, port: u16, ids: &[Uuid]) { + use futures::StreamExt; + use windmill_common::worker::Connection; + + let mut listener = listen_for_completed_jobs(db).await; + let mut remaining = ids + .iter() + .copied() + .collect::>(); + in_test_worker( + Connection::Sql(db.clone()), + async { + while !remaining.is_empty() { + if let Some(id) = listener.next().await { + remaining.remove(&id); + } + } + }, + port, + ) + .await; +} + /// Pooler-safety regression test for #8988: when every arg has a resolvable /// otyp (the common SDK case), the dispatch must use unnamed prepared /// statements (`query_typed_raw`) and *must not* leak named statements @@ -3010,11 +3009,7 @@ CREATE SCHEMA wm_pg_cached_test; CREATE TYPE wm_pg_cached_test.color AS ENUM ('red', 'green', 'blue'); CREATE DOMAIN wm_pg_cached_test.short_name AS TEXT CHECK (length(VALUE) BETWEEN 1 AND 10); "#; - make_pg_job(setup.to_owned()) - .run_until_complete(&db, false, port) - .await - .json_result() - .unwrap(); + let setup_id = make_pg_job(setup.to_owned()).push(&db).await; // Run a long alternating sequence of enum + domain queries on the same // cached connection. Each query goes through the prepare-fallback path @@ -3022,21 +3017,36 @@ CREATE DOMAIN wm_pg_cached_test.short_name AS TEXT CHECK (length(VALUE) BETWEEN // tokio_postgres' typeinfo cache to resolve the Oids server-side. Pre-fix // this would fail intermittently with `prepared statement "sN" does not // exist` after the first cached-conn reuse. + let mut pairs = Vec::with_capacity(10); for i in 0..10 { - let r = make_pg_job("-- $1 arg1\nSELECT $1::wm_pg_cached_test.color AS c".to_owned()) + let enum_id = make_pg_job("-- $1 arg1\nSELECT $1::wm_pg_cached_test.color AS c".to_owned()) .arg("arg1", json!(if i % 2 == 0 { "red" } else { "blue" })) - .run_until_complete(&db, false, port) + .push(&db) .await; + let domain_id = + make_pg_job("-- $1 arg1\nSELECT $1::wm_pg_cached_test.short_name AS s".to_owned()) + .arg("arg1", json!(format!("v{i}"))) + .push(&db) + .await; + pairs.push((enum_id, domain_id)); + } + let mut ids = vec![setup_id]; + ids.extend(pairs.iter().flat_map(|(e, d)| [*e, *d])); + run_on_one_worker(&db, port, &ids).await; + + completed_job(setup_id, &db) + .await + .json_result() + .expect("setup: no json result"); + for (i, (enum_id, domain_id)) in pairs.into_iter().enumerate() { + let r = completed_job(enum_id, &db).await; assert!( r.success, "iter {i} enum query failed (was the DISCARD ALL bug); result: {:?}", r.result ); - let r = make_pg_job("-- $1 arg1\nSELECT $1::wm_pg_cached_test.short_name AS s".to_owned()) - .arg("arg1", json!(format!("v{i}"))) - .run_until_complete(&db, false, port) - .await; + let r = completed_job(domain_id, &db).await; assert!( r.success, "iter {i} domain query failed (was the DISCARD ALL bug); result: {:?}", @@ -4698,6 +4708,224 @@ async fn test_run_wait_result_early_return_with_failure_module( Ok(()) } +/// An early-return node inside a branch to one resolves while the flow is still running when its +/// branch is taken, and falls back to the flow's final result when it is not. A nested branch to +/// all resolves to its list, though its status is not in the root flow's modules. +#[cfg(all(feature = "deno_core", feature = "quickjs"))] +#[sqlx::test(fixtures("base"))] +async fn test_run_wait_result_early_return_inside_branch_one( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let returns = |v: &str, sleep_ms: u64| { + json!({ + "type": "rawscript", + "language": "deno", + "input_transforms": {}, + "content": format!( + "export async function main() {{ await new Promise((r) => setTimeout(r, {sleep_ms})); return '{v}' }}" + ), + }) + }; + let flow: FlowValue = serde_json::from_value(json!({ + "modules": [ + { + "id": "a", + "value": { + "type": "branchone", + "branches": [{ "expr": "flow_input.take", "modules": [ + { + "id": "b", + "value": { + "type": "branchone", + "branches": [], + "default": [{ "id": "c", "value": returns("c", 0) }], + }, + }, + { + "id": "e", + "value": { + "type": "branchall", + "branches": [{ "modules": [{ "id": "f", "value": returns("f", 0) }] }], + }, + }, + ] }], + "default": [], + }, + }, + { "id": "d", "value": returns("d", 3000) }, + ], + })) + .unwrap(); + + for (early_return, take, expected) in [ + ("c", true, json!("c")), + ("c", false, json!("d")), + ("e", true, json!(["f"])), + ] { + let mut flow = flow.clone(); + flow.early_return = Some(early_return.to_string()); + let id = + RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None }) + .arg("take", json!(take)) + .push(&db) + .await; + + // The completion check runs inside the worker's lifetime: the worker finishes `d` before + // quitting, so checking afterwards would always see the flow completed. + let (resp, flow_completed) = in_test_worker( + windmill_common::worker::Connection::Sql(db.clone()), + async { + let resp = windmill_api::jobs::run_wait_result( + &db, + id, + "test-workspace", + Some(early_return.to_string()), + false, + "test-user", + ) + .await + .unwrap(); + let completed: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM v2_job_completed WHERE id = $1)", + ) + .bind(id) + .fetch_one(&db) + .await + .unwrap(); + (resp, completed) + }, + port, + ) + .await; + // Only the taken branch returns before `d` finishes sleeping. + assert_eq!(flow_completed, !take, "{early_return}, take = {take}"); + + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await?; + let body: serde_json::Value = serde_json::from_slice(&bytes)?; + assert_eq!(expected, body, "{early_return}, take = {take}"); + } + + Ok(()) +} + +/// The step whose stream `run_and_stream` follows: the early-return step inside branches to one +/// and to all, the last step when the early-return step's branch is not taken, and neither when +/// the early-return step ran inside a loop. Only a deployed flow reads `early_return` here. +#[cfg(all(feature = "deno_core", feature = "quickjs"))] +#[sqlx::test(fixtures("base"))] +async fn test_early_return_stream_job_inside_branches(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let streams = |v: &str| { + json!({ + "type": "rawscript", + "language": "deno", + "input_transforms": {}, + "content": format!( + "export async function main() {{ console.log('WM_STREAM: {v}'); await new Promise((r) => setTimeout(r, 1000)); return '{v}' }}" + ), + }) + }; + let modules = json!([ + { + "id": "a", + "value": { + "type": "branchone", + "branches": [{ "expr": "flow_input.take", "modules": [ + { + "id": "b", + "value": { + "type": "branchall", + "branches": [{ "modules": [{ "id": "c", "value": streams("c") }] }], + }, + }, + { + "id": "l", + "value": { + "type": "forloopflow", + "iterator": { "type": "javascript", "expr": "[1]" }, + "skip_failures": false, + // Two steps, so the loop body is not inlined into the iteration job. + "modules": [ + { "id": "j", "value": streams("j") }, + { "id": "m", "value": { "type": "identity" } }, + ], + }, + }, + ] }], + "default": [], + }, + }, + { "id": "d", "value": streams("d") }, + ]); + + for (version, early_return, take, expected) in [ + (1, "c", true, Some("c")), + (2, "c", false, Some("d")), + (3, "j", true, None), + ] { + let path = format!("f/system/early_return_stream_{version}"); + let value = json!({ "modules": modules, "early_return": early_return }); + sqlx::query( + "INSERT INTO flow(workspace_id, summary, description, path, versions, schema, value, edited_by) VALUES ('test-workspace', '', '', $1, ARRAY[$2]::bigint[], '{}'::jsonb, $3, 'system')", + ) + .bind(&path) + .bind(version) + .bind(&value) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO flow_version(id, workspace_id, path, schema, value, created_by) VALUES ($1, 'test-workspace', $2, '{}'::jsonb, $3, 'system')", + ) + .bind(version) + .bind(&path) + .bind(&value) + .execute(&db) + .await?; + RunJob::from(JobPayload::FlowDependencies { + path: path.clone(), + dedicated_worker: None, + version, + debouncing_settings: Default::default(), + }) + .run_until_complete(&db, false, port) + .await; + + let completed = RunJob::from(JobPayload::Flow { + path, + dedicated_worker: None, + apply_preprocessor: false, + labels: None, + version, + job_token_scopes: None, + }) + .arg("take", json!(take)) + .run_until_complete(&db, false, port) + .await; + + let stream_step: Option = sqlx::query_scalar( + "SELECT flow_step_id FROM v2_job + WHERE id = (SELECT (flow_status->>'stream_job')::uuid FROM v2_job_completed WHERE id = $1)", + ) + .bind(completed.id) + .fetch_optional(&db) + .await?; + assert_eq!( + expected.map(String::from), + stream_step, + "{early_return}, take = {take}" + ); + } + + Ok(()) +} + /// `recover: true` from the error handler of a step that fails inside a loop turns the flow /// green, as it does for a top-level step, without changing which steps run: a loop that /// stops at a failed iteration still stops there, one that skips failures still carries on. @@ -5634,42 +5862,6 @@ async fn test_flow_schedule_handlers(db: Pool) -> anyhow::Result<()> { Ok(()) } -#[sqlx::test(fixtures("base", "relative_bun"))] -async fn test_relative_imports_bun(db: Pool) -> anyhow::Result<()> { - let content = r#" -import { main as test1 } from "/f/system/same_folder_script.ts"; -import { main as test2 } from "./same_folder_script.ts"; -import { main as test3 } from "/f/system_relative/different_folder_script.ts"; -import { main as test4 } from "../system_relative/different_folder_script.ts"; - -export async function main() { - return [test1(), test2(), test3(), test4()]; -} -"# - .to_string(); - - run_deployed_relative_imports(&db, content.clone(), ScriptLang::Bun).await?; - run_preview_relative_imports(&db, content, ScriptLang::Bun).await?; - Ok(()) -} - -#[cfg(feature = "deno_core")] -#[sqlx::test(fixtures("base", "relative_bun"))] -async fn test_nested_imports_bun(db: Pool) -> anyhow::Result<()> { - let content = r#" -import { main as test } from "/f/system_relative/nested_script.ts"; - -export async function main() { - return test(); -} -"# - .to_string(); - - run_deployed_relative_imports(&db, content.clone(), ScriptLang::Bun).await?; - run_preview_relative_imports(&db, content, ScriptLang::Bun).await?; - Ok(()) -} - #[sqlx::test(fixtures("base", "relative_deno"))] async fn test_relative_imports_deno(db: Pool) -> anyhow::Result<()> { let content = r#" diff --git a/backend/tests/workspace_dependencies.rs b/backend/tests/workspace_dependencies.rs index 773a4fd965..8136416190 100644 --- a/backend/tests/workspace_dependencies.rs +++ b/backend/tests/workspace_dependencies.rs @@ -1,23 +1,10 @@ mod workspace_dependencies { use sqlx::{Pool, Postgres}; - use tokio_stream::StreamExt; use windmill_common::scripts::ScriptLang; use windmill_dep_map::workspace_dependencies::NewWorkspaceDependencies; - use windmill_test_utils::in_test_worker; - use windmill_test_utils::init_client; - use windmill_test_utils::listen_for_completed_jobs; - mod deps { - pub const REQUIREMENTS_IN: &'static str = "tiny==0.1.3"; - // pub const GO_MOD: &'static str = r##" - // module example.com/project - // go 1.20 - - // require github.com/gin-gonic/gin v1.8.1 - // "##; - - pub const PACKAGE_JSON: &'static str = r##" + const PACKAGE_JSON: &'static str = r##" { "name": "example-project", "version": "1.0.0", @@ -27,101 +14,13 @@ mod workspace_dependencies { } "##; - pub const COMPOSER_JSON: &'static str = r##" - { - "name": "example/project", - "require": { - "monolog/monolog": "^2.3" - } - } - "##; - } - - #[cfg(feature = "python")] - #[sqlx::test(fixtures("base", "workspace_dependencies_leafs"))] - #[ignore] - async fn basic_manual_named(db: Pool) -> anyhow::Result<()> { - let ((_client, port, _s), db, mut completed) = ( - init_client(db.clone()).await, - &db, - listen_for_completed_jobs(&db).await, - ); - - for (idx, (l, c)) in [ - (ScriptLang::Python3, deps::REQUIREMENTS_IN), - (ScriptLang::Bun, deps::PACKAGE_JSON), - (ScriptLang::Php, deps::COMPOSER_JSON), - // (ScriptLang::Go, deps::GO_MOD), - ] - .iter() - .enumerate() - { - let id = NewWorkspaceDependencies { - workspace_id: "test-workspace".into(), - language: *l, - content: (*c).into(), - name: Some("test".to_owned()), - description: None, - } - .create(("".to_owned(), "".to_owned(), "".to_owned()), db.clone()) - .await - .unwrap(); - - assert_eq!(idx + 1, id as usize); - } - - // Wait for 4 jobs. - // Creating those dependencies will trigger redeployment of all scripts in workspace_dependencies_leafs.sql - in_test_worker( - db, - async { - completed.next().await; - completed.next().await; - completed.next().await; - // completed.next().await; - }, - port, - ) - .await; - - // Verify all scripts have correct locks - // let mut langs = vec![]; - // for r in sqlx::query!( - // r#"SELECT language AS "language: ScriptLang",lock FROM script WHERE archived = false"# - // ) - // .fetch_all(db) - // .await - // .unwrap() - // { - // match r.language { - // ScriptLang::Python3 => assert_eq!("", &r.lock.unwrap()), - // ScriptLang::Go => todo!(), - // ScriptLang::Bun => todo!(), - // ScriptLang::Bunnative => todo!(), - // ScriptLang::Php => todo!(), - // _ => panic!("Unsupported language"), - // } - - // langs.push(r.language); - // } - - // langs.sort(); - // // Just tiny additional verification for peace of mind. - // assert_eq!(langs.as_slice(), &[]); - - Ok(()) - } - #[sqlx::test(fixtures("base", "hub_sync_blacklist"))] async fn hub_sync_blacklist_from_workspace_deps(db: Pool) -> anyhow::Result<()> { - let ((_client, port, _s), db, mut completed) = ( - init_client(db.clone()).await, - &db, - listen_for_completed_jobs(&db).await, - ); + // The first unnamed workspace dependencies wait out the existence cache before + // redeploying their dependents. Read once per process, and this binary has no other test. + std::env::set_var("EXISTS_CACHE_TIMEOUT_MS", "0"); + let db = &db; - // Verify built-in fixtures exist - // Check that hub_sync script exists and is a Bun script let hub_sync_lang = sqlx::query_scalar!( r#"SELECT language AS "language: ScriptLang" FROM script WHERE path = 'u/admin/hub_sync'"# ) @@ -134,51 +33,55 @@ mod workspace_dependencies { "Expected hub_sync to be a Bun script" ); - // Create unnamed (default) workspace dependencies for Bun - let _id = NewWorkspaceDependencies { + // Unnamed (default) workspace dependencies for Bun + NewWorkspaceDependencies { workspace_id: "admins".into(), language: ScriptLang::Bun, - content: deps::PACKAGE_JSON.into(), - name: None, // No name = default workspace dependencies + content: PACKAGE_JSON.into(), + name: None, description: None, } .create(("".to_owned(), "".to_owned(), "".to_owned()), db.clone()) .await .unwrap(); - // Wait for exactly 1 job (only u/admin/simple_bun, not hub_sync) - let job_id = in_test_worker(db, async { completed.next().await }, port) - .await - .expect("Expected one job to complete"); - - // Query the job's runnable_path - let runnable_path = - sqlx::query_scalar!("SELECT runnable_path FROM v2_job WHERE id = $1", job_id) - .fetch_one(db) - .await - .unwrap(); - + // The dependency map decides which runnables are redeployed, and is rebuilt before + // `create` returns. + let importers: Vec = sqlx::query_scalar( + "SELECT importer_path FROM dependency_map + WHERE workspace_id = 'admins' AND imported_path = 'dependencies/package.json' + ORDER BY importer_path", + ) + .fetch_all(db) + .await?; assert_eq!( - runnable_path, - Some("u/admin/simple_bun".to_string()), - "Expected job runnable_path to be 'u/admin/simple_bun' (hub_sync should be blacklisted)" + importers, + vec!["u/admin/simple_bun".to_string()], + "hub_sync must not be recorded as importing the default workspace dependencies" ); - // Assert total job count is 1 - let job_count = sqlx::query_scalar!("SELECT COUNT(*) FROM v2_job") - .fetch_one(db) - .await - .unwrap(); - - assert_eq!(job_count, Some(1), "Expected exactly one job total"); - - // Assert v2_job_queue is empty - let queue_count = sqlx::query_scalar!("SELECT COUNT(*) FROM v2_job_queue") - .fetch_one(db) - .await - .unwrap(); - - assert_eq!(queue_count, Some(0), "Expected job queue to be empty"); + // The redeploy is pushed in the background as a debounced dependency job; it never needs + // to run for what is asserted here. + let mut jobs: Vec<(Option, String)> = vec![]; + for _ in 0..200 { + jobs = sqlx::query_as( + "SELECT runnable_path, kind::text FROM v2_job WHERE workspace_id = 'admins'", + ) + .fetch_all(db) + .await?; + if !jobs.is_empty() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + assert_eq!( + jobs, + vec![( + Some("u/admin/simple_bun".to_string()), + "dependencies".to_string() + )], + "Expected exactly one dependency job, for u/admin/simple_bun (hub_sync should be blacklisted)" + ); Ok(()) } diff --git a/backend/tests/workspace_fairness.rs b/backend/tests/workspace_fairness.rs index f5922037e4..7f1e82782a 100644 --- a/backend/tests/workspace_fairness.rs +++ b/backend/tests/workspace_fairness.rs @@ -46,6 +46,7 @@ use windmill_common::worker::{ WORKSPACE_FAIRNESS_MIN_TOTAL, WORKSPACE_FAIRNESS_OVERLOADED, }; use windmill_queue::workspace_fairness::refresh_overloaded; +use windmill_test_utils::ensure_today_audit_partition; // --------------------------------------------------------------------------- // Shared helpers @@ -331,22 +332,6 @@ async fn fairness_lifts_when_load_drops(db: Pool) { assert!(overloaded_set().is_empty()); } -/// Ensure today's audit_partitioned partition exists — the migration only -/// creates partitions for the day it ran + 3 days, after which production -/// relies on `monitor::manage_audit_partitions` to roll new ones. That -/// maintenance task does not run in the test binary, so inserts silently -/// fail-and-warn without it. -async fn ensure_today_audit_partition(db: &Pool) { - let today: chrono::NaiveDate = chrono::Utc::now().date_naive(); - let next = today + chrono::Duration::days(1); - let partition = format!("audit_{}", today.format("%Y%m%d")); - let sql = format!( - "CREATE TABLE IF NOT EXISTS \"{partition}\" PARTITION OF audit_partitioned \ - FOR VALUES FROM ('{today}') TO ('{next}')" - ); - let _ = sqlx::query(&sql).execute(db).await; -} - /// Both cap AND uncap transitions must produce audit-log rows. This test /// drives a cap → uncap cycle and inspects `audit_partitioned` directly. #[sqlx::test(fixtures("base"))] diff --git a/backend/tests/workspace_handlers.rs b/backend/tests/workspace_handlers.rs new file mode 100644 index 0000000000..0ad1e044bd --- /dev/null +++ b/backend/tests/workspace_handlers.rs @@ -0,0 +1,422 @@ +#![cfg(all(feature = "enterprise", feature = "private"))] + +use sqlx::{Pool, Postgres}; + +use windmill_test_utils::*; + +const ERROR_HANDLER_EMAIL: &str = "error_handler@windmill.dev"; +const SUCCESS_HANDLER_EMAIL: &str = "success_handler@windmill.dev"; + +/// Every test seeds `f/test/error_handler` or `f/test/success_handler` with a different hash, +/// each in its own database, while the deployed-script caches are process-global and keyed by +/// workspace and path. With them on, one test resolves the handler to another's hash and the +/// handler push 404s whenever that hash's info is not cached yet. +fn disable_deployed_script_cache() { + windmill_common::DEPLOYED_SCRIPT_CACHE_DISABLED + .store(true, std::sync::atomic::Ordering::Relaxed); +} + +/// Polls so the assertion does not depend on the handler being pushed before +/// `run_until_complete` returns, which only holds while the worker drains its completions on exit. +async fn wait_for_handler_job( + db: &Pool, + handler_email: &str, +) -> anyhow::Result, Option)>> { + for _ in 0..100 { + let job = sqlx::query_as::<_, (Option, Option)>( + "SELECT runnable_path, parent_job FROM v2_job + WHERE workspace_id = 'test-workspace' AND permissioned_as_email = $1", + ) + .bind(handler_email) + .fetch_optional(db) + .await?; + if job.is_some() { + return Ok(job); + } + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + Ok(None) +} + +#[cfg(feature = "deno_core")] +async fn insert_bun_script( + db: &Pool, + hash: i64, + path: &str, + content: &str, + ws_error_handler_muted: bool, +) -> anyhow::Result<()> { + // The lock must be non-null for the script to count as deployed. + sqlx::query( + r#"INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock, ws_error_handler_muted) + VALUES ('test-workspace', $1, $2, $3, 'bun', 'script', 'test-user', '{}', '', '', E'{}\n//bun.lock\n', $4)"#, + ) + .bind(hash) + .bind(path) + .bind(content) + .bind(ws_error_handler_muted) + .execute(db) + .await?; + Ok(()) +} + +/// Configures `f/test/error_handler` and `f/test/success_handler` as the workspace handlers. +/// Both settings are cached per workspace id across the tests of this binary, and any test's +/// top-level job can fill either cache, so every test configures both, with the same paths. +async fn configure_workspace_handlers(db: &Pool) -> anyhow::Result<()> { + sqlx::query( + r#"UPDATE workspace_settings + SET error_handler = '{"path": "script/f/test/error_handler"}'::jsonb, + success_handler = '{"path": "script/f/test/success_handler"}'::jsonb + WHERE workspace_id = 'test-workspace'"#, + ) + .execute(db) + .await?; + sqlx::query( + r#"INSERT INTO group_ (workspace_id, name, summary, extra_perms) + VALUES ('test-workspace', 'error_handler', '', '{"u/test-user": true}'), + ('test-workspace', 'success_handler', '', '{"u/test-user": true}') + ON CONFLICT DO NOTHING"#, + ) + .execute(db) + .await?; + Ok(()) +} + +/// Runs a deployed script by hash, which makes a `script` job rather than a preview. +#[cfg(feature = "deno_core")] +async fn run_script_hash(db: &Pool, port: u16, hash: i64, path: &str) -> CompletedJob { + use windmill_common::jobs::JobPayload; + use windmill_common::runnable_settings::{ConcurrencySettings, DebouncingSettings}; + use windmill_common::scripts::{ScriptHash, ScriptLang}; + + RunJob::from(JobPayload::ScriptHash { + hash: ScriptHash(hash), + path: 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, + }) + .run_until_complete(db, false, port) + .await +} + +/// A job's handler, if any, is pushed before the worker that ran it exits, so once a control +/// job has been handled, every handler job that the jobs run before it would produce exists. +#[cfg(feature = "deno_core")] +async fn assert_only_handled( + db: &Pool, + control_job: uuid::Uuid, + message: &str, +) -> anyhow::Result<()> { + assert!( + wait_for_handler_job(db, ERROR_HANDLER_EMAIL) + .await? + .is_some(), + "Error handler should have handled the control failure" + ); + let handled = sqlx::query_scalar::<_, Option>( + "SELECT parent_job FROM v2_job + WHERE workspace_id = 'test-workspace' AND permissioned_as_email = $1", + ) + .bind(ERROR_HANDLER_EMAIL) + .fetch_all(db) + .await?; + assert_eq!(handled, vec![Some(control_job)], "{message}"); + Ok(()) +} + +/// Test that a failed job triggers the workspace error handler +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +async fn test_error_handler_triggered_on_failure(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + disable_deployed_script_cache(); + + let server = ApiServer::start(db.clone()).await?; + + insert_bun_script( + &db, + 1111111111, + "f/test/error_handler", + r#" +export async function main(path: string, email: string, job_id: string, is_flow: boolean, workspace_id: string, error: any) { + console.log("Error handler called for job:", job_id); + return { handled: true, original_path: path }; +} +"#, + false, + ) + .await?; + insert_bun_script( + &db, + 2222222222, + "f/test/failing_script", + "export function main() { throw new Error('intentional failure'); }", + false, + ) + .await?; + configure_workspace_handlers(&db).await?; + + let completed_job = + run_script_hash(&db, server.addr.port(), 2222222222, "f/test/failing_script").await; + assert!(!completed_job.success, "Job should have failed"); + + assert_eq!( + wait_for_handler_job(&db, ERROR_HANDLER_EMAIL).await?, + Some(( + Some("f/test/error_handler".to_string()), + Some(completed_job.id) + )), + "Error handler should run the configured script with the failed job as parent" + ); + + Ok(()) +} + +/// Test that error handler is NOT triggered when ws_error_handler_muted is set on the script +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +async fn test_error_handler_muted_on_script(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + disable_deployed_script_cache(); + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + insert_bun_script( + &db, + 3333333333, + "f/test/error_handler", + r#"export function main() { return "handled"; }"#, + false, + ) + .await?; + let failing = r#"export function main() { throw new Error("fail"); }"#; + insert_bun_script( + &db, + 4444444444, + "f/test/muted_failing_script", + failing, + true, + ) + .await?; + insert_bun_script(&db, 4444444445, "f/test/failing_script", failing, false).await?; + configure_workspace_handlers(&db).await?; + + let muted_job = run_script_hash(&db, port, 4444444444, "f/test/muted_failing_script").await; + assert!(!muted_job.success, "Job should have failed"); + + let control_job = run_script_hash(&db, port, 4444444445, "f/test/failing_script").await; + assert!(!control_job.success, "Job should have failed"); + + assert_only_handled( + &db, + control_job.id, + "Error handler should NOT have been triggered for a muted script", + ) + .await +} + +/// Test that error handler is NOT triggered on successful job completion +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +async fn test_error_handler_not_triggered_on_success(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + disable_deployed_script_cache(); + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + insert_bun_script( + &db, + 5555555555, + "f/test/error_handler", + r#"export function main() { return "handled"; }"#, + false, + ) + .await?; + insert_bun_script( + &db, + 6666666666, + "f/test/success_script", + r#"export function main() { return "ok"; }"#, + false, + ) + .await?; + insert_bun_script( + &db, + 6666666667, + "f/test/failing_script", + r#"export function main() { throw new Error("fail"); }"#, + false, + ) + .await?; + configure_workspace_handlers(&db).await?; + + let success_job = run_script_hash(&db, port, 6666666666, "f/test/success_script").await; + assert!(success_job.success, "Job should have succeeded"); + + let control_job = run_script_hash(&db, port, 6666666667, "f/test/failing_script").await; + assert!(!control_job.success, "Job should have failed"); + + assert_only_handled( + &db, + control_job.id, + "Error handler should NOT have been triggered for a successful job", + ) + .await +} + +/// A hub script run at the top level (a schedule, a trigger, the API) is a `script_hub` job, +/// and its failure must reach the workspace error handler like a workspace script's. +#[sqlx::test(fixtures("base"))] +async fn test_error_handler_triggered_on_hub_script_failure( + db: Pool, +) -> anyhow::Result<()> { + use windmill_common::jobs::JobPayload; + + initialize_tracing().await; + disable_deployed_script_cache(); + + let server = ApiServer::start(db.clone()).await?; + + let version = "990000002"; + let hub_dir = &*windmill_common::worker::HUB_CACHE_DIR; + tokio::fs::create_dir_all(hub_dir).await?; + tokio::fs::write( + format!("{hub_dir}/{version}"), + r#"{"content":"exit 1","lockfile":null,"language":"bash","schema":{},"summary":null}"#, + ) + .await?; + + sqlx::query( + "INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock) + VALUES ('test-workspace', 1111111112, 'f/test/error_handler', 'echo handled', 'bash', 'script', 'test-user', '{}', '', '', '')", + ) + .execute(&db) + .await?; + configure_workspace_handlers(&db).await?; + + let completed_job = RunJob::from(JobPayload::ScriptHub { + path: format!("hub/{version}/test/fail"), + apply_preprocessor: false, + }) + .run_until_complete(&db, false, server.addr.port()) + .await; + assert!(!completed_job.success, "Hub script should have failed"); + + let handler_job = wait_for_handler_job(&db, ERROR_HANDLER_EMAIL).await?; + assert_eq!( + handler_job.map(|(_, parent)| parent), + Some(Some(completed_job.id)), + "Workspace error handler should handle the failed hub script" + ); + + Ok(()) +} + +/// Test that a successful job completion triggers the success handler +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +async fn test_success_handler_triggered_on_success(db: Pool) -> anyhow::Result<()> { + use serde_json::json; + + initialize_tracing().await; + disable_deployed_script_cache(); + + let server = ApiServer::start(db.clone()).await?; + + insert_bun_script( + &db, + 1234567890, + "f/test/success_handler", + r#" +export async function main(path: string, email: string, job_id: string, is_flow: boolean, workspace_id: string, result: any) { + console.log("Success handler called for job:", job_id); + return { handled: true, original_path: path }; +} +"#, + false, + ) + .await?; + insert_bun_script( + &db, + 9876543210, + "f/test/simple_script", + "export function main() { return 'success'; }", + false, + ) + .await?; + configure_workspace_handlers(&db).await?; + + let completed_job = + run_script_hash(&db, server.addr.port(), 9876543210, "f/test/simple_script").await; + assert_eq!(completed_job.json_result().unwrap(), json!("success")); + + assert_eq!( + wait_for_handler_job(&db, SUCCESS_HANDLER_EMAIL).await?, + Some(( + Some("f/test/success_handler".to_string()), + Some(completed_job.id) + )), + "Success handler should run the configured script with the main job as parent" + ); + + Ok(()) +} + +/// A hub script run at the top level (a schedule, a trigger, the API) is a `script_hub` job, +/// and its success must reach the workspace success handler like a workspace script's. +#[sqlx::test(fixtures("base"))] +async fn test_success_handler_triggered_on_hub_script_success( + db: Pool, +) -> anyhow::Result<()> { + use windmill_common::jobs::JobPayload; + + initialize_tracing().await; + disable_deployed_script_cache(); + + let server = ApiServer::start(db.clone()).await?; + + let version = "990000003"; + let hub_dir = &*windmill_common::worker::HUB_CACHE_DIR; + tokio::fs::create_dir_all(hub_dir).await?; + tokio::fs::write( + format!("{hub_dir}/{version}"), + r#"{"content":"echo ok","lockfile":null,"language":"bash","schema":{},"summary":null}"#, + ) + .await?; + + sqlx::query( + "INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock) + VALUES ('test-workspace', 1234567891, 'f/test/success_handler', 'echo handled', 'bash', 'script', 'test-user', '{}', '', '', '')", + ) + .execute(&db) + .await?; + configure_workspace_handlers(&db).await?; + + let completed_job = RunJob::from(JobPayload::ScriptHub { + path: format!("hub/{version}/test/ok"), + apply_preprocessor: false, + }) + .run_until_complete(&db, false, server.addr.port()) + .await; + assert!(completed_job.success, "Hub script should have succeeded"); + + let handler_job = wait_for_handler_job(&db, SUCCESS_HANDLER_EMAIL).await?; + assert_eq!( + handler_job.map(|(_, parent)| parent), + Some(Some(completed_job.id)), + "Workspace success handler should handle the successful hub script" + ); + + Ok(()) +} diff --git a/backend/windmill-ai/Cargo.toml b/backend/windmill-ai/Cargo.toml index 44c4ac83c7..38f62fa16f 100644 --- a/backend/windmill-ai/Cargo.toml +++ b/backend/windmill-ai/Cargo.toml @@ -12,6 +12,8 @@ mcp = ["dep:windmill-mcp"] [lib] name = "windmill_ai" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [dependencies] windmill-common = { workspace = true, default-features = false } diff --git a/backend/windmill-ai/src/ai_providers.rs b/backend/windmill-ai/src/ai_providers.rs index 3b3af833d6..311c8d3eb7 100644 --- a/backend/windmill-ai/src/ai_providers.rs +++ b/backend/windmill-ai/src/ai_providers.rs @@ -5,6 +5,9 @@ use serde::{Deserialize, Deserializer, Serialize}; use windmill_common::db::DB; use windmill_common::error::{Error, Result}; +use windmill_common::ssrf::{PrivateHostAllowance, SsrfValidationError, ValidatedTarget}; + +const ALLOW_PRIVATE_AI_BASE_URLS_ENV: &str = "ALLOW_PRIVATE_AI_BASE_URLS"; /// Deserializes an Option where empty strings become None. /// Use with `#[serde(default, deserialize_with = "empty_string_as_none")]` @@ -20,10 +23,10 @@ where lazy_static::lazy_static! { static ref OPENAI_AZURE_BASE_PATH: Option = std::env::var("OPENAI_AZURE_BASE_PATH").ok(); - pub static ref ALLOW_PRIVATE_AI_BASE_URLS: bool = std::env::var("ALLOW_PRIVATE_AI_BASE_URLS") - .ok() - .map(|v| v == "true" || v == "1") - .unwrap_or(false); + /// Covers every AI base URL, whoever supplied it: the workspace or instance AI config, a + /// resource a member picked, or an inline resource value sent to the proxy. + pub static ref ALLOW_PRIVATE_AI_BASE_URLS: PrivateHostAllowance = + PrivateHostAllowance::from_env(ALLOW_PRIVATE_AI_BASE_URLS_ENV); /// Drops the cache breakpoints from agent-step requests on every Anthropic platform, /// not just the one that motivates it: a Google Cloud project can have explicit prompt /// caching turned off (by request to Cloud support), and Vertex then rejects any request @@ -131,6 +134,23 @@ pub enum AIProvider { Cloudflare, } +/// Check an AI endpoint URL against the SSRF rules, allowing the private hosts +/// `ALLOW_PRIVATE_AI_BASE_URLS` names. Callers that connect next pin the returned target. +pub async fn validate_ai_url(url: &str) -> Result { + windmill_common::ssrf::validate_url_for_ssrf_allowing(url, &ALLOW_PRIVATE_AI_BASE_URLS) + .await + .map_err(|e| match e { + // Only a private target is fixed by the env var; for a malformed URL or bad + // scheme the hint would send users the wrong way (issue #9171). + e @ SsrfValidationError::Private { .. } => Error::BadRequest(format!( + "{e}. To use a private/internal AI endpoint, add its host (host, host:port or \ + CIDR range) to the comma-separated {ALLOW_PRIVATE_AI_BASE_URLS_ENV} environment \ + variable, or set it to true to allow every private host" + )), + e => Error::from(e), + }) +} + /// The Workers AI models URL of a Cloudflare account. The id goes into the path, so it is held to /// the characters an account id has rather than trusted to stay inside its segment. pub fn cloudflare_workers_ai_base_url(account_id: Option<&str>) -> Result { @@ -161,22 +181,7 @@ impl AIProvider { /// Get the base URL for the AI provider pub async fn get_base_url(&self, resource_base_url: Option, db: &DB) -> Result { if let Some(base_url) = resource_base_url { - if !*ALLOW_PRIVATE_AI_BASE_URLS { - use windmill_common::ssrf::SsrfValidationError; - windmill_common::ssrf::validate_url_for_ssrf(&base_url) - .await - .map_err(|e| match e { - // The env-var hint is only actionable when the URL is - // well-formed but blocked for targeting a private - // address. For a malformed URL or bad scheme, surface - // the real error so users fix the URL (issue #9171). - e @ SsrfValidationError::Private { .. } => Error::BadRequest(format!( - "{e}. If you need to use private/internal AI endpoints, \ - set the ALLOW_PRIVATE_AI_BASE_URLS=true environment variable" - )), - e => Error::from(e), - })?; - } + validate_ai_url(&base_url).await?; return Ok(base_url); } diff --git a/backend/windmill-ai/src/providers/mod.rs b/backend/windmill-ai/src/providers/mod.rs index 7dcddc93a3..82dceaa02f 100644 --- a/backend/windmill-ai/src/providers/mod.rs +++ b/backend/windmill-ai/src/providers/mod.rs @@ -30,6 +30,11 @@ pub fn effective_reasoning_effort<'a>(model: &str, effort: Option<&'a str>) -> O } } +/// Whether Chat Completions refuses function tools while this model reasons. +pub(crate) fn completions_rejects_tools_with_reasoning(model: &str) -> bool { + reasoning_rule(model).is_some_and(|rule| rule.completions_tools_need_off) +} + /// Whether a request with function tools must send the off sentinel on Chat Completions: /// the model refuses tools there while it reasons, and does accept being turned off. pub(crate) fn completions_tools_need_reasoning_off(model: &str) -> bool { @@ -208,6 +213,12 @@ pub fn create_query_builder( AIProvider::AzureFoundry if AIProvider::is_anthropic_model(model) => Box::new( AnthropicQueryBuilder::new(AIProvider::AzureFoundry, AIPlatform::Standard), ), + // Foundry serves its OpenAI models on `/openai/v1/responses` too. `/chat/completions` + // takes function tools from these only with reasoning off, which gpt-6.1-sol and + // gpt-6-astra cannot do at all. + AIProvider::AzureFoundry if completions_rejects_tools_with_reasoning(model) => { + Box::new(OpenAIQueryBuilder::new(AIProvider::AzureFoundry)) + } // Only for forwarding through the API proxy, which is bearer auth on `/` as // TypeSafe and Cloudflare expect. Neither serves a chat route for its decision models, and // `run_agent` refuses both before a chat request could be built. @@ -408,6 +419,12 @@ mod parity_tests { "claude-sonnet-5", "v1/messages", ), + case( + AIProvider::AzureFoundry, + "https://example.services.ai.azure.com", + "gpt-6.1-sol", + "responses", + ), case( AIProvider::AzureOpenAI, "https://example.openai.azure.com/openai", diff --git a/backend/windmill-ai/src/proxy.rs b/backend/windmill-ai/src/proxy.rs index 887cab0f3e..ca6ee1b59b 100644 --- a/backend/windmill-ai/src/proxy.rs +++ b/backend/windmill-ai/src/proxy.rs @@ -19,6 +19,25 @@ pub struct ProxyBuildArgs<'a> { pub credentials: &'a ProviderCredentials, } +/// Refuses a client-supplied proxy path that could resolve outside the provider's base +/// path once appended to it. URL parsing treats `\` as `/`, drops tabs and newlines, trims +/// spaces and ends the path at `?` or `#`, so `.\t./`, `..\`, `.. ` and `..?x` climb a +/// segment like `../`. The path arrives decoded once, so any `%` left is a second encoding +/// (`%2e`, `%2f`) that the URL parser or a gateway in front of the provider may decode again. +pub fn validate_proxy_path(path: &str) -> Result<()> { + let invalid = || Error::BadRequest(format!("Invalid AI proxy path: {path}")); + if path + .chars() + .any(|c| matches!(c, '\\' | ' ' | '?' | '#' | '%') || c.is_control()) + || path + .split('/') + .any(|segment| segment == "." || segment == "..") + { + return Err(invalid()); + } + Ok(()) +} + /// Provider-specific request produced by proxy request builders. #[derive(Clone, Debug)] pub struct ProxyRequest { @@ -271,6 +290,39 @@ mod tests { } } + #[test] + fn proxy_path_refuses_dot_segments() { + for path in [ + "chat/completions", + "v1/messages", + "models", + "openai/deployments/gpt-4.1/chat/completions", + "a..b/c", + ] { + assert!(validate_proxy_path(path).is_ok(), "{path}"); + } + for path in [ + "../key/info", + "..", + "models/../../key/info", + "./x", + "%2e%2e/key/info", + "%2E./key/info", + ".%2E/key/info", + "..\\key/info", + ".\t./key/info", + "..\n/key/info", + ".. ", + "..?x=1", + "..#f", + "%2e%2e?x", + "..%2fkey/info", + "%2e%2e%2fkey", + ] { + assert!(validate_proxy_path(path).is_err(), "{path:?}"); + } + } + #[test] fn builds_openai_compatible_proxy_request() { let credentials = credentials(AIProvider::OpenRouter, "https://openrouter.ai/api/v1/"); diff --git a/backend/windmill-ai/src/utils.rs b/backend/windmill-ai/src/utils.rs index 328d2e0458..de7efda183 100644 --- a/backend/windmill-ai/src/utils.rs +++ b/backend/windmill-ai/src/utils.rs @@ -88,30 +88,17 @@ pub fn ai_http_client_builder() -> reqwest::ClientBuilder { /// after the check (DNS-rebinding TOCTOU). /// /// Returns the shared pooled [`AI_HTTP_CLIENT`] unchanged when there is nothing -/// to pin — an IP-literal host, or a deployment that opted into private AI -/// endpoints via `ALLOW_PRIVATE_AI_BASE_URLS`. The same opt-out and error hint -/// as `AIProvider::get_base_url` apply, so this is consistent with the +/// to pin — an IP-literal host, or a host `ALLOW_PRIVATE_AI_BASE_URLS` allows +/// by name or wholesale. The same allowlist and error hint as +/// `AIProvider::get_base_url` apply, so this is consistent with the /// credential-time validation while additionally closing the connect-time window. pub async fn pinned_ai_client_for( url: &str, ) -> windmill_common::error::Result> { use std::borrow::Cow; - use windmill_common::error::{to_anyhow, Error}; - use windmill_common::ssrf::SsrfValidationError; + use windmill_common::error::to_anyhow; - if *crate::ai_providers::ALLOW_PRIVATE_AI_BASE_URLS { - return Ok(Cow::Borrowed(&AI_HTTP_CLIENT)); - } - - let target = windmill_common::ssrf::validate_url_for_ssrf(url) - .await - .map_err(|e| match e { - e @ SsrfValidationError::Private { .. } => Error::BadRequest(format!( - "{e}. If you need to use private/internal AI endpoints, \ - set the ALLOW_PRIVATE_AI_BASE_URLS=true environment variable" - )), - e => Error::from(e), - })?; + let target = crate::ai_providers::validate_ai_url(url).await?; if target.pinned_addrs().is_empty() { return Ok(Cow::Borrowed(&AI_HTTP_CLIENT)); diff --git a/backend/windmill-alerting/Cargo.toml b/backend/windmill-alerting/Cargo.toml index 6f7352426e..a1c46edbb9 100644 --- a/backend/windmill-alerting/Cargo.toml +++ b/backend/windmill-alerting/Cargo.toml @@ -7,6 +7,9 @@ edition.workspace = true [lib] name = "windmill_alerting" path = "src/lib.rs" +# src/ has no unit tests or doctests: remove both lines before adding one, or cargo test never runs it. +test = false +doctest = false [dependencies] windmill-common = { workspace = true, default-features = false } diff --git a/backend/windmill-api-agent-workers/Cargo.toml b/backend/windmill-api-agent-workers/Cargo.toml index feabde8b12..4b3a202003 100644 --- a/backend/windmill-api-agent-workers/Cargo.toml +++ b/backend/windmill-api-agent-workers/Cargo.toml @@ -7,17 +7,20 @@ edition.workspace = true [lib] name = "windmill_api_agent_workers" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] enterprise = ["windmill-common/enterprise", "windmill-queue/enterprise"] -private = ["windmill-common/private", "windmill-queue/private"] +private = ["windmill-common/private", "windmill-queue/private", "windmill-audit/private"] python = ["dep:windmill-parser-py-imports"] benchmark = ["windmill-queue/benchmark"] [dependencies] windmill-api-auth.workspace = true windmill-common = { workspace = true, default-features = false } +windmill-audit.workspace = true windmill-queue.workspace = true windmill-worker.workspace = true axum.workspace = true diff --git a/backend/windmill-api-assets/Cargo.toml b/backend/windmill-api-assets/Cargo.toml index 62bc3d9c8d..5b43e3f2a5 100644 --- a/backend/windmill-api-assets/Cargo.toml +++ b/backend/windmill-api-assets/Cargo.toml @@ -7,6 +7,9 @@ edition.workspace = true [lib] name = "windmill_api_assets" path = "src/lib.rs" +# src/ has no unit tests or doctests: remove both lines before adding one, or cargo test never runs it. +test = false +doctest = false [features] private = ["windmill-common/private"] diff --git a/backend/windmill-api-auth/Cargo.toml b/backend/windmill-api-auth/Cargo.toml index 515a51cab7..1461e95237 100644 --- a/backend/windmill-api-auth/Cargo.toml +++ b/backend/windmill-api-auth/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_api_auth" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index 720111e89d..e278b644d5 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -6,6 +6,7 @@ use axum::{ }; use chrono::TimeZone; use http::{request::Parts, StatusCode}; +use itertools::Itertools; use quick_cache::sync::Cache; use serde::{Deserialize, Serialize}; use sqlx::FromRow; @@ -465,8 +466,8 @@ impl AuthCache { let lookup = if super_admin { Some((true, false)) } else { - sqlx::query!( - "SELECT is_admin, operator FROM usr where username = $1 AND \ + let row = sqlx::query!( + "SELECT is_admin, operator, workspace_role FROM usr where username = $1 AND \ workspace_id = $2 AND disabled = false", name, &w_id.as_ref().unwrap() @@ -474,8 +475,24 @@ impl AuthCache { .fetch_optional(&self.db) .await .ok() - .flatten() - .map(|r| (r.is_admin, r.operator)) + .flatten(); + match row { + Some(r) => { + // Fails closed: an unreadable role config + // gates the member rather than freeing it. + let restricted = + windmill_common::roles::member_is_restricted_db( + &self.db, + w_id.as_ref().unwrap(), + r.workspace_role, + r.operator, + ) + .await + .unwrap_or(true); + Some((r.is_admin, restricted && !r.is_admin)) + } + None => None, + } }; if let Some((is_admin, is_operator)) = lookup { @@ -595,15 +612,35 @@ impl AuthCache { username_override_from_label(label); if w_id.is_some() { let row_o = sqlx::query!( - "SELECT username, is_admin, operator FROM usr WHERE + "SELECT username, is_admin, operator, workspace_role FROM usr WHERE email = $1 AND workspace_id = $2 AND disabled = false", &email, w_id.as_ref().unwrap() ) - .map(|x| (x.username, x.is_admin, x.operator)) + .map(|x| (x.username, x.is_admin, x.operator, x.workspace_role)) .fetch_optional(&self.db) .await - .unwrap_or(Some(("error".to_string(), false, false))); + .unwrap_or(Some(("error".to_string(), false, false, None))); + let row_o = match row_o { + Some((username, is_admin, operator, workspace_role)) => { + // Fails closed, as above. + let restricted = + windmill_common::roles::member_is_restricted_db( + &self.db, + w_id.as_ref().unwrap(), + workspace_role, + operator, + ) + .await + .unwrap_or(true); + Some(( + username, + is_admin, + restricted && !is_admin && !super_admin, + )) + } + None => None, + }; match row_o { Some((username, is_admin, is_operator)) => { @@ -866,10 +903,9 @@ async fn admit_and_record_guest_jwt(db: &DB, w_id: &str, email: &str, app_path: return false; } GUEST_JWT_ACTIVITY_CACHE.insert(cache_key, ()); - // Audit last, best-effort, on its own connection: the EE writer swallows an - // `audit_partitioned` failure but that failing statement still aborts the - // transaction it runs in, so auditing before the commit would let the whole - // activity row roll back while this returned success, admitting an uncounted guest. + // Audit last, best-effort, on its own connection: a failed `audit_partitioned` + // insert aborts the transaction it runs in, so auditing before the commit would + // let an audit failure drop the activity row that counts this guest. if first_jwt { let author = windmill_common::audit::AuditAuthor { email: email.to_string(), @@ -1022,6 +1058,49 @@ where } } +/// Adds the suffixed form of each tag of an `if_jobs:filter_tags:` scope: in a dev workspace a +/// job allowed on `gpu` is queued on `gpu-dev`, and the token must still reach the jobs it starts. +fn expand_filter_tags_with_dev_suffix(scopes: &mut [String], suffix: &str) { + for scope in scopes.iter_mut() { + if let Some(tags) = scope.strip_prefix("if_jobs:filter_tags:") { + let expanded = tags + .split(',') + .flat_map(|t| { + let suffixed = windmill_common::worker::with_dev_workspace_suffix(t, suffix); + [t.to_string(), suffixed] + }) + .unique() + .join(","); + *scope = format!("if_jobs:filter_tags:{expanded}"); + } + } +} + +/// Only while isolation is on and for a workspace whose jobs get the suffix then: otherwise the +/// suffixed tags are other tags the token was not given. +async fn expand_filter_tags_for_dev_workspace( + db: &DB, + workspace_id: &str, + scopes: Option<&mut Vec>, +) { + let Some(scopes) = scopes else { return }; + if !windmill_common::worker::DEV_WORKSPACE_ISOLATION.load(std::sync::atomic::Ordering::Relaxed) + || !scopes.iter().any(|s| s.starts_with("if_jobs:filter_tags:")) + { + return; + } + let (is_dev, in_dev_lineage) = windmill_common::workspaces::dev_lineage(db, workspace_id) + .await + .unwrap_or(( + workspace_id.starts_with(windmill_common::workspaces::WM_FORK_PREFIX), + true, + )); + if windmill_common::worker::is_isolated_workspace(is_dev, in_dev_lineage) { + let suffix = windmill_common::worker::DEV_WORKSPACE_TAG_SUFFIX.load(); + expand_filter_tags_with_dev_suffix(scopes, &suffix); + } +} + pub fn transform_old_scope_to_new_scope(scopes: Option<&mut Vec>) { if let Some(scopes) = scopes { for scope in scopes.iter_mut() { @@ -1110,10 +1189,10 @@ pub async fn resolve_opt_job_authed( }; if is_no_auth() { - return Ok(( - OptJobAuthed { authed: no_auth_admin_authed(), job_id: None }, - parts, - )); + let authed = no_auth_admin_authed(); + // Handlers taking `Extension` read this insert, as on the token path. + parts.extensions.insert(authed.clone()); + return Ok((OptJobAuthed { authed, job_id: None }, parts)); } // Safe only while what the route checks below read is fixed before the first @@ -1172,6 +1251,14 @@ pub async fn resolve_opt_job_authed( let authed = &mut opt_job_authed.authed; if authed.scopes.is_some() && !own_job_runtime_route { transform_old_scope_to_new_scope(authed.scopes.as_mut()); + if let Some(w_id) = workspace_id.as_deref() { + expand_filter_tags_for_dev_workspace( + &cache.db, + w_id, + authed.scopes.as_mut(), + ) + .await; + } if let Err(err) = crate::scopes::check_scopes_for_route( authed.scopes.as_deref(), @@ -1192,6 +1279,7 @@ pub async fn resolve_opt_job_authed( // could approve an OAuth flow that mints a new non-read-only // token. let is_mcp_transport = path == "/api/mcp/gateway" + || path == "/api/mcp" || (path.starts_with("/api/mcp/w/") && (path.ends_with("/mcp") || path.ends_with("/sse") @@ -1359,6 +1447,19 @@ pub async fn list_tokens_internal( mod tests { use super::*; + #[test] + fn filter_tags_reach_their_dev_workspace_form() { + let mut scopes = vec![ + "jobs:read".to_string(), + "if_jobs:filter_tags:gpu,bash-dev".to_string(), + ]; + expand_filter_tags_with_dev_suffix(&mut scopes, "-dev"); + assert_eq!( + scopes, + vec!["jobs:read", "if_jobs:filter_tags:gpu,gpu-dev,bash-dev"] + ); + } + /// Dropping the `AUTH_CACHE` entry between the two resolutions is what makes a memo /// hit observable: only a working memo can answer the second one. #[tokio::test] diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index 30c3bb65af..bfbd4470c8 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -943,7 +943,7 @@ pub async fn gate_operator_writes( let w_id = params.get("workspace_id").ok_or_else(|| { Error::internal_err("operator write gate layered outside a workspaced router".to_string()) })?; - check_operator_can_manage(&db, w_id, authed.is_operator, kind).await?; + check_operator_can_manage(&db, w_id, &authed, kind).await?; Ok(next.run(Request::from_parts(parts, body)).await) } diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index 21f916bf94..bdbab1bfe7 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -73,7 +73,12 @@ pub fn check_route_access( .to_string(), )); } - if required_domain == ScopeDomain::Users && suffix != "users/whoami" { + if required_domain == ScopeDomain::Users { + // Whoami returns what the bundle already has in `window.ctx`, so it + // needs no declared scope. + if suffix == "users/whoami" { + return Ok(()); + } return Err(Error::PermissionDenied( "Access denied. A raw app frontend SDK token can only read the viewer's own identity." .to_string(), @@ -418,8 +423,9 @@ pub fn guest_session_scopes(app_path: &str) -> windmill_common::error::Result) -> bool { @@ -462,8 +468,9 @@ fn app_embed_route_denied(domain: ScopeDomain, suffix: &str) -> bool { } } -/// App routes a running app uses: its own definition (`apps/get/p/`, further -/// path-scoped by `apps:read:`) and the public app-serving endpoints +/// App routes a running app uses: its own definition (`apps/get/p/`, or +/// `apps/get/lite/` that the viewer runs from, both further path-scoped by +/// `apps:read:`) and the public app-serving endpoints /// (`apps_u/*`: public_app, public_resource, get_data, and the path-taking /// `execute_component` / `download_s3_file`, which re-check `apps:run|read:` /// in their handlers so they stay confined to this app). Everything else in the @@ -477,7 +484,9 @@ fn app_embed_apps_route_allowed(suffix: &str) -> bool { if suffix.starts_with("apps_u/embed_token") { return false; } - suffix.starts_with("apps/get/p/") || suffix.starts_with("apps_u/") + suffix.starts_with("apps/get/p/") + || suffix.starts_with("apps/get/lite/") + || suffix.starts_with("apps_u/") } /// Routes a guest session is denied: the app-embed allowlist, plus the embed-token @@ -937,11 +946,15 @@ fn is_global_read_open_to_job_token(route_path: &str) -> bool { /// not send. /// - tutorial progress upserts a UI bitfield keyed on the caller's own email. Its path /// serves a `GET` too, which the read list above carries. -const GLOBAL_WRITES_OPEN_TO_JOB_TOKEN: [&str; 4] = [ +/// - a Teams message from a job (the Teams error, recovery and success handlers) posts as +/// the instance-wide bot. The handler refuses a job token whose conversation is not a +/// channel of the team connected to the job's workspace. +const GLOBAL_WRITES_OPEN_TO_JOB_TOKEN: [&str; 5] = [ "/api/settings/test_object_storage_config", "/api/workspaces/exists", "/api/schedules/preview", "/api/users/tutorial_progress", + "/api/teams/activities", ]; /// Confines a job token (`$WM_TOKEN`) to routes that name a workspace. It is minted diff --git a/backend/windmill-api-client/Cargo.toml b/backend/windmill-api-client/Cargo.toml index 5d7ea274d9..ac326f9215 100644 --- a/backend/windmill-api-client/Cargo.toml +++ b/backend/windmill-api-client/Cargo.toml @@ -7,6 +7,9 @@ edition.workspace = true [lib] name = "windmill_api_client" path = "./src/lib.rs" +# src/ has no unit tests or doctests: remove both lines before adding one, or cargo test never runs it. +test = false +doctest = false [dependencies] diff --git a/backend/windmill-api-configs/Cargo.toml b/backend/windmill-api-configs/Cargo.toml index a24e0b9540..f8c1c48dab 100644 --- a/backend/windmill-api-configs/Cargo.toml +++ b/backend/windmill-api-configs/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_api_configs" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] diff --git a/backend/windmill-api-configs/src/lib.rs b/backend/windmill-api-configs/src/lib.rs index f1f4792e71..cfa74bd2f0 100644 --- a/backend/windmill-api-configs/src/lib.rs +++ b/backend/windmill-api-configs/src/lib.rs @@ -224,13 +224,15 @@ async fn update_config( #[cfg(not(feature = "enterprise"))] let config = if name.starts_with("worker__") { - // In CE, only allow setting worker_tags, cache_clear, init_bash, native_mode and paused + // In CE, only allow setting worker_tags, cache_clear, init_bash, native_mode, paused and + // disable_job_proxy serde_json::json!({ "worker_tags": config.get("worker_tags"), "cache_clear": config.get("cache_clear"), "init_bash": config.get("init_bash"), "native_mode": config.get("native_mode"), - "paused": config.get("paused") + "paused": config.get("paused"), + "disable_job_proxy": config.get("disable_job_proxy") }) } else { config @@ -273,18 +275,22 @@ async fn update_config( } let mut tx = db.begin().await?; - let was_paused = if name.starts_with("worker__") { + let previous = if name.starts_with("worker__") { sqlx::query_scalar!( - "SELECT COALESCE(config->'paused' = 'true'::jsonb, false) FROM config WHERE name = $1 FOR UPDATE", + "SELECT config FROM config WHERE name = $1 FOR UPDATE", &name ) .fetch_optional(&mut *tx) .await? .flatten() - .unwrap_or(false) } else { - false + None }; + let is_set = |c: Option<&serde_json::Value>, k: &str| { + c.and_then(|c| c.get(k)).and_then(|v| v.as_bool()) == Some(true) + }; + let was_paused = is_set(previous.as_ref(), "paused"); + let was_serving_dev = is_set(previous.as_ref(), "serves_dev_workspaces"); sqlx::query!( "INSERT INTO config (name, config) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET config = EXCLUDED.config", &name, @@ -306,7 +312,7 @@ async fn update_config( tx.commit().await?; if name.starts_with("worker__") { - let paused = config.get("paused").and_then(|v| v.as_bool()) == Some(true); + let paused = is_set(Some(&config), "paused"); if paused != was_paused { windmill_common::feature_usage::log_feature_usage( "worker_group", @@ -314,6 +320,14 @@ async fn update_config( if paused { "pause" } else { "resume" }, ); } + let serving_dev = is_set(Some(&config), "serves_dev_workspaces"); + if serving_dev != was_serving_dev { + windmill_common::feature_usage::log_feature_usage( + "worker_group", + "dev_workspaces_toggle", + if serving_dev { "serve" } else { "stop" }, + ); + } } Ok(format!("Updated config {name}")) } diff --git a/backend/windmill-api-debug/Cargo.toml b/backend/windmill-api-debug/Cargo.toml index c262ac3d32..8e3ce3daf0 100644 --- a/backend/windmill-api-debug/Cargo.toml +++ b/backend/windmill-api-debug/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_api_debug" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] diff --git a/backend/windmill-api-embeddings/Cargo.toml b/backend/windmill-api-embeddings/Cargo.toml index ff7c3435aa..411dc0efb0 100644 --- a/backend/windmill-api-embeddings/Cargo.toml +++ b/backend/windmill-api-embeddings/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_api_embeddings" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] diff --git a/backend/windmill-api-flow-conversations/Cargo.toml b/backend/windmill-api-flow-conversations/Cargo.toml index dfb927e92e..d9e0c743ee 100644 --- a/backend/windmill-api-flow-conversations/Cargo.toml +++ b/backend/windmill-api-flow-conversations/Cargo.toml @@ -7,6 +7,9 @@ edition.workspace = true [lib] name = "windmill_api_flow_conversations" path = "src/lib.rs" +# src/ has no unit tests or doctests: remove both lines before adding one, or cargo test never runs it. +test = false +doctest = false [dependencies] windmill-api-auth.workspace = true diff --git a/backend/windmill-api-flow-conversations/src/lib.rs b/backend/windmill-api-flow-conversations/src/lib.rs index 7320721ea5..2ed3531cca 100644 --- a/backend/windmill-api-flow-conversations/src/lib.rs +++ b/backend/windmill-api-flow-conversations/src/lib.rs @@ -14,7 +14,7 @@ pub use windmill_common::flow_conversations::FlowConversation; use windmill_common::{ db::{UserDB, DB}, error::{JsonResult, Result}, - flow_conversations::{running_turns, MessageType, RunningTurn}, + flow_conversations::{running_turns, MessageType, RunningTurn, ANONYMOUS_CONVERSATION_OWNER}, utils::{not_found_if_none, paginate, truncate_with_ellipsis, Pagination}, }; @@ -96,7 +96,8 @@ async fn list_conversations( "created_by", "is_test", ]) - .and_where_eq("workspace_id", "?".bind(&w_id)); + .and_where_eq("workspace_id", "?".bind(&w_id)) + .and_where_ne("created_by", "?".bind(&ANONYMOUS_CONVERSATION_OWNER)); if let Some(flow_path) = &query.flow_path { sqlb.and_where_eq("flow_path", "?".bind(flow_path)); diff --git a/backend/windmill-api-flows/Cargo.toml b/backend/windmill-api-flows/Cargo.toml index 3fc2c7b572..c0bb7ae5e2 100644 --- a/backend/windmill-api-flows/Cargo.toml +++ b/backend/windmill-api-flows/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_api_flows" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index eab73bc015..3ef3de7a33 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -19,6 +19,7 @@ use windmill_api_auth::{ build_scope_path_predicate, check_scopes, get_scope_tags, maybe_refresh_folders, require_owner_of_path, ApiAuthed, }; +use windmill_common::roles::AuthorLevel; use windmill_common::workspaces::{ check_deploy_rules, check_operator_can_build, operator_builder_rights, BuilderKind, RuleCheckResult, @@ -244,7 +245,7 @@ async fn list_flows( // Append the authed user's drafts at paths with no deployed flow; see scripts.rs. if lq.include_draft_only.unwrap_or(false) - && (!authed.is_operator || operator_builder_rights(&db, &w_id).await?.flows) + && operator_builder_rights(&db, &w_id, &authed).await?.flows && offset == 0 && lq.path_start.is_none() && lq.path_exact.is_none() @@ -576,6 +577,7 @@ async fn list_paths_linking_agent( async fn validate_flow( new_flow: &NewFlow, + flows_level: AuthorLevel, authed: &ApiAuthed, db: &DB, user_db: &UserDB, @@ -591,7 +593,7 @@ async fn validate_flow( guard_flow_from_debounce_data(new_flow).await?; - if authed.is_operator { + if flows_level == AuthorLevel::Compose { validate_operator_flow( &new_flow.parse_flow_value()?, &new_flow.tag, @@ -732,14 +734,8 @@ async fn create_flow( Path(w_id): Path, Json(mut nf): Json, ) -> Result<(StatusCode, String)> { - check_operator_can_build( - &db, - &w_id, - authed.is_operator, - BuilderKind::Flows, - "create flows", - ) - .await?; + let flows_level = + check_operator_can_build(&db, &w_id, &authed, BuilderKind::Flows, "create flows").await?; check_scopes(&authed, || format!("flows:write:{}", nf.path))?; // A `<= 0` flow timeout is "unset", not a 0-second limit that kills every run instantly. @@ -759,7 +755,7 @@ async fn create_flow( return Err(Error::PermissionDenied(msg)); } - validate_flow(&nf, &authed, &db, &user_db, &w_id).await?; + validate_flow(&nf, flows_level, &authed, &db, &user_db, &w_id).await?; if *CLOUD_HOSTED { let nb_flows = sqlx::query_scalar!("SELECT COUNT(*) FROM flow WHERE workspace_id = $1", &w_id) @@ -805,6 +801,21 @@ async fn create_flow( nf.preserve_on_behalf_of = Some(true); } } + // Before `tx` opens, as every `db` read here must be: `user_db` wraps the same pool, so + // taking a second connection while holding `tx` lets concurrent deploys starve it. + let resolved_on_behalf_of = windmill_common::resolve_on_behalf_of( + nf.on_behalf_of_email.as_deref(), + nf.on_behalf_of.as_deref(), + nf.preserve_on_behalf_of.unwrap_or(false), + &authed, + &w_id, + &db, + ) + .await?; + // Written beside the principal only while a worker that still reads it may be live. + let legacy_on_behalf_of_email = + windmill_common::legacy_on_behalf_of_email(resolved_on_behalf_of.as_deref(), &w_id, &db) + .await?; let mut tx = user_db.clone().begin(&authed).await?; @@ -830,19 +841,6 @@ async fn create_flow( if restricts_steps { windmill_common::feature_usage::log_feature_usage("job_token_scopes", "deploy", "step:set"); } - let resolved_on_behalf_of = windmill_common::resolve_on_behalf_of( - nf.on_behalf_of_email.as_deref(), - nf.on_behalf_of.as_deref(), - nf.preserve_on_behalf_of.unwrap_or(false), - &authed, - &w_id, - &db, - ) - .await?; - // Written beside the principal only while a worker that still reads it may be live. - let legacy_on_behalf_of_email = - windmill_common::legacy_on_behalf_of_email(resolved_on_behalf_of.as_deref(), &w_id, &db) - .await?; sqlx::query!( r#"INSERT INTO flow ( workspace_id, path, summary, description, @@ -1352,14 +1350,8 @@ async fn update_flow( Path((w_id, flow_path)): Path<(String, StripPath)>, Json(ef): Json, ) -> Result { - check_operator_can_build( - &db, - &w_id, - authed.is_operator, - BuilderKind::Flows, - "update flows", - ) - .await?; + let flows_level = + check_operator_can_build(&db, &w_id, &authed, BuilderKind::Flows, "update flows").await?; let flow_path = flow_path.to_path(); // The URL identifies the flow being updated; the body path is only needed to rename. let mut nf = ef.into_new_flow(flow_path); @@ -1386,9 +1378,23 @@ async fn update_flow( return Err(Error::PermissionDenied(msg)); } - validate_flow(&nf, &authed, &db, &user_db, &w_id).await?; + validate_flow(&nf, flows_level, &authed, &db, &user_db, &w_id).await?; let authed = maybe_refresh_folders(&flow_path, &w_id, authed, &db).await; + // Before `tx` opens, for the reason given in `create_flow`. + let resolved_on_behalf_of = windmill_common::resolve_on_behalf_of( + nf.on_behalf_of_email.as_deref(), + nf.on_behalf_of.as_deref(), + nf.preserve_on_behalf_of.unwrap_or(false), + &authed, + &w_id, + &db, + ) + .await?; + // Written beside the principal only while a worker that still reads it may be live. + let legacy_on_behalf_of_email = + windmill_common::legacy_on_behalf_of_email(resolved_on_behalf_of.as_deref(), &w_id, &db) + .await?; let mut tx = user_db.clone().begin(&authed).await?; check_schedule_conflict(&mut tx, &w_id, flow_path).await?; @@ -1426,19 +1432,6 @@ async fn update_flow( if restricts_steps { windmill_common::feature_usage::log_feature_usage("job_token_scopes", "deploy", "step:set"); } - let resolved_on_behalf_of = windmill_common::resolve_on_behalf_of( - nf.on_behalf_of_email.as_deref(), - nf.on_behalf_of.as_deref(), - nf.preserve_on_behalf_of.unwrap_or(false), - &authed, - &w_id, - &db, - ) - .await?; - // Written beside the principal only while a worker that still reads it may be live. - let legacy_on_behalf_of_email = - windmill_common::legacy_on_behalf_of_email(resolved_on_behalf_of.as_deref(), &w_id, &db) - .await?; sqlx::query!( " @@ -2053,14 +2046,7 @@ async fn archive_flow_by_path( Path((w_id, path)): Path<(String, StripPath)>, Json(archived): Json, ) -> Result { - check_operator_can_build( - &db, - &w_id, - authed.is_operator, - BuilderKind::Flows, - "archive flows", - ) - .await?; + check_operator_can_build(&db, &w_id, &authed, BuilderKind::Flows, "archive flows").await?; let path = path.to_path(); check_scopes(&authed, || format!("flows:write:{}", path))?; if let RuleCheckResult::Blocked(msg) = check_deploy_rules( @@ -2201,14 +2187,7 @@ async fn delete_flow_by_path( Path((w_id, path)): Path<(String, StripPath)>, Query(query): Query, ) -> Result { - check_operator_can_build( - &db, - &w_id, - authed.is_operator, - BuilderKind::Flows, - "delete flows", - ) - .await?; + check_operator_can_build(&db, &w_id, &authed, BuilderKind::Flows, "delete flows").await?; let path = path.to_path(); check_scopes(&authed, || format!("flows:write:{}", path))?; if let RuleCheckResult::Blocked(msg) = check_deploy_rules( diff --git a/backend/windmill-api-groups/Cargo.toml b/backend/windmill-api-groups/Cargo.toml index 35443008f0..3887f3b03e 100644 --- a/backend/windmill-api-groups/Cargo.toml +++ b/backend/windmill-api-groups/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_api_groups" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] diff --git a/backend/windmill-api-groups/src/granular_acls.rs b/backend/windmill-api-groups/src/granular_acls.rs index f7c3398adc..c867e89a60 100644 --- a/backend/windmill-api-groups/src/granular_acls.rs +++ b/backend/windmill-api-groups/src/granular_acls.rs @@ -117,7 +117,7 @@ async fn add_granular_acl( } if let Some(manage_kind) = manage_kind_for_acl_kind(kind) { - check_operator_can_manage(&db, &w_id, authed.is_operator, manage_kind).await?; + check_operator_can_manage(&db, &w_id, &authed, manage_kind).await?; } let identifier = if kind == "group_" || kind == "folder" || kind == "volume" { @@ -368,7 +368,7 @@ async fn remove_granular_acl( } if let Some(manage_kind) = manage_kind_for_acl_kind(kind) { - check_operator_can_manage(&db, &w_id, authed.is_operator, manage_kind).await?; + check_operator_can_manage(&db, &w_id, &authed, manage_kind).await?; } if !authed.is_admin { diff --git a/backend/windmill-api-groups/src/groups.rs b/backend/windmill-api-groups/src/groups.rs index d9550d2f5b..860e486a75 100644 --- a/backend/windmill-api-groups/src/groups.rs +++ b/backend/windmill-api-groups/src/groups.rs @@ -238,6 +238,13 @@ async fn create_group( Json(ng): Json, ) -> Result { crate::check_demo_workspace_restriction(&authed, &w_id, "Group creation")?; + // Admins stay allowed: `wmill sync push` recreates exported groups through this endpoint. + if !authed.is_admin && windmill_common::RESERVED_GROUP_NAMES.contains(&ng.name.as_str()) { + return Err(Error::BadRequest(format!( + "Group name {} is reserved by Windmill; only an admin can create it", + ng.name + ))); + } let mut tx = user_db.begin(&authed).await?; check_name_conflict(&mut tx, &w_id, &ng.name).await?; diff --git a/backend/windmill-api-inputs/Cargo.toml b/backend/windmill-api-inputs/Cargo.toml index 30d3f0d9f5..f5fbe3b0ea 100644 --- a/backend/windmill-api-inputs/Cargo.toml +++ b/backend/windmill-api-inputs/Cargo.toml @@ -7,6 +7,9 @@ edition.workspace = true [lib] name = "windmill_api_inputs" path = "src/lib.rs" +# src/ has no unit tests or doctests: remove both lines before adding one, or cargo test never runs it. +test = false +doctest = false [dependencies] windmill-api-auth.workspace = true diff --git a/backend/windmill-api-integration-tests/Cargo.toml b/backend/windmill-api-integration-tests/Cargo.toml index 69dbaeaaa2..8302b5b252 100644 --- a/backend/windmill-api-integration-tests/Cargo.toml +++ b/backend/windmill-api-integration-tests/Cargo.toml @@ -7,6 +7,9 @@ edition.workspace = true [lib] name = "windmill_api_integration_tests" path = "src/lib.rs" +# src/ has no unit tests or doctests: remove both lines before adding one, or cargo test never runs it. +test = false +doctest = false [features] default = [] diff --git a/backend/windmill-api-integration-tests/tests/agent_on_behalf_of.rs b/backend/windmill-api-integration-tests/tests/agent_on_behalf_of.rs new file mode 100644 index 0000000000..540282de9a --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/agent_on_behalf_of.rs @@ -0,0 +1,228 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; + +use windmill_test_utils::*; + +const AGENT: &str = "u/test-user-3/agent"; + +async fn post( + base_url: &str, + token: &str, + route: &str, + body: serde_json::Value, +) -> anyhow::Result<(u16, String)> { + let resp = reqwest::Client::new() + .post(format!("{base_url}/w/test-workspace/{route}")) + .header("Authorization", format!("Bearer {token}")) + .json(&body) + .send() + .await?; + Ok((resp.status().as_u16(), resp.text().await?)) +} + +async fn agent_identity(db: &Pool) -> anyhow::Result> { + Ok(sqlx::query_scalar::<_, Option>( + "SELECT value->>'on_behalf_of' FROM resource + WHERE workspace_id = 'test-workspace' AND path = $1", + ) + .bind(AGENT) + .fetch_one(db) + .await?) +} + +async fn job_identity(db: &Pool, id: &str) -> anyhow::Result<(String, String)> { + Ok( + sqlx::query_as("SELECT permissioned_as, created_by FROM v2_job WHERE id = $1::uuid") + .bind(id) + .fetch_one(db) + .await?, + ) +} + +/// The identity an agent runs as is in its value, which anyone who can write the agent writes: it +/// is kept only for an admin asking to keep it, and every other write makes it the writer's own, +/// so a writer cannot change what runs while it keeps running as someone else. It exists only +/// while the agent opts into running as it. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_agent_write_resets_identity(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let base_url = format!("http://localhost:{}/api", server.addr.port()); + let value = json!({ "system_prompt": "hi", "on_behalf_of": "u/test-user-2", + "run_on_behalf_of": true }); + + let (status, body) = post( + &base_url, + "SECRET_TOKEN", + "resources/create", + json!({ "path": AGENT, "resource_type": "ai_agent", "value": value, + "preserve_on_behalf_of": true }), + ) + .await?; + assert!((200..300).contains(&status), "create: {status} {body}"); + assert_eq!(agent_identity(&db).await?.as_deref(), Some("u/test-user-2")); + + // The agent's owner, not an admin, asking to keep it. + let (status, body) = post( + &base_url, + "SECRET_TOKEN_3", + &format!("resources/update/{AGENT}"), + json!({ "value": value, "preserve_on_behalf_of": true }), + ) + .await?; + assert!((200..300).contains(&status), "update: {status} {body}"); + assert_eq!(agent_identity(&db).await?.as_deref(), Some("u/test-user-3")); + + let mut off = value.clone(); + off["run_on_behalf_of"] = json!(false); + let (status, body) = post( + &base_url, + "SECRET_TOKEN", + &format!("resources/update/{AGENT}"), + json!({ "value": off, "preserve_on_behalf_of": true }), + ) + .await?; + assert!((200..300).contains(&status), "toggle off: {status} {body}"); + assert_eq!(agent_identity(&db).await?, None); + + Ok(()) +} + +/// Reading an agent is what allows running it. The run is the caller's unless the agent opts into +/// running as its identity: then `test-user-2`, a plain member, runs as `test-user-3`. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_agent_run_identity_and_needs_read(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let base_url = format!("http://localhost:{}/api", server.addr.port()); + + sqlx::query( + "INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) + VALUES ('test-workspace', $1, $2, 'ai_agent', '{}', 'test-user-3')", + ) + .bind(AGENT) + .bind(json!({ "system_prompt": "hi", "on_behalf_of": "u/test-user-3" })) + .execute(&db) + .await?; + + let run = format!("jobs/run/agent/{AGENT}"); + let (status, body) = post(&base_url, "SECRET_TOKEN_2", &run, json!({})).await?; + assert_eq!(status, 404, "an unreadable agent: {body}"); + + sqlx::query( + "UPDATE resource SET extra_perms = '{\"u/test-user-2\": false}' + WHERE workspace_id = 'test-workspace' AND path = $1", + ) + .bind(AGENT) + .execute(&db) + .await?; + let (status, body) = post( + &base_url, + "SECRET_TOKEN_2", + &run, + json!({ "user_message": "hi" }), + ) + .await?; + assert_eq!(status, 201, "a readable agent: {body}"); + assert_eq!( + job_identity(&db, &body).await?, + ("u/test-user-2".into(), "test-user-2".into()) + ); + + sqlx::query( + "UPDATE resource SET value = value || '{\"run_on_behalf_of\": true}' + WHERE workspace_id = 'test-workspace' AND path = $1", + ) + .bind(AGENT) + .execute(&db) + .await?; + let (status, body) = post( + &base_url, + "SECRET_TOKEN_2", + &run, + json!({ "user_message": "hi" }), + ) + .await?; + assert_eq!(status, 201, "an agent run on behalf of: {body}"); + assert_eq!( + job_identity(&db, &body).await?, + ("u/test-user-3".into(), "test-user-2".into()) + ); + + // The worker would resolve it with the identity's token, however the JSON spells the `$`. + for raw in [ + r#"{"user_message":"$var:u/test-user-3/secret"}"#, + r#"{"user_message":"\u0024var:u/test-user-3/secret"}"#, + // Resolved once a step combines it with an argument holding a reference. + r#"{"user_message":"$WM_TOKEN"}"#, + // jsonb drops the undecodable duplicate and keeps the reference. + r#"{"user_message":{"n":1e400,"n":0,"r":"\u0024var:u/test-user-3/secret"}}"#, + ] { + let resp = reqwest::Client::new() + .post(format!("{base_url}/w/test-workspace/{run}")) + .header("Authorization", "Bearer SECRET_TOKEN_2") + .header("Content-Type", "application/json") + .body(raw) + .send() + .await?; + assert_eq!(resp.status().as_u16(), 400, "a reference in {raw}"); + } + let (status, body) = post( + &base_url, + "SECRET_TOKEN_2", + &run, + json!({ "user_message": {"$match": {"currency": "$USD", "_id": "$category"}} }), + ) + .await?; + assert_eq!(status, 201, "nothing the worker resolves: {body}"); + + // The run takes its conversation outside the caller's row policy, so a reader must not + // continue another user's conversation; an admin still may, as the policy allows. + let chat = format!("{run}?memory_id=7d3f6a52-1c4e-4b8a-9f2d-0e5c6b7a8d91"); + let msg = json!({ "user_message": "hi" }); + let (status, body) = post(&base_url, "SECRET_TOKEN_3", &chat, msg.clone()).await?; + assert_eq!(status, 201, "the owner starts it: {body}"); + let (status, body) = post(&base_url, "SECRET_TOKEN_2", &chat, msg.clone()).await?; + assert_eq!(status, 400, "another reader: {body}"); + assert!(body.contains("another user"), "{body}"); + let (status, body) = post(&base_url, "SECRET_TOKEN", &chat, msg).await?; + assert_eq!( + status, 409, + "an admin passes ownership, held only by the running turn: {body}" + ); + + Ok(()) +} + +/// The identity is a username inside the value, so renaming the user has to move it there too. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_agent_identity_follows_user_rename(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let base_url = format!("http://localhost:{}/api", server.addr.port()); + + sqlx::query( + "INSERT INTO resource (workspace_id, path, value, resource_type, created_by) + VALUES ('test-workspace', $1, $2, 'ai_agent', 'test-user-3')", + ) + .bind(AGENT) + .bind(json!({ "run_on_behalf_of": true, "on_behalf_of": "u/test-user-2" })) + .execute(&db) + .await?; + let email: String = + sqlx::query_scalar("SELECT email FROM usr WHERE username = 'test-user-2' LIMIT 1") + .fetch_one(&db) + .await?; + + let resp = reqwest::Client::new() + .post(format!("{base_url}/users/rename/{email}")) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ "new_username": "renamed" })) + .send() + .await?; + let status = resp.status().as_u16(); + assert_eq!(status, 200, "rename: {}", resp.text().await?); + assert_eq!(agent_identity(&db).await?.as_deref(), Some("u/renamed")); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/agent_runs.rs b/backend/windmill-api-integration-tests/tests/agent_runs.rs deleted file mode 100644 index b0302187cb..0000000000 --- a/backend/windmill-api-integration-tests/tests/agent_runs.rs +++ /dev/null @@ -1,60 +0,0 @@ -use serde_json::json; -use sqlx::{Pool, Postgres}; - -use windmill_test_utils::*; - -const AGENT: &str = "u/test-user-3/agent"; - -async fn run(base_url: &str, token: &str) -> anyhow::Result<(u16, String)> { - let resp = reqwest::Client::new() - .post(format!( - "{base_url}/w/test-workspace/jobs/run/agent/{AGENT}" - )) - .header("Authorization", format!("Bearer {token}")) - .json(&json!({ "user_message": "hi" })) - .send() - .await?; - Ok((resp.status().as_u16(), resp.text().await?)) -} - -/// Reading an agent is what allows running it, and the run is the caller's own: `test-user-2`, a -/// plain member, runs `test-user-3`'s agent once it may read it, lending nobody's permissions. -#[sqlx::test(migrations = "../migrations", fixtures("base"))] -async fn test_agent_run_needs_read_and_runs_as_caller(db: Pool) -> anyhow::Result<()> { - initialize_tracing().await; - let server = ApiServer::start(db.clone()).await?; - let base_url = format!("http://localhost:{}/api", server.addr.port()); - - sqlx::query( - "INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) - VALUES ('test-workspace', $1, $2, 'ai_agent', '{}', 'test-user-3')", - ) - .bind(AGENT) - .bind(json!({ "system_prompt": "hi" })) - .execute(&db) - .await?; - - let (status, body) = run(&base_url, "SECRET_TOKEN_2").await?; - assert_eq!(status, 404, "an unreadable agent: {body}"); - - sqlx::query( - "UPDATE resource SET extra_perms = '{\"u/test-user-2\": false}' - WHERE workspace_id = 'test-workspace' AND path = $1", - ) - .bind(AGENT) - .execute(&db) - .await?; - let (status, body) = run(&base_url, "SECRET_TOKEN_2").await?; - assert_eq!(status, 201, "a readable agent: {body}"); - - let (permissioned_as, created_by) = sqlx::query_as::<_, (String, String)>( - "SELECT permissioned_as, created_by FROM v2_job WHERE id = $1::uuid", - ) - .bind(&body) - .fetch_one(&db) - .await?; - assert_eq!(permissioned_as, "u/test-user-2"); - assert_eq!(created_by, "test-user-2"); - - Ok(()) -} diff --git a/backend/windmill-api-integration-tests/tests/datatable_roles.rs b/backend/windmill-api-integration-tests/tests/datatable_roles.rs index 0acba27c41..e1623ef901 100644 --- a/backend/windmill-api-integration-tests/tests/datatable_roles.rs +++ b/backend/windmill-api-integration-tests/tests/datatable_roles.rs @@ -810,7 +810,7 @@ async fn a_data_table_under_roles_is_cloned_only_with_its_grants( // The copy an admin asks for goes ahead in an edition that replays grants, and is refused // before any database exists in one that does not. The fixture's database does not exist, so - // the dump fails either way — and leaves neither a database nor a fork behind. + // reading it fails either way — and leaves neither a database nor a fork behind. let resp = fork( "SECRET_TOKEN", json!({"name": "main", "new_dbname": target, "fork_behavior": "schema_only"}), @@ -820,7 +820,7 @@ async fn a_data_table_under_roles_is_cloned_only_with_its_grants( assert_ne!(resp.status(), 200); let body = resp.text().await?; #[cfg(all(feature = "private", feature = "enterprise"))] - assert!(body.contains("pg_dump"), "{body}"); + assert!(body.contains("\"dt_main\" does not exist"), "{body}"); #[cfg(not(all(feature = "private", feature = "enterprise")))] assert!(body.contains("Enterprise Edition"), "{body}"); assert!(!database_exists(&db, target).await?); @@ -981,6 +981,104 @@ async fn a_data_table_under_roles_is_not_copied_without_its_grants( Ok(()) } +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn data_tables_on_one_database_are_copied_together(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let shared = json!({"database": {"resource_type": "instance", "resource_path": "dt_shared"}}); + sqlx::query( + "UPDATE workspace_settings SET datatable = jsonb_set(jsonb_set(datatable, + '{datatables,a}', $1), '{datatables,b}', $1) WHERE workspace_id = 'test-workspace'", + ) + .bind(&shared) + .execute(&db) + .await?; + + // One copy is made for both, so it cannot be made two ways. + let resp = authed( + client().post(format!( + "http://localhost:{port}/api/w/test-workspace/workspaces/create_fork" + )), + "SECRET_TOKEN", + ) + .json( + &json!({"id": "wm-fork-pair", "name": "pair", "forked_datatables": [ + {"name": "a", "new_dbname": "wm_fork_pair__a", "fork_behavior": "schema_only"}, + {"name": "b", "new_dbname": "wm_fork_pair__b", "fork_behavior": "schema_and_data"} + ]}), + ) + .send() + .await?; + assert_eq!(resp.status(), 400); + assert!(resp.text().await?.contains("same database")); + assert!(!database_exists(&db, "wm_fork_pair__a").await?); + + // A fork's entries sharing a copy each count as a use of it: dropped together, neither blocks + // the other. + let copy = json!({"database": {"resource_type": "instance", "resource_path": "wm_fork_dt__a"}, + "forked_from": {"schema": {}}}); + sqlx::query( + "UPDATE workspace_settings SET datatable = jsonb_build_object('datatables', + jsonb_build_object('a', $1::jsonb, 'b', $1::jsonb)) WHERE workspace_id = 'wm-fork-dt'", + ) + .bind(©) + .execute(&db) + .await?; + sqlx::query( + "UPDATE global_settings SET value = jsonb_set(value, '{databases,wm_fork_dt__a}', + '{\"tag\": \"datatable\", \"workspace_id\": \"test-workspace\"}') + WHERE name = 'custom_instance_pg_databases'", + ) + .execute(&db) + .await?; + let errors: Vec = authed( + client().post(format!( + "http://localhost:{port}/api/w/wm-fork-dt/workspaces/drop_forked_datatable_databases" + )), + "SECRET_TOKEN", + ) + .json(&json!({"datatable_names": ["a", "b"]})) + .send() + .await? + .json() + .await?; + assert!(errors.is_empty(), "{errors:?}"); + let left: Value = sqlx::query_scalar( + "SELECT datatable->'datatables' FROM workspace_settings WHERE workspace_id = 'wm-fork-dt'", + ) + .fetch_one(&db) + .await?; + assert_eq!(left, json!({})); + + // Clones on one resource share its copy too: dropping it for one would take the other's data. + let copy = json!({"database": {"resource_type": "postgresql", "resource_path": "u/test-user/pg"}, + "forked_from": {"schema": {}}}); + sqlx::query( + "UPDATE workspace_settings SET datatable = jsonb_build_object('datatables', + jsonb_build_object('a', $1::jsonb, 'b', $1::jsonb)) WHERE workspace_id = 'wm-fork-dt'", + ) + .bind(©) + .execute(&db) + .await?; + let errors: Vec = authed( + client().post(format!( + "http://localhost:{port}/api/w/wm-fork-dt/workspaces/drop_forked_datatable_databases" + )), + "SECRET_TOKEN", + ) + .json(&json!({"datatable_names": ["a"]})) + .send() + .await? + .json() + .await?; + assert!( + errors.len() == 1 && errors[0].contains("still used by data tables 'b'"), + "{errors:?}" + ); + Ok(()) +} + /// The fork's `forked_from` for one of its entries; `None` whether it is absent or `null`. async fn forked_from_of(db: &Pool, name: &str) -> Option { sqlx::query_scalar::<_, Option>( @@ -1639,7 +1737,7 @@ async fn an_operator_neither_writes_nor_runs_a_migration(db: Pool) -> let status = resp.status(); let text = resp.text().await?; assert!( - status == 401 && text.contains("Operators cannot"), + status == 403 && text.contains("Your role cannot"), "{status}: {text}" ); } diff --git a/backend/windmill-api-integration-tests/tests/drafts.rs b/backend/windmill-api-integration-tests/tests/drafts.rs index 9c08c14a6d..2c0ab54c42 100644 --- a/backend/windmill-api-integration-tests/tests/drafts.rs +++ b/backend/windmill-api-integration-tests/tests/drafts.rs @@ -143,6 +143,12 @@ async fn test_update_draft_write_authorization(db: Pool) -> anyhow::Re .send() .await?; assert_eq!(r.status(), 200, "own namespace allowed"); + let r = user2 + .post(save_url(port, "data_pipeline", "u/test-user-2/pipeline")) + .json(&json!({ "value": { "a": 1 } })) + .send() + .await?; + assert_eq!(r.status(), 200, "a developer saves data pipeline drafts"); // Another user's namespace, no grant → rejected. let r = user2 @@ -166,6 +172,16 @@ async fn test_update_draft_write_authorization(db: Pool) -> anyhow::Re .send() .await?; assert_eq!(r.status(), 403, "operator rejected"); + let r = op + .post(save_url(port, "data_pipeline", "u/test-user-3/pipeline")) + .json(&json!({ "value": { "a": 1 } })) + .send() + .await?; + assert_eq!( + r.status(), + 403, + "a role that cannot code has no pipeline drafts" + ); Ok(()) } diff --git a/backend/windmill-api-integration-tests/tests/fork_clone_on_behalf_of.rs b/backend/windmill-api-integration-tests/tests/fork_clone_on_behalf_of.rs index bc6c10f1ee..10fc0a8028 100644 --- a/backend/windmill-api-integration-tests/tests/fork_clone_on_behalf_of.rs +++ b/backend/windmill-api-integration-tests/tests/fork_clone_on_behalf_of.rs @@ -62,14 +62,31 @@ async fn fork_with_public_app( /// An app policy's `on_behalf_of` is the identity anonymous and publisher executions queue jobs /// under, and the fork's endpoint outlives any revocation in the parent — so a creator who may -/// not preserve someone else's identity must not receive one by forking. `test-user-2` is a -/// plain member of the parent. +/// not preserve someone else's identity must not receive one by forking. The same goes for the +/// one an agent's value carries. `test-user-2` is a plain member of the parent. #[sqlx::test(migrations = "../migrations", fixtures("base"))] async fn test_fork_repoints_app_identity_for_unprivileged_creator( db: Pool, ) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) + VALUES ('test-workspace', 'u/test-user/agent', $1, 'ai_agent', '{}', 'test-user')", + ) + .bind(json!({ "system_prompt": "hi", "on_behalf_of": "u/test-user" })) + .execute(&db) + .await?; let (policy, custom_path) = fork_with_public_app(&db, "SECRET_TOKEN_2").await?; + assert_eq!( + sqlx::query_scalar::<_, String>( + "SELECT value->>'on_behalf_of' FROM resource + WHERE workspace_id = 'wm-fork-app' AND path = 'u/test-user/agent'" + ) + .fetch_one(&db) + .await?, + "u/test-user-2" + ); + assert_eq!(policy["on_behalf_of"], json!("u/test-user-2")); assert_eq!(policy["on_behalf_of_email"], json!("test2@windmill.dev")); // `execution_mode` rides along untouched — see `clone_apps`. @@ -312,8 +329,9 @@ async fn test_fork_keeps_only_resolvable_on_behalf_of(db: Pool) -> any Ok(()) } -/// Apps, schedules, triggers and their drafts cannot drop an identity the way scripts and flows -/// do, so one naming nobody in the fork goes to its creator while one that still resolves stays. +/// Apps, agents, schedules, triggers and their drafts cannot drop an identity the way scripts and +/// flows do, so one naming nobody in the fork goes to its creator while one that still resolves +/// stays. /// Forked as an admin, whose app policies the clone otherwise keeps. #[sqlx::test(migrations = "../migrations", fixtures("base"))] async fn test_fork_repoints_unresolvable_identities(db: Pool) -> anyhow::Result<()> { @@ -367,6 +385,13 @@ async fn test_fork_repoints_unresolvable_identities(db: Pool) -> anyho )) .execute(&db) .await?; + sqlx::query( + "INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) + VALUES ('test-workspace', 'u/test-user/stranger', $1, 'ai_agent', '{}', 'test-user')", + ) + .bind(json!({ "system_prompt": "hi", "on_behalf_of": "u/test-user-2" })) + .execute(&db) + .await?; sqlx::query( "INSERT INTO schedule (workspace_id, path, edited_by, schedule, script_path, email, permissioned_as, enabled) VALUES ('test-workspace', 'u/test-user/stranger', 'test-user', '0 0 * * * *', 'u/test-user/s', 'test2@windmill.dev', 'u/test-user-2', false)", @@ -424,6 +449,10 @@ async fn test_fork_repoints_unresolvable_identities(db: Pool) -> anyho text("SELECT value->>'permissioned_as' FROM draft WHERE workspace_id = 'wm-fork-repoint' AND typ = 'trigger_websocket'").await?, "u/test-user" ); + assert_eq!( + text("SELECT value->>'on_behalf_of' FROM resource WHERE workspace_id = 'wm-fork-repoint' AND path = 'u/test-user/stranger'").await?, + "u/test-user" + ); assert_eq!( text("SELECT permissioned_as || ' ' || email FROM schedule WHERE workspace_id = 'wm-fork-repoint'").await?, "u/test-user test@windmill.dev" diff --git a/backend/windmill-api-integration-tests/tests/fork_secret_replication_azure.rs b/backend/windmill-api-integration-tests/tests/fork_secret_replication_azure.rs index 19682de70a..855b45e2ca 100644 --- a/backend/windmill-api-integration-tests/tests/fork_secret_replication_azure.rs +++ b/backend/windmill-api-integration-tests/tests/fork_secret_replication_azure.rs @@ -14,14 +14,16 @@ //! (lowkey-vault), which the `AzureKeyVaultBackend` talks to via its //! static-token / self-signed-cert emulator mode. //! -//! Run it: +//! The test is `#[ignore]`d, so a plain `cargo test` skips it without creating +//! a database. Run it with `--ignored` (`AZURE_KV_URL` overrides the default +//! https://localhost:8443): //! ```bash //! podman run -d --name lowkey -p 8443:8443 \ //! -e LOWKEY_ARGS="--LOWKEY_VAULT_NAMES=default" \ //! docker.io/nagyesta/lowkey-vault:7.3.0 //! -//! RUN_AZURE_KV_TESTS=1 cargo test -p windmill-api-integration-tests \ -//! --features private,enterprise --test fork_secret_replication_azure -- --nocapture +//! cargo test -p windmill-api-integration-tests \ +//! --features private,enterprise --test fork_secret_replication_azure -- --ignored --nocapture //! ``` #[cfg(all(feature = "private", feature = "enterprise"))] @@ -55,11 +57,8 @@ mod azure_fork { } #[sqlx::test(migrations = "../migrations", fixtures("base"))] + #[ignore = "requires a lowkey-vault Azure Key Vault emulator at AZURE_KV_URL"] async fn migrated_secret_is_replicated_on_fork(db: Pool) -> anyhow::Result<()> { - if std::env::var("RUN_AZURE_KV_TESTS").as_deref() != Ok("1") { - eprintln!("skipping: set RUN_AZURE_KV_TESTS=1 and start lowkey-vault to run"); - return Ok(()); - } initialize_tracing().await; // The Azure KV emulator persists across runs; derive unique names per run diff --git a/backend/windmill-api-integration-tests/tests/groups.rs b/backend/windmill-api-integration-tests/tests/groups.rs index 9928d1c060..20e866cc37 100644 --- a/backend/windmill-api-integration-tests/tests/groups.rs +++ b/backend/windmill-api-integration-tests/tests/groups.rs @@ -1116,3 +1116,38 @@ async fn test_instance_group_member_that_is_not_an_email_is_skipped( Ok(()) } + +/// A non-admin who creates a reserved group first becomes its member and inherits every grant +/// Windmill later gives it. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_reserved_group_names_admin_only(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let base = format!( + "http://localhost:{}/api/w/test-workspace/groups", + server.addr.port() + ); + + for name in windmill_common::RESERVED_GROUP_NAMES { + let resp = client() + .post(format!("{base}/create")) + .header("Authorization", "Bearer SECRET_TOKEN_2") + .json(&json!({ "name": name })) + .send() + .await?; + assert_eq!(resp.status(), 400, "non-admin creating {name}"); + assert!(resp.text().await?.contains("reserved")); + + let resp = authed(client().post(format!("{base}/create"))) + .json(&json!({ "name": name })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "admin creating {name}: {}", + resp.text().await? + ); + } + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/jobs_authed.rs b/backend/windmill-api-integration-tests/tests/jobs_authed.rs index 2c5d2ee78e..2592ecdf7a 100644 --- a/backend/windmill-api-integration-tests/tests/jobs_authed.rs +++ b/backend/windmill-api-integration-tests/tests/jobs_authed.rs @@ -626,10 +626,9 @@ async fn test_resolve_completed_jobs_scoping(db: Pool) -> anyhow::Resu .json(&json!({ "job_ids": [mine] })) .send() .await?; - // Error::NotAuthorized maps to 401 (403 is RequireAdmin/PermissionDenied). assert_eq!( resp.status().as_u16(), - 401, + 403, "operators must be refused: {}", resp.text().await? ); diff --git a/backend/windmill-api-integration-tests/tests/jobs_unauthed.rs b/backend/windmill-api-integration-tests/tests/jobs_unauthed.rs index fa8b6cb66f..5cb7d0dfb5 100644 --- a/backend/windmill-api-integration-tests/tests/jobs_unauthed.rs +++ b/backend/windmill-api-integration-tests/tests/jobs_unauthed.rs @@ -236,14 +236,17 @@ async fn test_jobs_unauthed_complex_reachability(db: Pool) -> anyhow:: "GET /get_log_file", ); - let resp = authed(client().post(format!("{base}/queue/cancel_persistent/u/test-user/fake"))) + // Unauthenticated, so the handler refuses before cancelling, which sleeps for seconds. + let resp = client() + .post(format!("{base}/queue/cancel_persistent/u/test-user/fake")) .json(&serde_json::json!({"reason": "test"})) .send() .await?; - assert_route_reachable( - resp.status().as_u16(), - &resp.text().await?, - "POST /queue/cancel_persistent", + let status = resp.status().as_u16(); + let body = resp.text().await?; + assert!( + status == 400 && body.contains("Cancelling persistent script require"), + "POST /queue/cancel_persistent did not reach its handler: {status} {body}" ); Ok(()) diff --git a/backend/windmill-api-integration-tests/tests/mcp_preprocessor_headers.rs b/backend/windmill-api-integration-tests/tests/mcp_preprocessor_headers.rs index 25942e845c..7ae631b5dc 100644 --- a/backend/windmill-api-integration-tests/tests/mcp_preprocessor_headers.rs +++ b/backend/windmill-api-integration-tests/tests/mcp_preprocessor_headers.rs @@ -127,7 +127,7 @@ async fn test_mcp_preprocessor_receives_the_callers_headers( .and_then(|list| { list.iter() .filter_map(|t| t["name"].as_str()) - .find(|n| n.contains("mcp__hdr__probe")) + .find(|n| *n == "mcp_hdr_probe") }) .ok_or_else(|| anyhow::anyhow!("the deployed script was not listed as a tool: {tools}"))? .to_string(); diff --git a/backend/windmill-api-integration-tests/tests/native_triggers.rs b/backend/windmill-api-integration-tests/tests/native_triggers.rs index e7f0147323..285d560588 100644 --- a/backend/windmill-api-integration-tests/tests/native_triggers.rs +++ b/backend/windmill-api-integration-tests/tests/native_triggers.rs @@ -14,6 +14,7 @@ use sqlx::{Pool, Postgres}; use axum::http::StatusCode; use windmill_api_auth::{ApiAuthed, ScopePathFilter}; use windmill_common::{ + db::UserDB, error::Error, variables::{build_crypt, encrypt}, }; @@ -22,12 +23,13 @@ use windmill_native_triggers::{ get_workspace_integration, github::GitHub, google::{parse_stop_channel_params, should_renew_channel}, - grant_refused, http_error_status, list_native_triggers, map_external_error, - native_trigger_is_enabled, + grant_refused, http_error_status, list_native_triggers, list_usable_connections, + map_external_error, native_trigger_is_enabled, nextcloud::NextCloud, - require_native_integration_use, set_native_trigger_enabled, store_native_trigger, - store_workspace_integration, update_native_trigger, External, ExternalReadFailure, - HttpRequestError, NativeTriggerConfig, OAuthConfig, ServiceName, + require_native_integration_use, resolve_usable_connection, set_native_trigger_enabled, + store_native_trigger, store_workspace_integration, update_native_trigger, + update_oauth_token_resource, External, ExternalReadFailure, HttpRequestError, + NativeTriggerConfig, OAuthConfig, ServiceName, }; // ============================================================================ @@ -133,7 +135,6 @@ async fn setup_oauth_integration( "test-workspace", service_name, oauth_data, - Some(resource_path), ) .await?; tx.commit().await?; @@ -149,73 +150,125 @@ fn now_ms() -> i64 { } // ============================================================================ -// 1. Resource Path Change +// 1. Several connections of one service // ============================================================================ +/// Each connection keeps its own tokens: a refresh written back for one, or disconnecting one, +/// must not touch another. Providers that rotate refresh tokens invalidate the old one, so a +/// refresh token copied onto the wrong account breaks that account's next refresh. #[sqlx::test(migrations = "../migrations", fixtures("base"))] -async fn test_resource_path_change(db: Pool) -> anyhow::Result<()> { - let path_a = "u/test-user/native_gworkspace"; - setup_oauth_integration( +async fn test_connections_are_isolated(db: Pool) -> anyhow::Result<()> { + let path_a = "u/test-user/native_nextcloud"; + let path_b = "u/other-user/native_nextcloud"; + let mut account_a = 0; + for (path, token) in [(path_a, "a"), (path_b, "b")] { + let account = setup_oauth_integration( + &db, + ServiceName::Nextcloud, + path, + &format!("access-{token}"), + &format!("refresh-{token}"), + None, + ) + .await?; + if path == path_a { + account_a = account; + } + } + + update_oauth_token_resource( &db, - ServiceName::Google, - path_a, - "token-a", - "refresh-a", - None, - ) - .await?; - - // Verify decrypt works at path A - let config: OAuthConfig = - decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?; - assert_eq!(config.access_token, "token-a"); - - // Cleanup old path - let mut tx = db.begin().await?; - windmill_native_triggers::workspace_integrations::cleanup_oauth_resource( - &mut *tx, "test-workspace", - ServiceName::Google, + account_a, + "access-a2", + Some("refresh-a2"), ) .await; - tx.commit().await?; - // Recreate at path B - let path_b = "u/test-user/native_gworkspace_v2"; - setup_oauth_integration( - &db, - ServiceName::Google, - path_b, - "token-b", - "refresh-b", - None, + let a: OAuthConfig = + decrypt_oauth_data(&db, "test-workspace", ServiceName::Nextcloud, Some(path_a)).await?; + let b: OAuthConfig = + decrypt_oauth_data(&db, "test-workspace", ServiceName::Nextcloud, Some(path_b)).await?; + assert_eq!( + (a.access_token.as_str(), a.refresh_token.as_deref()), + ("access-a2", Some("refresh-a2")) + ); + assert_eq!( + (b.access_token.as_str(), b.refresh_token.as_deref()), + ("access-b", Some("refresh-b")) + ); + + let mut tx = db.begin().await?; + windmill_native_triggers::workspace_integrations::cleanup_connection( + &mut *tx, + "test-workspace", + path_a, ) .await?; + tx.commit().await?; - // Path A resources should be gone - let var_count: i64 = sqlx::query_scalar!( - "SELECT count(*) FROM variable WHERE workspace_id = 'test-workspace' AND path = $1", - path_a, + assert!(decrypt_oauth_data::( + &db, + "test-workspace", + ServiceName::Nextcloud, + Some(path_a) ) - .fetch_one(&db) - .await? - .unwrap_or(0); - assert_eq!(var_count, 0, "variable at old path should be deleted"); + .await + .is_err()); + let b: OAuthConfig = + decrypt_oauth_data(&db, "test-workspace", ServiceName::Nextcloud, Some(path_b)).await?; + assert_eq!(b.refresh_token.as_deref(), Some("refresh-b")); - let res_count: i64 = sqlx::query_scalar!( - "SELECT count(*) FROM resource WHERE workspace_id = 'test-workspace' AND path = $1", - path_a, + Ok(()) +} + +/// A member who cannot read the admin's connection may still use it once it is shared as the +/// workspace connection. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_workspace_connection_usable_by_members(db: Pool) -> anyhow::Result<()> { + let path = "u/test-user/native_nextcloud"; + setup_oauth_integration(&db, ServiceName::Nextcloud, path, "a", "r", None).await?; + let member = ApiAuthed { + email: "test2@windmill.dev".to_string(), + username: "test-user-2".to_string(), + is_admin: false, + ..test_authed() + }; + let usable_paths = || async { + let mut tx = UserDB::new(db.clone()).begin(&member).await?; + let connections = + list_usable_connections(&db, &mut tx, "test-workspace", ServiceName::Nextcloud).await?; + tx.commit().await?; + anyhow::Ok( + connections + .into_iter() + .map(|c| (c.path, c.workspace)) + .collect::>(), + ) + }; + + assert!(usable_paths().await?.is_empty()); + + sqlx::query( + "UPDATE workspace_integrations SET resource_path = $1 + WHERE workspace_id = 'test-workspace' AND service_name = 'nextcloud'", ) - .fetch_one(&db) - .await? - .unwrap_or(0); - assert_eq!(res_count, 0, "resource at old path should be deleted"); + .bind(path) + .execute(&db) + .await?; + assert_eq!(usable_paths().await?, vec![(path.to_string(), true)]); - // Path B should work - let config: OAuthConfig = - decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?; - assert_eq!(config.access_token, "token-b"); - assert_eq!(config.refresh_token.as_deref(), Some("refresh-b")); + // Clients and specs that predate connections name none: they keep getting the shared + // account once the member also has their own. + let own = "u/test-user-2/native_nextcloud"; + setup_oauth_integration(&db, ServiceName::Nextcloud, own, "b", "s", None).await?; + let mut tx = UserDB::new(db.clone()).begin(&member).await?; + let default = + resolve_usable_connection(&db, &mut tx, "test-workspace", ServiceName::Nextcloud, None) + .await?; + tx.commit().await?; + assert_eq!(usable_paths().await?.len(), 2); + assert_eq!(default, path); Ok(()) } @@ -237,8 +290,13 @@ async fn test_decrypt_workspace_level(db: Pool) -> anyhow::Result<()> ) .await?; - let config: OAuthConfig = - decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?; + let config: OAuthConfig = decrypt_oauth_data( + &db, + "test-workspace", + ServiceName::Google, + Some(resource_path), + ) + .await?; assert_eq!(config.access_token, "ws-access-token"); assert_eq!(config.refresh_token.as_deref(), Some("ws-refresh-token")); @@ -282,8 +340,13 @@ async fn test_decrypt_instance_level(db: Pool) -> anyhow::Result<()> { ) .await?; - let config: OAuthConfig = - decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?; + let config: OAuthConfig = decrypt_oauth_data( + &db, + "test-workspace", + ServiceName::Google, + Some(resource_path), + ) + .await?; assert_eq!(config.client_id, "instance-client-id"); assert_eq!(config.client_secret, "instance-client-secret"); @@ -306,8 +369,13 @@ async fn test_token_update_persists(db: Pool) -> anyhow::Result<()> { .await?; // Verify old tokens - let config: OAuthConfig = - decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?; + let config: OAuthConfig = decrypt_oauth_data( + &db, + "test-workspace", + ServiceName::Google, + Some(resource_path), + ) + .await?; assert_eq!(config.access_token, "old-access-token"); // Simulate token refresh: update variable + account @@ -330,8 +398,13 @@ async fn test_token_update_persists(db: Pool) -> anyhow::Result<()> { .await?; // Verify new tokens - let config: OAuthConfig = - decrypt_oauth_data(&db, "test-workspace", ServiceName::Google).await?; + let config: OAuthConfig = decrypt_oauth_data( + &db, + "test-workspace", + ServiceName::Google, + Some(resource_path), + ) + .await?; assert_eq!(config.access_token, "new-access-token"); assert_eq!(config.refresh_token.as_deref(), Some("new-refresh-token")); @@ -342,24 +415,57 @@ async fn test_token_update_persists(db: Pool) -> anyhow::Result<()> { // 3. Channel Expiration Renewal — should_renew_channel // ============================================================================ -#[test] -fn test_require_native_integration_use_blocks_operators() { - // Regression: the integration *use* routes (calendar/drive/repo/event pickers) - // must reject read-only operators, who cannot create native triggers and so - // must not be able to drive the admin-configured integration's upstream API. +#[sqlx::test(migrations = "../migrations", fixtures("base", "permissions_test"))] +async fn test_require_native_integration_use_needs_code_and_manage_triggers( + db: Pool, +) -> anyhow::Result<()> { + // The pickers drive the admin-connected account's upstream API: the default operator, which + // may manage triggers but not write code, must stay locked out of them. + windmill_common::roles::invalidate_all_roles_cache(); let mut operator = test_authed(); + operator.username = "operator".to_string(); operator.is_admin = false; operator.is_operator = true; - assert!(require_native_integration_use(&operator).is_err()); + assert!( + require_native_integration_use(&db, "test-workspace", &operator) + .await + .is_err() + ); - // A regular non-admin author (the population that configures triggers) is allowed. - let mut author = test_authed(); - author.is_admin = false; - author.is_operator = false; - assert!(require_native_integration_use(&author).is_ok()); + let set_override = |caps: &'static str| { + let db = db.clone(); + async move { + sqlx::query( + "UPDATE workspace_settings SET role_overrides = jsonb_build_object('operator', + jsonb_build_object('capabilities', $1::jsonb)) + WHERE workspace_id = 'test-workspace'", + ) + .bind(caps) + .execute(&db) + .await + .unwrap(); + windmill_common::roles::invalidate_all_roles_cache(); + } + }; + set_override(r#"{"code": true}"#).await; + assert!( + require_native_integration_use(&db, "test-workspace", &operator) + .await + .is_ok() + ); + set_override(r#"{"code": true, "manage_triggers": false}"#).await; + assert!( + require_native_integration_use(&db, "test-workspace", &operator) + .await + .is_err() + ); - // Admins are allowed. - assert!(require_native_integration_use(&test_authed()).is_ok()); + assert!( + require_native_integration_use(&db, "test-workspace", &test_authed()) + .await + .is_ok() + ); + Ok(()) } #[test] @@ -458,6 +564,7 @@ async fn test_delete_integration_full_cascade(db: Pool) -> anyhow::Res json!({"triggerType": "drive"}), None, true, + "u/test-user/native_conn", ) .await?; @@ -468,12 +575,12 @@ async fn test_delete_integration_full_cascade(db: Pool) -> anyhow::Res // Step 2: Cleanup OAuth resources let mut tx = db.begin().await?; - windmill_native_triggers::workspace_integrations::cleanup_oauth_resource( + windmill_native_triggers::workspace_integrations::cleanup_service_connections( &mut *tx, "test-workspace", ServiceName::Google, ) - .await; + .await?; tx.commit().await?; // Step 3: Delete workspace integration @@ -549,17 +656,18 @@ async fn test_cleanup_preserves_triggers(db: Pool) -> anyhow::Result<( json!({"triggerType": "drive"}), None, true, + "u/test-user/native_conn", ) .await?; // Cleanup OAuth only — should NOT remove the trigger let mut tx = db.begin().await?; - windmill_native_triggers::workspace_integrations::cleanup_oauth_resource( + windmill_native_triggers::workspace_integrations::cleanup_connection( &mut *tx, "test-workspace", - ServiceName::Google, + resource_path, ) - .await; + .await?; tx.commit().await?; // OAuth resources gone @@ -607,6 +715,7 @@ async fn test_rename_moves_native_trigger(db: Pool) -> anyhow::Result< json!({"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent"}), None, true, + "u/test-user/native_conn", ) .await?; // An unrelated trigger already sitting on the target path must not be reported as moved. @@ -624,6 +733,7 @@ async fn test_rename_moves_native_trigger(db: Pool) -> anyhow::Result< json!({"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent"}), None, true, + "u/test-user/native_conn", ) .await?; @@ -696,6 +806,7 @@ async fn test_list_native_triggers_scope_filter(db: Pool) -> anyhow::R json!({"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent"}), None, true, + "u/test-user/native_conn", ) .await?; } @@ -801,7 +912,7 @@ fn test_provider_rejection_is_readable_and_not_internal() { "the envelope should not reach the user: {message}" ); assert!( - message.contains("Workspace settings > Integrations"), + message.contains("the workspace's shared account"), "the hint should say what to do: {message}" ); } @@ -931,6 +1042,7 @@ async fn test_native_trigger_enabled_toggle(db: Pool) -> anyhow::Resul json!({"event": "OCA\\Files\\Event\\LoadAdditionalScriptsEvent"}), None, true, + "u/test-user/native_conn", ) .await?; @@ -980,6 +1092,7 @@ async fn test_native_trigger_enabled_toggle(db: Pool) -> anyhow::Resul json!({}), None, false, + "u/test-user/native_conn", ) .await?; assert!( @@ -997,6 +1110,7 @@ async fn test_native_trigger_enabled_toggle(db: Pool) -> anyhow::Resul json!({}), None, true, + "u/test-user/native_conn", ) .await?; assert!( diff --git a/backend/windmill-api-integration-tests/tests/nested_dev_workspace.rs b/backend/windmill-api-integration-tests/tests/nested_dev_workspace.rs index a9549b91a8..fb92236ae1 100644 --- a/backend/windmill-api-integration-tests/tests/nested_dev_workspace.rs +++ b/backend/windmill-api-integration-tests/tests/nested_dev_workspace.rs @@ -432,13 +432,35 @@ async fn test_archive_takes_the_pairing_lock_for_a_standalone_workspace( .execute(&mut *held) .await?; - let finished = - tokio::time::timeout(std::time::Duration::from_secs(5), archive(port, "h-cand")).await; - assert!( - finished.is_err(), - "archive of a standalone workspace completed while its pairing lock was held: {finished:?}" - ); + let archiving = tokio::spawn(async move { archive(port, "h-cand").await }); + + let mut parked = false; + for _ in 0..400 { + let waiting: i64 = sqlx::query_scalar( + "SELECT count(*) FROM pg_stat_activity WHERE wait_event_type = 'Lock' \ + AND wait_event = 'advisory' AND datname = current_database() \ + AND query LIKE '%dev_workspace_pairing%' AND pid <> pg_backend_pid()", + ) + .fetch_one(&db) + .await?; + if waiting > 0 { + parked = true; + break; + } + assert!( + !archiving.is_finished(), + "archive of a standalone workspace completed while its pairing lock was held: {:?}", + archiving.await + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + assert!(parked, "archive never waited on the pairing lock"); held.rollback().await?; + let (status, body) = archiving.await?; + assert!( + status.is_success(), + "archive after the lock was released returned {status}: {body}" + ); Ok(()) } diff --git a/backend/windmill-api-integration-tests/tests/offboarding.rs b/backend/windmill-api-integration-tests/tests/offboarding.rs index 7bdeab924b..8dc8079096 100644 --- a/backend/windmill-api-integration-tests/tests/offboarding.rs +++ b/backend/windmill-api-integration-tests/tests/offboarding.rs @@ -90,6 +90,25 @@ async fn test_offboard_to_user(db: Pool) -> anyhow::Result<()> { .execute(&db) .await?; + // An agent records who it runs as inside its value, on a path the user does not own. + sqlx::query( + "INSERT INTO resource (workspace_id, path, value, resource_type, created_by) + VALUES ('test-workspace', 'f/shared/agent', $1, 'ai_agent', 'test-user')", + ) + .bind(json!({ "run_on_behalf_of": true, "on_behalf_of": "u/test-user-2" })) + .execute(&db) + .await?; + let preview: serde_json::Value = + authed(client().get(ws_url(port, "offboard_preview/test-user-2"))) + .send() + .await? + .json() + .await?; + assert_eq!( + preview["executing_on_behalf"]["resources"], + json!(["f/shared/agent"]) + ); + let resp = authed(client().post(ws_url(port, "offboard/test-user-2"))) .json(&json!({ "reassign_to": "u/test-user", @@ -100,6 +119,14 @@ async fn test_offboard_to_user(db: Pool) -> anyhow::Result<()> { .await?; assert_eq!(resp.status(), 200); + let agent_obo: Option = sqlx::query_scalar( + "SELECT value->>'on_behalf_of' FROM resource + WHERE path = 'f/shared/agent' AND workspace_id = 'test-workspace'", + ) + .fetch_one(&db) + .await?; + assert_eq!(agent_obo.as_deref(), Some("u/test-user")); + let body: serde_json::Value = resp.json().await?; assert!(body["conflicts"].as_array().map_or(true, |a| a.is_empty())); assert!(body["summary"].is_object()); @@ -117,7 +144,10 @@ async fn test_offboard_to_user(db: Pool) -> anyhow::Result<()> { .fetch_one(&db) .await?; assert_eq!( - (obo.on_behalf_of.as_deref(), obo.on_behalf_of_email.as_deref()), + ( + obo.on_behalf_of.as_deref(), + obo.on_behalf_of_email.as_deref() + ), (Some("u/test-user"), Some("test@windmill.dev")), "the reassignment moves the whole identity, not just the half this release reads" ); diff --git a/backend/windmill-api-integration-tests/tests/operator_builder_apps.rs b/backend/windmill-api-integration-tests/tests/operator_builder_apps.rs index 7033d093f2..71a7a2b8fa 100644 --- a/backend/windmill-api-integration-tests/tests/operator_builder_apps.rs +++ b/backend/windmill-api-integration-tests/tests/operator_builder_apps.rs @@ -1,7 +1,6 @@ use serde_json::json; use serial_test::serial; use sqlx::{Pool, Postgres}; -use windmill_common::workspaces::invalidate_operator_rights_cache; use windmill_test_utils::*; /// Every test here must be `#[serial]`: the rights cache is process-global and keyed by workspace id @@ -22,16 +21,18 @@ fn client(token: &str) -> reqwest::Client { async fn set_builder(db: &Pool, flows: bool, apps: bool) -> anyhow::Result<()> { sqlx::query( - "UPDATE workspace_settings SET operator_settings = $1::text::jsonb WHERE workspace_id = $2", + "UPDATE workspace_settings SET role_overrides = $1::text::jsonb WHERE workspace_id = $2", ) .bind(format!( - r#"{{"builder_flows": {flows}, "builder_apps": {apps}}}"# + r#"{{"operator": {{"capabilities": {{"flows": "{}", "apps": "{}"}}}}}}"#, + if flows { "compose" } else { "none" }, + if apps { "compose" } else { "none" }, )) .bind(WS) .execute(db) .await?; // The rights are read through a process-global 60s cache keyed by workspace id. - invalidate_operator_rights_cache(WS); + windmill_common::roles::invalidate_all_roles_cache(); Ok(()) } 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 7be3920124..b9c4e60800 100644 --- a/backend/windmill-api-integration-tests/tests/operator_builder_flows.rs +++ b/backend/windmill-api-integration-tests/tests/operator_builder_flows.rs @@ -1,6 +1,5 @@ use serde_json::json; use sqlx::{Pool, Postgres}; -use windmill_common::workspaces::invalidate_operator_rights_cache; use windmill_test_utils::*; const WS: &str = "test-workspace"; @@ -19,14 +18,17 @@ fn operator_client() -> reqwest::Client { async fn set_builder(db: &Pool, flows: bool) -> anyhow::Result<()> { sqlx::query( - "UPDATE workspace_settings SET operator_settings = $1::text::jsonb WHERE workspace_id = $2", + "UPDATE workspace_settings SET role_overrides = $1::text::jsonb WHERE workspace_id = $2", ) - .bind(format!(r#"{{"builder_flows": {flows}}}"#)) + .bind(format!( + r#"{{"operator": {{"capabilities": {{"flows": "{}"}}}}}}"#, + if flows { "compose" } else { "none" } + )) .bind(WS) .execute(db) .await?; // The right is read through a process-global 60s cache keyed by workspace id. - invalidate_operator_rights_cache(WS); + windmill_common::roles::invalidate_all_roles_cache(); Ok(()) } @@ -115,6 +117,16 @@ async fn test_operator_builder_flows_boundary(db: Pool) -> anyhow::Res .send() .await?; assert_eq!(resp.status(), 200, "{}", resp.text().await?); + let notified: Vec = + sqlx::query_scalar("SELECT payload FROM notify_event WHERE channel = $1") + .bind(windmill_common::roles::ROLE_OVERRIDES_CHANGE_CHANNEL) + .fetch_all(&db) + .await?; + assert_eq!( + notified, + vec![WS.to_string()], + "other replicas drop their cached roles only through this event" + ); let resp = c .post(format!("{api}/flows/create")) 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 ae1979f2fb..cfbeae7a69 100644 --- a/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs +++ b/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs @@ -1,13 +1,12 @@ use serde_json::json; use serial_test::serial; use sqlx::{Pool, Postgres}; -use windmill_common::workspaces::invalidate_operator_rights_cache; use windmill_test_utils::*; -/// Every test here must be `#[serial]`. The rights cache is process-global and keyed by workspace -/// id alone, while `sqlx::test` gives each test its own database under this same id — so two -/// running at once can answer each other's reads from one cached entry, and a withdrawal in one -/// database reads as granted in the other. +/// Every test here must be `#[serial]`, and start by dropping the cache entry. The rights cache is +/// process-global and keyed by workspace id alone, while `sqlx::test` gives each test its own +/// database under this same id — so two running at once can answer each other's reads from one +/// cached entry, and a withdrawal a test ends on reads as withdrawn in the next one's database. const WS: &str = "test-workspace"; fn operator_client() -> reqwest::Client { @@ -44,7 +43,7 @@ async fn set_settings(api: &str, body: serde_json::Value) -> anyhow::Result .send() .await?; let status = resp.status().as_u16(); - invalidate_operator_rights_cache(WS); + windmill_common::roles::invalidate_all_roles_cache(); Ok(status) } @@ -53,6 +52,7 @@ async fn set_settings(api: &str, body: serde_json::Value) -> anyhow::Result #[serial] async fn test_operator_manage_rights(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}"); @@ -145,6 +145,7 @@ async fn test_manage_triggers_covers_a_route_outside_the_shared_handler( 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}"); @@ -213,3 +214,195 @@ async fn test_manage_triggers_covers_a_route_outside_the_shared_handler( Ok(()) } + +/// A draft is what the AI chat writes before it deploys, so an operator holding a manage right +/// may draft that kind, and only while they hold it. Builder rights are left off throughout: they +/// open flow drafts, and would otherwise be what admits these. +#[sqlx::test(migrations = "../migrations", fixtures("base", "permissions_test"))] +#[serial] +async fn test_manage_rights_admit_matching_drafts(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 c = operator_client(); + + let save = async |kind: &str, path: &str| -> anyhow::Result<(u16, String)> { + let resp = c + .post(format!("{api}/drafts/update/{kind}/{path}")) + .json(&json!({ "value": { "path": path } })) + .send() + .await?; + Ok((resp.status().as_u16(), resp.text().await?)) + }; + let listed = async || -> anyhow::Result { + let resp = c.get(format!("{api}/drafts/list")).send().await?; + assert_eq!(resp.status(), 200); + Ok(resp.json().await?) + }; + let schedule_list_has_draft = async || -> anyhow::Result { + let resp = c + .get(format!("{api}/schedules/list?include_draft_only=true")) + .send() + .await?; + assert_eq!(resp.status(), 200); + let rows: Vec = resp.json().await?; + Ok(rows.iter().any(|r| r["path"] == "u/operator/sched_draft")) + }; + + let resp = c + .post(format!( + "{api}/drafts/update/trigger_schedule/u/operator/sched_draft" + )) + .json(&json!({ "value": { + "path": "u/operator/sched_draft", + "schedule": "0 0 12 * * *", + "timezone": "UTC", + "script_path": "f/system/hello", + "is_flow": false, + "args": {}, + "enabled": false + } })) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + assert!(schedule_list_has_draft().await?); + let (status, body) = save("trigger_http", "u/operator/http_draft").await?; + assert_eq!(status, 200, "{body}"); + // The rights name schedules and triggers, nothing else. + let (status, body) = save("script", "u/operator/script_draft").await?; + assert_eq!(status, 403, "{body}"); + + let drafts = listed().await?; + assert_eq!(drafts.as_array().map(Vec::len), Some(2), "{drafts}"); + + assert_eq!( + set_settings(&api, json!({"manage_schedules": false})).await?, + 200 + ); + let (status, body) = save("trigger_schedule", "u/operator/sched_draft").await?; + assert_eq!(status, 403, "{body}"); + // The list follows the right too, as the app list follows `builder_apps`. + assert!(!schedule_list_has_draft().await?); + let (status, body) = save("trigger_http", "u/operator/http_draft").await?; + assert_eq!(status, 200, "{body}"); + + assert_eq!( + set_settings(&api, json!({"manage_triggers": false})).await?, + 200 + ); + let (status, body) = save("trigger_http", "u/operator/http_draft").await?; + assert_eq!(status, 403, "{body}"); + // Still listed, as their own, but no longer writable. + let drafts = listed().await?; + assert_eq!(drafts.as_array().map(Vec::len), Some(2), "{drafts}"); + assert!( + drafts + .as_array() + .unwrap() + .iter() + .all(|d| d["can_write"] == json!(false)), + "{drafts}" + ); + + Ok(()) +} + +/// Operators create resources and variables on their pages with no operator setting in the way, +/// so they may draft them in any workspace — even one that withdrew every manage right — and +/// deploying a secret variable draft goes through the create route like a direct write. +#[sqlx::test(migrations = "../migrations", fixtures("base", "permissions_test"))] +#[serial] +async fn test_operators_draft_resources_and_variables(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 c = operator_client(); + + assert_eq!( + set_settings( + &api, + json!({"manage_schedules": false, "manage_triggers": false}) + ) + .await?, + 200 + ); + + let save = async |kind: &str, path: &str, value: serde_json::Value| { + let resp = c + .post(format!("{api}/drafts/update/{kind}/{path}")) + .json(&json!({ "value": value })) + .send() + .await?; + anyhow::Ok((resp.status().as_u16(), resp.text().await?)) + }; + + let (status, body) = save( + "resource", + "u/operator/res_draft", + json!({ "path": "u/operator/res_draft", "args": {}, "resource_type": "object" }), + ) + .await?; + assert_eq!(status, 200, "{body}"); + let (status, body) = save( + "variable", + "u/operator/secret_draft", + json!({ "path": "u/operator/secret_draft", "variable": { "value": "hunter2", "is_secret": true } }), + ) + .await?; + assert_eq!(status, 200, "{body}"); + let (status, body) = save("script", "u/operator/script_draft", json!({})).await?; + assert_eq!(status, 403, "{body}"); + + // The Resources and Variables pages list a draft that has never been deployed. + for (route, path) in [ + ("resources", "u/operator/res_draft"), + ("variables", "u/operator/secret_draft"), + ] { + let listed: serde_json::Value = c + .get(format!("{api}/{route}/list?include_draft_only=true")) + .send() + .await? + .json() + .await?; + assert!( + listed + .as_array() + .unwrap() + .iter() + .any(|r| r["path"] == path && r["draft_only"] == json!(true)), + "{route}: {listed}" + ); + } + + // Deploying a draft sends its stored value, the `$encrypted:` marker, to the create route. + let stored: String = sqlx::query_scalar( + "SELECT value::jsonb->'variable'->>'value' FROM draft + WHERE workspace_id = $1 AND path = 'u/operator/secret_draft'", + ) + .bind(WS) + .fetch_one(&db) + .await?; + assert!(stored.starts_with("$encrypted:"), "{stored}"); + let resp = c + .post(format!("{api}/variables/create")) + .json(&json!({ + "path": "u/operator/secret_draft", + "value": stored, + "is_secret": true, + "description": "", + })) + .send() + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + let resp = c + .get(format!("{api}/variables/get_value/u/operator/secret_draft")) + .send() + .await?; + assert_eq!(resp.json::().await?, "hunter2"); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/preview_on_behalf_of.rs b/backend/windmill-api-integration-tests/tests/preview_on_behalf_of.rs new file mode 100644 index 0000000000..a1bb7876c6 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/preview_on_behalf_of.rs @@ -0,0 +1,135 @@ +//! A preview's `on_behalf_of` runs request-supplied code as another account, so it must +//! change the job's identity for admins and be refused for everyone else. + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use uuid::Uuid; +use windmill_common::auth::create_jwt_token; +use windmill_common::db::Authed; +use windmill_test_utils::*; + +/// `route` is under `/api/w/test-workspace/`. +async fn post(port: u16, token: &str, route: &str, body: serde_json::Value) -> (u16, String) { + let resp = reqwest::Client::new() + .post(format!("http://localhost:{port}/api/w/test-workspace/{route}")) + .header("Authorization", format!("Bearer {token}")) + .json(&body) + .send() + .await + .unwrap(); + (resp.status().as_u16(), resp.text().await.unwrap()) +} + +async fn identity(db: &Pool, id: &str) -> (String, String) { + sqlx::query_as("SELECT permissioned_as, permissioned_as_email FROM v2_job WHERE id = $1") + .bind(Uuid::parse_str(id).unwrap()) + .fetch_one(db) + .await + .unwrap() +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_preview_on_behalf_of(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let script = |obo: &str| { + json!({ + "content": "export function main() { return 1; }", + "language": "deno", + "args": {}, + "on_behalf_of": obo, + }) + }; + let flow = |obo: &str| json!({ "value": {"modules": []}, "args": {}, "on_behalf_of": obo }); + // The app editor's preview of an inline runnable. + let app = |obo: &str| { + json!({ + "args": {}, + "component": "c", + "raw_code": { "language": "deno", "content": "export function main() { return 1; }" }, + "force_viewer_static_fields": {}, + "on_behalf_of": obo, + }) + }; + let app_route = "apps_u/execute_component/u/test-user/app"; + + for (route, body) in [ + ("jobs/run/preview", script("test2@windmill.dev")), + ("jobs/run/preview_flow", flow("u/test-user-2")), + (app_route, app("u/test-user-2")), + ] { + let (status, id) = post(port, "SECRET_TOKEN", route, body).await; + assert!((200..300).contains(&status), "{route}: {status} {id}"); + assert_eq!( + identity(&db, &id).await, + ( + "u/test-user-2".to_string(), + "test2@windmill.dev".to_string() + ), + "{route}" + ); + let logs: String = sqlx::query_scalar("SELECT logs FROM job_logs WHERE job_id = $1") + .bind(Uuid::parse_str(&id).unwrap()) + .fetch_one(&db) + .await?; + assert!( + logs.starts_with("Preview run on behalf of u/test-user-2 (test2@windmill.dev), requested by test@windmill.dev"), + "{route}: {logs}" + ); + } + + for (route, body) in [ + ("jobs/run/preview", script("test@windmill.dev")), + ("jobs/run/preview_flow", flow("u/test-user")), + (app_route, app("u/test-user")), + ] { + let (status, body) = post(port, "SECRET_TOKEN_2", route, body).await; + assert_eq!(status, 403, "{route}: {body}"); + } + + let (status, id) = post(port, "SECRET_TOKEN_2", "jobs/run/preview", script("u/test-user-2")).await; + assert_eq!(status, 201, "{id}"); + assert_eq!(identity(&db, &id).await.0, "u/test-user-2"); + + // Reading data as the end user is the direct caller's own choice. A job token's end user is + // whoever ran that job, so a job's code must not be able to ask for it. + set_jwt_secret().await; + let job_token = create_jwt_token( + Authed { + email: "test@windmill.dev".to_string(), + username: "test-user".to_string(), + is_admin: true, + is_operator: false, + groups: vec![], + folders: vec![], + scopes: None, + token_prefix: None, + }, + "test-workspace", + 3600, + Some(Uuid::new_v4()), + Some("ephemeral-script".to_string()), + None, + None, + ) + .await?; + let viewer_data = json!({ + "content": "export function main() { return 1; }", + "language": "deno", + "args": {}, + "viewer_data_access": true, + }); + for (token, expected) in [("SECRET_TOKEN", true), (job_token.as_str(), false)] { + let (status, id) = post(port, token, "jobs/run/preview", viewer_data.clone()).await; + assert_eq!(status, 201, "{id}"); + let stamped: bool = + sqlx::query_scalar("SELECT viewer_data_access FROM job_perms WHERE job_id = $1") + .bind(Uuid::parse_str(&id).unwrap()) + .fetch_one(&db) + .await?; + assert_eq!(stamped, expected, "job token: {}", !expected); + } + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/resources.rs b/backend/windmill-api-integration-tests/tests/resources.rs index 823c415818..ebca295339 100644 --- a/backend/windmill-api-integration-tests/tests/resources.rs +++ b/backend/windmill-api-integration-tests/tests/resources.rs @@ -648,6 +648,15 @@ async fn test_interpolated_references_need_the_token_scopes( read_probe(json!({"v": "$var:u/test-user/secret"}), with_var.clone()).await; assert_eq!((status, body.as_str()), (200, r#"{"v":"CANARY"}"#)); + // A wildcard resource pattern extends to the variables it matches, and only to those. + let wildcard = mint(json!(["resources:read:u/test-user/*"])).await; + let (status, body) = + read_probe(json!({"v": "$var:u/test-user/secret"}), wildcard.clone()).await; + assert_eq!((status, body.as_str()), (200, r#"{"v":"CANARY"}"#)); + let elsewhere = mint(json!(["resources:read:u/test-user/probe,f/other/*"])).await; + let (status, body) = read_probe(json!({"v": "$var:u/test-user/secret"}), elsewhere).await; + assert_eq!(status, 403, "{body}"); + // The resource's own linked secrets (at its path, or `_`) are part of it. for (path, value) in [ ("u/test-user/probe", "LINKED"), diff --git a/backend/windmill-api-integration-tests/tests/users.rs b/backend/windmill-api-integration-tests/tests/users.rs index 56656e54dd..3a52e94e4a 100644 --- a/backend/windmill-api-integration-tests/tests/users.rs +++ b/backend/windmill-api-integration-tests/tests/users.rs @@ -1046,3 +1046,117 @@ async fn test_impersonation_session_is_never_refreshed(db: Pool) -> an Ok(()) } + +#[cfg(all(feature = "enterprise", feature = "private"))] +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_set_password_revokes_mcp_oauth_grants(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + + sqlx::raw_sql( + "INSERT INTO mcp_oauth_server_client (client_id, client_name, redirect_uris) + VALUES ('mcp-client', 'MCP client', ARRAY['http://localhost/cb']); + INSERT INTO token (token_hash, token_prefix, email, label, expiration) + VALUES (encode(sha256('MCP_ACCESS_TOKEN'::bytea), 'hex'), 'MCP_ACCESS', 'test2@windmill.dev', + 'mcp-oauth-mcp-client', now() + interval '7 days'), + (encode(sha256('OTHER_MCP_TOKEN'::bytea), 'hex'), 'OTHER_MCP_', 'test3@windmill.dev', + 'mcp-oauth-mcp-client', now() + interval '7 days'); + INSERT INTO mcp_oauth_refresh_token + (refresh_token, access_token_hash, client_id, user_email, workspace_id, scopes, token_family, expires_at) + VALUES ('test2-refresh', encode(sha256('MCP_ACCESS_TOKEN'::bytea), 'hex'), 'mcp-client', + 'test2@windmill.dev', 'test-workspace', ARRAY['mcp:all'], gen_random_uuid(), now() + interval '30 days'), + ('test3-refresh', encode(sha256('OTHER_MCP_TOKEN'::bytea), 'hex'), 'mcp-client', + 'test3@windmill.dev', 'test-workspace', ARRAY['mcp:all'], gen_random_uuid(), now() + interval '30 days'); + INSERT INTO mcp_oauth_server_code (code, client_id, user_email, workspace_id, scopes, redirect_uri) + VALUES ('test2-code', 'mcp-client', 'test2@windmill.dev', 'test-workspace', ARRAY['mcp:all'], + 'http://localhost/cb');", + ) + .execute(&db) + .await?; + + // A refresh in flight: it holds the grant lock and writes its rotated tokens while the + // password change is waiting on that lock. + let mut grant = db.begin().await?; + sqlx::query("SELECT pg_advisory_xact_lock_shared(hashtext('mcp_oauth_grant:' || $1))") + .bind("test2@windmill.dev") + .execute(&mut *grant) + .await?; + let request = tokio::spawn( + authed(client().post(format!( + "http://localhost:{}/api/users/set_password_of/test2@windmill.dev", + server.addr.port() + ))) + .json(&json!({"password": "new-test-password-123"})) + .send(), + ); + let mut waiting = false; + for _ in 0..100 { + waiting = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM pg_locks WHERE locktype = 'advisory' AND NOT granted + AND database = (SELECT oid FROM pg_database WHERE datname = current_database()))", + ) + .fetch_one(&db) + .await?; + if waiting { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + assert!( + waiting, + "the password change never waited on the grant lock" + ); + sqlx::raw_sql( + "INSERT INTO token (token_hash, token_prefix, email, label, expiration) + VALUES (encode(sha256('MCP_ROTATED_TOKEN'::bytea), 'hex'), 'MCP_ROTATE', 'test2@windmill.dev', + 'mcp-oauth-mcp-client', now() + interval '7 days'); + INSERT INTO mcp_oauth_refresh_token + (refresh_token, access_token_hash, client_id, user_email, workspace_id, scopes, token_family, expires_at) + VALUES ('test2-rotated', encode(sha256('MCP_ROTATED_TOKEN'::bytea), 'hex'), 'mcp-client', + 'test2@windmill.dev', 'test-workspace', ARRAY['mcp:all'], gen_random_uuid(), now() + interval '30 days');", + ) + .execute(&mut *grant) + .await?; + grant.commit().await?; + + let resp = request.await??; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + + let labels: Vec<(String, String)> = sqlx::query_as( + "SELECT email, label FROM token WHERE email IN ('test2@windmill.dev', 'test3@windmill.dev') ORDER BY email, label", + ) + .fetch_all(&db) + .await?; + assert_eq!( + labels, + vec![ + ("test2@windmill.dev".to_string(), "test token 2".to_string()), + ( + "test3@windmill.dev".to_string(), + "mcp-oauth-mcp-client".to_string() + ), + ("test3@windmill.dev".to_string(), "test token 3".to_string()), + ] + ); + let refresh: Vec = sqlx::query_scalar( + "SELECT refresh_token FROM mcp_oauth_refresh_token ORDER BY refresh_token", + ) + .fetch_all(&db) + .await?; + assert_eq!(refresh, vec!["test3-refresh".to_string()]); + let invalidated: Vec = sqlx::query_scalar( + "SELECT payload FROM notify_event WHERE channel = 'notify_token_invalidation' ORDER BY payload", + ) + .fetch_all(&db) + .await?; + assert_eq!( + invalidated, + vec!["MCP_ACCESS".to_string(), "MCP_ROTATE".to_string()] + ); + let codes: i64 = sqlx::query_scalar("SELECT count(*) FROM mcp_oauth_server_code") + .fetch_one(&db) + .await?; + assert_eq!(codes, 0); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/variables.rs b/backend/windmill-api-integration-tests/tests/variables.rs index e5018f4f97..7332383936 100644 --- a/backend/windmill-api-integration-tests/tests/variables.rs +++ b/backend/windmill-api-integration-tests/tests/variables.rs @@ -338,3 +338,65 @@ async fn test_variables_are_cached(db: Pool) -> anyhow::Result<()> { 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"))] +async fn test_variable_endpoints_ignore_account_link(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/variables"); + + let account: i32 = sqlx::query_scalar( + "INSERT INTO account (workspace_id, client, expires_at, refresh_token) + VALUES ('test-workspace', 'github', now(), 'r') RETURNING id", + ) + .fetch_one(&db) + .await?; + let link = |path: &'static str| { + let db = db.clone(); + async move { + sqlx::query_scalar::<_, Option>( + "SELECT account FROM variable WHERE workspace_id = 'test-workspace' AND path = $1", + ) + .bind(path) + .fetch_one(&db) + .await + .unwrap() + } + }; + + let path = "u/test-user/oauth_probe"; + let resp = authed(client().post(format!("{base}/create"))) + .json( + &json!({ "path": path, "value": "t", "is_secret": true, "description": "", + "is_oauth": true, "account": account }), + ) + .send() + .await?; + assert_eq!(resp.status(), 201); + assert_eq!(link(path).await, None); + + let other: i32 = sqlx::query_scalar( + "INSERT INTO account (workspace_id, client, expires_at, refresh_token) + VALUES ('test-workspace', 'github', now(), 'r') RETURNING id", + ) + .fetch_one(&db) + .await?; + sqlx::query( + "UPDATE variable SET account = $1 WHERE workspace_id = 'test-workspace' AND path = $2", + ) + .bind(account) + .bind(path) + .execute(&db) + .await?; + let resp = authed(client().post(variable_url(port, "update", path))) + .json(&json!({ "description": "d", "account": other })) + .send() + .await?; + assert_eq!(resp.status(), 200); + assert_eq!(link(path).await, Some(account)); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/workspace_comparison.rs b/backend/windmill-api-integration-tests/tests/workspace_comparison.rs index 1aa2bc8df2..bef3b5dd78 100644 --- a/backend/windmill-api-integration-tests/tests/workspace_comparison.rs +++ b/backend/windmill-api-integration-tests/tests/workspace_comparison.rs @@ -2549,6 +2549,26 @@ async fn test_rename_records_the_vacated_path(db: Pool) -> anyhow::Res "script deploy failed: {status} — {hash}" ); parent_hash = Some(hash.trim().trim_matches('"').to_string()); + // Each deploy tallies from its own spawned task: let the create's `write` land + // before the rename, or it can overwrite the `rename_from` asserted below. + if path == "u/admin/inline" { + let mut written = false; + for _ in 0..40 { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + written = sqlx::query_scalar!( + "SELECT EXISTS (SELECT 1 FROM workspace_diff + WHERE fork_workspace_id = 'wm-fork-renamed' AND kind = 'script' + AND path = 'u/admin/inline' AND fork_last_event_kind = 'write')" + ) + .fetch_one(&db) + .await? + .unwrap_or(false); + if written { + break; + } + } + assert!(written, "the inline create should be tallied"); + } } let mut vacated = None; for _ in 0..40 { @@ -2556,7 +2576,7 @@ async fn test_rename_records_the_vacated_path(db: Pool) -> anyhow::Res vacated = sqlx::query!( "SELECT fork_last_event_kind, fork_last_event_origin FROM workspace_diff WHERE fork_workspace_id = 'wm-fork-renamed' AND kind = 'script' - AND path = 'u/admin/inline' AND fork_last_event_kind IS NOT NULL" + AND path = 'u/admin/inline' AND fork_last_event_kind <> 'write'" ) .fetch_optional(&db) .await?; @@ -2570,3 +2590,183 @@ async fn test_rename_records_the_vacated_path(db: Pool) -> anyhow::Res Ok(()) } + +/// Regression: a resource or resource type can be stored with a NULL description while deploy +/// writes "", so the comparison must read the two as equal or the item never settles. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_compare_null_description_equals_empty(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 client = windmill_api_client::create_client( + &format!("http://localhost:{port}"), + "SECRET_TOKEN".to_string(), + ); + + for q in [ + "INSERT INTO workspace (id, name, owner, parent_workspace_id) + VALUES ('wm-fork-test-workspace', 'Fork', 'test-user', 'test-workspace')", + "INSERT INTO workspace_settings (workspace_id) VALUES ('wm-fork-test-workspace')", + "INSERT INTO workspace_key(workspace_id, kind, key) + VALUES ('wm-fork-test-workspace', 'cloud', 'test-key')", + "INSERT INTO resource (workspace_id, path, value, resource_type, description, created_by) VALUES + ('test-workspace', 'u/test-user/null_desc', '{}', 'generic', NULL, 'test-user'), + ('wm-fork-test-workspace', 'u/test-user/null_desc', '{}', 'generic', '', 'test-user'), + ('test-workspace', 'u/test-user/other_desc', '{}', 'generic', NULL, 'test-user'), + ('wm-fork-test-workspace', 'u/test-user/other_desc', '{}', 'generic', 'x', 'test-user')", + "INSERT INTO resource_type (workspace_id, name, schema, description, created_by) VALUES + ('test-workspace', 'null_desc_rt', '{}', NULL, 'test-user'), + ('wm-fork-test-workspace', 'null_desc_rt', '{}', '', 'test-user'), + ('test-workspace', 'other_desc_rt', '{}', NULL, 'test-user'), + ('wm-fork-test-workspace', 'other_desc_rt', '{}', 'x', 'test-user')", + "INSERT INTO workspace_diff + (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes) VALUES + ('test-workspace', 'wm-fork-test-workspace', 'u/test-user/null_desc', 'resource', 1, 1, NULL), + ('test-workspace', 'wm-fork-test-workspace', 'u/test-user/other_desc', 'resource', 1, 1, NULL), + ('test-workspace', 'wm-fork-test-workspace', 'null_desc_rt', 'resource_type', 1, 1, NULL), + ('test-workspace', 'wm-fork-test-workspace', 'other_desc_rt', 'resource_type', 1, 1, NULL)", + ] { + sqlx::query(q).execute(&db).await?; + } + + let comparison: serde_json::Value = client + .client() + .get(&format!( + "{base_url}/w/test-workspace/workspaces/compare/wm-fork-test-workspace" + )) + .send() + .await? + .json() + .await?; + let mut paths: Vec<&str> = comparison["diffs"] + .as_array() + .unwrap_or_else(|| panic!("no diffs: {comparison}")) + .iter() + .map(|d| d["path"].as_str().unwrap()) + .collect(); + paths.sort(); + assert_eq!( + paths, + ["other_desc_rt", "u/test-user/other_desc"], + "{comparison}" + ); + + Ok(()) +} + +/// Runs its closure when dropped, so process-wide state is put back even when an assertion +/// fails. +struct OnDrop(F); +impl Drop for OnDrop { + fn drop(&mut self) { + (self.0)() + } +} + +/// Secrets are encrypted with a fresh nonce on every write, so a secret holding the same value on +/// both sides must compare by plaintext, not ciphertext. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_compare_secret_variables_by_value(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let client = windmill_api_client::create_client( + &format!("http://localhost:{port}"), + "SECRET_TOKEN".to_string(), + ); + let base_url = format!("http://localhost:{port}/api"); + + let resp = client + .client() + .post(&format!( + "{base_url}/w/test-workspace/workspaces/create_fork" + )) + .json(&json!({ "id": "wm-fork-secrets", "name": "Secrets Fork" })) + .send() + .await?; + assert!( + resp.status().is_success(), + "fork creation failed: {}", + resp.status() + ); + + // Secrets are written in CBC, which is deterministic, until every worker reads AES-GCM. + use windmill_common::min_version::{MIN_VERSION, MIN_VERSION_SUPPORTS_AEAD_SECRETS}; + let before = MIN_VERSION.load_full(); + let _restore = OnDrop(move || MIN_VERSION.store(before.clone())); + MIN_VERSION.store(std::sync::Arc::new( + MIN_VERSION_SUPPORTS_AEAD_SECRETS.version().clone(), + )); + + for (ws, path, value) in [ + ("test-workspace", "u/test-user/same", "s3cret"), + ("wm-fork-secrets", "u/test-user/same", "s3cret"), + ("test-workspace", "u/test-user/different", "one"), + ("wm-fork-secrets", "u/test-user/different", "two"), + ] { + let resp = client + .client() + .post(&format!("{base_url}/w/{ws}/variables/create")) + .json(&json!({ "path": path, "value": value, "is_secret": true, "description": "" })) + .send() + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + } + let stored = sqlx::query_scalar!( + "SELECT COUNT(DISTINCT value) FROM variable WHERE path = 'u/test-user/same'" + ) + .fetch_one(&db) + .await?; + assert_eq!( + stored, + Some(2), + "the same secret must be stored as two different ciphertexts" + ); + + // The creates above tally these rows from a spawned task that can land after the + // DELETE, so the plant must upsert. + sqlx::query!("DELETE FROM workspace_diff WHERE fork_workspace_id = 'wm-fork-secrets'") + .execute(&db) + .await?; + sqlx::query!( + "INSERT INTO workspace_diff + (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes) + VALUES + ('test-workspace', 'wm-fork-secrets', 'u/test-user/same', 'variable', 1, 1, NULL), + ('test-workspace', 'wm-fork-secrets', 'u/test-user/different', 'variable', 1, 1, NULL) + ON CONFLICT (source_workspace_id, fork_workspace_id, path, kind) + DO UPDATE SET ahead = 1, behind = 1, has_changes = NULL" + ) + .execute(&db) + .await?; + + let comparison: serde_json::Value = client + .client() + .get(&format!( + "{base_url}/w/test-workspace/workspaces/compare/wm-fork-secrets" + )) + .send() + .await? + .json() + .await?; + let diffs = comparison["diffs"].as_array().unwrap(); + let listed = |path: &str| { + diffs + .iter() + .any(|d| d["path"] == path && d["kind"] == "variable") + }; + + assert!( + !listed("u/test-user/same"), + "equal secrets must compare as unchanged: {comparison}" + ); + assert!( + listed("u/test-user/different"), + "different secrets must compare as changed: {comparison}" + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/workspaces.rs b/backend/windmill-api-integration-tests/tests/workspaces.rs index a159bf6090..de5ed4baa0 100644 --- a/backend/windmill-api-integration-tests/tests/workspaces.rs +++ b/backend/windmill-api-integration-tests/tests/workspaces.rs @@ -1262,5 +1262,35 @@ async fn test_admin_and_operator_are_exclusive(db: Pool) -> anyhow::Re .await?; assert_eq!(resp.status(), 400, "{}", resp.text().await?); + // Rows written before the rule can still carry both flags: the admin wins, and `whoami` + // says so, since the frontend hides every gated page from an operator. + sqlx::query( + "UPDATE usr SET is_admin = true, operator = true + WHERE workspace_id = 'test-workspace' AND username = 'operator'", + ) + .execute(&db) + .await?; + let resp = client() + .get(format!("{api}/users/whoami")) + .header("Authorization", "Bearer OPERATOR_TOKEN_1") + .send() + .await?; + assert_eq!(resp.status(), 200); + let me: serde_json::Value = resp.json().await?; + assert_eq!( + (me["is_admin"].as_bool(), me["operator"].as_bool()), + (Some(true), Some(false)) + ); + let resp = client() + .post(format!("{api}/scripts/create")) + .header("Authorization", "Bearer OPERATOR_TOKEN_1") + .json(&json!({ + "path": "u/operator/legacy_admin", "summary": "", "description": "", + "content": "export function main() {}", "language": "deno", "schema": {} + })) + .send() + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + Ok(()) } diff --git a/backend/windmill-api-jobs/Cargo.toml b/backend/windmill-api-jobs/Cargo.toml index dad6469947..ca4153ac44 100644 --- a/backend/windmill-api-jobs/Cargo.toml +++ b/backend/windmill-api-jobs/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_api_jobs" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] diff --git a/backend/windmill-api-jobs/src/execution.rs b/backend/windmill-api-jobs/src/execution.rs index 330e96c2fb..53c318b70d 100644 --- a/backend/windmill-api-jobs/src/execution.rs +++ b/backend/windmill-api-jobs/src/execution.rs @@ -916,6 +916,35 @@ pub async fn handle_chat_conversation_messages( is_test: bool, // The run's args, for the files the message carried. args: &HashMap>, +) -> error::Result<()> { + file_chat_turn( + tx, + &authed.username, + authed.is_admin, + w_id, + flow_path, + run_query, + user_message_raw, + job_id, + is_test, + args, + ) + .await +} + +/// [`handle_chat_conversation_messages`] for a conversation owned by `owner`, which need not be a +/// user: an anonymous app visitor's is owned by `ANONYMOUS_CONVERSATION_OWNER`. +pub async fn file_chat_turn( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + owner: &str, + is_admin: bool, + w_id: &str, + flow_path: &str, + run_query: &RunJobQuery, + user_message_raw: Option<&Box>, + job_id: Uuid, + is_test: bool, + args: &HashMap>, ) -> error::Result<()> { // Names the query parameter rather than the field: it is not a flow argument, and // supplying it as one is the first thing tried on reading `memory_id is required`. @@ -945,7 +974,8 @@ pub async fn handle_chat_conversation_messages( tx, w_id, flow_path, - &authed.username, + owner, + is_admin, &user_message, memory_id, is_test, diff --git a/backend/windmill-api-jobs/src/jobs_export.rs b/backend/windmill-api-jobs/src/jobs_export.rs index b2b69367bc..c426497cf0 100644 --- a/backend/windmill-api-jobs/src/jobs_export.rs +++ b/backend/windmill-api-jobs/src/jobs_export.rs @@ -434,6 +434,7 @@ pub async fn import_completed_jobs( pub async fn import_queued_jobs( authed: ApiAuthed, + Extension(db): Extension, Extension(user_db): Extension, Path(w_id): Path, Json(jobs): Json>, @@ -468,7 +469,9 @@ pub async fn import_queued_jobs( let mut tx = user_db.begin(&authed).await?; - for job in jobs { + for mut job in jobs { + // A fork's admin is whoever created it, so an imported tag is as untrusted as a pushed one. + job.tag = windmill_queue::tags::apply_dev_workspace_suffix(job.tag, &w_id, &db).await?; sqlx::query!( r#" INSERT INTO v2_job ( @@ -622,6 +625,21 @@ pub async fn delete_jobs( let mut tx = user_db.begin(&authed).await?; + // job_ids are request-supplied, and resume_job, v2_job_runtime, v2_job_status, + // concurrency_key and zombie_job_counter have no workspace_id: keep only this workspace's + // jobs, or a workspace admin could erase another workspace's rows by passing its job ids. + // (Side-table list kept in sync with windmill_common::jobs::delete_jobs.) + let job_ids = sqlx::query_scalar!( + "SELECT id FROM v2_job WHERE workspace_id = $1 AND id = ANY($2)", + &w_id, + &job_ids + ) + .fetch_all(&mut *tx) + .await?; + if job_ids.is_empty() { + return Ok("No jobs to delete".to_string()); + } + let logs_deleted = sqlx::query!( "DELETE FROM job_logs WHERE workspace_id = $1 AND job_id = ANY($2)", &w_id, @@ -690,14 +708,8 @@ pub async fn delete_jobs( .await? .rows_affected(); - // job_ids are request-supplied, so scope every side-table delete to the workspace exactly - // like the v2_job delete below — otherwise a workspace admin could erase another - // workspace's side rows by passing foreign job ids. zombie_job_counter and - // flow_conversation_message have no workspace_id, so scope them via v2_job / their conversation. - // (Side-table list kept in sync with windmill_common::jobs::delete_jobs.) let zombie_deleted = sqlx::query!( - "DELETE FROM zombie_job_counter WHERE job_id IN (SELECT id FROM v2_job WHERE workspace_id = $1 AND id = ANY($2))", - &w_id, + "DELETE FROM zombie_job_counter WHERE job_id = ANY($1)", &job_ids ) .execute(&mut *tx) diff --git a/backend/windmill-api-npm-proxy/Cargo.toml b/backend/windmill-api-npm-proxy/Cargo.toml index d6e123abe3..e5d326e660 100644 --- a/backend/windmill-api-npm-proxy/Cargo.toml +++ b/backend/windmill-api-npm-proxy/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_api_npm_proxy" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] diff --git a/backend/windmill-api-openapi/Cargo.toml b/backend/windmill-api-openapi/Cargo.toml index 24a6f2aed8..97d11f1f0d 100644 --- a/backend/windmill-api-openapi/Cargo.toml +++ b/backend/windmill-api-openapi/Cargo.toml @@ -7,6 +7,9 @@ edition.workspace = true [lib] name = "windmill_api_openapi" path = "src/lib.rs" +# src/ has no unit tests or doctests: remove both lines before adding one, or cargo test never runs it. +test = false +doctest = false [dependencies] windmill-api-auth.workspace = true diff --git a/backend/windmill-api-schedule/Cargo.toml b/backend/windmill-api-schedule/Cargo.toml index 5017985820..f7a612c628 100644 --- a/backend/windmill-api-schedule/Cargo.toml +++ b/backend/windmill-api-schedule/Cargo.toml @@ -7,6 +7,9 @@ edition.workspace = true [lib] name = "windmill_api_schedule" path = "src/lib.rs" +# src/ has no unit tests or doctests: remove both lines before adding one, or cargo test never runs it. +test = false +doctest = false [features] default = [] diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs index b8ee9ded91..787742d18d 100644 --- a/backend/windmill-api-schedule/src/lib.rs +++ b/backend/windmill-api-schedule/src/lib.rs @@ -916,7 +916,6 @@ async fn list_schedule( // Append the authed user's draft-only schedules; see scripts.rs. if lsq.include_draft_only.unwrap_or(false) - && !authed.is_operator && offset == 0 && lsq.path.is_none() && lsq.is_flow.is_none() @@ -927,6 +926,13 @@ async fn list_schedule( && lsq.summary.is_none() && lsq.broad_filter.is_none() && lsq.label.is_none() + && windmill_common::workspaces::operator_can_draft( + &db, + &w_id, + &authed, + UserDraftItemKind::TriggerSchedule, + ) + .await? { let draft_only_rows = fetch_draft_only_list_rows( &db, diff --git a/backend/windmill-api-scripts/Cargo.toml b/backend/windmill-api-scripts/Cargo.toml index 72353c6f1b..73bbfd49bb 100644 --- a/backend/windmill-api-scripts/Cargo.toml +++ b/backend/windmill-api-scripts/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_api_scripts" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 4f4c962759..e6dcfe8155 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -185,6 +185,10 @@ async fn list_scripts( Query(pagination): Query, Query(lq): Query, ) -> JsonResult> { + // A role that cannot write code lists only what it can run: no libraries, no drafts. + let cannot_code = !windmill_common::roles::caller_capabilities(&db, &w_id, &authed) + .await? + .code; let (per_page, offset) = paginate_without_limits(pagination); let mut sqlb = SqlBuilder::select_from("script as o") .fields(&[ @@ -255,7 +259,7 @@ async fn list_scripts( .as_ref() .map(|x| !x.contains(&"preprocessor".to_string())) .unwrap_or(true)) - || authed.is_operator + || cannot_code { // only include scripts that have a runnable entrypoint. Use a // deny-list: anything that isn't a 'lib' (library script without @@ -312,7 +316,7 @@ async fn list_scripts( ); } } - if authed.is_operator { + if cannot_code { sqlb.and_where_eq("kind", quote("script")); } else if let Some(lowercased_kinds) = lowercased_kinds { let safe_kinds = lowercased_kinds @@ -360,7 +364,7 @@ async fn list_scripts( // `include_draft_only` so picker callers stay deployed-only (home page opts in); // skipped past page 0 or under any narrowing filter to keep pagination clean. if lq.include_draft_only.unwrap_or(false) - && !authed.is_operator + && !cannot_code && offset == 0 && lq.path_start.is_none() && lq.path_exact.is_none() @@ -900,7 +904,7 @@ async fn is_noop_deploy_against_parent( parent: &Script, resolved_on_behalf_of: Option<&str>, resolved_job_token_scopes: Option<&[String]>, - db: &DB, + tx: &mut Transaction<'_, Postgres>, ) -> Result { if parent.archived || parent.deleted { return Ok(false); @@ -1056,12 +1060,13 @@ async fn is_noop_deploy_against_parent( return Ok(false); } + let parent_settings = windmill_common::runnable_settings::from_handle( + parent.runnable_settings.runnable_settings_handle, + &mut **tx, + ) + .await?; let (parent_debouncing, parent_concurrency) = - windmill_common::runnable_settings::prefetch_cached_from_handle( - parent.runnable_settings.runnable_settings_handle, - db, - ) - .await?; + windmill_common::runnable_settings::prefetch_cached_tx(&parent_settings, tx).await?; if concurrency_settings != &parent_concurrency { return Ok(false); } @@ -1130,6 +1135,69 @@ fn lock_hash_entry(path: &str, lock: &str) -> [(String, i64); 1] { [(path.to_string(), hash_script(lock))] } +/// The principal a deploy would store, and the address written beside it only while a worker +/// that still reads that address may be live. +async fn resolve_deploy_identity( + on_behalf_of_email: Option<&str>, + on_behalf_of: Option<&str>, + preserve: bool, + authed: &ApiAuthed, + w_id: &str, + db: &DB, +) -> Result<(Option, Option)> { + let resolved = windmill_common::resolve_on_behalf_of( + on_behalf_of_email, + on_behalf_of, + preserve, + authed, + w_id, + db, + ) + .await?; + let legacy_email = + windmill_common::legacy_on_behalf_of_email(resolved.as_deref(), w_id, db).await?; + Ok((resolved, legacy_email)) +} + +/// Lineage reads must see versions the caller cannot, yet run on the deploy's own connection, +/// after the row locks that make them sound: lifting RLS for them is what stands in for asking +/// the pool, which while `tx` is held is a hold-and-wait. Every lift must be undone before `tx` +/// is handed on, or the rest of the deploy writes unscoped. +async fn set_lineage_reads_unscoped( + tx: &mut Transaction<'_, Postgres>, + authed: &ApiAuthed, + unscoped: bool, +) -> Result<()> { + // An admin's transaction already runs as `windmill_admin`. + if !authed.is_admin { + let role = if unscoped { + "SET LOCAL ROLE windmill_admin" + } else { + "SET LOCAL ROLE windmill_user" + }; + sqlx::query(role).execute(&mut **tx).await?; + } + Ok(()) +} + +/// The folder's default identity for `path` and what deploying under it would store. +async fn resolve_folder_default_identity( + authed: &ApiAuthed, + w_id: &str, + path: &str, + db: &DB, +) -> Result, Option))>> { + let Some((email, permissioned_as)) = + windmill_common::folders::resolve_folder_default_on_behalf_of(db, w_id, path).await? + else { + return Ok(None); + }; + let identity = + resolve_deploy_identity(Some(&email), Some(&permissioned_as), true, authed, w_id, db) + .await?; + Ok(Some((email, permissioned_as, identity))) +} + /// The `dbt://` relation both halves of a deploy have to agree on: a whole /// `//`, under a warehouse this workspace configures. /// @@ -1184,7 +1252,7 @@ async fn validate_dbt_relation( /// The schema the editor would have derived from `content`, for a deploy that sends /// none (MCP, a bare API call). `None` for a language whose parser this build lacks, /// or code that does not parse. -fn infer_main_schema( +pub fn infer_main_schema( language: &ScriptLang, kind: Option<&ScriptKind>, content: &str, @@ -1288,11 +1356,7 @@ async fn create_script_internal<'c>( Option, Vec, )> { - if authed.is_operator { - return Err(Error::NotAuthorized( - "Operators cannot create scripts for security reasons".to_string(), - )); - } + windmill_common::workspaces::check_role_can_code(&db, &w_id, &authed, "create scripts").await?; check_scopes(&authed, || format!("scripts:write:{}", ns.path))?; // Normalize positive-only settings so a `<= 0` value (e.g. a CLI-pushed `0`) persists as @@ -1341,38 +1405,24 @@ async fn create_script_internal<'c>( let skip_draft_deletion = ns.skip_draft_deletion.unwrap_or(false); let authed = maybe_refresh_folders(&ns.path, &w_id, authed, &db).await; - let mut tx: Transaction<'_, Postgres> = user_db.begin(&authed).await?; - - // Apply folder default_permissioned_as the first time a script is deployed - // at this path. Check inside the transaction to avoid TOCTOU with concurrent deploys. + // Everything read from `db` happens before `tx` opens: `user_db` wraps the same pool, so a + // deploy taking a second connection while holding `tx` lets N concurrent deploys hold the + // whole pool between them and each wait out the acquire timeout for one more. let explicit_preserve = (ns.on_behalf_of_email.is_some() || ns.on_behalf_of.is_some()) && ns.preserve_on_behalf_of.unwrap_or(false) && windmill_common::can_preserve_on_behalf_of(&authed); - if !explicit_preserve && windmill_common::can_preserve_on_behalf_of(&authed) { - let path_already_exists = sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2)", - &ns.path, - &w_id - ) - .fetch_one(&mut *tx) - .await? - .unwrap_or(false); - if !path_already_exists { - if let Some((default_email, default_permissioned_as)) = - windmill_common::folders::resolve_folder_default_on_behalf_of(&db, &w_id, &ns.path) - .await? - { - ns.on_behalf_of_email = Some(default_email); - ns.on_behalf_of = Some(default_permissioned_as); - ns.preserve_on_behalf_of = Some(true); - } - } - } - let authed_principal = windmill_common::users::username_to_permissioned_as(&authed.username); + // Held rather than raised: a rule naming a principal that no longer exists fails here, and + // must fail only a first deploy, which is all the default applies to. + let folder_default = + if !explicit_preserve && windmill_common::can_preserve_on_behalf_of(&authed) { + resolve_folder_default_identity(&authed, &w_id, &ns.path, &db).await + } else { + Ok(None) + }; // Resolved here rather than at the INSERT so the no-op check below compares the identity // that would actually be stored: the parent row holds only the principal, while a // preserving push may name that same principal by address alone. - let resolved_on_behalf_of = windmill_common::resolve_on_behalf_of( + let requested_identity = resolve_deploy_identity( ns.on_behalf_of_email.as_deref(), ns.on_behalf_of.as_deref(), ns.preserve_on_behalf_of.unwrap_or(false), @@ -1381,10 +1431,46 @@ async fn create_script_internal<'c>( &db, ) .await?; - // Written beside the principal only while a worker that still reads it may be live. - let legacy_on_behalf_of_email = - windmill_common::legacy_on_behalf_of_email(resolved_on_behalf_of.as_deref(), &w_id, &db) - .await?; + // Not on `tx`: these inserts are cached as written, which a rolled-back deploy would undo. + let runnable_settings_handle = windmill_common::runnable_settings::insert_rs( + RunnableSettings { + debouncing_settings: ns.debouncing_settings.insert_cached(&db).await?, + concurrency_settings: ns.concurrency_settings.insert_cached(&db).await?, + retry_settings: None, + }, + &db, + ) + .await?; + + let mut tx: Transaction<'_, Postgres> = user_db.begin(&authed).await?; + + // Apply folder default_permissioned_as the first time a script is deployed + // at this path. Check inside the transaction to avoid TOCTOU with concurrent deploys. + let path_already_exists = match folder_default { + Ok(None) => true, + _ => sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2)", + &ns.path, + &w_id + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false), + }; + let (resolved_on_behalf_of, legacy_on_behalf_of_email) = if path_already_exists { + requested_identity + } else { + match folder_default? { + Some((default_email, default_permissioned_as, identity)) => { + ns.on_behalf_of_email = Some(default_email); + ns.on_behalf_of = Some(default_permissioned_as); + ns.preserve_on_behalf_of = Some(true); + identity + } + None => requested_identity, + } + }; + let authed_principal = windmill_common::users::username_to_permissioned_as(&authed.username); // When auto_parent is set, serialize concurrent creates for the same (workspace, path) // so the clashing_script query always sees the latest committed head. if ns.auto_parent.unwrap_or(false) { @@ -1467,66 +1553,70 @@ async fn create_script_internal<'c>( // nothing being live there, so a parentless deploy onto a live path still meets the // path conflict the match below raises. let mut parent_adopted_from_retired_path = false; - if ns.parent_hash.is_none() && clashing_script.is_none() { - // Locked, not merely read: a competing deploy chaining onto this same candidate - // takes `FOR UPDATE` on it before inserting, so holding the row is what serializes - // the two. Probe first and the child still uncommitted reads as absent. - let candidate = sqlx::query_scalar::<_, i64>( + // Locked, not merely read: a competing deploy chaining onto this same candidate + // takes `FOR UPDATE` on it before inserting, so holding the row is what serializes + // the two. Probe first and the child still uncommitted reads as absent. + let adoption_candidate = if ns.parent_hash.is_none() && clashing_script.is_none() { + sqlx::query_scalar::<_, i64>( "SELECT hash FROM script WHERE path = $1 AND workspace_id = $2 \ ORDER BY created_at DESC LIMIT 1 FOR UPDATE", ) .bind(&ns.path) .bind(&w_id) .fetch_optional(&mut *tx) - .await?; + .await? + } else { + None + }; + set_lineage_reads_unscoped(&mut tx, &authed, true).await?; + if let Some(hash) = adoption_candidate { // Adoptable only if nothing already descends from it: a rename leaves its source // path holding a version whose child lives at the destination, and a second child // forks a lineage the guard below requires to be linear. Nothing adoptable means a // fresh lineage, which has no parent to vary its hash and can still collide. - ns.parent_hash = match candidate { - Some(hash) => sqlx::query_scalar!( - "SELECT 1 FROM script WHERE parent_hashes[1] = $1 AND workspace_id = $2", - hash, - &w_id - ) - .fetch_optional(&db) - .await? - .is_none() - .then_some(ScriptHash(hash)), - None => None, - }; + ns.parent_hash = sqlx::query_scalar!( + "SELECT 1 FROM script WHERE parent_hashes[1] = $1 AND workspace_id = $2", + hash, + &w_id + ) + .fetch_optional(&mut *tx) + .await? + .is_none() + .then_some(ScriptHash(hash)); parent_adopted_from_retired_path = ns.parent_hash.is_some(); } // Absent keeps the previous version's value, read once the parent is settled (a rename // adopts its source head above), so a client unaware of the setting cannot drop a // restriction by redeploying or renaming. - let resolved_job_token_scopes: Option> = match (&ns.job_token_scopes, &ns.parent_hash) { - (Some(Some(scopes)), _) => { - windmill_common::min_version::MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES - .assert() - .await?; - Some(windmill_common::scopes::validate_job_token_scopes(scopes)?) - } - (Some(None), _) => None, - (None, Some(parent_hash)) => sqlx::query_scalar!( - "SELECT job_token_scopes FROM script WHERE hash = $1 AND workspace_id = $2", - parent_hash.0, - &w_id - ) - .fetch_optional(&db) - .await? - .flatten(), - (None, None) => sqlx::query_scalar!( - "SELECT job_token_scopes FROM script WHERE path = $1 AND workspace_id = $2 \ + let resolved_job_token_scopes: Option> = + match (&ns.job_token_scopes, &ns.parent_hash) { + (Some(Some(scopes)), _) => { + windmill_common::min_version::MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES + .assert() + .await?; + Some(windmill_common::scopes::validate_job_token_scopes(scopes)?) + } + (Some(None), _) => None, + (None, Some(parent_hash)) => sqlx::query_scalar!( + "SELECT job_token_scopes FROM script WHERE hash = $1 AND workspace_id = $2", + parent_hash.0, + &w_id + ) + .fetch_optional(&mut *tx) + .await? + .flatten(), + (None, None) => sqlx::query_scalar!( + "SELECT job_token_scopes FROM script WHERE path = $1 AND workspace_id = $2 \ AND deleted = false ORDER BY created_at DESC LIMIT 1", - &ns.path, - &w_id - ) - .fetch_optional(&db) - .await? - .flatten(), - }; + &ns.path, + &w_id + ) + .fetch_optional(&mut *tx) + .await? + .flatten(), + }; + set_lineage_reads_unscoped(&mut tx, &authed, false).await?; ns.job_token_scopes = Some(resolved_job_token_scopes.clone()); // Before hashing, so the hash and the no-op check see the schema that gets stored. @@ -1610,14 +1700,16 @@ async fn create_script_internal<'c>( // Unscoped, and sound only under the lock above: linearity is a property of the // lineage, not of what this caller may read. A child can sit where they cannot // see it — a folder they renamed it into, or grants an adopting deploy reset — - // and asked through `tx` it reads as absent, letting the fork through. + // and asked through a scoped `tx` it reads as absent, letting the fork through. + set_lineage_reads_unscoped(&mut tx, &authed, true).await?; let clashing_hash_o = sqlx::query_scalar!( "SELECT hash FROM script WHERE parent_hashes[1] = $1 AND workspace_id = $2", p_hash.0, &w_id ) - .fetch_optional(&db) + .fetch_optional(&mut *tx) .await?; + set_lineage_reads_unscoped(&mut tx, &authed, false).await?; if let Some(clashing_hash) = clashing_hash_o { // Named only when the caller could already read it. The probe above has to be @@ -1662,7 +1754,7 @@ async fn create_script_internal<'c>( &ps, resolved_on_behalf_of.as_deref(), resolved_job_token_scopes.as_deref(), - &db, + &mut tx, ) .await? { @@ -2207,16 +2299,6 @@ async fn create_script_internal<'c>( auto_kind }; - let runnable_settings_handle = windmill_common::runnable_settings::insert_rs( - RunnableSettings { - debouncing_settings: ns.debouncing_settings.insert_cached(&db).await?, - concurrency_settings: ns.concurrency_settings.insert_cached(&db).await?, - retry_settings: None, - }, - &db, - ) - .await?; - let ( guarded_concurrent_limit, guarded_concurrency_time_window_s, @@ -4004,11 +4086,8 @@ async fn archive_script_by_path( Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, ) -> Result<()> { - if authed.is_operator { - return Err(Error::NotAuthorized( - "Operators cannot archive scripts for security reasons".to_string(), - )); - } + windmill_common::workspaces::check_role_can_code(&db, &w_id, &authed, "archive scripts") + .await?; let path = path.to_path(); check_scopes(&authed, || format!("scripts:write:{}", path))?; if let RuleCheckResult::Blocked(msg) = check_deploy_rules( @@ -4100,11 +4179,8 @@ async fn archive_script_by_hash( Extension(webhook): Extension, Path((w_id, hash)): Path<(String, ScriptHash)>, ) -> JsonResult> { - if authed.is_operator { - return Err(Error::NotAuthorized( - "Operators cannot archive scripts for security reasons".to_string(), - )); - } + windmill_common::workspaces::check_role_can_code(&db, &w_id, &authed, "archive scripts") + .await?; if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, AuditAuthorable::username(&authed), diff --git a/backend/windmill-api-settings/Cargo.toml b/backend/windmill-api-settings/Cargo.toml index 0ec99ef6d0..816a2223ac 100644 --- a/backend/windmill-api-settings/Cargo.toml +++ b/backend/windmill-api-settings/Cargo.toml @@ -7,13 +7,15 @@ edition.workspace = true [lib] name = "windmill_api_settings" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] enterprise = ["license"] private = ["windmill-common/private", "windmill-audit/private"] parquet = ["windmill-common/parquet", "windmill-object-store/parquet"] -license = ["dep:rsa"] +license = ["dep:rsa", "dep:ed25519-dalek"] [dependencies] windmill-ai = { workspace = true, default-features = false } @@ -38,4 +40,5 @@ uuid.workspace = true base64.workspace = true sha2.workspace = true rsa = { workspace = true, optional = true } +ed25519-dalek = { workspace = true, optional = true } windmill-object-store.workspace = true diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index f9ba1ff86f..2dbb7d427c 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -74,7 +74,7 @@ use windmill_common::{ RETENTION_PERIOD_SECS_OVERRIDES_SETTING, RUFF_CONFIG_SETTING, UNIQUE_ID_SETTING, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, - WS_BASE_URL_SETTING, + WORKSPACE_ROLES_SETTING, WS_BASE_URL_SETTING, }, instance_config::{self, ApplyMode, InstanceConfig}, server::Smtp, @@ -916,6 +916,7 @@ pub async fn set_global_setting_internal( run_setting_pre_write_hook(db, &key, &value).await?; + let turns_on = value.as_bool() == Some(true); match value { serde_json::Value::Null => { if instance_config::PROTECTED_SETTINGS.contains(&key.as_str()) { @@ -949,10 +950,27 @@ pub async fn set_global_setting_internal( } }; + if key == windmill_common::global_settings::DEV_WORKSPACE_ISOLATION_SETTING { + windmill_common::feature_usage::log_feature_usage( + "dev_workspace_isolation", + "toggle", + if turns_on { "on" } else { "off" }, + ); + } + if key == windmill_common::global_settings::DEV_WORKSPACE_ISOLATION_SKIP_NON_DEV_FORKS_SETTING { + windmill_common::feature_usage::log_feature_usage( + "dev_workspace_isolation", + "skip_non_dev_forks_toggle", + if turns_on { "on" } else { "off" }, + ); + } + if should_bump_instance_ai_revision { bump_instance_ai_config_revision(); } + run_setting_post_write_hook(db, &key).await?; + // Tag reads are served from an in-memory cache that this process otherwise only // refreshes on the next global-settings poll, so without this a refetch right after // the write still returns the pre-write list. The setting is already persisted at @@ -967,6 +985,14 @@ pub async fn set_global_setting_internal( Ok(()) } +/// Side effects a setting needs once it is stored, for both the per-key and the bulk endpoint. +async fn run_setting_post_write_hook(db: &DB, key: &str) -> error::Result<()> { + if key == WORKSPACE_ROLES_SETTING { + windmill_common::roles::after_instance_roles_write(db).await?; + } + Ok(()) +} + /// Run side-effect hooks for specific settings before writing to DB. /// Extracted from `set_global_setting_internal` for reuse by the bulk endpoint. async fn run_setting_pre_write_hook( @@ -975,6 +1001,17 @@ async fn run_setting_pre_write_hook( value: &serde_json::Value, ) -> error::Result<()> { match key { + windmill_common::global_settings::DEV_WORKSPACE_TAG_SUFFIX_SETTING => match value { + serde_json::Value::Null => {} + serde_json::Value::String(s) if s.trim().is_empty() => {} + suffix => windmill_common::global_settings::validate_dev_workspace_tag_suffix(suffix) + .map_err(|e| { + error::Error::BadRequest(format!("The dev workspace tag suffix {e}")) + })?, + }, + WORKSPACE_ROLES_SETTING => { + windmill_common::roles::validate_instance_roles_write(db, Some(value)).await?; + } // The instance AI config is written as an untyped blob through this generic // endpoint, so it never passes the typed check the workspace handler applies. // Rates that reach a cost total unbounded would make it negative or infinite. @@ -1254,6 +1291,13 @@ async fn run_setting_pre_write_hook( v => windmill_common::global_settings::validate_accent_color(v) .map_err(|e| error::Error::BadRequest(format!("{ACCENT_COLOR_SETTING}: {e}")))?, }, + windmill_common::global_settings::AGENT_WORKLOAD_IDENTITY_ISSUERS_SETTING => { + if !value.is_null() { + let key = windmill_common::global_settings::AGENT_WORKLOAD_IDENTITY_ISSUERS_SETTING; + windmill_common::agent_workload_identity::validate_issuers_value(value) + .map_err(|e| error::Error::BadRequest(format!("{key}: {e}")))?; + } + } _ => {} } Ok(()) @@ -1338,6 +1382,14 @@ async fn set_instance_config( run_setting_pre_write_hook(&db, key, value).await?; } } + if settings_diff + .deletes + .iter() + .any(|k| k == WORKSPACE_ROLES_SETTING) + { + run_setting_pre_write_hook(&db, WORKSPACE_ROLES_SETTING, &serde_json::Value::Null) + .await?; + } let auditor = InstanceConfigAuditor { db: &db, author: &authed, source: None }; // The external-instance write removes its own key from the diff. windmill_common::external_instance_pg::write_external_instance_pg_from_diff( @@ -1353,6 +1405,14 @@ async fn set_instance_config( if ai_config_changed { bump_instance_ai_config_revision(); } + for key in settings_diff + .upserts + .iter() + .map(|(k, _)| k) + .chain(settings_diff.deletes.iter()) + { + run_setting_post_write_hook(&db, key).await?; + } } if !desired.worker_configs.is_empty() { diff --git a/backend/windmill-api-sse/Cargo.toml b/backend/windmill-api-sse/Cargo.toml index b8127c3d36..d70f760163 100644 --- a/backend/windmill-api-sse/Cargo.toml +++ b/backend/windmill-api-sse/Cargo.toml @@ -7,6 +7,9 @@ edition.workspace = true [lib] name = "windmill_api_sse" path = "src/lib.rs" +# src/ has no unit tests or doctests: remove both lines before adding one, or cargo test never runs it. +test = false +doctest = false [features] default = [] diff --git a/backend/windmill-api-users/Cargo.toml b/backend/windmill-api-users/Cargo.toml index 13ab8143d8..cfd683434c 100644 --- a/backend/windmill-api-users/Cargo.toml +++ b/backend/windmill-api-users/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_api_users" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index 46bd6825e8..dbc883305e 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -262,6 +262,7 @@ pub struct WorkspaceInvite { pub email: String, pub is_admin: bool, pub operator: bool, + pub role: String, pub parent_workspace_id: Option, } @@ -301,7 +302,10 @@ pub struct EditUser { #[derive(Deserialize)] pub struct EditWorkspaceUser { pub is_admin: Option, + /// Legacy role selector: `true` is the `operator` role, `false` `developer`. `role` wins. pub operator: Option, + /// The member's role, one of the workspace's roles. + pub role: Option, pub disabled: Option, } @@ -414,7 +418,9 @@ async fn list_users( let rows = sqlx::query_as!( User, " - SELECT workspace_id, username, email, is_admin, created_at, operator, disabled, role, added_via, is_service_account + SELECT workspace_id, username, email, is_admin, created_at, operator, disabled, + COALESCE(workspace_role, CASE WHEN operator THEN 'operator' ELSE 'developer' END) AS role, + added_via, is_service_account FROM usr WHERE workspace_id = $1 ORDER BY email @@ -636,6 +642,7 @@ async fn list_invites( workspace_invite.email, workspace_invite.is_admin, workspace_invite.operator, + COALESCE(workspace_invite.workspace_role, CASE WHEN workspace_invite.operator THEN 'operator' ELSE 'developer' END) AS \"role!\", workspace.parent_workspace_id FROM workspace_invite JOIN workspace ON workspace_invite.workspace_id = workspace.id WHERE email = $1", authed.email @@ -765,7 +772,7 @@ async fn whoami( authed: ApiAuthed, ) -> JsonResult { let is_guest = windmill_api_auth::scopes::has_guest_sentinel(authed.scopes.as_deref()); - let ApiAuthed { username, email, is_admin, groups, folders, .. } = authed; + let ApiAuthed { username, email, is_admin, is_operator, groups, folders, .. } = authed; // A guest would otherwise fall through to the non-member branch below and be // handed a `superadmin` role. Answer it here, as the operator-shaped identity it is. if is_guest { @@ -794,7 +801,13 @@ async fn whoami( // never happen, but guard against it so a non-member superadmin is never // shown another member's identity/role. if let Some(user) = user.filter(|u| u.email == email) { - Ok(Json(user)) + // The frontend gates pages on `operator`, so it reports what `caller_capabilities` + // decides rather than the raw column: an admin (or superadmin) whose row still carries + // `operator` holds everything. + Ok(Json(UserInfo { + operator: is_operator && !is_admin, + ..user + })) } else { Ok(Json(UserInfo { workspace_id: w_id, @@ -937,12 +950,13 @@ pub struct User2 { #[serde(skip_serializing_if = "Option::is_none")] pub added_via: Option, pub is_service_account: bool, + pub workspace_role: Option, } async fn get_user(w_id: &str, username: &str, db: &DB) -> Result> { let user = sqlx::query_as!( User2, - "SELECT usr.*, COALESCE(password.super_admin, false) as \"super_admin!\", password.name FROM usr LEFT JOIN password ON usr.email = password.email Where usr.username = $1 AND workspace_id = $2 + "SELECT usr.workspace_id, usr.email, usr.username, usr.is_admin, usr.created_at, usr.operator, usr.disabled, usr.role, usr.added_via, usr.is_service_account, usr.workspace_role, COALESCE(password.super_admin, false) as \"super_admin!\", password.name FROM usr LEFT JOIN password ON usr.email = password.email Where usr.username = $1 AND workspace_id = $2 ", username, w_id @@ -972,7 +986,10 @@ async fn get_user(w_id: &str, username: &str, db: &DB) -> Result, Json(eu): Json, ) -> Result { - let mut tx = db.begin().await?; - require_admin(authed.is_admin, &authed.username)?; + #[cfg(feature = "enterprise")] + let seat_baseline = windmill_common::ee_oss::seat_cap_baseline(&db).await?; + let mut tx = db.begin().await?; + update_workspace_user_internal( &w_id, &username_to_update, eu.is_admin, eu.operator, + eu.role, eu.disabled, &mut tx, Some(&authed), ) .await?; + #[cfg(feature = "enterprise")] + if let Some(msg) = + windmill_common::ee_oss::check_seat_cap_for_role_change(&seat_baseline, &mut tx).await? + { + return Err(Error::BadRequest(msg)); + } + let user_email = sqlx::query_scalar!( "SELECT email FROM usr WHERE username = $1 AND workspace_id = $2", &username_to_update, @@ -1417,65 +1457,44 @@ async fn convert_user_to_group( )); } - // Determine the group with highest precedence (same logic as reconcile_workspace_instance_groups) - let roles: std::collections::HashMap = - if let Some(roles_json) = &eligible_groups[0].instance_groups_roles { - serde_json::from_value(roles_json.clone()).unwrap_or_default() - } else { - std::collections::HashMap::new() - }; + let roles: std::collections::HashMap = eligible_groups[0] + .instance_groups_roles + .clone() + .and_then(|v| serde_json::from_value(v).ok()) + .unwrap_or_default(); + let group_names: Vec = eligible_groups + .iter() + .map(|g| g.group_name.clone()) + .collect(); + let role_order: Vec = windmill_common::roles::workspace_roles_on(&mut *tx, &w_id) + .await? + .iter() + .map(|r| r.name.clone()) + .collect(); + let (primary_group_name, is_admin, role) = + windmill_common::users::compute_highest_workspace_role( + &group_names, + &group_names, + &roles, + &role_order, + ); - let mut best_group = &eligible_groups[0].group_name; - let mut best_precedence = 0u8; - - for group in &eligible_groups { - let default_role = "developer".to_string(); - let role = roles.get(&group.group_name).unwrap_or(&default_role); - - let precedence = match role.as_str() { - "admin" => 3, - "developer" => 2, - "operator" => 1, - _ => 2, - }; - - if precedence > best_precedence { - best_precedence = precedence; - best_group = &group.group_name; - } - } - - let primary_group_name = best_group; - - // Determine role from group configuration using the selected primary group - let default_role = "developer".to_string(); - let role = roles - .get(primary_group_name) - .unwrap_or(&default_role) - .as_str(); - - let (is_admin, is_operator) = match role { - "admin" => (true, false), - "operator" => (false, true), - _ => (false, false), - }; - - // Update user with instance group information let instance_group_source = serde_json::json!({ "source": "instance_group", "group": primary_group_name }); sqlx::query!( - "UPDATE usr SET added_via = $1, is_admin = $2, operator = $3 WHERE username = $4 AND workspace_id = $5", + "UPDATE usr SET added_via = $1, is_admin = $2 WHERE username = $3 AND workspace_id = $4", instance_group_source, is_admin, - is_operator, username_to_convert, &w_id ) .execute(&mut *tx) .await?; + set_workspace_role(&mut tx, &w_id, &username_to_convert, &role).await?; + let role = if is_admin { "admin" } else { role.as_str() }; audit_log( &mut *tx, @@ -2120,6 +2139,15 @@ async fn change_user_email( .execute(&mut *tx) .await?; + sqlx::query( + "UPDATE resource SET value = jsonb_set(value, '{on_behalf_of}', to_jsonb($1::text)) + WHERE resource_type = 'ai_agent' AND value->>'on_behalf_of' = $2", + ) + .bind(&new_principal) + .bind(&old_principal) + .execute(&mut *tx) + .await?; + sqlx::query!( "UPDATE script SET on_behalf_of = $1 WHERE on_behalf_of = $2", &new_principal, @@ -2405,26 +2433,28 @@ lazy_static::lazy_static! { } /// Internal helper for updating workspace user permissions - used by both API and system operations +/// +/// `role` names one of the workspace's roles; without it, the legacy `operator` flag selects the +/// `operator` or `developer` role. Admin is a flag on top of the role, so it is set independently. pub async fn update_workspace_user_internal( w_id: &str, username_to_update: &str, is_admin: Option, operator: Option, + role: Option, disabled: Option, tx: &mut Transaction<'_, Postgres>, authed: Option<&ApiAuthed>, // None for system operations ) -> Result<()> { - // The role is one choice stored as two flags. Granting one clears the other, or a request - // setting only `is_admin` leaves a user shown as admin but refused as an operator. - let (is_admin, operator) = match (is_admin, operator) { - (Some(true), Some(true)) => { - return Err(Error::BadRequest( - "A user cannot be both admin and operator".to_string(), - )) - } - (Some(true), _) => (Some(true), Some(false)), - (_, Some(true)) => (Some(false), Some(true)), - flags => flags, + if role.is_none() && is_admin == Some(true) && operator == Some(true) { + return Err(Error::BadRequest( + "A user cannot be both admin and operator".to_string(), + )); + } + // The legacy selector made admin and operator one choice: granting operator demoted. + let is_admin = match (&role, is_admin, operator) { + (None, None, Some(true)) => Some(false), + _ => is_admin, }; if let Some(a) = is_admin { sqlx::query_scalar!( @@ -2436,15 +2466,20 @@ pub async fn update_workspace_user_internal( .execute(&mut **tx) .await?; } - if let Some(a) = operator { - sqlx::query_scalar!( - "UPDATE usr SET operator = $1 WHERE username = $2 AND workspace_id = $3", - a, - username_to_update, - w_id - ) - .execute(&mut **tx) - .await?; + // A legacy payload granting admin also cleared `operator`; keep that, as the default role. + let role = match (role, is_admin, operator) { + (Some(role), _, _) => Some(role), + (None, _, Some(true)) => Some(windmill_common::roles::OPERATOR_ROLE.to_string()), + (None, Some(true), _) | (None, _, Some(false)) => { + let roles = windmill_common::roles::workspace_roles_on(&mut **tx, w_id).await?; + Some(windmill_common::roles::default_role_name( + roles.iter().map(|r| r.name.as_str()), + )) + } + _ => None, + }; + if let Some(role) = role { + set_workspace_role(tx, w_id, username_to_update, &role).await?; } if let Some(a) = disabled { sqlx::query_scalar!( @@ -2474,6 +2509,39 @@ pub async fn update_workspace_user_internal( Ok(()) } +/// Gives a member role `role`, which must be one of the workspace's roles. `usr.operator` is +/// kept as "this role cannot write code", the reading clients that predate roles give it. +/// +/// AUTHORIZATION: changes what the member may do and checks nothing. Callers MUST have verified +/// that the caller is an admin of `w_id`, or run as trusted server-side code (instance group sync, +/// invite acceptance). +pub async fn set_workspace_role( + tx: &mut Transaction<'_, Postgres>, + w_id: &str, + username: &str, + role: &str, +) -> Result<()> { + let caps = windmill_common::roles::workspace_roles_on(&mut **tx, w_id) + .await? + .iter() + .find(|r| r.name == role) + .map(|r| r.capabilities) + .ok_or_else(|| { + Error::BadRequest(format!("role `{role}` is not defined in workspace {w_id}")) + })?; + sqlx::query!( + "UPDATE usr SET workspace_role = $1, operator = $2 WHERE username = $3 AND workspace_id = $4", + role, + !caps.code, + username, + w_id + ) + .execute(&mut **tx) + .await?; + windmill_common::roles::invalidate_member_role_cache(w_id, username); + Ok(()) +} + /// Internal helper for deleting workspace users - used by both API and system operations pub async fn delete_workspace_user_internal( w_id: &str, diff --git a/backend/windmill-api-workers/Cargo.toml b/backend/windmill-api-workers/Cargo.toml index 8e54d84e77..fd87b8f4be 100644 --- a/backend/windmill-api-workers/Cargo.toml +++ b/backend/windmill-api-workers/Cargo.toml @@ -7,10 +7,14 @@ edition.workspace = true [lib] name = "windmill_api_workers" path = "src/lib.rs" +# src/ has no unit tests or doctests: remove both lines before adding one, or cargo test never runs it. +test = false +doctest = false [dependencies] windmill-api-auth.workspace = true windmill-common = { workspace = true, default-features = false } +windmill-queue.workspace = true axum.workspace = true chrono.workspace = true serde.workspace = true diff --git a/backend/windmill-api-workers/src/lib.rs b/backend/windmill-api-workers/src/lib.rs index 8ebee2a967..2e224beb06 100644 --- a/backend/windmill-api-workers/src/lib.rs +++ b/backend/windmill-api-workers/src/lib.rs @@ -21,7 +21,11 @@ use windmill_common::{ jobs::{HIDE_WORKERS_FOR_NON_ADMINS, TAGS_ARE_SENSITIVE}, queue_metrics::{read_queue_metrics_series, QueueMetricsSeries}, utils::{paginate, Pagination}, - worker::{ALL_TAGS, CUSTOM_TAGS_PER_WORKSPACE, DEFAULT_TAGS, DEFAULT_TAGS_PER_WORKSPACE}, + worker::{ + ALL_TAGS, CUSTOM_TAGS_PER_WORKSPACE, DEFAULT_TAGS, DEFAULT_TAGS_PER_WORKSPACE, + DEV_WORKSPACE_ISOLATION, DEV_WORKSPACE_ISOLATION_SKIP_NON_DEV_FORKS, + DEV_WORKSPACE_TAG_SUFFIX, + }, workspaces::workspace_with_fork_ancestors, DB, }; @@ -37,6 +41,7 @@ pub fn global_service() -> Router { "/is_default_tags_per_workspace", get(get_default_tags_per_workspace), ) + .route("/dev_workspace_isolation", get(get_dev_workspace_isolation)) .route("/get_default_tags", get(get_default_tags)) .route("/queue_metrics", get(get_queue_metrics)) .route("/queue_metrics_series", get(get_queue_metrics_series)) @@ -186,7 +191,18 @@ async fn exists_workers_with_tags( let chain = workspace_with_fork_ancestors(&db, workspace).await?; let custom_tags = CUSTOM_TAGS_PER_WORKSPACE.load(); let allowed_tags = custom_tags.to_string_vec(Some(&chain)); - tags.retain(|t| allowed_tags.contains(t)); + // A dev workspace's jobs run on the suffixed form of the tags it may use. + let dev_suffix = windmill_queue::tags::dev_workspace_suffix_for(workspace, &db) + .await + .ok() + .flatten(); + tags.retain(|t| { + allowed_tags.contains(t) + || dev_suffix.as_deref().is_some_and(|s| { + t.strip_suffix(s) + .is_some_and(|b| allowed_tags.iter().any(|a| a == b)) + }) + }); } else { // No workspace provided and not superadmin - return empty return Ok(Json(std::collections::HashMap::new())); @@ -259,6 +275,22 @@ async fn get_default_tags_per_workspace() -> JsonResult { )) } +#[derive(Serialize)] +struct DevWorkspaceIsolation { + enabled: bool, + suffix: String, + skip_non_dev_forks: bool, +} + +async fn get_dev_workspace_isolation() -> JsonResult { + Ok(Json(DevWorkspaceIsolation { + enabled: DEV_WORKSPACE_ISOLATION.load(std::sync::atomic::Ordering::Relaxed), + suffix: DEV_WORKSPACE_TAG_SUFFIX.load().to_string(), + skip_non_dev_forks: DEV_WORKSPACE_ISOLATION_SKIP_NON_DEV_FORKS + .load(std::sync::atomic::Ordering::Relaxed), + })) +} + async fn get_default_tags() -> JsonResult> { Ok(Json(DEFAULT_TAGS.clone())) } diff --git a/backend/windmill-api-workspaces/Cargo.toml b/backend/windmill-api-workspaces/Cargo.toml index 9f05a26f28..646536a9ec 100644 --- a/backend/windmill-api-workspaces/Cargo.toml +++ b/backend/windmill-api-workspaces/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_api_workspaces" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] diff --git a/backend/windmill-api-workspaces/src/datatable_clone.rs b/backend/windmill-api-workspaces/src/datatable_clone.rs index 6083b7b619..1e6c6b55c2 100644 --- a/backend/windmill-api-workspaces/src/datatable_clone.rs +++ b/backend/windmill-api-workspaces/src/datatable_clone.rs @@ -28,16 +28,22 @@ use windmill_common::workspaces::{ use windmill_common::{PgDatabase, DB}; use crate::datatable_acl::connect_with_notices; +use crate::datatable_migrations::{ + lock_migration_runs_on, read_applied_migrations, write_applied_migrations, AppliedMigrations, +}; use crate::datatable_permissions::ensure_reaches_governing_datatable; use crate::workspaces::{ - create_database_on_server, ensure_datatable_is_clonable, pg_dump_database, pg_import_dump, - DumpFile, PgDumpOptions, + copy_database_settings, create_database_on_server, ensure_datatable_is_clonable, + pg_dump_database, pg_import_dump, DumpFile, PgDumpOptions, }; -/// A database this request created and filled for one data table of the fork. +/// A database this request created and filled for the data tables of the fork on one source +/// database. pub(crate) struct MadeCopy { - /// The data table's name, in the parent and in the fork. - pub(crate) name: String, + /// The data tables' names, in the parent and in the fork, the one it was made for first. Data + /// tables on one database share its copy: a resource-backed fork has one resource for them all + /// to be pointed through. + pub(crate) names: Vec, pub(crate) dbname: String, pub(crate) behavior: DataTableForkBehavior, /// The database the source resolved to when it was copied. @@ -237,6 +243,7 @@ pub(crate) async fn authorize_copies( parent_w_id: &str, requests: &[CopyRequest<'_>], ) -> Result<()> { + let mut sources: Vec<(&CopyRequest<'_>, GoverningDatatable)> = Vec::new(); for request in requests { match request.behavior { DataTableForkBehavior::KeepOriginal => { @@ -265,9 +272,50 @@ pub(crate) async fn authorize_copies( let governing = ensure_datatable_is_clonable(db, parent_w_id, request.name).await?; ensure_reaches_governing_datatable(db, parent_w_id, request.name, &governing, authed) .await?; - if governing.datatable.permissions.is_some() { + let under_roles = governing.datatable.permissions.is_some(); + if under_roles { crate::datatable_replay_oss::ensure_replay()?; } + if let Some((first, first_governing)) = sources + .iter() + .find(|(_, earlier)| same_source(earlier, &governing)) + { + ensure_shares_copy(first.name, first.behavior, request)?; + if first_governing.datatable.permissions.is_some() != under_roles { + return Err(Error::BadRequest(format!( + "Data tables '{}' and '{}' use the same database, which the fork copies once, \ + but only one of them is under roles", + first.name, request.name + ))); + } + } + sources.push((request, governing)); + } + Ok(()) +} + +/// Whether two data tables are on one database. A resource is the governing workspace's. +fn same_source(a: &GoverningDatatable, b: &GoverningDatatable) -> bool { + same_database(&a.datatable.database, &b.datatable.database) + && (a.workspace_id == b.workspace_id + || a.datatable + .database + .as_ref() + .is_some_and(|d| d.resource_type.is_windmill_managed())) +} + +/// Data tables on one database get one copy, made as the first of them asks. +fn ensure_shares_copy( + first: &str, + behavior: DataTableForkBehavior, + request: &CopyRequest<'_>, +) -> Result<()> { + if behavior != request.behavior { + return Err(Error::BadRequest(format!( + "Data tables '{}' and '{}' use the same database, which the fork copies once: fork \ + them with the same behavior", + first, request.name + ))); } Ok(()) } @@ -281,10 +329,28 @@ pub(crate) async fn make_copies( requests: &[CopyRequest<'_>], ) -> Result> { authorize_copies(db, authed, parent_w_id, requests).await?; - let mut copies = Vec::with_capacity(requests.len()); + let mut copies: Vec = Vec::with_capacity(requests.len()); + // What each copy was made from, by index. + let mut sources: Vec = Vec::with_capacity(requests.len()); for request in requests { + // Checked again by the fork against the copy's `source_database`, under the locks. + let governing = match ensure_datatable_is_clonable(db, parent_w_id, request.name).await { + Ok(governing) => governing, + Err(e) => return Err(drop_copies_after(db, copies, e).await), + }; + let shared = sources.iter().position(|s| same_source(s, &governing)); + if let Some(c) = shared { + if let Err(e) = ensure_shares_copy(&copies[c].names[0], copies[c].behavior, request) { + return Err(drop_copies_after(db, copies, e).await); + } + copies[c].names.push(request.name.to_string()); + continue; + } match make_copy(db, authed, parent_w_id, request).await { - Ok(copy) => copies.push(copy), + Ok(copy) => { + copies.push(copy); + sources.push(governing); + } Err(e) => return Err(drop_copies_after(db, copies, e).await), } } @@ -336,8 +402,8 @@ async fn drop_copy(db: &DB, source_database: &DataTableDatabase, dbname: &str) - Ok(()) } DataTableCatalogResourceType::Postgresql => { - // On a server of the workspace's own, where a resource edited meanwhile can already name it - // and nothing locks such an edit: dropping it could take someone's data. + // On a server of the workspace's own, where a resource edited meanwhile can already name it + // and nothing locks such an edit: dropping it could take someone's data. Err(Error::BadRequest( "kept on its PostgreSQL server, where it may already be in use; drop it there once \ it is not, before forking the same data table under this id again" @@ -392,20 +458,25 @@ async fn make_copy( (server, Some(snapshot)) }; + let schema_only = request.behavior == DataTableForkBehavior::SchemaOnly; + let migrations_lock = if schema_only { + Some(lock_migration_runs_on(db, &server, request.name).await?) + } else { + None + }; // Dumped before the database exists, so a source that cannot be read leaves nothing behind. // Ownership never carries over: the restore runs as the target's connection user. Grants do, // except on a cluster Windmill manages, which plants its own and where the replay below is what // reproduces the roles'. let dump = pg_dump_database( &server, - PgDumpOptions { - schema_only: request.behavior == DataTableForkBehavior::SchemaOnly, - no_owner: true, - no_acl: managed, - ..Default::default() - }, + PgDumpOptions { schema_only, no_owner: true, no_acl: managed, ..Default::default() }, ) .await?; + let applied = match migrations_lock { + Some(client) => Some(read_applied_migrations(&client).await?), + None => None, + }; match source_database.resource_type { DataTableCatalogResourceType::Instance => { @@ -444,11 +515,12 @@ async fn make_copy( &server, &target, &dump, + applied.as_ref(), ) .await; match filled { Ok(replayed) => Ok(MadeCopy { - name: request.name.to_string(), + names: vec![request.name.to_string()], dbname: request.dbname.to_string(), behavior: request.behavior, source_database, @@ -484,8 +556,12 @@ async fn fill( source: &PgDatabase, target: &PgDatabase, dump: &DumpFile, + applied: Option<&AppliedMigrations>, ) -> Result { pg_import_dump(target, dump).await?; + if let Some(applied) = applied { + write_applied_migrations(db, target, applied).await?; + } // Held until the replay commits: a role renamed or dropped meanwhile would change what the // replay names, and a settings save could move the source onto another database or put it under @@ -554,6 +630,7 @@ async fn fill( })?; } tx.commit().await?; + copy_database_settings(db, source, target).await?; Ok(replayed) } diff --git a/backend/windmill-api-workspaces/src/datatable_migration_scope.rs b/backend/windmill-api-workspaces/src/datatable_migration_scope.rs new file mode 100644 index 0000000000..ea98bc7309 --- /dev/null +++ b/backend/windmill-api-workspaces/src/datatable_migration_scope.rs @@ -0,0 +1,319 @@ +/* + * 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. + */ + +//! A data table migration's scope: the schemas it declares it touches, written as a +//! `-- schemas sales, billing` annotation in its up SQL. A migration that declares none applies to +//! the whole database. +//! +//! Scope decides ordering, not access: a migration waits on every earlier migration whose scope +//! overlaps its own, and its rollback on every later overlapping one. A whole-database migration +//! overlaps everything, so it is a barrier; migrations on disjoint schemas do not wait on each +//! other. Nothing checks that the SQL stays inside its declared scope. + +use std::collections::HashSet; + +use windmill_common::error::{Error, Result}; + +/// The schemas a migration declares, lowercased, or `None` for the whole database. +pub(crate) type Scope = Option>; + +const KEYWORD: &str = "schemas"; + +/// Read the `-- schemas a, b` annotation from the leading comment block of `code`, the way +/// `SqlAnnotations::datatable_role` reads `-- role`. A leading comment whose first word is +/// `schemas` is an annotation attempt, and a malformed one is an error rather than a silent +/// whole-database scope. +/// +/// Names are lowercased: SQL folds unquoted identifiers, and two migrations spelling one schema +/// `Sales` and `sales` must still be ordered against each other. A false overlap only costs +/// waiting; a false disjointness lets a migration run before one it depends on. +pub(crate) fn parse_migration_schemas(code: &str) -> Result { + for line in code.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + if !line.starts_with("--") { + break; + } + let body = line[2..].trim_start(); + let Some(after) = body + .get(..KEYWORD.len()) + .filter(|kw| kw.eq_ignore_ascii_case(KEYWORD)) + .map(|_| &body[KEYWORD.len()..]) + else { + continue; + }; + if !after.is_empty() + && !after.starts_with(char::is_whitespace) + && !after.starts_with([':', '=']) + { + continue; + } + let after = after.trim_start(); + let after = after.strip_prefix([':', '=']).unwrap_or(after).trim(); + let after = after.strip_suffix(';').unwrap_or(after); + let mut schemas: Vec = vec![]; + for piece in after.split(',') { + let name = piece.trim(); + if !is_annotatable_schema_name(name) { + return Err(Error::BadRequest(format!( + "Malformed data table migration scope annotation: `{line}`. Write it as \ + `-- schemas , ` on a line of its own, where each name is \ + letters, digits, '_', '$' or '-', or leave it out for a migration that \ + applies to the whole database. A comment in the leading block that starts \ + with the word 'schemas' is read as this annotation; move it below the first \ + statement if it is prose." + ))); + } + let name = name.to_ascii_lowercase(); + if !schemas.contains(&name) { + schemas.push(name); + } + } + return Ok(Some(schemas)); + } + Ok(None) +} + +/// Whether `name` can be written in a `-- schemas` annotation. +fn is_annotatable_schema_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 63 + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '$' | '-')) +} + +pub(crate) fn scopes_overlap(a: &Scope, b: &Scope) -> bool { + match (a, b) { + (Some(a), Some(b)) => a.iter().any(|s| b.contains(s)), + _ => true, + } +} + +/// One migration of a data table's chain, as ordering sees it. +pub(crate) struct ChainEntry { + pub(crate) timestamp: i64, + pub(crate) name: String, + /// A malformed annotation counts as the whole database, so it orders conservatively. It is + /// refused only when a migration is written: one stored before scopes existed may open with + /// prose that starts with the word, and must keep running as it always did. + pub(crate) scope: Scope, +} + +impl ChainEntry { + pub(crate) fn new(timestamp: i64, name: String, code_up: &str) -> Self { + let scope = parse_migration_schemas(code_up).unwrap_or(None); + Self { timestamp, name, scope } + } + + fn label(&self) -> String { + format!("{} ({})", self.timestamp, self.name) + } +} + +/// Why `later` has to wait for `earlier`, for an error message. +fn overlap_reason(later: &ChainEntry, earlier: &ChainEntry) -> String { + match (&later.scope, &earlier.scope) { + (_, None) => format!("{} applies to the whole database", earlier.label()), + (None, _) => format!("{} applies to the whole database", later.label()), + (Some(a), Some(b)) => { + let shared: Vec<&str> = a + .iter() + .filter(|s| b.contains(s)) + .map(String::as_str) + .collect(); + format!("both touch schema {}", shared.join(", ")) + } + } +} + +/// Which pending migrations one run applies. +pub(crate) enum RunSelection { + /// Every pending migration, or those up to and including a version. + UpTo(Option), + /// One version. + Only(i64), + /// The pending migrations that declare this schema. A whole-database one is never part of + /// it: running for one schema must not change the others. + Schema(String), +} + +/// The chain indices one run applies, in order, or why it cannot start. Every selected migration +/// needs each earlier overlapping migration applied or applied by this same run, so the whole run +/// is refused before anything is applied rather than stopping half-way. +/// +/// `Only` is exempt: it is the explicit out-of-order run, which the editors confirm with the user +/// (through `blocked_by`) before asking for it. +pub(crate) fn plan_run( + chain: &[ChainEntry], + applied: &HashSet, + selection: &RunSelection, +) -> Result> { + let pending = |i: usize| !applied.contains(&chain[i].timestamp); + let selected: Vec = (0..chain.len()) + .filter(|&i| { + pending(i) + && match selection { + RunSelection::UpTo(None) => true, + RunSelection::UpTo(Some(up_to)) => chain[i].timestamp <= *up_to, + RunSelection::Only(version) => chain[i].timestamp == *version, + RunSelection::Schema(schema) => { + chain[i].scope.as_ref().is_some_and(|s| s.contains(schema)) + } + } + }) + .collect(); + if matches!(selection, RunSelection::Only(_)) { + return Ok(selected); + } + let in_run: HashSet = selected.iter().copied().collect(); + for &i in &selected { + let blocker = (0..i).find(|&j| { + pending(j) && !in_run.contains(&j) && scopes_overlap(&chain[i].scope, &chain[j].scope) + }); + if let Some(j) = blocker { + return Err(Error::BadRequest(format!( + "Migration {} cannot run before {}, which is not applied yet: {}. Apply it first, \ + or run all pending migrations.", + chain[i].label(), + chain[j].label(), + overlap_reason(&chain[i], &chain[j]), + ))); + } + } + Ok(selected) +} + +/// The earliest pending migration `chain[i]` has to wait for, when it is pending; the latest +/// applied migration that has to be rolled back before it, when it is applied. +pub(crate) fn blocked_by(chain: &[ChainEntry], applied: &HashSet, i: usize) -> Option { + let is_applied = |j: usize| applied.contains(&chain[j].timestamp); + let overlaps = |j: usize| scopes_overlap(&chain[i].scope, &chain[j].scope); + if is_applied(i) { + (i + 1..chain.len()) + .rev() + .find(|&j| is_applied(j) && overlaps(j)) + } else { + (0..i).find(|&j| !is_applied(j) && overlaps(j)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn s(names: &[&str]) -> Scope { + Some(names.iter().map(|n| n.to_string()).collect()) + } + + #[test] + fn schemas_annotation_is_read_from_the_leading_comment_block() { + let p = |code| parse_migration_schemas(code).unwrap(); + assert_eq!(p("-- schemas sales\nBEGIN;"), s(&["sales"])); + assert_eq!( + p("-- role analyst\n-- Schemas: Sales, billing,sales;\nBEGIN;"), + s(&["sales", "billing"]) + ); + assert_eq!(p("-- schemas=a\nBEGIN;"), s(&["a"])); + assert_eq!(p("BEGIN;\n-- schemas sales"), None); + assert_eq!(p("-- schemasx sales\nBEGIN;"), None); + assert_eq!(p("-- up migration: x\nBEGIN;"), None); + for malformed in [ + "-- schemas\nBEGIN;", + "-- schemas:\nBEGIN;", + "-- schemas sales billing\nBEGIN;", + "-- schemas: changes for the orders table\nBEGIN;", + "-- schemas a,,b\nBEGIN;", + "-- schemas a.b\nBEGIN;", + ] { + assert!(parse_migration_schemas(malformed).is_err(), "{malformed}"); + } + } + + fn chain(entries: &[(i64, Scope)]) -> Vec { + entries + .iter() + .map(|(t, scope)| ChainEntry { + timestamp: *t, + name: format!("m{t}"), + scope: scope.clone(), + }) + .collect() + } + + fn versions(c: &[ChainEntry], idx: Vec) -> Vec { + idx.into_iter().map(|i| c[i].timestamp).collect() + } + + #[test] + fn a_run_waits_only_on_overlapping_earlier_migrations() { + let c = chain(&[ + (1, None), + (2, s(&["sales"])), + (3, s(&["billing"])), + (4, s(&["sales", "billing"])), + (5, None), + (6, s(&["sales"])), + ]); + let applied: HashSet = [1].into(); + + // Disjoint schemas don't wait on each other; a cross-schema migration waits on both, a + // whole-database one on everything. + assert_eq!(blocked_by(&c, &applied, 2).map(|j| c[j].timestamp), None); + assert_eq!(blocked_by(&c, &applied, 3).map(|j| c[j].timestamp), Some(2)); + assert_eq!(blocked_by(&c, &applied, 4).map(|j| c[j].timestamp), Some(2)); + // An explicit single run is the confirmed override. + assert_eq!( + versions(&c, plan_run(&c, &applied, &RunSelection::Only(4)).unwrap()), + vec![4] + ); + + // Running for a schema takes what declares it, refuses past a pending whole-database + // migration, and refuses when a selected migration also needs another schema's. + let for_schema = + |a: &HashSet, x: &str| plan_run(&c, a, &RunSelection::Schema(x.into())); + let err = for_schema(&applied, "sales").unwrap_err().to_string(); + assert!(err.contains("4 (m4)") && err.contains("3 (m3)"), "{err}"); + let applied: HashSet = [1, 3].into(); + let err = for_schema(&applied, "sales").unwrap_err().to_string(); + assert!( + err.contains("5 (m5)") && err.contains("whole database"), + "{err}" + ); + let applied: HashSet = [1, 3, 5].into(); + assert_eq!( + versions(&c, for_schema(&applied, "sales").unwrap()), + vec![2, 4, 6] + ); + // Everything pending in order needs no check: each earlier pending one is in the run. + assert_eq!( + versions( + &c, + plan_run(&c, &[1].into(), &RunSelection::UpTo(None)).unwrap() + ), + vec![2, 3, 4, 5, 6] + ); + } + + #[test] + fn a_rollback_waits_on_later_overlapping_migrations() { + let c = chain(&[ + (1, None), + (2, s(&["sales"])), + (3, s(&["billing"])), + (4, s(&["sales"])), + ]); + let applied: HashSet = [1, 2, 3, 4].into(); + let blocker = |i| blocked_by(&c, &applied, i).map(|j| c[j].timestamp); + assert_eq!(blocker(2), None); + assert_eq!(blocker(1), Some(4)); + assert_eq!(blocker(0), Some(4)); + } +} diff --git a/backend/windmill-api-workspaces/src/datatable_migrations.rs b/backend/windmill-api-workspaces/src/datatable_migrations.rs index 21e460359e..35ea6210dd 100644 --- a/backend/windmill-api-workspaces/src/datatable_migrations.rs +++ b/backend/windmill-api-workspaces/src/datatable_migrations.rs @@ -10,6 +10,9 @@ //! management, and the workspace-merge diff helper. Split out of `workspaces.rs` //! to keep that file focused on core workspace configuration. +use crate::datatable_migration_scope::{ + blocked_by, parse_migration_schemas, plan_run, ChainEntry, RunSelection, +}; use crate::workspaces::{ managed_datatable_kind, pg_dump_database, strip_unreplayable_dump_lines, ItemComparison, PgDumpOptions, @@ -154,15 +157,30 @@ async fn ensure_may_edit_migration( Ok(()) } -/// Operators run deployed code only, and a migration is SQL written or run by hand. Checked before -/// anything else, including the role checks: on a data table not under roles those pass everyone. -fn ensure_not_operator(authed: &ApiAuthed) -> Result<()> { - if authed.is_operator { - return Err(Error::NotAuthorized( - "Operators cannot manage or run data table migrations".to_string(), - )); - } - Ok(()) +/// A migration is SQL written or run by hand, so it needs a role that may run code of its own. +/// Checked before anything else, including the data table role checks: on a data table not under +/// roles those pass everyone. +async fn ensure_not_operator(db: &DB, w_id: &str, authed: &ApiAuthed) -> Result<()> { + windmill_common::workspaces::check_role_can_code( + db, + w_id, + authed, + "manage or run data table migrations", + ) + .await +} + +/// Whether new migrations declare a scope, which the editors push for but do not require. +fn log_migration_scope(scope: &crate::datatable_migration_scope::Scope) { + windmill_common::feature_usage::log_feature_usage( + "datatable", + "migration_scope", + if scope.is_some() { + "scoped" + } else { + "whole_database" + }, + ); } #[derive(Serialize)] @@ -182,8 +200,11 @@ struct RunDatatableMigrationsQuery { up_to: Option, /// When set, apply only this specific migration version (if not already /// applied), ignoring any other pending migrations. Takes precedence over - /// `up_to`. + /// `up_to` and `schema`. only: Option, + /// When set, apply only the pending migrations that declare this schema in their + /// `-- schemas` annotation. Takes precedence over `up_to`. + schema: Option, } /// Build the `database` argument for a migration job. Both resource-backed and @@ -509,6 +530,127 @@ async fn hold_migration_lock( } } +/// The `_wm_migrations` rows of a database a schema-only copy is being made of, read with +/// [`lock_migration_runs_on`] held so they describe the schema the dump captured. +pub(crate) struct AppliedMigrations { + /// `None` when the source has no `_wm_migrations`. + schema: Option, + datatables: Vec, + versions: Vec, + installed_at: Vec, +} + +/// Hold the migration-run lock on database `pg`, for a copy whose schema has to agree with the +/// `_wm_migrations` rows it carries. The lock is per database, whichever data table names it. +pub(crate) async fn lock_migration_runs_on( + db: &DB, + pg: &PgDatabase, + label: &str, +) -> Result { + let (client, connection) = pg.connect(Some(db)).await?; + tokio::spawn(async move { + if let Err(e) = connection.await { + tracing::error!("Datatable connection error: {}", e); + } + }); + hold_migration_lock(&client, label, MIGRATION_LOCK_WAIT_SECS).await?; + Ok(client) +} + +/// Read every `_wm_migrations` row of the database `client` is connected to, along with the +/// schema the table is in: the copy is written to by a connection whose `search_path` need not +/// resolve the way this one does. +pub(crate) async fn read_applied_migrations( + client: &tokio_postgres::Client, +) -> Result { + let read_err = |e: tokio_postgres::Error| { + Error::internal_err(format!( + "Failed to read _wm_migrations: {}", + pg_error_message(&e) + )) + }; + let schema: Option = client + .query_one( + "SELECT n.nspname::text FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \ + RIGHT JOIN (SELECT to_regclass('_wm_migrations')::oid AS oid) t ON c.oid = t.oid", + &[], + ) + .await + .map_err(read_err)? + .get(0); + let mut applied = + AppliedMigrations { schema, datatables: vec![], versions: vec![], installed_at: vec![] }; + let Some(schema) = applied.schema.as_deref() else { + return Ok(applied); + }; + let rows = client + .query( + &format!( + // ISO 8601 in UTC: `::text` follows the session's `DateStyle`, which the copy's + // session need not parse the same way. + "SELECT datatable, version, \ + to_char(installed_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US') \ + FROM {}._wm_migrations", + render_db_quoted_identifier(schema, DbType::Postgresql) + ), + &[], + ) + .await + .map_err(read_err)?; + for row in rows { + applied.datatables.push(row.get(0)); + applied.versions.push(row.get(1)); + applied.installed_at.push(row.get(2)); + } + Ok(applied) +} + +/// Write the rows [`read_applied_migrations`] read into a copy restored from a schema-only dump, +/// which brings `_wm_migrations` over empty: without them the copy reports every migration its +/// schema already holds as pending, and running them fails on objects that exist. +pub(crate) async fn write_applied_migrations( + db: &DB, + target: &PgDatabase, + applied: &AppliedMigrations, +) -> Result<()> { + let Some(schema) = applied.schema.as_deref() else { + return Ok(()); + }; + if applied.versions.is_empty() { + return Ok(()); + } + let (client, connection) = target.connect(Some(db)).await?; + tokio::spawn(async move { + if let Err(e) = connection.await { + tracing::error!("Datatable connection error: {}", e); + } + }); + client + .execute( + &format!( + "INSERT INTO {}._wm_migrations (datatable, version, installed_at) \ + SELECT d, v, i::timestamp AT TIME ZONE 'UTC' \ + FROM unnest($1::text[], $2::bigint[], $3::text[]) AS t(d, v, i) \ + ON CONFLICT DO NOTHING", + render_db_quoted_identifier(schema, DbType::Postgresql) + ), + &[ + &applied.datatables, + &applied.versions, + &applied.installed_at, + ], + ) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to copy _wm_migrations into '{}': {}", + target.dbname, + pg_error_message(&e) + )) + })?; + Ok(()) +} + /// Whether `e` is the lock wait running out, as opposed to the database being unreachable. fn is_migration_lock_busy(e: &Error) -> bool { matches!(e, Error::Generic(StatusCode::CONFLICT, _)) @@ -541,7 +683,9 @@ async fn read_applied_versions_on_client( /// Each migration runs as a normal Windmill `postgresql` job (permissioned as /// the requester, labelled `datatable_migration`); applied versions are then /// recorded in the data table's own `_wm_migrations` table, so only migrations -/// not recorded there are run, in ascending `timestamp` order. +/// not recorded there are run, in ascending `timestamp` order. A run for one +/// schema is refused while an earlier migration its selection overlaps is pending +/// (see `datatable_migration_scope`). async fn run_datatable_migrations( authed: ApiAuthed, Extension(db): Extension, @@ -549,7 +693,7 @@ async fn run_datatable_migrations( Path((w_id, datatable_name)): Path<(String, String)>, Query(query): Query, ) -> JsonResult { - ensure_not_operator(&authed)?; + ensure_not_operator(&db, &w_id, &authed).await?; // Before the admin connection is opened at all: the bookkeeping below is created and read // through it, so a caller no role covers must be refused here rather than after the fact. crate::datatable_permissions::ensure_reaches_datatable(&db, &w_id, &datatable_name, &authed) @@ -587,30 +731,29 @@ async fn run_datatable_migrations( let applied_versions = read_applied_versions_on_client(&client, &datatable_name).await?; + let selection = if let Some(only) = query.only { + RunSelection::Only(only) + } else if let Some(schema) = query.schema.as_deref() { + RunSelection::Schema(schema.to_ascii_lowercase()) + } else { + RunSelection::UpTo(query.up_to) + }; // How the user scoped the run, for the counter emitted on the first migration // that lands below. - let scope = if query.only.is_some() { - "only" - } else if query.up_to.is_some() { - "up_to" - } else { - "all" + let scope = match selection { + RunSelection::Only(_) => "only", + RunSelection::Schema(_) => "schema", + RunSelection::UpTo(Some(_)) => "up_to", + RunSelection::UpTo(None) => "all", }; + let chain: Vec = migrations + .iter() + .map(|m| ChainEntry::new(m.timestamp, m.name.clone(), &m.code_up)) + .collect(); + let plan = plan_run(&chain, &applied_versions, &selection)?; let mut applied = Vec::new(); - for m in migrations { - if let Some(only) = query.only { - // Run a single specific migration, skipping every other one. - if m.timestamp != only { - continue; - } - } else if query.up_to.is_some_and(|up_to| m.timestamp > up_to) { - // Migrations are ordered ascending, so once we pass `up_to` we're done. - break; - } - if applied_versions.contains(&m.timestamp) { - continue; - } + for m in plan.into_iter().map(|i| &migrations[i]) { ensure_migration_role_allowed( &db, &w_id, @@ -642,7 +785,7 @@ async fn run_datatable_migrations( pg_error_message(&e) )) })?; - applied.push(AppliedMigration { version: m.timestamp, name: m.name }); + applied.push(AppliedMigration { version: m.timestamp, name: m.name.clone() }); // One event per run that moved the data table forward, emitted on the // first migration that lands rather than after the loop: a later one // failing returns early, and that run still advanced the data table. A @@ -686,7 +829,7 @@ async fn rollback_datatable_migrations( Path((w_id, datatable_name)): Path<(String, String)>, Query(query): Query, ) -> JsonResult { - ensure_not_operator(&authed)?; + ensure_not_operator(&db, &w_id, &authed).await?; // Before the admin connection is opened at all: the bookkeeping below is created and read // through it, so a caller no role covers must be refused here rather than after the fact. crate::datatable_permissions::ensure_reaches_datatable(&db, &w_id, &datatable_name, &authed) @@ -865,6 +1008,13 @@ struct DatatableMigrationWithStatus { #[serde(skip_serializing_if = "Option::is_none")] code_down: Option, status: DatatableMigrationRunStatus, + /// The schemas its `-- schemas` annotation declares; absent for the whole database. + #[serde(skip_serializing_if = "Option::is_none")] + schemas: Option>, + /// When not run: the earliest pending migration that has to be applied before it. When run: + /// the latest applied migration that has to be rolled back before it. + #[serde(skip_serializing_if = "Option::is_none")] + blocked_by: Option, } #[derive(Serialize)] @@ -981,9 +1131,14 @@ async fn datatable_migrations_status( Err(e) => (None, Some(e.to_string())), }; + let chain: Vec = defs + .iter() + .map(|m| ChainEntry::new(m.timestamp, m.name.clone(), &m.code_up)) + .collect(); let migrations = defs .into_iter() - .map(|m| { + .enumerate() + .map(|(i, m)| { let status = match &applied { Some(set) if set.contains(&m.timestamp) => DatatableMigrationRunStatus::Ran, Some(_) => DatatableMigrationRunStatus::NotRun, @@ -995,6 +1150,11 @@ async fn datatable_migrations_status( code_up: m.code_up, code_down: m.code_down, status, + schemas: chain[i].scope.clone(), + blocked_by: applied + .as_ref() + .and_then(|set| blocked_by(&chain, set, i)) + .map(|j| chain[j].timestamp), } }) .collect(); @@ -1330,9 +1490,10 @@ async fn create_datatable_migration( Path((w_id, datatable_name)): Path<(String, String)>, Json(payload): Json, ) -> JsonResult { - ensure_not_operator(&authed)?; + ensure_not_operator(&db, &w_id, &authed).await?; validate_datatable_path_segment(&datatable_name)?; validate_migration_name(&payload.name)?; + let scope = parse_migration_schemas(&payload.code_up)?; ensure_datatable_migrations_enabled(&db, &w_id, &datatable_name).await?; ensure_may_edit_migration( &db, @@ -1379,6 +1540,7 @@ async fn create_datatable_migration( .await?; windmill_common::feature_usage::log_feature_usage("datatable", "migration_created", "manual"); + log_migration_scope(&scope); Ok(Json(DatatableMigration { datatable: datatable_name, @@ -1395,7 +1557,7 @@ async fn delete_datatable_migration( Extension(db): Extension, Path((w_id, datatable_name, timestamp)): Path<(String, String, i64)>, ) -> Result { - ensure_not_operator(&authed)?; + ensure_not_operator(&db, &w_id, &authed).await?; let deleted_message = format!("Deleted migration {} from {}", timestamp, datatable_name); // A definition already gone is not an error: `wmill sync push` deletes whatever the repo @@ -1532,7 +1694,7 @@ async fn upsert_datatable_migration( Path((w_id, datatable_name)): Path<(String, String)>, Json(payload): Json, ) -> Result { - ensure_not_operator(&authed)?; + ensure_not_operator(&db, &w_id, &authed).await?; validate_datatable_path_segment(&datatable_name)?; validate_migration_name(&payload.name)?; ensure_datatable_migrations_enabled(&db, &w_id, &datatable_name).await?; @@ -1581,6 +1743,16 @@ async fn upsert_datatable_migration( if unchanged { return Ok(upserted_message); } + // Only up SQL being written is held to the annotation: a stored up that opens with prose + // starting with the word `schemas` predates scopes, and must still accept a down added to it. + let up_changed = existing + .as_ref() + .is_none_or(|existing| existing.code_up != payload.code_up); + let scope = if up_changed { + parse_migration_schemas(&payload.code_up)? + } else { + None + }; ensure_may_edit_migration( &db, &w_id, @@ -1709,6 +1881,9 @@ async fn upsert_datatable_migration( "edited" }, ); + if existing.is_none() { + log_migration_scope(&scope); + } Ok(upserted_message) } @@ -1723,7 +1898,7 @@ async fn generate_initial_datatable_migration( Extension(db): Extension, Path((w_id, datatable_name)): Path<(String, String)>, ) -> JsonResult { - ensure_not_operator(&authed)?; + ensure_not_operator(&db, &w_id, &authed).await?; // Returns a `pg_dump` of the whole schema and writes into the data table's own bookkeeping, so // it answers to the workspace that governs it rather than to whoever is asking. ensure_datatable_admin_access( diff --git a/backend/windmill-api-workspaces/src/lib.rs b/backend/windmill-api-workspaces/src/lib.rs index c437d7e2b4..cc5a7b9ab0 100644 --- a/backend/windmill-api-workspaces/src/lib.rs +++ b/backend/windmill-api-workspaces/src/lib.rs @@ -4,12 +4,14 @@ pub mod data_metrics; pub mod datatable_acl; pub mod datatable_acl_oss; pub mod datatable_clone; +pub mod datatable_migration_scope; pub mod datatable_migrations; pub mod datatable_permissions; pub mod datatable_permissions_oss; pub mod datatable_replay_oss; pub mod deployment_requests; pub mod remote_deploy; +pub mod workspace_roles; pub mod workspaces; pub mod workspaces_extra; pub mod workspaces_oss; diff --git a/backend/windmill-api-workspaces/src/workspace_roles.rs b/backend/windmill-api-workspaces/src/workspace_roles.rs new file mode 100644 index 0000000000..58bf4e1904 --- /dev/null +++ b/backend/windmill-api-workspaces/src/workspace_roles.rs @@ -0,0 +1,290 @@ +//! The workspace's roles and its overrides of them. See `windmill_common::roles`. + +use axum::{ + extract::{Extension, Path}, + routing::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use windmill_api_auth::ApiAuthed; +use windmill_audit::{audit_oss::audit_log, ActionKind}; +use windmill_common::{ + error::{Error, JsonResult, Result}, + roles::{ + self, instance_roles, load_role_overrides, EffectiveRole, RoleCapabilities, RoleOverrides, + RoleSource, OPERATOR_ROLE, + }, + utils::require_admin, + DB, +}; +use windmill_git_sync::handle_deployment_metadata; + +pub fn workspaced_service() -> Router { + Router::new() + .route("/roles", get(list_roles)) + .route("/role_overrides", post(update_role_overrides)) +} + +#[derive(Serialize)] +struct WorkspaceRoles { + roles: Vec, + overrides: RoleOverrides, + /// Whether this workspace may add roles of its own. + custom_roles_enabled: bool, + /// The caller's role, `None` for a caller with no membership (a superadmin, a guest). + my_role: Option, + my_capabilities: RoleCapabilities, +} + +async fn list_roles( + authed: ApiAuthed, + Path(w_id): Path, + Extension(db): Extension, +) -> JsonResult { + let roles = roles::workspace_roles(&db, &w_id).await?; + let overrides = load_role_overrides(&db, &w_id).await?; + let my_role = sqlx::query!( + "SELECT workspace_role, operator FROM usr WHERE workspace_id = $1 AND username = $2", + &w_id, + &authed.username + ) + .fetch_optional(&db) + .await? + .map(|r| roles::resolve_role_name(r.workspace_role, r.operator)); + // Not `caller_capabilities`: its fast path answers FULL for an unrestricted member, which + // drops the visibility a role may still withhold. + let my_capabilities = match &my_role { + _ if authed.is_admin => roles::RoleCapabilities::FULL, + Some(role) => roles::role_capabilities_by_name(&db, &w_id, role).await?, + None => roles::RoleCapabilities::NONE, + }; + Ok(Json(WorkspaceRoles { + roles: roles.as_ref().clone(), + overrides, + custom_roles_enabled: roles::custom_roles_enabled(), + my_role, + my_capabilities, + })) +} + +async fn update_role_overrides( + authed: ApiAuthed, + Path(w_id): Path, + Extension(db): Extension, + Json(overrides): Json, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + write_role_overrides(&db, &authed, &w_id, overrides, None).await?; + Ok("Role overrides updated".to_string()) +} + +/// Replaces the workspace's role overrides with `overrides`. Refuses removing a workspace-only role +/// anything still names, and a change that pushes an offline license over its seat cap. +/// `operator_settings` is the legacy `/operator_settings` payload, mirrored into its column for +/// git sync and for servers that predate roles. +pub(crate) async fn write_role_overrides( + db: &DB, + authed: &ApiAuthed, + w_id: &str, + overrides: RoleOverrides, + operator_settings: Option, +) -> Result<()> { + let instance = instance_roles(db).await?; + roles::validate_overrides(&instance, &overrides)?; + roles::check_frozen_overrides( + &instance, + &roles::load_role_overrides(db, w_id).await?, + &overrides, + )?; + + let previous = roles::workspace_roles(db, w_id).await?; + let removed: Vec = previous + .iter() + .filter(|r| r.source == RoleSource::Workspace && !overrides.contains_key(&r.name)) + .map(|r| r.name.clone()) + .collect(); + for name in &removed { + roles::ensure_role_unused(db, Some(w_id), name).await?; + } + + let overrides_json = serde_json::to_value(&overrides) + .map_err(|e| Error::internal_err(format!("serializing role overrides: {e:#}")))?; + #[cfg(feature = "enterprise")] + let seat_baseline = windmill_common::ee_oss::seat_cap_baseline(db).await?; + let mut tx = db.begin().await?; + sqlx::query!( + "UPDATE workspace_settings SET role_overrides = $1 WHERE workspace_id = $2", + if overrides.is_empty() { + None + } else { + Some(&overrides_json) + }, + w_id + ) + .execute(&mut *tx) + .await?; + roles::notify_role_overrides_change(&mut tx, w_id).await?; + if let Some(settings) = &operator_settings { + sqlx::query!( + "UPDATE workspace_settings + SET operator_settings = COALESCE(operator_settings, '{}'::jsonb) || $1 + WHERE workspace_id = $2", + settings, + w_id + ) + .execute(&mut *tx) + .await?; + } + roles::invalidate_workspace_roles_cache(w_id); + roles::sync_role_seats(&mut tx, Some(w_id)).await?; + + #[cfg(feature = "enterprise")] + if let Some(msg) = + windmill_common::ee_oss::check_seat_cap_for_role_change(&seat_baseline, &mut tx).await? + { + return Err(Error::BadRequest(msg)); + } + + audit_log( + &mut *tx, + authed, + if operator_settings.is_some() { + "workspaces.update_operator_settings" + } else { + "workspaces.update_role_overrides" + }, + ActionKind::Update, + w_id, + None, + Some([("role_overrides", overrides_json.to_string().as_str())].into()), + ) + .await?; + tx.commit().await?; + roles::invalidate_workspace_roles_cache(w_id); + + handle_deployment_metadata( + &authed.email, + &authed.username, + db, + w_id, + windmill_git_sync::DeployedObject::Settings { + setting_type: if operator_settings.is_some() { + "operator_settings".to_string() + } else { + "role_overrides".to_string() + }, + }, + Some("Workspace roles updated".to_string()), + false, + None, + ) + .await?; + Ok(()) +} + +/// The legacy `/operator_settings` payload. Visibility flags always serialize; the rights are +/// `Option` because git-sync posts whole files that may predate them, and an absent key must +/// leave the stored value alone. +#[derive(Deserialize, Serialize)] +pub(crate) struct ChangeOperatorSettings { + #[serde(default)] + runs: bool, + #[serde(default)] + schedules: bool, + #[serde(default)] + resources: bool, + #[serde(default)] + variables: bool, + #[serde(default)] + assets: bool, + #[serde(default)] + triggers: bool, + #[serde(default)] + audit_logs: bool, + #[serde(default)] + groups: bool, + #[serde(default)] + folders: bool, + #[serde(default)] + workers: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + builder_flows: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + builder_apps: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + manage_schedules: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + manage_triggers: Option, +} + +/// Applies a legacy operator settings payload as an override of the `operator` role. A key equal +/// to what the instance gives the role is dropped rather than pinned, so it keeps following the +/// instance definition. +pub(crate) async fn update_operator_settings( + authed: ApiAuthed, + Path(w_id): Path, + Extension(db): Extension, + Json(settings): Json, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + + let instance = instance_roles(&db).await?; + let Some(base) = roles::effective_roles(&instance, &RoleOverrides::new())? + .into_iter() + .find(|r| r.name == OPERATOR_ROLE) + else { + return Err(Error::BadRequest( + "this instance defines no `operator` role: edit the workspace roles instead" + .to_string(), + )); + }; + let base = base.capabilities; + + let mut overrides = load_role_overrides(&db, &w_id).await?; + let current = roles::workspace_roles(&db, &w_id) + .await? + .iter() + .find(|r| r.name == OPERATOR_ROLE) + .map(|r| r.capabilities) + .unwrap_or(base); + + let mut target = current; + let v = &mut target.visibility; + (v.runs, v.schedules, v.resources, v.variables, v.assets) = ( + settings.runs, + settings.schedules, + settings.resources, + settings.variables, + settings.assets, + ); + (v.triggers, v.audit_logs, v.groups, v.folders, v.workers) = ( + settings.triggers, + settings.audit_logs, + settings.groups, + settings.folders, + settings.workers, + ); + let compose_or_keep = |cur: roles::AuthorLevel, grant: Option| match grant { + Some(true) if cur == roles::AuthorLevel::None => roles::AuthorLevel::Compose, + Some(false) => roles::AuthorLevel::None, + _ => cur, + }; + target.flows = compose_or_keep(current.flows, settings.builder_flows); + target.apps = compose_or_keep(current.apps, settings.builder_apps); + if let Some(b) = settings.manage_schedules { + target.manage_schedules = b; + } + if let Some(b) = settings.manage_triggers { + target.manage_triggers = b; + } + + let entry = overrides.entry(OPERATOR_ROLE.to_string()).or_default(); + entry.capabilities = roles::diff_capabilities(&base, &target); + if entry.label.is_none() && entry.capabilities == Default::default() { + overrides.remove(OPERATOR_ROLE); + } + + let settings_json = serde_json::json!(settings); + write_role_overrides(&db, &authed, &w_id, overrides, Some(settings_json)).await?; + Ok("Operator settings updated successfully".to_string()) +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index f34ad74977..a779c240cc 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -39,7 +39,7 @@ use windmill_common::global_settings::HTTP_ROUTE_WORKSPACED_ROUTE; use windmill_common::query_builders::{render_db_quoted_identifier, DbType}; use windmill_common::users::username_to_permissioned_as; use windmill_common::variables::{ - crypt_from_key_with_suffix, decrypt, encrypt, WORKSPACE_CRYPT_CACHE, + crypt_from_key_with_suffix, decrypt, decrypt_secret_value, encrypt, WORKSPACE_CRYPT_CACHE, }; use windmill_common::worker::{to_raw_value, CLOUD_HOSTED}; use windmill_common::workspaces::GitRepositorySettings; @@ -76,7 +76,9 @@ use hyper::StatusCode; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, Postgres, Row, Transaction}; use windmill_common::oauth2::InstanceEvent; -use windmill_common::secret_backend::{get_secret_backend, is_vault_backend_configured}; +use windmill_common::secret_backend::{ + get_secret_backend, is_external_stored_value, is_vault_backend_configured, +}; use windmill_common::utils::not_found_if_none; lazy_static::lazy_static! { @@ -113,6 +115,7 @@ pub fn workspaced_service() -> Router { .route("/slack_oauth_config", get(get_slack_oauth_config)) .route("/slack_oauth_config", post(set_slack_oauth_config)) .route("/slack_oauth_config", delete(delete_slack_oauth_config)) + .route("/slack_signing_secret", post(set_slack_signing_secret)) .route("/edit_webhook", post(edit_webhook)) .route("/edit_auto_invite", post(edit_auto_invite)) .route("/edit_instance_groups", post(edit_instance_groups)) @@ -192,7 +195,11 @@ pub fn workspaced_service() -> Router { .route("/usage", get(get_usage)) .route("/used_triggers", get(get_used_triggers)) .route("/public_app_rate_limit", post(edit_public_app_rate_limit)) - .route("/operator_settings", post(update_operator_settings)) + .route( + "/operator_settings", + post(crate::workspace_roles::update_operator_settings), + ) + .merge(crate::workspace_roles::workspaced_service()) .route( "/create_workspace_fork_branch", post(create_workspace_fork_branch), @@ -442,6 +449,8 @@ struct RunSlackMessageTestJobResponse { #[derive(Deserialize)] pub struct EditAutoInvite { pub operator: Option, + /// The role auto-invited members get; wins over `operator`. + pub role: Option, pub invite_all: Option, pub auto_add: Option, } @@ -515,6 +524,11 @@ struct CreateWorkspaceFork { /// fork namespace + read-defer. #[serde(default)] shared_ducklakes: Vec, + /// Workspace storages the fork reads and writes at the parent's keys (`_default_` for the + /// primary one). Every other storage is isolated: the fork writes under its own prefix + /// and reads through to the parent's objects. + #[serde(default)] + shared_storages: Vec, /// Create the fork as a persistent dev workspace: the id is not required to carry the /// `wm-fork-` prefix, and at most one dev workspace may exist per parent. #[serde(default)] @@ -574,6 +588,9 @@ struct UserWorkspace { pub created_by: Option, pub disabled: bool, pub is_service_account: bool, + /// The member's role in this workspace. + pub role: String, + pub capabilities: windmill_common::roles::RoleCapabilities, } #[derive(Deserialize)] @@ -591,7 +608,11 @@ struct ValidateUsername { pub struct NewWorkspaceInvite { pub email: String, pub is_admin: bool, + /// Legacy role selector, `true` for the `operator` role; `role` wins. + #[serde(default)] pub operator: bool, + #[serde(default)] + pub role: Option, } #[derive(Deserialize)] @@ -599,7 +620,34 @@ pub struct NewWorkspaceUser { pub email: String, pub username: Option, pub is_admin: bool, + #[serde(default)] pub operator: bool, + #[serde(default)] + pub role: Option, +} + +/// The role a new member or invite gets, `role` if given, else the one the legacy `operator` flag +/// names, and the `usr.operator` value that goes with it: "cannot write code". +async fn resolve_new_member_role( + db: &DB, + w_id: &str, + operator: bool, + role: Option<&str>, +) -> Result<(String, bool)> { + let roles = windmill_common::roles::workspace_roles(db, w_id).await?; + let name = match role { + Some(role) => role.to_string(), + None if operator => windmill_common::roles::OPERATOR_ROLE.to_string(), + None => windmill_common::roles::default_role_name(roles.iter().map(|r| r.name.as_str())), + }; + let caps = roles + .iter() + .find(|r| r.name == name) + .ok_or_else(|| { + Error::BadRequest(format!("role `{name}` is not defined in workspace {w_id}")) + })? + .capabilities; + Ok((name, !caps.code)) } /// The role is one choice stored as two flags; both set would show as admin in the UI while the @@ -713,6 +761,7 @@ async fn list_pending_invites( workspace_invite.email, workspace_invite.is_admin, workspace_invite.operator, + COALESCE(workspace_invite.workspace_role, CASE WHEN workspace_invite.operator THEN 'operator' ELSE 'developer' END) AS \"role!\", workspace.parent_workspace_id FROM workspace_invite JOIN workspace ON workspace_invite.workspace_id = workspace.id WHERE workspace_id = $1 @@ -1606,10 +1655,9 @@ async fn edit_slack_command( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, - ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(es): Json, ) -> Result { - require_admin(is_admin, &username)?; + require_admin(authed.is_admin, &authed.username)?; let mut tx = db.begin().await?; if es.slack_command_script.is_some() { @@ -1725,6 +1773,7 @@ struct SetSlackOAuthConfigRequest { struct GetSlackOAuthConfigResponse { slack_oauth_client_id: Option, slack_oauth_client_secret: Option, + slack_signing_secret: Option, } async fn get_slack_oauth_config( @@ -1735,7 +1784,8 @@ async fn get_slack_oauth_config( require_admin(authed.is_admin, &authed.username)?; let settings = sqlx::query!( - "SELECT slack_oauth_client_id, slack_oauth_client_secret FROM workspace_settings WHERE workspace_id = $1", + "SELECT slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret + FROM workspace_settings WHERE workspace_id = $1", &w_id ) .fetch_one(&db) @@ -1749,6 +1799,7 @@ async fn get_slack_oauth_config( Ok(Json(GetSlackOAuthConfigResponse { slack_oauth_client_id: settings.slack_oauth_client_id, slack_oauth_client_secret: masked_secret, + slack_signing_secret: settings.slack_signing_secret.map(|_| "***".to_string()), })) } @@ -1765,12 +1816,52 @@ async fn set_slack_oauth_config( "Both client ID and client secret are required".to_string(), )); } + // A settings.yaml pulled by `wmill sync` holds the secret encrypted, and a CLI that + // predates the settings document pushes it here as it reads it. One encrypted under another + // workspace's key keeps the stored secret, as the settings document does, so that CLI's push + // does not abort over it. + let mut req = req; + if let Some(ciphertext) = req + .slack_oauth_client_secret + .strip_prefix(windmill_common::variables::SETTINGS_ENCRYPTED_PREFIX) + { + match windmill_common::variables::build_crypt(&db, &w_id) + .await? + .decrypt_str(ciphertext) + { + Ok(plain) => req.slack_oauth_client_secret = plain, + Err(_) => { + let stored = sqlx::query_scalar!( + "SELECT slack_oauth_client_secret FROM workspace_settings WHERE workspace_id = $1", + &w_id + ) + .fetch_optional(&db) + .await? + .flatten(); + tracing::warn!( + workspace_id = %w_id, + "Slack client secret left unchanged: it is encrypted with another workspace's key" + ); + match stored { + Some(stored) => req.slack_oauth_client_secret = stored, + None => { + return Ok("Slack OAuth config left unchanged: the client secret is \ + encrypted with another workspace's key" + .to_string()) + } + } + } + } + } let mut tx = db.begin().await?; sqlx::query!( + // Another app's signing secret would reject every request the new app sends. "UPDATE workspace_settings - SET slack_oauth_client_id = $1, slack_oauth_client_secret = $2 + SET slack_oauth_client_id = $1, slack_oauth_client_secret = $2, + slack_signing_secret = CASE WHEN slack_oauth_client_id = $1::varchar + THEN slack_signing_secret END WHERE workspace_id = $3", &req.slack_oauth_client_id, &req.slack_oauth_client_secret, @@ -1807,6 +1898,51 @@ async fn set_slack_oauth_config( Ok(format!("Slack OAuth config set for workspace {}", &w_id)) } +#[derive(Deserialize)] +struct SetSlackSigningSecretRequest { + slack_signing_secret: Option, +} + +/// The signing secret of the workspace's own Slack app, which authenticates what Slack sends for it. +async fn set_slack_signing_secret( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Json(req): Json, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + let secret = req + .slack_signing_secret + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + + let mut tx = db.begin().await?; + sqlx::query!( + "UPDATE workspace_settings SET slack_signing_secret = $1 WHERE workspace_id = $2", + secret, + &w_id + ) + .execute(&mut *tx) + .await?; + audit_log( + &mut *tx, + &authed, + "workspaces.set_slack_signing_secret", + if secret.is_some() { + ActionKind::Update + } else { + ActionKind::Delete + }, + &w_id, + Some(&authed.email), + None, + ) + .await?; + tx.commit().await?; + + Ok(format!("Slack signing secret updated for workspace {w_id}")) +} + async fn delete_slack_oauth_config( authed: ApiAuthed, Extension(db): Extension, @@ -1818,7 +1954,8 @@ async fn delete_slack_oauth_config( sqlx::query!( "UPDATE workspace_settings - SET slack_oauth_client_id = NULL, slack_oauth_client_secret = NULL + SET slack_oauth_client_id = NULL, slack_oauth_client_secret = NULL, + slack_signing_secret = NULL WHERE workspace_id = $1", &w_id ) @@ -1939,10 +2076,9 @@ async fn edit_webhook( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, - ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(ew): Json, ) -> Result { - require_admin(is_admin, &username)?; + require_admin(authed.is_admin, &authed.username)?; if *CLOUD_HOSTED { return Err(Error::BadRequest( @@ -2056,10 +2192,9 @@ async fn edit_dbt_warehouses( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, - ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(new_config): Json, ) -> Result { - require_admin(is_admin, &username)?; + require_admin(authed.is_admin, &authed.username)?; let new_config = new_config.dbt_warehouses; if let Some(map) = new_config.as_ref() { for (name, cfg) in map.iter() { @@ -2094,14 +2229,45 @@ async fn edit_dbt_warehouses( Ok("Updated the workspace's dbt warehouses".to_string()) } +/// A fork's isolated storage is purged at deletion from the storage recorded at creation: under +/// another location, its objects would outlive the fork untracked, and a same-id fork would +/// read them. +async fn reject_isolated_storage_repoint( + tx: &mut Transaction<'_, Postgres>, + w_id: &str, + new_lfs: &serde_json::Value, +) -> Result<()> { + let rows = sqlx::query!( + "SELECT storage, storage_ref FROM fork_storage_namespace WHERE workspace_id = $1", + w_id + ) + .fetch_all(&mut **tx) + .await?; + for row in rows { + let new_ref = windmill_common::fork_storage::lfs_entry(new_lfs, &row.storage) + .and_then(windmill_common::workspaces::lfs_entry_storage_ref); + if new_ref.is_some_and(|r| r != row.storage_ref) { + let name = if row.storage.is_empty() { + windmill_types::s3::DEFAULT_STORAGE + } else { + row.storage.as_str() + }; + return Err(Error::BadRequest(format!( + "storage `{name}` is isolated from the parent in this fork, so where it points \ + cannot be changed" + ))); + } + } + Ok(()) +} + async fn edit_large_file_storage_config( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, - ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(new_config): Json, ) -> Result { - require_admin(is_admin, &username)?; + require_admin(authed.is_admin, &authed.username)?; let mut tx = db.begin().await?; @@ -2131,6 +2297,15 @@ async fn edit_large_file_storage_config( windmill_types::s3::DEFAULT_STORAGE ))); } + if lfs_config + .secondary_storage + .contains_key(windmill_types::s3::BUCKET_ADDRESSED_SEGMENT) + { + return Err(Error::BadRequest(format!( + "`{}` is reserved by the S3 proxy and cannot name a secondary storage", + windmill_types::s3::BUCKET_ADDRESSED_SEGMENT + ))); + } if !windmill_common::workspaces::filesystem_storage_allowed() { let named = std::iter::once(("primary storage", &lfs_config.large_file_storage)).chain( @@ -2152,6 +2327,7 @@ async fn edit_large_file_storage_config( let serialized_lfs_config = serde_json::to_value::(lfs_config) .map_err(|err| Error::internal_err(err.to_string()))?; + reject_isolated_storage_repoint(&mut tx, &w_id, &serialized_lfs_config).await?; // A workspace whose AI session backups fell back to the instance store leaves it // here: the generation moves on, so nothing it left in any instance store is read @@ -2248,6 +2424,9 @@ struct DataTableListItem { /// at its parent. Its permissions apply here, and only its admins may edit them. #[serde(skip_serializing_if = "Option::is_none")] governing_workspace_id: Option, + /// The workspace owning the database this entry points at, when it is not this workspace. + #[serde(skip_serializing_if = "Option::is_none")] + database_workspace_id: Option, /// Whether the governing entry is under roles. permissioned: bool, } @@ -2277,6 +2456,8 @@ async fn list_datatables( resource_path: database.resource_path.clone(), governing_workspace_id: (governing.governing_workspace_id() != w_id) .then(|| governing.governing_workspace_id().to_string()), + database_workspace_id: (governing.workspace_id != w_id) + .then(|| governing.workspace_id.clone()), permissioned: governing.datatable.permissions.is_some(), }); } @@ -3594,6 +3775,97 @@ pub(crate) async fn pg_import_dump(target_db: &PgDatabase, dump_file: &DumpFile) Ok(()) } +/// Carry the source database's own settings (`ALTER DATABASE ... SET`) over to a copy. `pg_dump` +/// only emits them with `--create`, so a copy otherwise resolves unqualified names, its +/// `_wm_migrations` included, through the server's default `search_path` rather than the source's. +/// A setting the copy's connection may not set is skipped with a warning rather than failing it. +/// Called once nothing else writes to the copy, which a copied read-only default would refuse. +pub(crate) async fn copy_database_settings( + db: &DB, + source: &PgDatabase, + target: &PgDatabase, +) -> Result<()> { + let (source_client, connection) = source.connect(Some(db)).await?; + tokio::spawn(async move { + if let Err(e) = connection.await { + tracing::error!("Datatable connection error: {}", e); + } + }); + let settings: Vec = source_client + .query( + "SELECT unnest(setconfig) FROM pg_db_role_setting WHERE setrole = 0 \ + AND setdatabase = (SELECT oid FROM pg_database WHERE datname = current_database())", + &[], + ) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to read the settings of database '{}': {}", + source.dbname, + pg_error_message(&e) + )) + })? + .iter() + .map(|row| row.get(0)) + .collect(); + drop(source_client); + if settings.is_empty() { + return Ok(()); + } + + let (mut client, connection) = target.connect(Some(db)).await?; + tokio::spawn(async move { + if let Err(e) = connection.await { + tracing::error!("Datatable connection error: {}", e); + } + }); + let quoted_db = render_db_quoted_identifier(&target.dbname, DbType::Postgresql); + for setting in settings { + let Some((name, value)) = setting.split_once('=') else { + continue; + }; + if name.is_empty() + || !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.') + { + tracing::warn!( + "Not copying database setting '{name}' to '{}'", + target.dbname + ); + continue; + } + let quoted_name = name + .split('.') + .map(|part| render_db_quoted_identifier(part, DbType::Postgresql)) + .collect::>() + .join("."); + // `FROM CURRENT` takes the value in the stored text form, which `SET name TO value` would + // need re-quoted per setting type. Set transaction-locally: a session-wide `role` or + // `default_transaction_read_only` would make every later `ALTER DATABASE` fail. + let applied = async { + let tx = client.transaction().await?; + tx.execute("SELECT set_config($1, $2, true)", &[&name, &value]) + .await?; + tx.batch_execute(&format!( + "ALTER DATABASE {quoted_db} SET {quoted_name} FROM CURRENT" + )) + .await?; + tx.commit().await + } + .await; + if let Err(e) = applied { + tracing::warn!( + "Could not copy setting '{name}' of database '{}' to '{}': {}", + source.dbname, + target.dbname, + pg_error_message(&e) + ); + } + } + Ok(()) +} + #[derive(Deserialize)] struct CreatePgDatabaseRequest { /// The datatable source to determine connection info: 'datatable://name' or '$res:path' @@ -3857,12 +4129,33 @@ async fn import_pg_database( // the ACLs are user intent (`REVOKE ... FROM PUBLIC`) and dropping them widens access. let no_acl = target_kind.is_some() || source_kind.is_some(); + // Only a database made to be a copy takes on the source's bookkeeping and settings: without an + // override the target is a data table's own live database, whose settings are its owner's. + let fresh_copy = req.target_dbname_override.is_some(); + let migrations_lock = if schema_only && fresh_copy { + Some( + crate::datatable_migrations::lock_migration_runs_on(&db, &source_pg, &req.source) + .await?, + ) + } else { + None + }; let dump_file = pg_dump_database( &source_pg, PgDumpOptions { schema_only, no_owner: true, no_acl, ..Default::default() }, ) .await?; + let applied = match migrations_lock { + Some(client) => Some(crate::datatable_migrations::read_applied_migrations(&client).await?), + None => None, + }; pg_import_dump(&target_pg, &dump_file).await?; + if let Some(applied) = applied { + crate::datatable_migrations::write_applied_migrations(&db, &target_pg, &applied).await?; + } + if fresh_copy { + copy_database_settings(&db, &source_pg, &target_pg).await?; + } if let Some(tx) = fork_lock { tx.commit().await?; } @@ -3926,10 +4219,9 @@ async fn edit_ducklake_config( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, - ApiAuthed { is_admin, username, email, .. }: ApiAuthed, Json(new_config): Json, ) -> Result { - require_admin(is_admin, &username)?; + require_admin(authed.is_admin, &authed.username)?; let is_superadmin = require_super_admin(&db, &authed).await.is_ok(); // Lake names end up interpolated in `ATTACH 'ducklake://'`, @@ -4055,8 +4347,8 @@ async fn edit_ducklake_config( &w_id, &new_config.settings.ducklakes, &old_ducklakes, - &username, - &email, + &authed.username, + &authed.email, ) .await?; @@ -4084,10 +4376,9 @@ async fn edit_datatable_config( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, - ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(mut new_config): Json, ) -> JsonResult { - require_admin(is_admin, &username)?; + require_admin(authed.is_admin, &authed.username)?; let is_superadmin = require_super_admin(&db, &authed).await.is_ok(); let mut tx = db.begin().await?; @@ -4822,10 +5113,9 @@ async fn edit_git_sync_config( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, - ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(new_config): Json, ) -> Result { - require_admin(is_admin, &username)?; + require_admin(authed.is_admin, &authed.username)?; check_git_sync_access(&db, &w_id).await?; let mut tx = db.begin().await?; @@ -5076,10 +5366,9 @@ async fn edit_git_sync_repository( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, - ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(mut new_config): Json, ) -> Result { - require_admin(is_admin, &username)?; + require_admin(authed.is_admin, &authed.username)?; check_git_sync_access(&db, &w_id).await?; // Validate the resource path format @@ -5299,10 +5588,9 @@ async fn delete_git_sync_repository( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, - ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(request): Json, ) -> Result { - require_admin(is_admin, &username)?; + require_admin(authed.is_admin, &authed.username)?; // No check_git_sync_access here — admins should always be able to delete/clean up repos if request.git_repo_resource_path.is_empty() { @@ -5452,10 +5740,9 @@ async fn edit_deploy_ui_config( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, - ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(new_config): Json, ) -> Result { - require_admin(is_admin, &username)?; + require_admin(authed.is_admin, &authed.username)?; let mut tx = db.begin().await?; let args_for_audit = format!("{:?}", new_config.deploy_ui_settings); @@ -5671,10 +5958,9 @@ async fn edit_default_scripts( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, - ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(new_config): Json>, ) -> Result { - require_admin(is_admin, &username)?; + require_admin(authed.is_admin, &authed.username)?; let mut tx = db.begin().await?; @@ -5745,7 +6031,6 @@ async fn edit_default_app( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, - ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(new_config): Json, ) -> Result { #[cfg(not(feature = "enterprise"))] @@ -5756,7 +6041,7 @@ async fn edit_default_app( )); } - require_admin(is_admin, &username)?; + require_admin(authed.is_admin, &authed.username)?; let mut tx = db.begin().await?; @@ -5838,10 +6123,9 @@ async fn edit_error_handler( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, - ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(ee): Json, ) -> Result { - require_admin(is_admin, &username)?; + require_admin(authed.is_admin, &authed.username)?; // Normalize to new format (handles both old CLI and new CLI requests) let ee = ee.into_normalized(); @@ -6501,13 +6785,11 @@ async fn user_workspaces( Extension(db): Extension, ApiAuthed { email, .. }: ApiAuthed, ) -> JsonResult { - let mut tx = db.begin().await?; - let workspaces = sqlx::query_as!( - UserWorkspace, + let rows = sqlx::query!( "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id, workspace.is_dev_workspace, workspace.dev_workspace_label, workspace.owner AS \"created_by?\", - CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings, + usr.is_admin, usr.operator, usr.workspace_role, usr.disabled, usr.is_service_account FROM workspace JOIN usr ON usr.workspace_id = workspace.id @@ -6515,9 +6797,46 @@ async fn user_workspaces( WHERE usr.email = $1 AND workspace.deleted = false", email ) - .fetch_all(&mut *tx) + .fetch_all(&db) .await?; - tx.commit().await?; + let mut workspaces = Vec::with_capacity(rows.len()); + for r in rows { + let role = windmill_common::roles::resolve_role_name(r.workspace_role, r.operator); + let capabilities = if r.is_admin { + windmill_common::roles::RoleCapabilities::FULL + } else { + windmill_common::roles::role_capabilities_by_name(&db, &r.id, &role).await? + }; + // The pre-roles shape the frontend gates on: present exactly for a restricted member. + let operator_settings = (capabilities.is_restricted()).then(|| { + let v = &capabilities.visibility; + Some(serde_json::json!({ + "runs": v.runs, "schedules": v.schedules, "resources": v.resources, + "variables": v.variables, "assets": v.assets, "triggers": v.triggers, + "audit_logs": v.audit_logs, "groups": v.groups, "folders": v.folders, + "workers": v.workers, + "builder_flows": capabilities.flows != windmill_common::roles::AuthorLevel::None, + "builder_apps": capabilities.apps != windmill_common::roles::AuthorLevel::None, + "manage_schedules": capabilities.manage_schedules, + "manage_triggers": capabilities.manage_triggers, + })) + }); + workspaces.push(UserWorkspace { + id: r.id, + name: r.name, + username: r.username, + color: r.color, + operator_settings, + parent_workspace_id: r.parent_workspace_id, + is_dev_workspace: r.is_dev_workspace, + dev_workspace_label: r.dev_workspace_label, + created_by: r.created_by, + disabled: r.disabled, + is_service_account: r.is_service_account, + role, + capabilities, + }); + } Ok(Json(WorkspaceList { email, workspaces })) } @@ -6981,7 +7300,7 @@ async fn clone_workspace_data( clone_resource_types(tx, source_workspace_id, target_workspace_id).await?; // Clone resources - clone_resources(tx, source_workspace_id, target_workspace_id).await?; + clone_resources(tx, source_workspace_id, target_workspace_id, authed).await?; // Clone variables (including external secret backend replication) clone_variables(tx, db, source_workspace_id, target_workspace_id).await?; @@ -7484,9 +7803,9 @@ async fn copy_workspace_members( // instance-group config, so a copied `instance_group` provenance would let the fork's // reconciliation delete them and their data. sqlx::query!( - "INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via) + "INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via, workspace_role) SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account, - CASE WHEN $3 THEN NULL ELSE added_via END + CASE WHEN $3 THEN NULL ELSE added_via END, workspace_role FROM usr WHERE workspace_id = $2 AND (NOT $3 OR (NOT operator AND NOT disabled AND NOT is_service_account)) ON CONFLICT DO NOTHING", @@ -7496,6 +7815,27 @@ async fn copy_workspace_members( ) .execute(&mut **tx) .await?; + // The members keep their roles, so the fork needs the source's workspace-only roles and + // overrides to give them the same meaning. + sqlx::query!( + "UPDATE workspace_settings SET role_overrides = + (SELECT role_overrides FROM workspace_settings WHERE workspace_id = $2) + WHERE workspace_id = $1", + target_workspace_id, + source_workspace_id, + ) + .execute(&mut **tx) + .await?; + sqlx::query!( + "INSERT INTO role_seat (workspace_id, role, author) + SELECT $1, role, author FROM role_seat WHERE workspace_id = $2 + ON CONFLICT DO NOTHING", + target_workspace_id, + source_workspace_id, + ) + .execute(&mut **tx) + .await?; + windmill_common::roles::invalidate_workspace_roles_cache(target_workspace_id); Ok(()) } @@ -7518,18 +7858,30 @@ async fn clone_resource_types( Ok(()) } +/// An agent's value carries the identity it runs as, re-pointed at the fork's creator when they +/// may not preserve someone else's, as `clone_apps` does an app's policy. The rest is swept by +/// `repoint_unresolvable_cloned_identities` once the fork's membership is final. async fn clone_resources( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, + authed: &ApiAuthed, ) -> Result<()> { + let repoint = (!windmill_common::can_preserve_on_behalf_of(authed)) + .then(|| username_to_permissioned_as(&authed.username)); sqlx::query!( "INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, edited_at, created_by) - SELECT $2, path, value, description, resource_type, extra_perms, edited_at, created_by + SELECT $2, path, + CASE WHEN $3::text IS NOT NULL AND resource_type = 'ai_agent' + AND jsonb_typeof(value) = 'object' AND value ? 'on_behalf_of' + THEN jsonb_set(value, '{on_behalf_of}', to_jsonb($3::text)) + ELSE value END, + description, resource_type, extra_perms, edited_at, created_by FROM resource WHERE workspace_id = $1", source_workspace_id, target_workspace_id, + repoint, ) .execute(&mut **tx) .await?; @@ -8016,6 +8368,18 @@ async fn repoint_unresolvable_cloned_identities( .await?; } + sqlx::query(&format!( + "UPDATE resource SET value = jsonb_set(value, '{{on_behalf_of}}', to_jsonb($2::text)) + WHERE workspace_id = $1 AND resource_type = 'ai_agent' + AND jsonb_typeof(value) = 'object' AND value ? 'on_behalf_of' + AND NOT ({})", + principal_resolves_sql("(value->>'on_behalf_of')") + )) + .bind(target_workspace_id) + .bind(&principal) + .execute(&mut **tx) + .await?; + // `email` is still written for workers that predate `permissioned_as`. sqlx::query(&format!( "UPDATE schedule SET permissioned_as = $2, email = $3 @@ -8398,7 +8762,7 @@ async fn clone_apps( // Clone app scripts with recomputed hashes let app_scripts = sqlx::query!( - "SELECT app, hash, lock, code, code_sha256 + "SELECT id, app, hash, lock, code, code_sha256, modules FROM app_script WHERE app = ANY(SELECT id FROM app WHERE workspace_id = $1)", source_workspace_id @@ -8406,34 +8770,104 @@ async fn clone_apps( .fetch_all(&mut **tx) .await?; + let mut app_script_id_mapping: HashMap = HashMap::new(); for app_script in app_scripts { if let Some(&new_app_id) = app_id_mapping.get(&app_script.app) { - // Recompute hash using app_id, code_sha256, and lock + // Recompute hash using app_id, code_sha256, lock and modules let mut hasher = Sha256::new(); hasher.update(new_app_id.to_be_bytes()); hasher.update(hex::decode(&app_script.code_sha256)?); if let Some(lock) = &app_script.lock { hasher.update(lock.as_bytes()); } + if let Some(modules) = &app_script.modules { + hasher.update(modules.to_string()); + } let new_hash = hex::encode(hasher.finalize()); - sqlx::query!( - "INSERT INTO app_script (app, hash, lock, code, code_sha256) - VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING", + let new_id = sqlx::query_scalar!( + "INSERT INTO app_script (app, hash, lock, code, code_sha256, modules) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (hash) DO UPDATE SET app = EXCLUDED.app -- trivial update to return the id + RETURNING id", new_app_id, new_hash, app_script.lock, app_script.code, app_script.code_sha256, + app_script.modules, ) - .execute(&mut **tx) + .fetch_one(&mut **tx) .await?; + app_script_id_mapping.insert(app_script.id, new_id); } } + // A version's lite value is what points each inline script at its `app_script` row (lock and + // modules). Without it the fork serves the full value, whose inline scripts carry no id and + // so run as bare code: no lock, and no modules for a raw app's sibling imports. Every + // version, not just `versions.last()`: the fork's `versions` is rebuilt from all rows above, + // so its current one can be a row the parent's array never listed. + let source_versions: Vec = version_id_mapping.keys().copied().collect(); + let lite_versions = sqlx::query!( + "SELECT id, value AS \"value!: serde_json::Value\" FROM app_version_lite WHERE id = ANY($1)", + &source_versions, + ) + .fetch_all(&mut **tx) + .await?; + let (mut lite_ids, mut lite_values) = (vec![], vec![]); + for lite in lite_versions { + let Some(&new_version_id) = version_id_mapping.get(&lite.id) else { + continue; + }; + let mut value = lite.value; + // Lite inline scripts have no code of their own, so one left without a row cannot run. + if !remap_app_script_ids(&mut value, &app_script_id_mapping) { + continue; + } + if let Some(object) = value.as_object_mut() { + object.insert("version".to_string(), serde_json::json!(new_version_id)); + } + lite_ids.push(new_version_id); + lite_values.push(value); + } + sqlx::query!( + "INSERT INTO app_version_lite (id, value) + SELECT * FROM UNNEST($1::bigint[], $2::jsonb[]) + ON CONFLICT (id) DO NOTHING", + &lite_ids, + &lite_values, + ) + .execute(&mut **tx) + .await?; + Ok(app_id_mapping) } +/// Points every `inlineScript.id` of a cloned lite app value at the fork's copy of that row. +/// False when some id has no copy. +fn remap_app_script_ids(value: &mut serde_json::Value, mapping: &HashMap) -> bool { + match value { + serde_json::Value::Object(object) => { + if let Some(serde_json::Value::Object(script)) = object.get_mut("inlineScript") { + if let Some(old_id) = script.get("id").and_then(serde_json::Value::as_i64) { + let Some(new_id) = mapping.get(&old_id) else { + return false; + }; + script.insert("id".to_string(), serde_json::json!(new_id)); + } + } + object + .values_mut() + .all(|child| remap_app_script_ids(child, mapping)) + } + serde_json::Value::Array(array) => array + .iter_mut() + .all(|child| remap_app_script_ids(child, mapping)), + _ => true, + } +} + async fn clone_raw_apps( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, @@ -8568,6 +9002,7 @@ async fn create_workspace_fork_branch( { return Err(Error::PermissionDenied(msg)); } + enforce_isolated_forks(&db, &authed, &w_id, &nw).await?; // Two-phase create for git-synced workspaces: this endpoint only creates the git branch(es) and // validates up front; it does NOT create the workspace row. The caller follows up with @@ -8777,11 +9212,12 @@ async fn apply_forked_datatable( fdt: &ForkedDatatableInfo, copy: &crate::datatable_clone::MadeCopy, ) -> Result<()> { - windmill_common::validate_dbname(&fdt.new_dbname)?; - if !fdt.new_dbname.starts_with("wm_fork_") { + // Not `fdt.new_dbname`: a data table sharing its database with an earlier one shares its copy. + let dbname = copy.dbname.as_str(); + windmill_common::validate_dbname(dbname)?; + if !dbname.starts_with("wm_fork_") { return Err(Error::BadRequest(format!( - "Forked datatable database name '{}' must start with 'wm_fork_'", - fdt.new_dbname + "Forked datatable database name '{dbname}' must start with 'wm_fork_'" ))); } // Held until the fork commits, as the permissions save holds them: the source moved onto @@ -8870,27 +9306,27 @@ async fn apply_forked_datatable( if database.resource_type == DataTableCatalogResourceType::ExternalInstance { windmill_common::external_instance_pg::ensure_external_instance_database_registered( - tx, - &fdt.new_dbname, + tx, dbname, ) .await?; } if database.resource_type.is_windmill_managed() { // Held until the fork commits, as every save newly naming a database takes it: none may // claim the copy between the check below and this fork's entry landing on it. - windmill_common::datatable_roles::lock_instance_databases_governance( - &mut **tx, - [fdt.new_dbname.as_str()], - ) - .await?; + windmill_common::datatable_roles::lock_instance_databases_governance(&mut **tx, [dbname]) + .await?; } + // The data tables after the first on a shared copy find it named by the first, and the + // resource it is reached through already pointed at it. + let first_on_copy = copy.names.first() == Some(&fdt.name); if database.resource_type.is_windmill_managed() + && first_on_copy && !windmill_api_auth::is_super_admin_authed(db, authed).await? { windmill_common::ensure_fork_database_available_to( &mut **tx, database.resource_type, - &fdt.new_dbname, + dbname, parent_w_id, ) .await?; @@ -8901,7 +9337,7 @@ async fn apply_forked_datatable( // on the same cluster as its source, so it keeps the source's kind. let new_database = serde_json::json!({ "resource_type": database.resource_type, - "resource_path": &fdt.new_dbname, + "resource_path": dbname, }); sqlx::query( r#"UPDATE workspace_settings @@ -8915,7 +9351,7 @@ async fn apply_forked_datatable( .bind(new_database) .execute(&mut **tx) .await?; - } else { + } else if first_on_copy { // Resource: point it at the copy and mark it ws_specific. What the settings clone carried is // the source's resource and variables as they are now, which an edit made while the copy // was being made can have changed, and changed back: the clone has to be what the copy's @@ -8938,7 +9374,7 @@ async fn apply_forked_datatable( ) .bind(forked_w_id) .bind(resource_path) - .bind(&fdt.new_dbname) + .bind(dbname) .execute(&mut **tx) .await?; @@ -9279,6 +9715,25 @@ async fn create_workspace_fork( i.msg ); } + // Unlike a dropped lake's files, a deleted fork's objects are live to a same-id fork: it + // reads its own prefix first. + let leftover_storages = crate::workspaces_extra::prepare_fork_storage_purges( + &db, + &nw.id, + Some(&parent_workspace_id), + ) + .await?; + let storage_issues = + crate::workspaces_extra::purge_fork_storages_unless_live(&db, &nw.id, leftover_storages) + .await?; + if !storage_issues.is_empty() { + return Err(Error::BadRequest(format!( + "a previously deleted workspace with id '{}' left objects in its fork prefix that \ + could not be deleted: {}; retry once the storage is reachable again", + nw.id, + storage_issues.join("; ") + ))); + } #[cfg(not(feature = "enterprise"))] _check_nb_of_workspaces(&db).await?; @@ -9298,6 +9753,7 @@ async fn create_workspace_fork( { return Err(Error::PermissionDenied(msg)); } + enforce_isolated_forks(&db, &authed, &parent_workspace_id, &nw).await?; if nw.is_dev_workspace { ensure_dev_parent_can_host_dev(&db, &parent_workspace_id).await?; @@ -9672,6 +10128,185 @@ fn copy_requests( .collect() } +/// Under `RequireIsolatedForks` on `parent`, refuse a fork that would leave any of its data +/// shared: a shared storage or lake, or a data table not cloned (`Keep original` points the fork +/// at the parent's database). +async fn enforce_isolated_forks( + db: &DB, + authed: &ApiAuthed, + parent: &str, + nw: &CreateWorkspaceFork, +) -> Result<()> { + let Some(msg) = isolation_required(db, authed, parent).await? else { + return Ok(()); + }; + let mut shared: Vec = nw + .shared_storages + .iter() + .map(|s| format!("storage `{s}`")) + .chain( + nw.shared_ducklakes + .iter() + .map(|l| format!("ducklake `{l}`")), + ) + .collect(); + shared.extend( + kept_parent_datatables(db, parent, &nw.forked_datatables) + .await? + .into_iter() + .map(|name| format!("data table `{name}`")), + ); + refuse_shared(msg, shared) +} + +/// The rule's message when `RequireIsolatedForks` on `parent` binds `authed`. +async fn isolation_required(db: &DB, authed: &ApiAuthed, parent: &str) -> Result> { + match check_user_against_rule( + parent, + &ProtectionRuleKind::RequireIsolatedForks, + AuditAuthorable::username(authed), + &authed.groups, + authed.is_admin, + db, + ) + .await? + { + RuleCheckResult::Blocked(msg) => Ok(Some(msg)), + RuleCheckResult::Allowed => Ok(None), + } +} + +/// `parent`'s own data tables that `forked` leaves as pointers at its database. An entry whose +/// database another workspace owns is not `parent`'s data, so the rule does not cover it. +async fn kept_parent_datatables( + db: &DB, + parent: &str, + forked: &[ForkedDatatableInfo], +) -> Result> { + Ok( + windmill_common::workspaces::resolve_workspace_governing_datatables(db, parent) + .await? + .into_iter() + .filter(|(_, governing)| governing.workspace_id == parent) + .filter(|(name, _)| { + !forked.iter().any(|d| { + &d.name == name + && matches!( + d.fork_behavior, + Some( + DataTableForkBehavior::SchemaOnly + | DataTableForkBehavior::SchemaAndData + ) + ) + }) + }) + .map(|(name, _)| name) + .collect(), + ) +} + +fn refuse_shared(msg: String, shared: Vec) -> Result<()> { + if shared.is_empty() { + return Ok(()); + } + Err(Error::PermissionDenied(format!( + "{msg}. Shared in this request: {}. Only a bypasser of the rule can fork while one of \ + them cannot be isolated", + shared.join(", ") + ))) +} + +/// Record every storage of the fork's cloned settings as isolated, except `shared` ones. +/// Isolation is a per-fork-creation choice, never inherited: a fork of a shared fork is +/// isolated unless it asks otherwise. +async fn isolate_fork_storages( + tx: &mut Transaction<'_, Postgres>, + fork_id: &str, + shared: &[String], + shared_ducklakes: &[String], +) -> Result<()> { + use windmill_common::fork_storage::{lfs_entry, registry_storage_name}; + + let settings = sqlx::query!( + "SELECT large_file_storage, ducklake FROM workspace_settings WHERE workspace_id = $1", + fork_id + ) + .fetch_optional(&mut **tx) + .await?; + let (lfs, ducklake) = settings + .map(|s| (s.large_file_storage, s.ducklake)) + .unwrap_or_default(); + let lfs = lfs.filter(|v| v.get("type").is_some()); + + // A shared lake keeps the parent's data path: its files must land at the parent's keys, or + // the parent's catalog would point at objects in the fork's prefix, purged with the fork. + if lfs.is_some() { + for lake in shared_ducklakes { + let lake_storage = ducklake + .as_ref() + .and_then(|d| { + d.get("ducklakes")? + .get(lake)? + .get("storage")? + .get("storage") + }) + .and_then(|s| s.as_str()); + let storage = registry_storage_name(lake_storage); + if !shared + .iter() + .any(|s| registry_storage_name(Some(s)) == storage) + { + return Err(Error::BadRequest(format!( + "ducklake `{lake}` is shared with the parent, so the storage holding its \ + data (`{}`) must be shared too", + lake_storage.unwrap_or(windmill_types::s3::DEFAULT_STORAGE) + ))); + } + } + } + let mut storages: Vec<&str> = vec![]; + if let Some(lfs) = &lfs { + storages.push(""); + if let Some(secondary) = lfs.get("secondary_storage").and_then(|s| s.as_object()) { + storages.extend(secondary.keys().map(String::as_str)); + } + } + for name in shared { + if !storages.contains(®istry_storage_name(Some(name))) { + return Err(Error::BadRequest(format!( + "cannot share storage `{name}` with the parent: no such storage in the workspace \ + settings" + ))); + } + } + let Some(lfs) = &lfs else { + return Ok(()); + }; + for storage in storages { + if shared + .iter() + .any(|s| registry_storage_name(Some(s)) == storage) + { + continue; + } + let Some(storage_ref) = + lfs_entry(lfs, storage).and_then(windmill_common::workspaces::lfs_entry_storage_ref) + else { + continue; + }; + sqlx::query!( + "INSERT INTO fork_storage_namespace (workspace_id, storage, storage_ref) + VALUES ($1, $2, $3)", + fork_id, + storage, + storage_ref + ) + .execute(&mut **tx) + .await?; + } + Ok(()) +} + /// Write the fork, with its data tables pointed at `copies`. async fn write_workspace_fork( db: DB, @@ -9694,6 +10329,17 @@ async fn write_workspace_fork( // Also before the external cluster's lifecycle lock, which finalizing an external copy takes: // fork cleanup takes the two in this order. windmill_common::workspaces::lock_fork_datatables(&mut tx, &parent_workspace_id).await?; + // Data table edits take the lock above: one committed since the creation check would reach + // this fork as a pointer at the parent's database. + if let Some(msg) = isolation_required(&db, &authed, &parent_workspace_id).await? { + let kept = kept_parent_datatables(&db, &parent_workspace_id, &nw.forked_datatables).await?; + refuse_shared( + msg, + kept.into_iter() + .map(|name| format!("data table `{name}`")) + .collect(), + )?; + } if nw.is_dev_workspace { // The checks above ran outside a transaction, so the parent's eligibility and the chain's @@ -9875,9 +10521,12 @@ async fn write_workspace_fork( // Update forked datatable settings to point to new databases for fdt in &nw.forked_datatables { - let copy = copies.iter().find(|c| c.name == fdt.name).ok_or_else(|| { - Error::internal_err(format!("No copy was made of data table '{}'", fdt.name)) - })?; + let copy = copies + .iter() + .find(|c| c.names.contains(&fdt.name)) + .ok_or_else(|| { + Error::internal_err(format!("No copy was made of data table '{}'", fdt.name)) + })?; apply_forked_datatable( &db, &mut tx, @@ -9934,6 +10583,14 @@ async fn write_workspace_fork( } } + isolate_fork_storages( + &mut tx, + &forked_id, + &nw.shared_storages, + &nw.shared_ducklakes, + ) + .await?; + // Lock the parent ("prod") so edits are funneled through this dev workspace. let locked_prod = nw.is_dev_workspace && (nw.lock_prod_deploy || nw.lock_prod_forking); if locked_prod { @@ -9951,7 +10608,7 @@ async fn write_workspace_fork( .map(|c| { format!( "{} ({})", - c.name, + c.names.join(" + "), match c.behavior { DataTableForkBehavior::SchemaOnly => "schema_only", _ => "schema_and_data", @@ -9981,6 +10638,9 @@ async fn write_workspace_fork( .await?; } tx.commit().await?; + // Same for a mapping cached before the isolation rows were visible: identity, writing the + // root's keys. + windmill_common::fork_storage::invalidate_fork_storage_keys_cache(&forked_id); // A pre-creation lookup could have cached an EMPTY ancestor chain for this id, which // would bypass ducklake fork isolation for the TTL. The same lookup could have cached the id @@ -10452,10 +11112,9 @@ async fn edit_workspace( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, - ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(ew): Json, ) -> Result { - require_admin(is_admin, &username)?; + require_admin(authed.is_admin, &authed.username)?; let mut tx = db.begin().await?; sqlx::query!( "UPDATE workspace SET name = $1, owner = $2 WHERE id = $3", @@ -10697,20 +11356,10 @@ async fn archive_workspace( if let Some(prod) = dev_lock_parent { windmill_common::workspaces::invalidate_protection_rules_cache(&prod); - // The teardown above cleared `is_dev_workspace`, which is what let this workspace keep its - // own id for tag routing; it and every fork resolving through it now land on an ancestor. - // Only a dev workspace reaches here, so archiving anything else needs no sweep. - windmill_queue::tags::invalidate_fork_parent_cache(&w_id); - for id in windmill_common::workspaces::list_fork_descendants(&db, &w_id) - .await - .unwrap_or_default() - { - windmill_queue::tags::invalidate_fork_parent_cache(&id); - } - if let Err(e) = windmill_queue::tags::notify_fork_lineage_reset(&db).await { - tracing::warn!("failed to broadcast fork lineage change: {e:#}"); - } } + // The tag lineage reads `deleted` (an archived ancestor counts as dev lineage), and a dev + // workspace's teardown above also cleared `is_dev_workspace`. + reset_tag_lineage(&db, &w_id).await; Ok(format!( "Archived workspace {}, disabled {} schedules, canceled {} jobs and deleted {} tokens", @@ -10811,10 +11460,26 @@ async fn unarchive_workspace( ) .await?; tx.commit().await?; + reset_tag_lineage(&db, &w_id).await; Ok(format!("Unarchived workspace {}", &w_id)) } +/// Drops the tag lineage cached for `w_id` and every fork resolving through it, here and on every +/// replica, after a change to a field the lineage reads. +async fn reset_tag_lineage(db: &DB, w_id: &str) { + windmill_queue::tags::invalidate_fork_parent_cache(w_id); + for id in windmill_common::workspaces::list_fork_descendants(db, w_id) + .await + .unwrap_or_default() + { + windmill_queue::tags::invalidate_fork_parent_cache(&id); + } + if let Err(e) = windmill_queue::tags::notify_fork_lineage_reset(db).await { + tracing::warn!("failed to broadcast fork lineage change: {e:#}"); + } +} + /// Whether the instance is configured to suppress the email notifications sent /// when a user is invited or added to a workspace. Defaults to false (emails on). async fn workspace_invite_emails_disabled(db: &DB) -> Result { @@ -10837,7 +11502,11 @@ async fn invite_user( Json(mut nu): Json, ) -> Result<(StatusCode, String)> { require_admin(is_admin, &username)?; - reject_admin_and_operator(nu.is_admin, nu.operator)?; + if nu.role.is_none() { + reject_admin_and_operator(nu.is_admin, nu.operator)?; + } + let (role, role_operator) = + resolve_new_member_role(&db, &w_id, nu.operator, nu.role.as_deref()).await?; #[cfg(not(feature = "enterprise"))] if w_id == "admins" { @@ -10852,7 +11521,7 @@ async fn invite_user( if let Some(msg) = windmill_common::ee_oss::check_seat_cap_for_new_user( &db, &nu.email, - windmill_common::workspaces::consumes_operator_seat(&db, &w_id, nu.operator).await?, + windmill_common::workspaces::consumes_operator_seat(&db, &w_id, nu.is_admin, &role).await?, ) .await? { @@ -10879,13 +11548,15 @@ async fn invite_user( sqlx::query!( "INSERT INTO workspace_invite - (workspace_id, email, is_admin, operator) - VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id, email) - DO UPDATE SET is_admin = EXCLUDED.is_admin, operator = EXCLUDED.operator", + (workspace_id, email, is_admin, operator, workspace_role) + VALUES ($1, $2, $3, $4, $5) ON CONFLICT (workspace_id, email) + DO UPDATE SET is_admin = EXCLUDED.is_admin, operator = EXCLUDED.operator, + workspace_role = EXCLUDED.workspace_role", &w_id, nu.email, nu.is_admin, - nu.operator + role_operator, + &role ) .execute(&mut *tx) .await?; @@ -10938,7 +11609,13 @@ async fn authorize_fork_owner_add_user( .await? .ok_or_else(|| Error::RequireAdmin(authed.username.clone()))?; - if nu.is_admin || nu.operator { + if nu.is_admin + || nu.operator + || nu + .role + .as_deref() + .is_some_and(|r| r != windmill_common::roles::DEVELOPER_ROLE) + { return Err(Error::PermissionDenied(format!( "as the creator of fork {w_id} you can only add members as developers; ask an admin of \ {w_id} for any other role" @@ -10989,7 +11666,11 @@ async fn add_user( Path(w_id): Path, Json(mut nu): Json, ) -> Result<(StatusCode, String)> { - reject_admin_and_operator(nu.is_admin, nu.operator)?; + if nu.role.is_none() { + reject_admin_and_operator(nu.is_admin, nu.operator)?; + } + let (role, role_operator) = + resolve_new_member_role(&db, &w_id, nu.operator, nu.role.as_deref()).await?; #[cfg(not(feature = "enterprise"))] if w_id == "admins" { @@ -11010,7 +11691,7 @@ async fn add_user( if let Some(msg) = windmill_common::ee_oss::check_seat_cap_for_new_user( &db, &nu.email, - windmill_common::workspaces::consumes_operator_seat(&db, &w_id, nu.operator).await?, + windmill_common::workspaces::consumes_operator_seat(&db, &w_id, nu.is_admin, &role).await?, ) .await? { @@ -11070,13 +11751,14 @@ async fn add_user( sqlx::query!( "INSERT INTO usr - (workspace_id, email, username, is_admin, operator) - VALUES ($1, $2, $3, $4, $5)", + (workspace_id, email, username, is_admin, operator, workspace_role) + VALUES ($1, $2, $3, $4, $5, $6)", &w_id, nu.email, username, nu.is_admin, - nu.operator + role_operator, + &role ) .execute(&mut *tx) .await?; @@ -11185,11 +11867,14 @@ async fn delete_invite( sqlx::query!( "DELETE FROM workspace_invite WHERE - workspace_id = $1 AND email = $2 AND is_admin = $3 AND operator = $4", + workspace_id = $1 AND email = $2 AND is_admin = $3 + AND ($5::text IS NOT NULL OR operator = $4) + AND ($5::text IS NULL OR $5 = COALESCE(workspace_role, CASE WHEN operator THEN 'operator' ELSE 'developer' END))", &w_id, nu.email, nu.is_admin, - nu.operator + nu.operator, + nu.role ) .execute(&mut *tx) .await?; @@ -11553,114 +12238,6 @@ pub async fn get_public_app_rate_limit(db: &DB, w_id: &str) -> Result, - #[serde(default, skip_serializing_if = "Option::is_none")] - builder_apps: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - manage_schedules: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - manage_triggers: Option, -} - -async fn update_operator_settings( - authed: ApiAuthed, - Path(w_id): Path, - Extension(db): Extension, - Json(settings): Json, -) -> Result { - require_admin(authed.is_admin, &authed.username)?; - - // Every operator of the workspace turns into a full seat, which an offline license may not - // cover. Either right costs the same seat, so this prices "any", and it is a no-op delta when - // one of them is already on. - #[cfg(feature = "enterprise")] - if settings.builder_flows == Some(true) || settings.builder_apps == Some(true) { - if let Some(msg) = - windmill_common::ee_oss::check_seat_cap_for_operator_builder(&db, &w_id).await? - { - return Err(Error::BadRequest(msg)); - } - } - - let mut tx = db.begin().await?; - - let settings_json = serde_json::json!(settings); - - // Merged for the `Option` fields above; the visibility flags always serialize, so for them - // this is a plain overwrite. - sqlx::query!( - "UPDATE workspace_settings - SET operator_settings = COALESCE(operator_settings, '{}'::jsonb) || $1 - WHERE workspace_id = $2", - settings_json, - &w_id - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "workspaces.update_operator_settings", - ActionKind::Update, - &w_id, - None, - Some([("operator_settings", settings_json.to_string().as_str())].into()), - ) - .await?; - tx.commit().await?; - - windmill_common::workspaces::invalidate_operator_rights_cache(&w_id); - - // Trigger git sync for operator settings changes - handle_deployment_metadata( - &authed.email, - &authed.username, - &db, - &w_id, - windmill_git_sync::DeployedObject::Settings { - setting_type: "operator_settings".to_string(), - }, - Some("Operator settings updated".to_string()), - false, - None, - ) - .await?; - - Ok("Operator settings updated successfully".to_string()) -} - // Protection Rules API endpoints #[derive(Deserialize)] @@ -13260,7 +13837,7 @@ pub async fn load_workspace_authed( let is_super_admin = windmill_api_auth::is_super_admin_authed(db, base_authed).await?; let user_row = sqlx::query!( - "SELECT username, is_admin, operator FROM usr + "SELECT username, is_admin, operator, workspace_role FROM usr WHERE workspace_id = $1 AND email = $2 AND disabled = false", workspace_id, &base_authed.email @@ -13306,7 +13883,14 @@ pub async fn load_workspace_authed( email: base_authed.email.clone(), username: user_row.username, is_admin: is_super_admin || user_row.is_admin, - is_operator: user_row.operator, + is_operator: !user_row.is_admin + && windmill_common::roles::member_is_restricted( + &mut conn, + workspace_id, + user_row.workspace_role.clone(), + user_row.operator, + ) + .await?, groups, folders, scopes: base_authed.scopes.clone(), @@ -13679,17 +14263,18 @@ async fn compare_two_flows( }); } -/// The policy minus the identity pair. Deploying cannot converge a difference there — the -/// target recomputes the identity from the deployer's own choice, which offers its current -/// value, the deployer, or a typed-in one, never the source's — so listing an app for it -/// alone leaves an entry no deploy can clear. `script` and `flow` compare no identity either. -fn policy_without_identity(policy: &serde_json::Value) -> serde_json::Value { - let mut policy = policy.clone(); - if let Some(obj) = policy.as_object_mut() { +/// An app's policy or an agent's value minus the identity it carries. Deploying cannot converge +/// a difference there — the target recomputes the identity from the deployer's own choice, which +/// offers its current value, the deployer, or a typed-in one, never the source's — so listing an +/// item for it alone leaves an entry no deploy can clear. `script` and `flow` compare no identity +/// either. +fn without_identity(value: &serde_json::Value) -> serde_json::Value { + let mut value = value.clone(); + if let Some(obj) = value.as_object_mut() { obj.remove("on_behalf_of"); obj.remove("on_behalf_of_email"); } - policy + value } async fn compare_two_apps( @@ -13732,7 +14317,7 @@ async fn compare_two_apps( // Check metadata and content differences if let (Some(source), Some(target)) = (&source_app, &target_app) { if source.summary != target.summary - || policy_without_identity(&source.policy) != policy_without_identity(&target.policy) + || without_identity(&source.policy) != without_identity(&target.policy) || source.value != target.value || source.raw_app != target.raw_app { @@ -13750,6 +14335,12 @@ async fn compare_two_apps( }); } +// Resources and resource types can be stored with a NULL description, but deploy writes "", +// so the two must compare equal or the item never settles after a deploy. +fn description_differs(a: &Option, b: &Option) -> bool { + a.as_deref().unwrap_or("") != b.as_deref().unwrap_or("") +} + async fn compare_two_resources( db: &DB, source_workspace_id: &str, @@ -13810,8 +14401,15 @@ async fn compare_two_resources( // Check metadata differences if let (Some(source), Some(target)) = (&source_resource, &target_resource) { - if source.value != target.value - || source.description != target.description + let agent = source.resource_type == "ai_agent" && target.resource_type == "ai_agent"; + let values_differ = if agent { + source.value.as_ref().map(without_identity) + != target.value.as_ref().map(without_identity) + } else { + source.value != target.value + }; + if values_differ + || description_differs(&source.description, &target.description) || source.resource_type != target.resource_type { has_changes = true; @@ -13889,11 +14487,22 @@ 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.value != target.value - || source.description != target.description - { + if source.is_secret != target.is_secret || source.description != target.description { has_changes = true; + } else if source.value != target.value + || (source.is_secret && is_external_stored_value(&source.value)) + { + // A secret's ciphertext is randomized on every write, so equal secrets are compared + // by plaintext, each decrypted with its own workspace's key. An external backend + // reference is the same text on both sides but names a per-workspace entry. + has_changes = !source.is_secret + || !matches!( + tokio::try_join!( + decrypt_secret_value(db, source_workspace_id, path, source.value.clone()), + decrypt_secret_value(db, fork_workspace_id, path, target.value.clone()), + ), + Ok((a, b)) if a == b + ); } } else if source_variable.is_some() || target_variable.is_some() { // The variable exists in one of source or target, but not the other, this is considered as a change @@ -13939,7 +14548,7 @@ async fn compare_two_resource_types( // Check metadata differences if let (Some(source), Some(target)) = (&source_resource_type, &target_resource_type) { if source.schema != target.schema - || source.description != target.description + || description_differs(&source.description, &target.description) || source.format_extension != target.format_extension || source.is_fileset != target.is_fileset { @@ -14697,3 +15306,149 @@ async fn set_ws_specific( body.value, body.item_kind, body.path )) } + +/// A setting the workspace settings document writes, each through the endpoint that owns it, +/// so applying a document validates, audits and has the side effects of the matching UI save. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsEdit { + Webhook, + AutoInvite, + InstanceGroups, + ErrorHandler, + SuccessHandler, + LargeFileStorage, + GitSync, + DefaultScripts, + DefaultApp, + Color, + OperatorSettings, + Datatable, + SlackCommand, + SetSlackOAuth, + DeleteSlackOAuth, + Ducklake, + DbtWarehouses, + DeployUi, + GuestAccess, + GuestJwtKey, + PublicAppRateLimit, + AddAdminsAndDevelopersToForks, + /// Body: `{ datatable, permissioned, default_role, roles: [{ id, tenants }] }`, role ids. + DatatablePermissions, + CreateProtectionRule, + /// Body: the update request plus `rule_name`, the rule it updates. + UpdateProtectionRule, + /// Body: `{ name }`. + DeleteProtectionRule, +} + +fn settings_edit_body( + edit: SettingsEdit, + body: serde_json::Value, +) -> Result> { + serde_json::from_value(body) + .map(Json) + .map_err(|e| Error::BadRequest(format!("Invalid value for {edit:?}: {e}"))) +} + +/// Returns the endpoint's JSON response, or `null` for one that answers with prose. +pub async fn apply_settings_edit( + authed: ApiAuthed, + db: DB, + w_id: String, + edit: SettingsEdit, + body: serde_json::Value, +) -> Result { + use SettingsEdit::*; + let a = authed; + let (d, p) = (Extension(db), Path(w_id)); + match edit { + Webhook => edit_webhook(a, d, p, settings_edit_body(edit, body)?).await?, + AutoInvite => edit_auto_invite(a, d, p, settings_edit_body(edit, body)?).await?, + 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?, + LargeFileStorage => { + edit_large_file_storage_config(a, d, p, settings_edit_body(edit, body)?).await? + } + GitSync => edit_git_sync_config(a, d, p, settings_edit_body(edit, body)?).await?, + DefaultScripts => edit_default_scripts(a, d, p, settings_edit_body(edit, body)?).await?, + DefaultApp => edit_default_app(a, d, p, settings_edit_body(edit, body)?).await?, + Color => change_workspace_color(a, p, d, settings_edit_body(edit, body)?).await?, + OperatorSettings => { + crate::workspace_roles::update_operator_settings( + a, + p, + d, + settings_edit_body(edit, body)?, + ) + .await? + } + Datatable => { + let Json(result) = + edit_datatable_config(a, d, p, settings_edit_body(edit, body)?).await?; + return serde_json::to_value(result).map_err(|e| Error::internal_err(e.to_string())); + } + SlackCommand => edit_slack_command(a, d, p, settings_edit_body(edit, body)?).await?, + SetSlackOAuth => set_slack_oauth_config(a, d, p, settings_edit_body(edit, body)?).await?, + DeleteSlackOAuth => delete_slack_oauth_config(a, d, p).await?, + Ducklake => edit_ducklake_config(a, d, p, settings_edit_body(edit, body)?).await?, + DbtWarehouses => edit_dbt_warehouses(a, d, p, settings_edit_body(edit, body)?).await?, + #[cfg(feature = "enterprise")] + DeployUi => edit_deploy_ui_config(a, d, p, settings_edit_body(edit, body)?).await?, + #[cfg(not(feature = "enterprise"))] + DeployUi => edit_deploy_ui_config(a, d, p).await?, + GuestAccess => edit_guest_access(a, d, p, settings_edit_body(edit, body)?).await?, + GuestJwtKey => edit_guest_jwt_key(a, d, p, settings_edit_body(edit, body)?).await?, + PublicAppRateLimit => { + edit_public_app_rate_limit(d, p, a, settings_edit_body(edit, body)?).await? + } + AddAdminsAndDevelopersToForks => { + edit_add_admins_and_developers_to_forks(a, d, p, settings_edit_body(edit, body)?) + .await? + } + #[cfg(all(feature = "private", feature = "enterprise"))] + DatatablePermissions => { + #[derive(Deserialize)] + struct Body { + datatable: String, + #[serde(flatten)] + req: crate::datatable_permissions_ee::SetDatatablePermissions, + } + let Json(Body { datatable, req }) = settings_edit_body(edit, body)?; + crate::datatable_permissions_oss::set_datatable_permissions( + a, + d, + Path((p.0, datatable)), + Json(req), + ) + .await? + } + #[cfg(not(all(feature = "private", feature = "enterprise")))] + DatatablePermissions => { + crate::datatable_permissions_oss::set_datatable_permissions(a).await? + } + CreateProtectionRule => { + create_protection_rule(a, d, p, settings_edit_body(edit, body)?).await? + } + UpdateProtectionRule => { + #[derive(Deserialize)] + struct Body { + rule_name: String, + #[serde(flatten)] + req: UpdateProtectionRuleRequest, + } + let Json(Body { rule_name, req }) = settings_edit_body(edit, body)?; + update_protection_rule(a, d, Path((p.0, rule_name)), Json(req)).await? + } + DeleteProtectionRule => { + #[derive(Deserialize)] + struct Body { + name: String, + } + let Json(Body { name }) = settings_edit_body(edit, body)?; + delete_protection_rule(a, d, Path((p.0, name))).await? + } + }; + Ok(serde_json::Value::Null) +} diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 2f0d4deb13..64aa5c6a08 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use windmill_api_auth::{require_super_admin, ApiAuthed}; use windmill_common::DB; @@ -54,6 +54,23 @@ pub(crate) async fn change_workspace_id( require_admin(authed.is_admin, &authed.username)?; } + // An isolating fork's objects live under a prefix derived from its id: under a new id it + // would see none of them, and descendants would lose them from their read-through. + let isolates_storage = sqlx::query_scalar!( + "SELECT EXISTS (SELECT 1 FROM fork_storage_namespace WHERE workspace_id = $1)", + &old_id + ) + .fetch_one(&db) + .await? + .unwrap_or(false); + if isolates_storage { + return Err(Error::BadRequest( + "This workspace isolates its object storage from its parent under a prefix derived \ + from its id, so its id cannot be changed" + .to_string(), + )); + } + let mut tx = db.begin().await?; // The settings copy below carries every data table entry to the new id, which fork cleanup of @@ -118,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, 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, 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", + "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", &rw.new_id, &old_id ) @@ -208,6 +225,14 @@ pub(crate) async fn change_workspace_id( .execute(&mut *tx) .await?; + sqlx::query!( + "UPDATE role_seat SET workspace_id = $1 WHERE workspace_id = $2", + &rw.new_id, + &old_id + ) + .execute(&mut *tx) + .await?; + info!("Updating account table"); sqlx::query!( "UPDATE account SET workspace_id = $1 WHERE workspace_id = $2", @@ -839,7 +864,7 @@ pub(crate) async fn change_workspace_id( info!("Duplicating usr table"); sqlx::query!( - "INSERT INTO usr SELECT $1, username, email, is_admin, created_at, operator, disabled, role FROM usr WHERE workspace_id = $2", + "INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, workspace_role) SELECT $1, username, email, is_admin, created_at, operator, disabled, role, workspace_role FROM usr WHERE workspace_id = $2", &rw.new_id, &old_id ) @@ -868,11 +893,21 @@ pub(crate) async fn change_workspace_id( "Re-pushing jobs for {} enabled schedules", enabled_schedule_paths.len() ); - for schedule_path in &enabled_schedule_paths { - if let Some(schedule) = get_schedule_opt(&mut *tx, &rw.new_id, schedule_path).await? { - tx = push_scheduled_job(&db, tx, &schedule, None, None).await?; + windmill_queue::tags::carry_lineage_over_rename(&old_id, &rw.new_id, &db).await?; + let new_id = &rw.new_id; + let repushed = async { + for schedule_path in &enabled_schedule_paths { + if let Some(schedule) = get_schedule_opt(&mut *tx, new_id, schedule_path).await? { + tx = push_scheduled_job(&db, tx, &schedule, None, None).await?; + } } + Ok::<_, Error>(tx) } + .await; + if repushed.is_err() { + windmill_queue::tags::invalidate_fork_parent_cache(new_id); + } + let mut tx = repushed?; // After every workspace_settings write above: fork cleanup locks a settings row before the // registry, so taking the registry first here would deadlock with it. @@ -1099,6 +1134,34 @@ pub(crate) async fn delete_workspace( tracing::warn!("deleting workspace {w_id}: preparing ducklake cleanup: {e:#}"); vec![] }); + // A child fork reads through to this fork's prefix, or to the one this fork shares, and a + // child sharing the storage writes into it: deleting this link would destroy the child's + // data, or leave its chain without the isolating fork, writing the root's own keys. Under the + // lock forking this workspace takes, so no child can commit between the check and the delete. + windmill_common::workspaces::lock_fork_datatables(&mut tx, &w_id).await?; + let chain = windmill_common::workspaces::workspace_with_fork_ancestors(&db, &w_id).await?; + let isolating_with_children = sqlx::query_scalar!( + "SELECT EXISTS (SELECT 1 FROM fork_storage_namespace WHERE workspace_id = ANY($1)) + AND EXISTS (SELECT 1 FROM workspace WHERE parent_workspace_id = $2)", + &chain, + &w_id + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false); + if isolating_with_children { + return Err(Error::BadRequest(format!( + "the forks of {w_id} reach its fork chain's isolated object storage through it: \ + delete them first" + ))); + } + // Nothing can reach a deleted fork's prefix once it is gone, so it is always purged. + let fork_storage_purges = prepare_fork_storage_purges(&db, &w_id, None) + .await + .unwrap_or_else(|e| { + tracing::warn!("deleting workspace {w_id}: preparing storage purge: {e:#}"); + vec![] + }); sqlx::query!("DELETE FROM ai_agent_memory WHERE workspace_id = $1", &w_id) .execute(&mut *tx) @@ -1203,6 +1266,10 @@ pub(crate) async fn delete_workspace( .execute(&mut *tx) .await?; + sqlx::query!("DELETE FROM role_seat WHERE workspace_id = $1", &w_id) + .execute(&mut *tx) + .await?; + sqlx::query!("DELETE FROM resource_type WHERE workspace_id = $1", &w_id) .execute(&mut *tx) .await?; @@ -1369,6 +1436,14 @@ pub(crate) async fn delete_workspace( e.msg ); } + match purge_fork_storages_unless_live(&db, &w_id, fork_storage_purges).await { + Ok(errors) => { + for e in errors { + tracing::warn!("deleted workspace {w_id}: storage purge: {e}"); + } + } + Err(e) => tracing::warn!("deleted workspace {w_id}: storage purge: {e:#}"), + } if let Some(parent) = dev_lock_parent { windmill_common::workspaces::invalidate_protection_rules_cache(&parent); @@ -1486,20 +1561,39 @@ pub async fn drop_forked_datatable_databases( serde_json::from_value(datatable_config).unwrap_or_default(); let mut errors: Vec = Vec::new(); + // Data tables cloned from one database share its copy, which is dropped once for all of them. + // Keyed by where the database lives too: copies on other clusters or servers can share a name. + let mut dropped_dbs: HashSet<(String, Option, String)> = HashSet::new(); + // Only a clone is droppable, and a clone is terminal by construction: a kept data table is a + // pointer at the parent's database, which this fork does not own. + let cloned_database = |name: &str| { + datatables + .get(name) + .filter(|dt| dt.forked_from.is_some()) + .and_then(|dt| dt.database.as_ref()) + }; for dt_name in &req.datatable_names { - // Only a clone is droppable, and a clone is terminal by construction: a kept data table is - // a pointer at the parent's database, which this fork does not own. - let database = match datatables.get(dt_name) { - Some(dt) if dt.forked_from.is_some() => match dt.database.as_ref() { - Some(database) => database, - None => continue, - }, - _ => continue, + let Some(database) = cloned_database(dt_name) else { + continue; }; if database.resource_type.is_windmill_managed() { let db_to_drop = &database.resource_path; + let cluster = database.resource_type.as_ref().to_string(); + if !dropped_dbs.insert((cluster, None, db_to_drop.clone())) { + continue; + } + let sharing: Vec = req + .datatable_names + .iter() + .filter(|name| { + cloned_database(name).is_some_and(|d| { + d.resource_type == database.resource_type && &d.resource_path == db_to_drop + }) + }) + .cloned() + .collect(); if !db_to_drop.starts_with("wm_fork_") { errors.push(format!( "Refusing to drop instance database '{}' for datatable://{}: name does not start with 'wm_fork_'", @@ -1513,12 +1607,8 @@ pub async fn drop_forked_datatable_databases( // A task of its own, so a client going away cannot stop it between dropping the // database and committing the entry's removal. let dropped = tokio::spawn({ - let (db, w_id, dt_name, db_to_drop) = ( - db.clone(), - w_id.clone(), - dt_name.clone(), - db_to_drop.clone(), - ); + let (db, w_id, sharing, db_to_drop) = + (db.clone(), w_id.clone(), sharing.clone(), db_to_drop.clone()); let resource_type = database.resource_type; async move { let mut tx = db.begin().await?; @@ -1527,26 +1617,29 @@ pub async fn drop_forked_datatable_databases( // rename this entry, or point another one here, either side of the check below. windmill_common::workspaces::lock_fork_datatables(&mut tx, &w_id).await?; // The snapshot above was read unlocked: a save committing since could have - // repointed this entry, and the entry is removed below whatever it names by then. - let current = sqlx::query_scalar::<_, Option>( - "SELECT datatable->'datatables'->$2 FROM workspace_settings - WHERE workspace_id = $1 FOR UPDATE", - ) - .bind(&w_id) - .bind(&dt_name) - .fetch_optional(&mut *tx) - .await? - .flatten() - .and_then(|v| serde_json::from_value::(v).ok()); - if !current.is_some_and(|dt| { - dt.forked_from.is_some() - && dt.database.is_some_and(|d| { - d.resource_type == resource_type && d.resource_path == db_to_drop - }) - }) { - return Err(Error::BadRequest( - "the data table changed while it was being cleaned up".to_string(), - )); + // repointed these entries, and they are removed below whatever they name by then. + for dt_name in &sharing { + let current = sqlx::query_scalar::<_, Option>( + "SELECT datatable->'datatables'->$2 FROM workspace_settings + WHERE workspace_id = $1 FOR UPDATE", + ) + .bind(&w_id) + .bind(dt_name) + .fetch_optional(&mut *tx) + .await? + .flatten() + .and_then(|v| serde_json::from_value::(v).ok()); + if !current.is_some_and(|dt| { + dt.forked_from.is_some() + && dt.database.is_some_and(|d| { + d.resource_type == resource_type + && d.resource_path == db_to_drop + }) + }) { + return Err(Error::BadRequest(format!( + "data table '{dt_name}' changed while it was being cleaned up" + ))); + } } let external = resource_type == windmill_common::workspaces::DataTableCatalogResourceType::ExternalInstance; @@ -1564,11 +1657,13 @@ pub async fn drop_forked_datatable_databases( ) .await?; // Before the entry is removed: child fork pointers are found through it. - let uses = windmill_common::workspaces::managed_database_uses( + let names: Vec<&str> = sharing.iter().map(String::as_str).collect(); + let uses = windmill_common::workspaces::managed_database_uses_except( &mut tx, resource_type, &db_to_drop, - Some((w_id.as_str(), dt_name.as_str())), + &w_id, + &names, ) .await?; if !uses.is_empty() { @@ -1579,20 +1674,22 @@ pub async fn drop_forked_datatable_databases( } // The entry goes with the database: a fork this one is cloned into afterwards // must not inherit a pointer at a data table whose database is gone. - sqlx::query( - "UPDATE workspace_settings SET datatable = datatable #- ARRAY['datatables', $2] - WHERE workspace_id = $1", - ) - .bind(&w_id) - .bind(&dt_name) - .execute(&mut *tx) - .await?; + for dt_name in &sharing { + sqlx::query( + "UPDATE workspace_settings SET datatable = datatable #- ARRAY['datatables', $2] + WHERE workspace_id = $1", + ) + .bind(&w_id) + .bind(dt_name) + .execute(&mut *tx) + .await?; + } if external { // Checks the uses of the database itself, and unregisters it. windmill_common::external_instance_pg::drop_external_instance_database_unchecked( &mut tx, &db_to_drop, - Some((w_id.as_str(), dt_name.as_str())), + sharing.first().map(|name| (w_id.as_str(), name.as_str())), ) .await?; } else { @@ -1615,10 +1712,38 @@ pub async fn drop_forked_datatable_databases( if let Err(e) = dropped { errors.push(format!( "Could not drop instance database '{}' for datatable://{}: {}", - db_to_drop, dt_name, e + db_to_drop, + sharing.join(", datatable://"), + e )); } } else { + // Clones on one resource share its copy, which stays while any of them is kept. Only + // clones: a kept entry naming the resource cannot be put in the request. + let mut kept: Vec<&String> = datatables + .iter() + .filter(|(name, dt)| { + !req.datatable_names.contains(name) + && dt.forked_from.is_some() + && dt.database.as_ref().is_some_and(|d| { + d.resource_type == database.resource_type + && d.resource_path == database.resource_path + }) + }) + .map(|(name, _)| name) + .collect(); + if !kept.is_empty() { + kept.sort(); + errors.push(format!( + "Could not drop the database of datatable://{dt_name}: it is still used by data \ + tables {}", + kept.iter() + .map(|name| format!("'{name}'")) + .collect::>() + .join(", ") + )); + continue; + } let fork_pg = match crate::workspaces::resolve_pg_source_checked( &db, &user_db, @@ -1673,6 +1798,9 @@ pub async fn drop_forked_datatable_databases( )); continue; } + if !dropped_dbs.insert((parent_pg.host.clone(), parent_pg.port, db_to_drop.clone())) { + continue; + } match parent_pg.connect(Some(&db)).await { Ok((client, connection)) => { @@ -2245,6 +2373,114 @@ async fn delete_fork_ducklake_data( )) } +/// A storage a fork isolated, resolved while its `$res:` still exists. +pub(crate) struct PreparedForkStoragePurge { + storage: String, + store: std::result::Result<(windmill_types::s3::LargeFileStorage, serde_json::Value), String>, +} + +/// Read-only: one entry per `fork_storage_namespace` row of `w_id`. `res_fallback_w_id` as in +/// [`prepare_fork_ducklake_cleanups`]. +pub(crate) async fn prepare_fork_storage_purges( + db: &DB, + w_id: &str, + res_fallback_w_id: Option<&str>, +) -> Result> { + let rows = sqlx::query!( + "SELECT storage, storage_ref FROM fork_storage_namespace WHERE workspace_id = $1", + w_id + ) + .fetch_all(db) + .await?; + let mut prepared = Vec::with_capacity(rows.len()); + for row in rows { + let storage = Some(row.storage.as_str()).filter(|s| !s.is_empty()); + let store = resolve_fork_storage(db, w_id, res_fallback_w_id, storage, &row.storage_ref) + .await + .map_err(|e| e.to_string()); + prepared.push(PreparedForkStoragePurge { storage: row.storage, store }); + } + Ok(prepared) +} + +/// [`purge_fork_storages`] under the lock same-id fork creation takes, unless a workspace with +/// that id exists again: the rows are then the new fork's, and its creation already purged the +/// ones it found. +pub(crate) async fn purge_fork_storages_unless_live( + db: &DB, + w_id: &str, + prepared: Vec, +) -> Result> { + // The lock only orders a purge against a same-id fork write; with nothing to purge, waiting + // on it would only hold fork creation's answer behind such a write. + if prepared.is_empty() { + return Ok(vec![]); + } + let mut lock = db.begin().await?; + sqlx::query("SELECT pg_advisory_xact_lock(hashtext('fork:' || $1))") + .bind(w_id) + .execute(&mut *lock) + .await?; + let live = sqlx::query_scalar!( + "SELECT EXISTS (SELECT 1 FROM workspace WHERE id = $1)", + w_id + ) + .fetch_one(&mut *lock) + .await? + .unwrap_or(false); + let errors = if live { + vec![] + } else { + purge_fork_storages(db, w_id, prepared).await + }; + lock.commit().await?; + Ok(errors) +} + +/// Delete everything under the fork's `__wm_forks//` in each prepared storage, then its +/// registry row. Returns one message per storage that could not be purged; its row survives. +async fn purge_fork_storages( + db: &DB, + w_id: &str, + prepared: Vec, +) -> Vec { + let area = format!( + "{}/{}", + windmill_common::workspaces::FORK_DUCKLAKE_DATA_DIR, + windmill_common::workspaces::fork_data_dir_segment(w_id) + ); + let mut errors = vec![]; + for p in prepared { + let name = if p.storage.is_empty() { + "default storage" + } else { + p.storage.as_str() + }; + let purged = match p.store { + Ok((lfs, value)) => delete_fork_ducklake_data(lfs, value, &area) + .await + .map_err(|e| e.to_string()), + Err(e) => Err(e), + }; + if let Err(e) = purged { + errors.push(format!("{name}: {e}")); + continue; + } + if let Err(e) = sqlx::query!( + "DELETE FROM fork_storage_namespace WHERE workspace_id = $1 AND storage = $2", + w_id, + p.storage + ) + .execute(db) + .await + { + errors.push(format!("{name}: removing registry row: {e}")); + } + } + windmill_common::fork_storage::invalidate_fork_storage_keys_cache(w_id); + errors +} + /// Destroying an ATTACHED dev workspace (or its data environments) must be a prod-admin /// action, not just the dev's own owner (dev ownership can diverge from prod's) — mirrors /// detach_dev_workspace. Shared by `delete_workspace` and `drop_forked_ducklake_namespaces` diff --git a/backend/windmill-api/Cargo.toml b/backend/windmill-api/Cargo.toml index b3aea64f72..583d139cf5 100644 --- a/backend/windmill-api/Cargo.toml +++ b/backend/windmill-api/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_api" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] diff --git a/backend/windmill-api/openapi-deref.json b/backend/windmill-api/openapi-deref.json index 4016222d30..ad312c43e3 100644 --- a/backend/windmill-api/openapi-deref.json +++ b/backend/windmill-api/openapi-deref.json @@ -34948,7 +34948,7 @@ }, "early_return": { "type": "string", - "description": "JavaScript expression to return early from the flow" + "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", @@ -48161,7 +48161,7 @@ }, "early_return": { "type": "string", - "description": "JavaScript expression to return early from the flow" + "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", diff --git a/backend/windmill-api/openapi-deref.yaml b/backend/windmill-api/openapi-deref.yaml index d541144231..aa97838a30 100644 --- a/backend/windmill-api/openapi-deref.yaml +++ b/backend/windmill-api/openapi-deref.yaml @@ -11884,8 +11884,11 @@ paths: early_return: type: string description: >- - JavaScript expression to return early from the - flow + 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 @@ -19210,7 +19213,12 @@ paths: description: Execution priority (higher numbers run first) early_return: type: string - description: JavaScript expression to return early from the flow + 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 diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index b214f78dd4..43fc4b1af0 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.833.0 + version: 1.847.0 title: Windmill API contact: @@ -3696,13 +3696,16 @@ paths: type: boolean operator: type: boolean + description: Legacy role selector, true for the `operator` role. + role: + type: string + description: The workspace role to give, one of the workspace's roles. Wins over `operator`. parent_workspace_id: type: string nullable: true required: - email - is_admin - - operator responses: "200": description: status @@ -3735,10 +3738,13 @@ paths: type: string operator: type: boolean + description: Legacy role selector, true for the `operator` role. + role: + type: string + description: The workspace role to give, one of the workspace's roles. Wins over `operator`. required: - email - is_admin - - operator responses: "200": description: status @@ -4078,6 +4084,46 @@ paths: schema: type: string + /w/{workspace}/workspaces/roles: + get: + operationId: listWorkspaceRoles + summary: List the workspace's roles + description: The roles members of this workspace can hold, as they apply here (instance definition with the workspace's override applied, then workspace-only roles), the stored overrides, and the caller's own role and capabilities. + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + responses: + "200": + description: workspace roles + content: + application/json: + schema: + $ref: "#/components/schemas/WorkspaceRoles" + + /w/{workspace}/workspaces/role_overrides: + post: + operationId: updateWorkspaceRoleOverrides + summary: Replace the workspace's role overrides + description: Replaces the workspace's overrides of instance roles, and its workspace-only roles (EE). Refuses removing a workspace-only role still held or referenced, and a change exceeding an offline license's seat cap. Requires workspace admin privileges. + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RoleOverrides" + responses: + "200": + description: role overrides updated + content: + text/plain: + schema: + type: string + /w/{workspace}/workspaces/compare/{target_workspace_id}: get: operationId: compareWorkspaces @@ -4778,6 +4824,10 @@ paths: type: string nullable: true description: Masked with *** if set + slack_signing_secret: + type: string + nullable: true + description: Masked with *** if set post: summary: set workspace slack oauth config operationId: setWorkspaceSlackOauthConfig @@ -4822,6 +4872,33 @@ paths: schema: type: string + /w/{workspace}/workspaces/slack_signing_secret: + post: + summary: set the signing secret of the workspace's own slack app + operationId: setWorkspaceSlackSigningSecret + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + description: The Slack app's signing secret, null or empty to remove it + required: true + content: + application/json: + schema: + type: object + properties: + slack_signing_secret: + type: string + nullable: true + responses: + "200": + description: status + content: + text/plain: + schema: + type: string + /w/{workspace}/workspaces/edit_teams_command: post: summary: edit teams command @@ -5075,6 +5152,9 @@ paths: properties: operator: type: boolean + role: + type: string + description: The workspace role to give, one of the workspace's roles. Wins over `operator`. invite_all: type: boolean auto_add: @@ -5184,6 +5264,107 @@ paths: - has_instance_ai_config - uses_instance_ai_config + /w/{workspace}/workspaces/settings_document: + get: + summary: get the workspace settings document + description: >- + The workspace's settings in the layout of the settings.yaml that `wmill sync` writes. + Admin only. + operationId: getWorkspaceSettingsDocument + x-mcp-tool: true + x-mcp-tool-include-query-params: [] + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: secrets + description: >- + How secrets are written: `redacted` (the default) replaces each set secret with + `__SENSITIVE_AND_UNCHANGED__`; `encrypted` writes it encrypted with the workspace key, + prefixed with `$encrypted:`. + in: query + schema: + type: string + enum: [redacted, encrypted] + responses: + "200": + description: the settings document + content: + application/json: + schema: + type: object + additionalProperties: true + post: + summary: apply a workspace settings document + description: >- + Applies a settings document, each setting through the endpoint that owns it. Admin only. + operationId: applyWorkspaceSettingsDocument + x-mcp-tool: true + x-mcp-instructions: "Send only the top-level keys to change, each whole as getWorkspaceSettingsDocument returns it: a key the document omits is left as it is, and a key set to null clears it. Keep `__SENSITIVE_AND_UNCHANGED__` for a secret you were not given. Call with dry_run true first, show the user the changes and apply only once they agree." + x-mcp-tool-include-fields: + - document + - dry_run + x-mcp-tool-opaque-fields: + - document + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + document: + description: >- + The settings to apply, in the layout of the document. A secret given as + `__SENSITIVE_AND_UNCHANGED__` keeps its stored value; one prefixed with + `$encrypted:` is decrypted with the workspace key. + type: object + additionalProperties: true + replace: + description: >- + Clear the settings `wmill sync push` clears when settings.yaml omits them. + Otherwise a setting the document omits is left as it is. + type: boolean + dry_run: + description: Only compute the changes, without applying them. + type: boolean + required: + - document + responses: + "200": + description: the changes, applied unless dry_run + content: + application/json: + schema: + type: object + properties: + changes: + type: array + items: + type: object + properties: + key: + type: string + before: {} + after: {} + required: + - key + - before + - after + applied: + type: boolean + warnings: + type: array + items: + type: string + required: + - changes + - applied + /w/{workspace}/workspaces/get_copilot_settings_state: get: summary: get copilot settings state @@ -5539,6 +5720,9 @@ paths: type: string governing_workspace_id: type: string + database_workspace_id: + type: string + description: The workspace owning the database this entry points at, when it is not this workspace. permissioned: type: boolean @@ -5967,6 +6151,12 @@ paths: schema: type: integer format: int64 + - name: schema + in: query + required: false + description: apply only the pending migrations whose `-- schemas` annotation declares this schema + schema: + type: string responses: "200": description: applied migrations @@ -8704,6 +8894,9 @@ paths: items: type: string description: "OAuth scopes to use for token refresh. Overrides instance-level scopes." + variable: + $ref: "#/components/schemas/CreateVariable" + description: "The variable holding this account's access token. It is created with the account, in one transaction and with the same checks as createVariable, and linked to it so it refreshes through it." required: - refresh_token - expires_in @@ -9367,6 +9560,12 @@ paths: required: false schema: type: boolean + - name: starred_only + description: Only the `ai_agent` resources the caller starred + in: query + required: false + schema: + type: boolean responses: "200": description: resource list @@ -11347,6 +11546,34 @@ paths: schema: type: boolean + /workers/dev_workspace_isolation: + get: + summary: get whether the jobs of dev workspaces and forks run on their own worker groups + operationId: getDevWorkspaceIsolation + tags: + - worker + responses: + "200": + description: dev workspace isolation + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + description: whether new jobs of dev workspaces and forks are queued on suffixed tags + suffix: + type: string + description: the suffix of those tags, which worker groups serving dev workspaces listen with + skip_non_dev_forks: + type: boolean + description: whether forks of a workspace that is not a dev workspace, nor under one, run on their parent's workers instead + required: + - enabled + - suffix + - skip_non_dev_forks + /w/{workspace}/workspace_dependencies/create: post: summary: create workspace dependencies @@ -12830,7 +13057,7 @@ paths: /w/{workspace}/runnables/list: get: - summary: list runnables (scripts, flows, apps) merged, ordered and keyset-paginated + summary: list runnables (scripts, flows, apps, agents) merged, ordered and keyset-paginated operationId: listRunnables tags: - script @@ -12847,7 +13074,7 @@ paths: - $ref: "#/components/parameters/OrderDesc" - name: kinds in: query - description: comma-separated subset of script,flow,app (default all) + description: comma-separated subset of script,flow,app,agent (default all) schema: type: string - name: show_archived @@ -12921,7 +13148,7 @@ paths: - $ref: "#/components/parameters/WorkspaceId" - name: kinds in: query - description: comma-separated subset of script,flow,app (default all) + description: comma-separated subset of script,flow,app,agent (default all) schema: type: string - name: include_without_main @@ -13517,6 +13744,188 @@ paths: items: $ref: "#/components/schemas/FlowConversation" + /w/{workspace}/slack_agents/channels: + get: + summary: list the Slack channels this workspace's agents are shared in + operationId: listSlackAgentChannels + tags: + - slack_agents + parameters: + - $ref: "#/components/parameters/WorkspaceId" + responses: + "200": + description: each agent shared in a channel, with whether it is the channel's default + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/SlackAgentChannel" + + /w/{workspace}/slack_agents/available_channels: + get: + summary: list the connected Slack workspace's public channels + operationId: listAvailableSlackChannels + tags: + - slack_agents + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: cursor + description: the next_cursor of the previous page + in: query + schema: + type: string + responses: + "200": + description: a page of public channels + content: + application/json: + schema: + type: object + properties: + channels: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + required: + - id + - name + next_cursor: + type: string + required: + - channels + + /w/{workspace}/slack_agents/status: + get: + summary: whether agents can answer in Slack on this instance + operationId: getSlackAgentsStatus + tags: + - slack_agents + parameters: + - $ref: "#/components/parameters/WorkspaceId" + responses: + "200": + description: the instance's readiness for agents in Slack + content: + application/json: + schema: + type: object + properties: + signing_secret_set: + type: boolean + description: agents answer in Slack only when the server has the Slack app's signing secret (SLACK_SIGNING_SECRET) + required: + - signing_secret_set + + /w/{workspace}/slack_agents/agent_channels/{path}: + get: + summary: the Slack channels an agent is shared in, and whether the caller can share it + operationId: getAgentSlackChannels + tags: + - slack_agents + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/Path" + responses: + "200": + description: the agent's Slack channels + content: + application/json: + schema: + type: object + properties: + slack_team_name: + type: string + description: the connected Slack workspace; absent when Slack is not connected + signing_secret_set: + type: boolean + description: agents answer in Slack only when the server has the Slack app's signing secret (SLACK_SIGNING_SECRET) + bot_name: + type: string + description: the bot's Slack handle; absent when Slack can't be reached + missing_scopes: + type: array + items: + type: string + description: agent scopes the workspace's Slack connection lacks, so it must be reconnected with AI agents enabled + runs_as: + type: string + description: who the agent runs as (its on_behalf_of); absent unless it runs on behalf of someone + anyone: + type: boolean + description: whether anyone in its channels can ask it (its slack_anyone, kept only while it runs on behalf of someone), rather than workspace members only + can_share: + type: boolean + description: whether the caller can change the agent's channels, as anyone who can edit it can + channels: + type: array + items: + $ref: "#/components/schemas/SlackAgentChannel" + required: + - channels + - signing_secret_set + - missing_scopes + - anyone + - can_share + post: + summary: share an agent in a Slack channel, or change how it answers there + description: the workspace members in the channel whose Slack email matches and who can read the agent can then ask it, or anyone there when its settings say so (slack_anyone, with run_on_behalf_of). Needs edit access to the agent; making it the channel's default takes an admin. + operationId: shareAgentInSlackChannel + tags: + - slack_agents + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/Path" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + channel_id: + type: string + channel_name: + type: string + is_default: + type: boolean + description: whether the agent answers bot mentions in the channel that name no agent + required: + - channel_id + - channel_name + responses: + "200": + description: agent shared + content: + text/plain: + schema: + type: string + delete: + summary: stop sharing an agent in a Slack channel + operationId: unshareAgentFromSlackChannel + tags: + - slack_agents + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/Path" + - name: channel_id + in: query + required: true + schema: + type: string + responses: + "200": + description: agent no longer shared there + content: + text/plain: + schema: + type: string + /w/{workspace}/flow_conversations/update/{conversation_id}: post: summary: rename flow conversation @@ -14611,6 +15020,168 @@ paths: schema: type: string + /w/{workspace}/ai/shared_sessions/share: + post: + summary: share an AI session transcript by link + description: > + Stores a read-only snapshot of the session that members of the workspace can open by + id, or anyone with the link once it is public. Sharing the same session again updates + that snapshot and keeps its id. + operationId: shareAiSession + tags: + - ai + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - session_id + - name + - message_count + - snapshot + properties: + session_id: + type: string + description: the session's id in the author's browser + name: + type: string + is_public: + type: boolean + description: left as it is on an existing share when absent; a new share starts private + message_count: + type: integer + minimum: 0 + snapshot: + type: object + additionalProperties: true + responses: + "200": + description: the shared snapshot + content: + application/json: + schema: + $ref: "#/components/schemas/SharedAiSessionInfo" + + /w/{workspace}/ai/shared_sessions/status: + get: + summary: get the calling user's share of one of their AI sessions + operationId: getAiSessionShareStatus + tags: + - ai + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: session_id + in: query + required: true + schema: + type: string + responses: + "200": + description: the share, if any + content: + application/json: + schema: + type: object + properties: + share: + $ref: "#/components/schemas/SharedAiSessionInfo" + + /w/{workspace}/ai/shared_sessions/set_public/{id}: + post: + summary: make a shared AI session public, or reserve it to workspace members + operationId: setAiSessionSharePublic + tags: + - ai + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - is_public + properties: + is_public: + type: boolean + responses: + "200": + description: the updated share + content: + application/json: + schema: + $ref: "#/components/schemas/SharedAiSessionInfo" + + /w/{workspace}/ai_u/shared_sessions/get/{id}: + get: + summary: get a shared AI session + description: > + A public share is served without authentication; any other one only to a member of + the workspace. + operationId: getSharedAiSession + tags: + - ai + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: the shared session + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/SharedAiSessionInfo" + - type: object + required: + - snapshot + - can_unshare + properties: + snapshot: + type: object + additionalProperties: true + can_unshare: + type: boolean + description: whether the caller authored the share or is a workspace admin + + /w/{workspace}/ai/shared_sessions/delete/{id}: + delete: + summary: stop sharing an AI session + operationId: unshareAiSession + tags: + - ai + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: share deleted + content: + text/plain: + schema: + type: string + /w/{workspace}/apps/get_data/v/{secretWithExtension}: get: summary: get raw app data by @@ -15534,6 +16105,12 @@ paths: type: integer tag: type: string + modules: + type: object + nullable: true + description: "Additional modules keyed by relative file path. Honored in preview only: a deployed app runs the modules stored with its runnable." + additionalProperties: + $ref: "#/components/schemas/ScriptModule" required: - content - language @@ -15562,6 +16139,14 @@ paths: description: "Map of relative-import script path -> temp storage hash. Only honored for inline-script (raw_code) execution so app dev resolves those imports from not-yet-deployed local content." additionalProperties: type: string + on_behalf_of: + type: string + nullable: true + description: "Preview mode (force_viewer_static_fields set) only: run as this identity (an email, `u/` or `g/`) instead of the caller. Naming anyone other than yourself requires being an admin or a member of wm_deployers." + viewer_data_access: + type: boolean + nullable: true + description: "Preview mode only: the inline runnable's viewer_data_access policy setting (run mode reads it from the deployed policy)." required: - args - component @@ -16037,6 +16622,9 @@ paths: /w/{workspace}/jobs/run/batch_rerun_jobs: post: summary: re-run multiple jobs + description: >- + Re-runs each job and marks every failed or canceled original that was re-run as + resolved, with the new job's id in its note. Operators re-run without resolving. operationId: batchReRunJobs tags: - job @@ -16538,8 +17126,9 @@ paths: summary: run a saved AI agent description: | Runs the `ai_agent` resource at `path` as stored, for any caller who can read it - (operators included), as the caller, who needs access to the agent's AI resource and to - whatever its tools use. With `memory_id`, the run is a turn + (operators included). The run is the caller's, or the agent's `on_behalf_of` when its + `run_on_behalf_of` is set; the caller's inputs may then not reference variables or + resources. With `memory_id`, the run is a turn of the caller's conversation with the agent; without, a single run. operationId: runAgent tags: @@ -16565,6 +17154,12 @@ paths: user_attachments: type: array items: {} + previous_messages: + description: | + The history to answer from, as chat messages, when the agent's memory is off. + An agent with managed memory reads its conversation instead and ignores this. + type: array + items: {} responses: "201": description: job created @@ -16798,6 +17393,7 @@ paths: - job parameters: - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/OrderDesc" - $ref: "#/components/parameters/CreatedBy" - $ref: "#/components/parameters/Label" - $ref: "#/components/parameters/Worker" @@ -16830,8 +17426,8 @@ paths: type: boolean - name: resolved description: >- - filter on whether a failure has been marked as handled. true keeps only - resolved failures, false hides them + filter on whether a failed or canceled run has been marked as handled. true + keeps only resolved runs, false hides them in: query schema: type: boolean @@ -17142,8 +17738,8 @@ paths: type: boolean - name: resolved description: >- - filter on whether a failure has been marked as handled. true keeps only - resolved failures, false hides them + filter on whether a failed or canceled run has been marked as handled. true + keeps only resolved runs, false hides them in: query schema: type: boolean @@ -17329,8 +17925,8 @@ paths: type: boolean - name: resolved description: >- - filter on whether a failure has been marked as handled. true keeps only - resolved failures, false hides them + filter on whether a failed or canceled run has been marked as handled. true + keeps only resolved runs, false hides them in: query schema: type: boolean @@ -18188,11 +18784,12 @@ paths: /w/{workspace}/jobs/completed/resolve: post: - summary: mark failed jobs as resolved + summary: mark failed or canceled jobs as resolved description: >- - Marks failed jobs as handled so triage surfaces stop showing them as failures. - The job status itself is unchanged. Returns the ids actually resolved: ids that - are not visible to the caller or did not fail are silently skipped. + Marks failed or canceled jobs as handled so triage surfaces stop showing them as + unhandled. The job status itself is unchanged. Returns the ids actually resolved: ids + that are not visible to the caller or neither failed nor were canceled are silently + skipped. operationId: resolveCompletedJobs tags: - job @@ -20558,12 +21155,22 @@ paths: schema: $ref: "#/components/schemas/NativeServiceName" requestBody: - description: redirect_uri + description: >- + redirect_uri, and where to save the connection (defaults to + u/{caller}/native_{resource_type}). Uses the workspace OAuth client, or the instance + one when the instance shares it. required: true content: application/json: schema: - $ref: "#/components/schemas/RedirectUri" + type: object + properties: + redirect_uri: + type: string + resource_path: + type: string + required: + - redirect_uri responses: "200": description: native trigger service connect url @@ -20593,10 +21200,59 @@ paths: schema: type: boolean - /w/{workspace}/native_triggers/integrations/{service_name}/generate_instance_connect_url: + /w/{workspace}/native_triggers/integrations/{service_name}/connections: + get: + summary: list the native trigger connections of a service the caller can use + operationId: listNativeTriggerConnections + tags: + - workspace_integration + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: service_name + in: path + required: true + schema: + $ref: "#/components/schemas/NativeServiceName" + responses: + "200": + description: connections + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/NativeTriggerConnection" + + /w/{workspace}/native_triggers/integrations/{service_name}/connections/delete: + delete: + summary: disconnect a native trigger connection and delete the triggers using it + operationId: deleteNativeTriggerConnection + tags: + - workspace_integration + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: service_name + in: path + required: true + schema: + $ref: "#/components/schemas/NativeServiceName" + - name: path + in: query + required: true + schema: + type: string + responses: + "200": + description: connection deleted + content: + application/json: + schema: + type: string + + /w/{workspace}/native_triggers/integrations/{service_name}/workspace_connection: post: - summary: generate connect url using instance-level credentials - operationId: generateInstanceConnectUrl + summary: share a connection of this service with every member of the workspace (admin only), or stop sharing + operationId: setNativeTriggerWorkspaceConnection tags: - workspace_integration parameters: @@ -20607,15 +21263,19 @@ paths: schema: $ref: "#/components/schemas/NativeServiceName" requestBody: - description: redirect_uri required: true content: application/json: schema: - $ref: "#/components/schemas/RedirectUri" + type: object + properties: + path: + type: string + nullable: true + description: The connection to share, or null to stop sharing responses: "200": - description: authorization URL using instance credentials + description: workspace connection updated content: application/json: schema: @@ -20669,17 +21329,15 @@ paths: type: string redirect_uri: type: string - resource_path: - type: string required: - code - state - redirect_uri responses: "200": - description: native trigger service oauth completed + description: path of the saved connection content: - text/plain: + application/json: schema: type: string @@ -20954,6 +21612,12 @@ paths: required: true schema: type: string + - name: connection_path + in: query + description: Connection to browse through; defaults to the caller's only usable one + required: false + schema: + type: string responses: "200": description: list of available NextCloud events @@ -20976,6 +21640,12 @@ paths: required: true schema: type: string + - name: connection_path + in: query + description: Connection to browse through; defaults to the caller's only usable one + required: false + schema: + type: string responses: "200": description: list of Google Calendars @@ -20998,6 +21668,12 @@ paths: required: true schema: type: string + - name: connection_path + in: query + description: Connection to browse through; defaults to the caller's only usable one + required: false + schema: + type: string - name: q in: query description: search query to filter files by name @@ -21039,6 +21715,12 @@ paths: required: true schema: type: string + - name: connection_path + in: query + description: Connection to browse through; defaults to the caller's only usable one + required: false + schema: + type: string responses: "200": description: list of shared drives @@ -21061,6 +21743,12 @@ paths: required: true schema: type: string + - name: connection_path + in: query + description: Connection to browse through; defaults to the caller's only usable one + required: false + schema: + type: string responses: "200": description: list of GitHub repositories @@ -24588,7 +25276,7 @@ paths: type: string favorite_kind: type: string - enum: [flow, app, script, raw_app, asset] + enum: [flow, app, script, raw_app, asset, agent] responses: "200": description: star item @@ -24611,7 +25299,7 @@ paths: type: string favorite_kind: type: string - enum: [flow, app, script, raw_app, asset] + enum: [flow, app, script, raw_app, asset, agent] responses: "200": description: unstar item @@ -24972,6 +25660,31 @@ paths: schema: $ref: "#/components/schemas/S3Resource" + /w/{workspace}/job_helpers/v2/s3_proxy_bucket: + get: + summary: Returns the bucket S3 clients name to reach a workspace storage + through the S3 proxy, or null when that storage is not S3 + operationId: s3ProxyBucket + tags: + - helpers + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: storage + in: query + schema: + type: string + responses: + "200": + description: The storage's bucket + content: + application/json: + schema: + type: object + properties: + bucket: + type: string + nullable: true + /w/{workspace}/job_helpers/test_connection: get: summary: Test connection to the workspace object storage @@ -26050,8 +26763,8 @@ paths: type: boolean - name: resolved description: >- - filter on whether a failure has been marked as handled. true keeps only - resolved failures, false hides them + filter on whether a failed or canceled run has been marked as handled. true + keeps only resolved runs, false hides them in: query schema: type: boolean @@ -29617,6 +30330,24 @@ components: definition_changed: type: boolean + SlackAgentChannel: + type: object + properties: + channel_id: + type: string + channel_name: + type: string + agent_path: + type: string + is_default: + type: boolean + description: whether the agent answers bot mentions in the channel that name no agent + required: + - channel_id + - channel_name + - agent_path + - is_default + FlowConversation: type: object required: @@ -30180,6 +30911,31 @@ components: - shared_at - expires_at + SharedAiSessionInfo: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + is_public: + type: boolean + message_count: + type: integer + created_by: + type: string + shared_at: + type: string + format: date-time + required: + - id + - name + - is_public + - message_count + - created_by + - shared_at + InstanceAIProviderSummary: type: object properties: @@ -30292,9 +31048,10 @@ components: description: | A row in the merged runnables listing. `type` is the discriminator; kind-specific fields (hash/language/kind for scripts, execution_mode/ - version for apps) are present only for that kind. `edited_at` is the - unified last-updated time (a script's created_at, a flow/app's edit - time). + version for apps, agent_memory/ws_specific for agents) are present only + for that kind. An agent is an `ai_agent` resource whose description is + reported as `summary`. `edited_at` is the unified last-updated time (a + script's created_at, a flow/app/agent's edit time). properties: type: type: string @@ -30302,6 +31059,7 @@ components: - script - flow - app + - agent path: type: string summary: @@ -30369,6 +31127,11 @@ components: version: type: integer format: int64 + agent_memory: + description: agent-only. The agent's `memory` setting. + ws_specific: + type: boolean + description: agent-only. Whether the agent is workspace-specific, which keeps it out of deploys to other workspaces. required: - type - path @@ -30974,7 +31737,7 @@ components: type: boolean resolved: type: boolean - description: whether this failure has been marked as handled + description: whether this failed or canceled run has been marked as handled resolved_by: type: string description: >- @@ -31446,6 +32209,10 @@ components: type: boolean operator: type: boolean + description: Legacy role selector, true for the `operator` role and false for `developer`. + role: + type: string + description: The member's workspace role, one of the workspace's roles. Wins over `operator`. disabled: type: boolean @@ -31912,7 +32679,8 @@ components: description: The description of the variable account: type: integer - description: The account identifier + deprecated: true + description: Ignored. A variable is linked to an OAuth account only when `createAccount` creates both. is_oauth: type: boolean description: Whether the variable is an OAuth variable @@ -32341,6 +33109,14 @@ components: description: "Map of relative-import script path -> temp storage hash so the preview job resolves those imports from not-yet-deployed local content instead of the deployed script" additionalProperties: type: string + on_behalf_of: + type: string + nullable: true + description: "Run the preview as this identity (an email, `u/` or `g/`) instead of the caller. Naming anyone other than yourself requires being an admin or a member of wm_deployers." + viewer_data_access: + type: boolean + nullable: true + description: "Data table and object storage access as the caller rather than as the job's identity, as a full-code app's inline runnable flagged viewer_data_access does for its viewer." required: - args @@ -32408,6 +33184,9 @@ components: type: string ws_specific: type: boolean + preserve_on_behalf_of: + type: boolean + description: ai_agent only. Keep the identity `value.on_behalf_of` names (`u/`, `g/`) rather than the caller's own. Honoured for admins and members of wm_deployers; otherwise every write of an agent sets it to the caller. required: - path - value @@ -32432,6 +33211,9 @@ components: type: string ws_specific: type: boolean + preserve_on_behalf_of: + type: boolean + description: ai_agent only. Keep the identity `value.on_behalf_of` names (`u/`, `g/`) rather than the caller's own. Honoured for admins and members of wm_deployers; otherwise every write of an agent sets it to the caller. Resource: type: object @@ -36274,6 +37056,11 @@ components: is_service_account: type: boolean description: Whether this membership is a service account. + role: + type: string + description: The member's workspace role. + capabilities: + $ref: "#/components/schemas/RoleCapabilities" required: - id - name @@ -36281,6 +37068,8 @@ components: - color - is_dev_workspace - disabled + - role + - capabilities required: - email - workspaces @@ -36340,6 +37129,11 @@ components: items: type: string description: "Lake names the fork SHARES with the parent (reads and writes the parent's lake directly). Every lake not listed gets the default isolated fork namespace with read-defer to the parent." + shared_storages: + type: array + items: + type: string + description: "Workspace storages the fork SHARES with the parent (`_default_` for the primary one): it reads and writes the parent's objects directly. Every storage not listed is isolated: the fork writes under its own prefix and reads through to the parent's objects." is_dev_workspace: type: boolean description: "Create the fork as a persistent dev workspace (id not required to carry the wm-fork- prefix; at most one per parent)" @@ -36454,6 +37248,9 @@ components: type: boolean operator: type: boolean + role: + type: string + description: The workspace role the invite grants. parent_workspace_id: type: string nullable: true @@ -36462,6 +37259,7 @@ components: - email - is_admin - operator + - role GlobalUserInfo: type: object @@ -36685,6 +37483,10 @@ components: description: "Map of relative-import script path -> temp storage hash, propagated to each flow step so inline-script relative imports resolve from not-yet-deployed local content instead of the deployed script" additionalProperties: type: string + on_behalf_of: + type: string + nullable: true + description: "Run the preview as this identity (an email, `u/` or `g/`) instead of the caller. Naming anyone other than yourself requires being an admin or a member of wm_deployers." required: - value @@ -36781,7 +37583,8 @@ components: the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, - flow_conversations:write). + flow_conversations:write). Every such token can call whoami without + consent, whether `users:read` is declared or not. ListableApp: type: object @@ -37377,6 +38180,15 @@ components: - ran - not_run - unknown + schemas: + type: array + description: the schemas its `-- schemas` annotation declares; absent when it applies to the whole database + items: + type: string + blocked_by: + type: integer + format: int64 + description: when not run, the earliest pending migration that must be applied first; when run, the latest applied migration that must be rolled back first DataTableSchema: type: object required: [datatable_name, schemas] @@ -38204,6 +39016,122 @@ components: type: boolean description: Whether operators can create, edit and delete triggers. Granted unless withdrawn; omitting the field leaves the stored value unchanged. + AuthorLevel: + type: string + enum: [none, compose, full] + description: How much of a kind of runnable a role may author. `compose` assembles already-deployed runnables with no code of one's own. + + RoleVisibility: + type: object + description: Pages a role sees. Not enforced. + properties: + runs: { type: boolean } + schedules: { type: boolean } + resources: { type: boolean } + variables: { type: boolean } + assets: { type: boolean } + triggers: { type: boolean } + audit_logs: { type: boolean } + groups: { type: boolean } + folders: { type: boolean } + workers: { type: boolean } + required: [runs, schedules, resources, variables, assets, triggers, audit_logs, groups, folders, workers] + + RoleCapabilities: + type: object + properties: + code: + type: boolean + description: Author scripts and run code of one's own (previews, dependency jobs, dynamic selects, resolving jobs, data table migrations). Required by `full` flows or apps. + flows: + $ref: "#/components/schemas/AuthorLevel" + apps: + $ref: "#/components/schemas/AuthorLevel" + manage_schedules: + type: boolean + manage_triggers: + type: boolean + visibility: + $ref: "#/components/schemas/RoleVisibility" + required: [code, flows, apps, manage_schedules, manage_triggers, visibility] + + RoleCapabilitiesPatch: + type: object + description: A partial RoleCapabilities; an unset key falls through to the layer below. + properties: + code: { type: boolean } + flows: + $ref: "#/components/schemas/AuthorLevel" + apps: + $ref: "#/components/schemas/AuthorLevel" + manage_schedules: { type: boolean } + manage_triggers: { type: boolean } + visibility: + type: object + additionalProperties: + type: boolean + + RoleDefinition: + type: object + description: One entry of the `workspace_roles` instance setting. + properties: + name: + type: string + label: + type: string + capabilities: + $ref: "#/components/schemas/RoleCapabilitiesPatch" + required: [name] + + RoleOverride: + type: object + properties: + label: + type: string + capabilities: + $ref: "#/components/schemas/RoleCapabilitiesPatch" + + RoleOverrides: + type: object + description: A workspace's overrides keyed by role name; a name the instance does not define adds a workspace-only role (EE). + additionalProperties: + $ref: "#/components/schemas/RoleOverride" + + EffectiveRole: + type: object + properties: + name: + type: string + label: + type: string + capabilities: + $ref: "#/components/schemas/RoleCapabilities" + source: + type: string + enum: [instance, workspace] + overridden: + type: boolean + required: [name, label, capabilities, source, overridden] + + WorkspaceRoles: + type: object + properties: + roles: + type: array + items: + $ref: "#/components/schemas/EffectiveRole" + overrides: + $ref: "#/components/schemas/RoleOverrides" + custom_roles_enabled: + type: boolean + description: Whether this workspace may add roles of its own. + my_role: + type: string + nullable: true + my_capabilities: + $ref: "#/components/schemas/RoleCapabilities" + required: [roles, overrides, custom_roles_enabled, my_capabilities] + WorkspaceComparison: type: object required: @@ -38827,6 +39755,7 @@ components: - RestrictAnonymousAppDeployment - RestrictPublicRunSharing - RestrictGuestAppDeployment + - RequireIsolatedForks RuleBypasserGroups: type: array description: Groups that can bypass this ruleset @@ -38981,6 +39910,10 @@ components: enabled: type: boolean description: Whether the trigger starts a job when it fires + connection_path: + type: string + nullable: true + description: Path of the connection the trigger acts through on the external service required: - external_id - workspace_id @@ -39023,6 +39956,10 @@ components: enabled: type: boolean description: Whether the trigger starts a job when it fires + connection_path: + type: string + nullable: true + description: Path of the connection the trigger acts through on the external service external_data: type: object nullable: true @@ -39054,10 +39991,6 @@ components: oauth_data: nullable: true $ref: "#/components/schemas/WorkspaceOAuthConfig" - resource_path: - type: string - nullable: true - description: Path to the resource storing the OAuth token required: - service_name @@ -39103,14 +40036,6 @@ components: - async - sync - RedirectUri: - type: object - properties: - redirect_uri: - type: string - required: - - redirect_uri - NativeTriggerData: type: object description: Data for creating or updating a native trigger @@ -39135,11 +40060,36 @@ components: Whether the trigger starts a job when it fires. Honoured on create only, so a trigger can be registered already paused; an update ignores it and setenabled is the only way to change an existing trigger's state. Defaults to true. + connection_path: + type: string + nullable: true + description: >- + Path of the connection the trigger acts through. Honoured on create only; an update + keeps the trigger's connection. When omitted on create, the caller's only usable + connection for the service is used. required: - script_path - is_flow - service_config + NativeTriggerConnection: + type: object + description: An account connected for native triggers, stored as an OAuth resource + properties: + path: + type: string + description: Path of the resource and variable holding the connection + owner: + type: string + nullable: true + description: Username of whoever connected the account, when the caller can read its resource + workspace: + type: boolean + description: Whether this is the workspace connection, which every member may use + required: + - path + - workspace + CreateTriggerResponse: type: object description: Response returned when a native trigger is created diff --git a/backend/windmill-api/src/agent_runs.rs b/backend/windmill-api/src/agent_runs.rs index 76f729e7e0..3bf65c9808 100644 --- a/backend/windmill-api/src/agent_runs.rs +++ b/backend/windmill-api/src/agent_runs.rs @@ -2,8 +2,9 @@ //! //! The run is built here from the stored agent rather than sent by the client, which is what lets //! anyone who can read the agent run it, operators included, the way a deployed flow runs by path: -//! a preview takes arbitrary code, this takes only the run's inputs. It runs as the caller, who -//! needs access to the agent's AI resource and to whatever its tools use. +//! a preview takes arbitrary code, this takes only the run's inputs. It runs as the caller, or as +//! the agent's `on_behalf_of` when its `run_on_behalf_of` is set; the server resolves +//! `on_behalf_of` on every write of the agent. use std::collections::HashMap; @@ -20,13 +21,16 @@ use windmill_common::{ jobs::JobPayload, users::username_to_permissioned_as, utils::{not_found_if_none, StripPath}, + variables::untrusted_arg_interpolates, }; use windmill_queue::{push, PushArgs, PushIsolationLevel}; use crate::ai_evals::run::config_to_draft; use crate::ai_evals::subject::AgentDraft; use crate::db::{ApiAuthed, DB}; -use crate::jobs::{handle_chat_conversation_messages, set_flow_memory_id, RunJobQuery}; +use crate::jobs::{ + handle_chat_conversation_messages, run_end_user_email, set_flow_memory_id, RunJobQuery, +}; /// The id the agent's step carries in the run, as the editor's chat names it. A token scoped to /// `jobs:run:agents:` reads its runs back by it (`require_job_within_read_scope`). @@ -42,62 +46,79 @@ pub(crate) async fn run_agent( Query(run_query): Query, Json(args): Json>>, ) -> Result<(StatusCode, String)> { - #[cfg(feature = "enterprise")] - crate::jobs::check_license_key_valid().await?; - let path = path.to_path(); check_scopes(&authed, || format!("jobs:run:agents:{path}"))?; + let uuid = run_agent_as(&authed, &db, &user_db, &w_id, path, &run_query, &args).await?; + Ok((StatusCode::CREATED, uuid.to_string())) +} + +/// [`run_agent`] for a caller already authenticated, such as the identity of an agent shared in a +/// Slack channel. +pub(crate) async fn run_agent_as( + authed: &ApiAuthed, + db: &DB, + user_db: &UserDB, + w_id: &str, + path: &str, + run_query: &RunJobQuery, + args: &HashMap>, +) -> Result { + #[cfg(feature = "enterprise")] + crate::jobs::check_license_key_valid().await?; + // Read through the caller's own permissions: reading the agent is what allows running it. - let mut tx = user_db.clone().begin(&authed).await?; - 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?; - tx.commit().await?; - let Some(sqlx::types::Json(value)) = not_found_if_none(value, "Agent", path)? else { - return Err(Error::BadRequest(format!( - "Agent {path} has no configuration" - ))); - }; - let config = config_to_draft(value)?; - - // A chat turn is filed where the agent's chat lists its conversations, which a flow cannot - // name (flow paths carry no `.`), so the two never share a conversation list. let chat = run_query.memory_id.is_some(); - let run_path = if chat { - format!("{path}.chat") - } else { - path.to_string() - }; - // A step memory id would replace the conversation's, and the agent would forget the turns - // before; history comes from the conversation alone. - let flow_value = agent_step_flow( - &config, - AGENT_NODE_ID, - chat, - &["memory_id", "previous_messages"], - )?; + let AgentTurn { flow_value, run_path, agent_obo } = + agent_turn(authed, user_db, w_id, path, chat).await?; - let push_authed = authed.clone().into(); - let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; + let on_behalf_of = + windmill_common::on_behalf_of_from_permissioned_as(agent_obo.as_deref(), w_id, db).await?; + // As `run_flow` picks the identity of a deployed flow: the agent's own when it has one, the + // caller's otherwise, which lends nobody's permissions. + let (email, permissioned_as, push_authed, isolation) = if let Some(obo) = on_behalf_of.as_ref() + { + ( + &obo.email, + obo.permissioned_as.clone(), + None, + PushIsolationLevel::IsolatedRoot(db.clone()), + ) + } else { + ( + &authed.email, + username_to_permissioned_as(&authed.username), + Some(authed.clone().into()), + PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()), + ) + }; + if let Some(obo) = on_behalf_of.as_ref() { + // The worker would interpolate such an input with the job's token, which is the + // identity's. + if args.values().any(|v| untrusted_arg_interpolates(v.get())) { + return Err(Error::BadRequest(format!( + "Agent {path} runs on behalf of {}, so its inputs cannot reference variables or resources", + obo.permissioned_as + ))); + } + #[cfg(all(feature = "parquet", feature = "private"))] + check_attachments_readable(db, authed, w_id, args).await?; + } + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(db, authed).await?; + let end_user_email = run_end_user_email(db, w_id, authed, None).await?; let (uuid, mut tx) = push( - &db, - PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()), - &w_id, + db, + isolation, + w_id, JobPayload::RawFlow { value: flow_value, path: Some(run_path.clone()), restarted_from: None, }, - PushArgs::from(&args), + PushArgs::from(args), authed.display_username(), - &authed.email, - username_to_permissioned_as(&authed.username), + email, + permissioned_as, authed.token_prefix.as_deref(), authed.username_override.as_deref(), None, @@ -114,42 +135,173 @@ pub(crate) async fn run_agent( None, None, None, - Some(&push_authed), + push_authed.as_ref(), false, - None, + end_user_email, authed.trigger_or_fallback(None), None, scope_ceiling.as_deref(), ) .await?; - if let Some(memory_id) = run_query.memory_key(&w_id, &run_path) { + if let Some(memory_id) = run_query.memory_key(w_id, &run_path) { set_flow_memory_id(&mut tx, uuid, memory_id).await?; } if chat { - // A real conversation, not a test one: this is the agent as deployed, not a draft. + // The caller's conversation, whoever the agent runs as, and a real one: this is the + // agent as deployed, not a draft under test. handle_chat_conversation_messages( &mut tx, - &authed, - &w_id, + authed, + w_id, &run_path, - &run_query, + run_query, args.get("user_message"), uuid, false, - &args, + args, ) .await?; } tx.commit().await?; - Ok((StatusCode::CREATED, uuid.to_string())) + Ok(uuid) +} + +/// A turn of a saved agent, as the flow that runs it. +pub(crate) struct AgentTurn { + pub flow_value: windmill_common::flows::FlowValue, + /// Where the turn is filed: a chat turn under `.chat`, which a flow cannot name (flow + /// paths carry no `.`), so the agent's conversations never share a list with a flow's. + pub run_path: String, + /// The identity the agent runs as when it opts in (`run_on_behalf_of`). A caller that runs it + /// as some other identity, such as an app's, ignores it. + pub agent_obo: Option, +} + +/// Reads the agent through `authed`'s own permissions and builds the flow of one of its turns. +/// Who it may see and which inputs or slack settings it carries are not inputs of the run. +pub(crate) async fn agent_turn( + authed: &ApiAuthed, + user_db: &UserDB, + w_id: &str, + path: &str, + chat: bool, +) -> Result { + let mut tx = user_db.clone().begin(authed).await?; + 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?; + tx.commit().await?; + let Some(sqlx::types::Json(mut value)) = not_found_if_none(value, "Agent", path)? else { + return Err(Error::BadRequest(format!( + "Agent {path} has no configuration" + ))); + }; + let obj = value.as_object_mut(); + let (agent_obo, run_obo) = obj + .map(|o| { + o.remove("slack_anyone"); + (o.remove("on_behalf_of"), o.remove("run_on_behalf_of")) + }) + .unwrap_or_default(); + let agent_obo = agent_obo + .filter(|_| run_obo.and_then(|v| v.as_bool()).unwrap_or(false)) + .and_then(|v| v.as_str().map(str::to_string)); + let config = config_to_draft(value)?; + let run_path = if chat { + format!("{path}.chat") + } else { + path.to_string() + }; + // A step memory id would replace the conversation's, and the agent would forget the turns + // before; history comes from the conversation alone. + let flow_value = agent_step_flow( + &config, + AGENT_NODE_ID, + chat, + &["memory_id", "previous_messages"], + )?; + Ok(AgentTurn { flow_value, run_path, agent_obo }) +} + +/// The step fetches the message's files with its job's token, so a run as the agent's identity +/// would read any file that identity can: a caller attaches only what they could download +/// themselves, checked as `sign_s3_objects` checks a key before signing it. +#[cfg(all(feature = "parquet", feature = "private"))] +pub(crate) async fn check_attachments_readable( + db: &DB, + authed: &ApiAuthed, + w_id: &str, + args: &HashMap>, +) -> Result<()> { + use crate::job_helpers_oss::{ + get_large_file_storage, get_workspace_s3_resource_and_check_paths, + }; + use windmill_common::db::DbWithOptAuthed; + use windmill_types::s3::S3Permission; + + for attachment in run_attachments(args)? { + // The path check is skipped, not failed, when the storage does not resolve. + if get_large_file_storage(db, w_id, attachment.storage.clone()) + .await? + .is_none() + { + return Err(windmill_object_store::workspace_storage_not_found( + attachment.storage.as_deref(), + )); + } + get_workspace_s3_resource_and_check_paths( + &DbWithOptAuthed::from_authed(authed, db.clone(), None), + Some(authed), + w_id, + attachment.storage.clone(), + &[(&attachment.s3, S3Permission::READ)], + None, + ) + .await?; + } + Ok(()) +} + +/// Every file the step fetches with its job's token: the message's attachments, and the +/// `s3_object` parts of the history a caller supplies, which the worker downloads alike. +pub(crate) fn run_attachments( + args: &HashMap>, +) -> Result> { + use windmill_ai::ai_types::{ContentPart, OpenAIContent, OpenAIMessage}; + + let mut files: Vec = match args.get("user_attachments") { + Some(raw) => serde_json::from_str::>>(raw.get()) + .map_err(|e| Error::BadRequest(format!("invalid user_attachments: {e}")))? + .unwrap_or_default(), + None => vec![], + }; + if let Some(raw) = args.get("previous_messages") { + let messages = serde_json::from_str::>>(raw.get()) + .map_err(|e| Error::BadRequest(format!("invalid previous_messages: {e}")))?; + for message in messages.into_iter().flatten() { + if let Some(OpenAIContent::Parts(parts)) = message.content { + files.extend(parts.into_iter().filter_map(|part| match part { + ContentPart::S3Object { s3_object } => Some(s3_object), + _ => None, + })); + } + } + } + files.retain(|f| !f.s3.is_empty()); + Ok(files) } /// The agent as a one-step flow, validated by deserializing through `FlowValue` rather than /// trusted as raw JSON. Its own transforms are the step's, minus `without`, and the run's inputs -/// supply the message and the attachments over the top. Always inlined, never a link to the -/// resource: a linked step would resolve the agent when it runs, not as it was read here. +/// supply the message, the attachments and any history over the top. Always inlined, never a link +/// to the resource: a linked step would resolve the agent when it runs, not as it was read here. pub(crate) fn agent_step_flow( config: &AgentDraft, id: &str, @@ -163,7 +315,7 @@ pub(crate) fn agent_step_flow( for key in without { input_transforms.remove(*key); } - for key in ["user_message", "user_attachments"] { + for key in ["user_message", "user_attachments", "previous_messages"] { input_transforms.insert( key.to_string(), serde_json::json!({ "type": "javascript", "expr": format!("flow_input.{}", key) }), @@ -184,3 +336,25 @@ pub(crate) fn agent_step_flow( } Ok(serde_json::from_value(flow)?) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn run_attachments_include_the_files_a_supplied_history_quotes() { + let args: HashMap> = serde_json::from_value(serde_json::json!({ + "user_attachments": [{ "s3": "u/a/attached.png" }], + "previous_messages": [ + { "role": "user", "content": [ + { "type": "text", "text": "look" }, + { "type": "s3_object", "s3_object": { "s3": "u/b/quoted.png" } } + ]}, + { "role": "assistant", "content": "ok" } + ] + })) + .unwrap(); + let keys: Vec<_> = run_attachments(&args).unwrap().into_iter().map(|f| f.s3).collect(); + assert_eq!(keys, ["u/a/attached.png", "u/b/quoted.png"]); + } +} diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index cf7447ceba..8de5e16c11 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -37,8 +37,8 @@ use windmill_ai::providers::{ }, }; use windmill_ai::proxy::{ - fim::maybe_transform_fim_request, proxy_execution_mode, ProxyBuildArgs, ProxyExecutionMode, - ProxyRequest, + fim::maybe_transform_fim_request, proxy_execution_mode, validate_proxy_path, ProxyBuildArgs, + ProxyExecutionMode, ProxyRequest, }; use windmill_ai::retry::send_with_retries; use windmill_audit::{audit_oss::audit_log, ActionKind}; @@ -166,27 +166,14 @@ fn ai_http_client_builder() -> reqwest::ClientBuilder { /// after the check (DNS-rebinding TOCTOU). /// /// Returns the shared pooled [`HTTP_CLIENT`] unchanged when there is nothing to -/// pin — an IP-literal host, or a deployment that opted into private AI -/// endpoints via `ALLOW_PRIVATE_AI_BASE_URLS`. The same opt-out and error hint -/// as `get_base_url` apply, so the guard here is consistent with save-time +/// pin — an IP-literal host, or a host `ALLOW_PRIVATE_AI_BASE_URLS` allows +/// by name or wholesale. The same allowlist and error hint as `get_base_url` +/// apply, so the guard here is consistent with save-time /// validation while additionally closing the connect-time window. async fn pinned_ai_client_for(url: &str) -> Result> { use std::borrow::Cow; - use windmill_common::ssrf::SsrfValidationError; - if *windmill_ai::ai_providers::ALLOW_PRIVATE_AI_BASE_URLS { - return Ok(Cow::Borrowed(&HTTP_CLIENT)); - } - - let target = windmill_common::ssrf::validate_url_for_ssrf(url) - .await - .map_err(|e| match e { - e @ SsrfValidationError::Private { .. } => Error::BadRequest(format!( - "{e}. If you need to use private/internal AI endpoints, \ - set the ALLOW_PRIVATE_AI_BASE_URLS=true environment variable" - )), - e => Error::from(e), - })?; + let target = windmill_ai::ai_providers::validate_ai_url(url).await?; if target.pinned_addrs().is_empty() { return Ok(Cow::Borrowed(&HTTP_CLIENT)); @@ -713,6 +700,10 @@ pub fn workspaced_service() -> Router { .nest( "/shared_artifacts", crate::ai_shared_artifacts::workspaced_service(), + ) + .nest( + "/shared_sessions", + crate::ai_shared_sessions::workspaced_service(), ); #[cfg(feature = "bedrock")] @@ -1099,6 +1090,7 @@ async fn global_proxy( headers: HeaderMap, body: Bytes, ) -> impl IntoResponse { + validate_proxy_path(&ai_path)?; let provider = headers .get("X-Provider") .map(|v| v.to_str().unwrap_or("").to_string()); @@ -1217,6 +1209,7 @@ async fn proxy( headers: HeaderMap, mut body: Bytes, ) -> impl IntoResponse { + validate_proxy_path(&ai_path)?; let provider = headers .get("X-Provider") .map(|v| v.to_str().unwrap_or("").to_string()); diff --git a/backend/windmill-api/src/ai_evals/datasets.rs b/backend/windmill-api/src/ai_evals/datasets.rs index 8e1acb3d2a..107a316676 100644 --- a/backend/windmill-api/src/ai_evals/datasets.rs +++ b/backend/windmill-api/src/ai_evals/datasets.rs @@ -123,17 +123,15 @@ pub async fn list_datasets( pub async fn create_dataset( authed: ApiAuthed, + Extension(db): Extension, Extension(user_db): Extension, Path(w_id): Path, Json(payload): Json, ) -> Result { check_proper_path(&payload.path)?; check_summary(payload.summary.as_deref())?; - if authed.is_operator { - return Err(Error::NotAuthorized( - "Operators cannot create eval datasets".to_string(), - )); - } + windmill_common::workspaces::check_role_can_code(&db, &w_id, &authed, "create eval datasets") + .await?; check_case_set( payload .cases @@ -207,15 +205,13 @@ pub async fn get_dataset( /// or not at all. pub async fn update_dataset( authed: ApiAuthed, + Extension(db): Extension, Extension(user_db): Extension, Path((w_id, path)): Path<(String, String)>, Json(payload): Json, ) -> Result { - if authed.is_operator { - return Err(Error::NotAuthorized( - "Operators cannot modify eval datasets".to_string(), - )); - } + windmill_common::workspaces::check_role_can_code(&db, &w_id, &authed, "modify eval datasets") + .await?; check_summary(payload.summary.as_deref())?; let new_path = match payload.path.filter(|p| *p != path) { Some(new_path) => { @@ -301,15 +297,13 @@ pub async fn update_dataset( /// own retention. pub async fn delete_dataset( authed: ApiAuthed, + Extension(db): Extension, Extension(user_db): Extension, Path((w_id, path)): Path<(String, String)>, ) -> Result { check_proper_path(&path)?; - if authed.is_operator { - return Err(Error::NotAuthorized( - "Operators cannot delete eval datasets".to_string(), - )); - } + windmill_common::workspaces::check_role_can_code(&db, &w_id, &authed, "delete eval datasets") + .await?; let mut tx = user_db.clone().begin(&authed).await?; let deleted = sqlx::query_scalar!( "DELETE FROM eval_dataset WHERE workspace_id = $1 AND path = $2 RETURNING path", diff --git a/backend/windmill-api/src/ai_evals/mod.rs b/backend/windmill-api/src/ai_evals/mod.rs index b4d6009857..867f638aa7 100644 --- a/backend/windmill-api/src/ai_evals/mod.rs +++ b/backend/windmill-api/src/ai_evals/mod.rs @@ -267,16 +267,14 @@ pub(crate) async fn read_dataset_and_cases( /// being mirrored here. async fn require_dataset_writable( authed: &ApiAuthed, + db: &DB, user_db: &UserDB, w_id: &str, path: &str, ) -> Result<()> { check_proper_path(path)?; - if authed.is_operator { - return Err(Error::NotAuthorized( - "Operators cannot modify eval datasets".to_string(), - )); - } + windmill_common::workspaces::check_role_can_code(db, w_id, authed, "modify eval datasets") + .await?; let mut tx = user_db.clone().begin(authed).await?; let writable = sqlx::query_scalar!( "SELECT path FROM eval_dataset WHERE workspace_id = $1 AND path = $2 FOR UPDATE", diff --git a/backend/windmill-api/src/ai_evals/run.rs b/backend/windmill-api/src/ai_evals/run.rs index 57e75b3f9f..6b219e5597 100644 --- a/backend/windmill-api/src/ai_evals/run.rs +++ b/backend/windmill-api/src/ai_evals/run.rs @@ -255,6 +255,8 @@ fn agent_module(config: &AgentDraft) -> Result { .entry("input_transforms") .or_insert_with(|| serde_json::json!({})); if let Some(transforms) = transforms.as_object_mut() { + // A case carries no history: each one is asked fresh. + transforms.remove("previous_messages"); for key in ["user_message", "user_attachments"] { transforms.insert( key.to_string(), @@ -561,7 +563,7 @@ pub async fn run_experiment( ) -> Result { check_scopes(&authed, || "jobs:run".to_string())?; // A write, not a read: it persists an experiment into the dataset. - require_dataset_writable(&authed, &user_db, &w_id, &payload.dataset).await?; + require_dataset_writable(&authed, &db, &user_db, &w_id, &payload.dataset).await?; let mut subject = payload.subject; let draft = validate_subject(&subject)?; diff --git a/backend/windmill-api/src/ai_shared_sessions.rs b/backend/windmill-api/src/ai_shared_sessions.rs new file mode 100644 index 0000000000..c6554305db --- /dev/null +++ b/backend/windmill-api/src/ai_shared_sessions.rs @@ -0,0 +1,368 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2026 + * 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. + */ + +//! Read-only snapshots of AI session transcripts, shared by their author. +//! +//! Sessions live in the author's browser; a row here exists only because the author asked for +//! a link. A share is readable by the members of its workspace, or by anyone holding the link +//! once its author makes it public. The snapshot is whatever the author's browser sent, so the +//! viewer treats it as untrusted data. + +use crate::db::{ApiAuthed, DB}; +use axum::{ + extract::{DefaultBodyLimit, Extension, Json, Path, Query}, + routing::{delete, get, post}, + Router, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::value::RawValue; +use uuid::Uuid; +use windmill_api_auth::{is_effectively_unscoped, OptAuthed}; +use windmill_audit::{audit_oss::audit_log, ActionKind}; +use windmill_common::error::{Error, JsonResult, Result}; + +const MAX_SNAPSHOT_BYTES: usize = 10 * 1024 * 1024; +const MAX_NAME_CHARS: usize = 255; +const MAX_SESSION_ID_CHARS: usize = 255; +const SHARE_BODY_LIMIT: usize = MAX_SNAPSHOT_BYTES + 64 * 1024; + +pub fn workspaced_service() -> Router { + Router::new() + .route( + "/share", + post(share_session).layer(DefaultBodyLimit::max(SHARE_BODY_LIMIT)), + ) + .route("/status", get(get_share_status)) + .route("/set_public/{id}", post(set_public)) + .route("/delete/{id}", delete(unshare_session)) +} + +/// Mounted outside the authenticated router: a public share is served to anyone. +pub fn workspaced_unauthed_service() -> Router { + Router::new().route("/get/{id}", get(get_shared_session)) +} + +#[derive(Deserialize)] +struct ShareSession { + session_id: String, + name: String, + /// Left as it is on an existing share when absent; a new share starts private. + is_public: Option, + message_count: i32, + snapshot: Box, +} + +#[derive(Serialize)] +struct SharedSessionInfo { + id: Uuid, + name: String, + is_public: bool, + message_count: i32, + created_by: String, + shared_at: DateTime, +} + +#[derive(Serialize)] +struct SharedSession { + #[serde(flatten)] + info: SharedSessionInfo, + snapshot: Box, + /// Whether the caller may stop sharing it: its author, or a workspace admin. + can_unshare: bool, +} + +#[derive(Serialize)] +struct ShareStatus { + #[serde(skip_serializing_if = "Option::is_none")] + share: Option, +} + +#[derive(Deserialize)] +struct ShareStatusQuery { + session_id: String, +} + +#[derive(Deserialize)] +struct SetPublic { + is_public: bool, +} + +/// Sharing exposes the author's own browser state, so only the author's own unscoped token may +/// do it: a job token can carry an `on_behalf_of` identity, and every scoped token (guest, +/// embed, app policy, MCP) was minted for something narrower. +fn require_plain_user_token(authed: &ApiAuthed) -> Result<()> { + if authed.job_id.is_some() || !is_effectively_unscoped(authed.scopes.as_deref()) { + return Err(Error::PermissionDenied( + "Shared AI sessions are only reachable with an unscoped user token".to_string(), + )); + } + Ok(()) +} + +/// It is compared against a `VARCHAR(255)` column, and Postgres answers a NUL in a text +/// parameter with an opaque 500. +fn check_session_id(session_id: &str) -> Result<()> { + if session_id.is_empty() || session_id.chars().count() > MAX_SESSION_ID_CHARS { + return Err(Error::BadRequest(format!( + "Session id must be between 1 and {MAX_SESSION_ID_CHARS} characters" + ))); + } + if session_id.contains('\0') { + return Err(Error::BadRequest( + "Session id cannot contain NUL characters".to_string(), + )); + } + Ok(()) +} + +/// Share a session, or move the caller's existing link for it to this snapshot. +async fn share_session( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Json(payload): Json, +) -> JsonResult { + require_plain_user_token(&authed)?; + check_session_id(&payload.session_id)?; + let name = payload.name.trim(); + if name.is_empty() || name.chars().count() > MAX_NAME_CHARS || name.contains('\0') { + return Err(Error::BadRequest(format!( + "Session name must be between 1 and {MAX_NAME_CHARS} characters, without NUL" + ))); + } + let snapshot = payload.snapshot.get(); + if snapshot.len() > MAX_SNAPSHOT_BYTES { + return Err(Error::BadRequest(format!( + "Session snapshot is {} bytes, above the {MAX_SNAPSHOT_BYTES} byte limit", + snapshot.len() + ))); + } + if !snapshot.trim_start().starts_with('{') { + return Err(Error::BadRequest( + "Session snapshot must be a JSON object".to_string(), + )); + } + if payload.message_count < 0 { + return Err(Error::BadRequest( + "Message count cannot be negative".to_string(), + )); + } + + let mut tx = db.begin().await?; + let row = sqlx::query!( + r#"INSERT INTO ai_shared_session + (workspace_id, session_id, email, created_by, name, is_public, message_count, snapshot) + VALUES ($1, $2, $3, $4, $5, COALESCE($6, false), $7, $8) + ON CONFLICT (workspace_id, email, session_id) DO UPDATE + SET created_by = EXCLUDED.created_by, + name = EXCLUDED.name, + is_public = COALESCE($6, ai_shared_session.is_public), + message_count = EXCLUDED.message_count, + snapshot = EXCLUDED.snapshot, + shared_at = now() + RETURNING id, is_public, shared_at, (xmax = 0) AS "inserted!""#, + &w_id, + &payload.session_id, + &authed.email, + &authed.username, + name, + payload.is_public, + payload.message_count, + snapshot, + ) + .fetch_one(&mut *tx) + .await?; + + let id = row.id.to_string(); + let public = row.is_public.to_string(); + audit_log( + &mut *tx, + &authed, + "ai.shared_sessions.share", + if row.inserted { + ActionKind::Create + } else { + ActionKind::Update + }, + &w_id, + Some(&id), + Some([("name", name), ("public", public.as_str())].into()), + ) + .await?; + tx.commit().await?; + + Ok(Json(SharedSessionInfo { + id: row.id, + name: name.to_string(), + is_public: row.is_public, + message_count: payload.message_count, + created_by: authed.username.clone(), + shared_at: row.shared_at, + })) +} + +/// The caller's own share of one of their sessions, if any. +async fn get_share_status( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Query(query): Query, +) -> JsonResult { + require_plain_user_token(&authed)?; + check_session_id(&query.session_id)?; + let share = sqlx::query_as!( + SharedSessionInfo, + "SELECT id, name, is_public, message_count, created_by, shared_at FROM ai_shared_session + WHERE workspace_id = $1 AND email = $2 AND session_id = $3", + &w_id, + &authed.email, + &query.session_id, + ) + .fetch_optional(&db) + .await?; + + Ok(Json(ShareStatus { share })) +} + +/// Only the author changes who can open their link; an admin may only take it down. +async fn set_public( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, id)): Path<(String, Uuid)>, + Json(payload): Json, +) -> JsonResult { + require_plain_user_token(&authed)?; + let mut tx = db.begin().await?; + let info = sqlx::query_as!( + SharedSessionInfo, + "UPDATE ai_shared_session SET is_public = $4 + WHERE workspace_id = $1 AND id = $2 AND email = $3 + RETURNING id, name, is_public, message_count, created_by, shared_at", + &w_id, + id, + &authed.email, + payload.is_public, + ) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| { + Error::NotFound(format!( + "Shared session {id} not found, or not shared by you" + )) + })?; + + let id_str = id.to_string(); + let public = info.is_public.to_string(); + audit_log( + &mut *tx, + &authed, + "ai.shared_sessions.set_public", + ActionKind::Update, + &w_id, + Some(&id_str), + Some([("name", info.name.as_str()), ("public", public.as_str())].into()), + ) + .await?; + tx.commit().await?; + + Ok(Json(info)) +} + +/// A public share is served to anyone with the link; any other one only to a member of its +/// workspace, which is what an `ApiAuthed` resolved on a `/w/{workspace_id}` path is. +async fn get_shared_session( + OptAuthed(opt_authed): OptAuthed, + Extension(db): Extension, + Path((w_id, id)): Path<(String, Uuid)>, +) -> JsonResult { + let row = sqlx::query!( + "SELECT id, email, name, is_public, message_count, created_by, snapshot, shared_at + FROM ai_shared_session + WHERE workspace_id = $1 AND id = $2", + &w_id, + id, + ) + .fetch_optional(&db) + .await?; + + let not_found = || { + Error::NotFound(format!( + "Shared session {id} not found: it may have been unshared, or be visible only to members of {w_id}" + )) + }; + let member = opt_authed + .as_ref() + .filter(|a| a.job_id.is_none() && is_effectively_unscoped(a.scopes.as_deref())); + let row = match row { + Some(row) if row.is_public || member.is_some() => row, + // A missing share and a private one answer a stranger alike, so the link does not + // confirm that a private share exists. + _ if member.is_none() => { + return Err(Error::NotAuthorized(format!( + "Shared session {id} is not public: sign in as a member of {w_id} to open it" + ))) + } + _ => return Err(not_found()), + }; + + Ok(Json(SharedSession { + can_unshare: member.is_some_and(|a| row.email == a.email || a.is_admin), + snapshot: RawValue::from_string(row.snapshot).map_err(|e| { + Error::internal_err(format!("Stored session snapshot is not valid JSON: {e}")) + })?, + info: SharedSessionInfo { + id: row.id, + name: row.name, + is_public: row.is_public, + message_count: row.message_count, + created_by: row.created_by, + shared_at: row.shared_at, + }, + })) +} + +async fn unshare_session( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, id)): Path<(String, Uuid)>, +) -> Result { + require_plain_user_token(&authed)?; + let mut tx = db.begin().await?; + let name = sqlx::query_scalar!( + "DELETE FROM ai_shared_session + WHERE workspace_id = $1 AND id = $2 AND (email = $3 OR $4::bool) + RETURNING name", + &w_id, + id, + &authed.email, + authed.is_admin, + ) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| { + Error::NotFound(format!( + "Shared session {id} not found, or not shared by you" + )) + })?; + + let id_str = id.to_string(); + audit_log( + &mut *tx, + &authed, + "ai.shared_sessions.unshare", + ActionKind::Delete, + &w_id, + Some(&id_str), + Some([("name", name.as_str())].into()), + ) + .await?; + tx.commit().await?; + + Ok(format!("Stopped sharing {name}")) +} diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 4b67f865f9..bc0124b685 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -55,19 +55,21 @@ use sqlx::{types::Uuid, FromRow}; use std::str; use windmill_audit::audit_oss::{audit_log, AuditAuthorable}; use windmill_audit::ActionKind; +use windmill_common::roles::{caller_capabilities, AuthorLevel}; use windmill_common::{ apps::{ - app_value_has_inline_script, app_value_runnable_paths, AppScriptId, ListAppQuery, - APP_WORKSPACED_ROUTE, + app_value_has_inline_script, app_value_runnable_paths, AppRunnableKind, AppScriptId, + ListAppQuery, APP_WORKSPACED_ROUTE, }, auth::{APP_EMBED_TOKEN_LABEL_PREFIX, RAW_APP_SDK_TOKEN_LABEL_PREFIX, TOKEN_PREFIX_LEN}, - cache::{self, future::FutureCachedExt}, + cache, 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, }, + scripts::ScriptLang, triggers::TriggerMetadata, user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay}, users::username_to_permissioned_as, @@ -75,7 +77,7 @@ use windmill_common::{ check_proper_path, http_get_from_hub, not_found_if_none, paginate, paginate_optional, query_elems_from_hub, require_admin, strip_json_nul, Pagination, RunnableKind, StripPath, }, - variables::{build_crypt, build_crypt_with_key_suffix, encrypt}, + variables::{build_crypt, build_crypt_with_key_suffix, encrypt, omit_untrusted_interpolation}, worker::{to_raw_value, CLOUD_HOSTED}, workspaces::{ check_deploy_rules, check_operator_can_build, check_user_against_rule, @@ -759,7 +761,7 @@ async fn list_apps( // Append the authed user's `app`/`raw_app` drafts at paths with no deployed app; // see scripts.rs. if lq.include_draft_only.unwrap_or(false) - && (!authed.is_operator || operator_builder_rights(&db, &w_id).await?.apps) + && operator_builder_rights(&db, &w_id, &authed).await?.apps && offset == 0 && lq.path_start.is_none() && lq.path_exact.is_none() @@ -1502,8 +1504,9 @@ async fn get_public_app_by_secret( /// - `users:read` → `users/whoami` only. /// - `folders:read` → `folders/listnames` only. /// Plus two path-scoped scopes minted per app (see `mint_app_embed_token`): -/// - `apps:read:` → the app's own definition (`apps/get/p/`); no -/// `apps:write`, so management routes are unreachable. +/// - `apps:read:` → the app's own definition (`apps/get/p/` and +/// `apps/get/lite/`); no `apps:write`, so management +/// routes are unreachable. /// - `apps:run:` → run THIS app's components (`execute_component`, which /// re-checks the path); `apps_u/*` public-serving routes. pub const APP_EMBED_SCOPES: [&str; 5] = [ @@ -1753,6 +1756,17 @@ pub struct PreviewSdkTokenRequest { /// conveys nothing `users/tokens/create` wouldn't. Scopes come from the request /// so the preview matches an app whose scopes aren't deployed yet, as /// `execute_component` already does for editor previews. +/// Gate for an app write only full app authoring allows: low-code apps, whose inline scripts are +/// code, and anything built on a worker. +async fn require_full_apps(db: &DB, w_id: &str, authed: &ApiAuthed, action: &str) -> Result<()> { + if caller_capabilities(db, w_id, authed).await?.apps != AuthorLevel::Full { + return Err(Error::PermissionDenied(format!( + "Your role cannot {action} in this workspace" + ))); + } + Ok(()) +} + async fn mint_preview_sdk_token( authed: ApiAuthed, OptJobAuthed { job_id, .. }: OptJobAuthed, @@ -1760,14 +1774,7 @@ async fn mint_preview_sdk_token( Path(w_id): Path, Json(req): Json, ) -> Result { - check_operator_can_build( - &db, - &w_id, - authed.is_operator, - BuilderKind::Apps, - "preview raw apps", - ) - .await?; + check_operator_can_build(&db, &w_id, &authed, BuilderKind::Apps, "preview raw apps").await?; check_scopes(&authed, || format!("apps:write:{}", req.path))?; let (token, _expiration) = mint_raw_app_sdk_token(&db, &w_id, &req.path, &authed, &req.scopes, job_id).await?; @@ -2300,14 +2307,14 @@ pub(crate) enum BuilderAppWrite { } /// The half of the builder-app check that needs no DB (see `docs/operator-builder-rights.md`). -/// Returns every runnable the app can invoke, as `(is_flow, path)`, from both the policy's +/// Returns every runnable the app can invoke, as `(kind, path)`, from both the policy's /// triggerables and the value's path runnables: they are not the same list. fn check_operator_composed_app( write: BuilderAppWrite, raw_app: bool, value: Option<&RawValue>, policy: Option<&mut Policy>, -) -> Result> { +) -> Result> { let verb = if write == BuilderAppWrite::Deploy { "deploy" } else { @@ -2318,7 +2325,7 @@ fn check_operator_composed_app( "Operators with builder rights can only author full-code apps".to_string(), )); } - let mut referenced: Vec<(bool, String)> = Vec::new(); + let mut referenced: Vec<(AppRunnableKind, String)> = Vec::new(); if let Some(value) = value { // The text stored, NULs stripped: a stripped NUL can rename a key into one this reads. let value: serde_json::Value = @@ -2379,9 +2386,9 @@ fn check_operator_composed_app( Ok(referenced) } -/// The runnables a builder app's policy authorizes, as `(is_flow, path)`; refuses a policy that +/// The runnables a builder app's policy authorizes, as `(kind, path)`; refuses a policy that /// pins inline code. -fn checked_policy_runnable_paths(policy: &Policy) -> Result> { +fn checked_policy_runnable_paths(policy: &Policy) -> Result> { // A `rawscript/` triggerable is the deployed app's authorization to run caller-supplied // `raw_code` hashing to it: pinning one hands a builder arbitrary code execution through an // app whose value passed the inline-script check. A composition-only app has none. @@ -2418,21 +2425,24 @@ fn checked_policy_runnable_paths(policy: &Policy) -> Result> std::iter::once(key.as_str()) .chain(key.match_indices(':').map(|(i, _)| &key[i + 1..])) .filter_map(|candidate| { - candidate - .strip_prefix("script/") - .map(|p| (false, p.to_string())) - .or_else(|| { - candidate - .strip_prefix("flow/") - .map(|p| (true, p.to_string())) - }) + [ + ("script/", AppRunnableKind::Script), + ("flow/", AppRunnableKind::Flow), + ("agent/", AppRunnableKind::Agent), + ] + .into_iter() + .find_map(|(prefix, kind)| { + candidate + .strip_prefix(prefix) + .map(|p| (kind, p.to_string())) + }) }) .collect::>() }) .collect()) } -fn refuse_hub_runnables(referenced: &[(bool, String)]) -> Result<()> { +fn refuse_hub_runnables(referenced: &[(AppRunnableKind, String)]) -> Result<()> { for (_, path) in referenced { if path.starts_with("hub/") { return Err(Error::PermissionDenied(format!( @@ -2487,7 +2497,7 @@ async fn require_runnables_readable( db: &DB, user_db: &UserDB, w_id: &str, - referenced: Vec<(bool, String)>, + referenced: Vec<(AppRunnableKind, String)>, ) -> Result<()> { if referenced.is_empty() { return Ok(()); @@ -2497,26 +2507,33 @@ async fn require_runnables_readable( // whichever version the caller happens to see. Resolved before the transaction opens so no // request holds two pool connections at once. let mut hashes = Vec::with_capacity(referenced.len()); - for (is_flow, path) in &referenced { - hashes.push(if *is_flow { - None - } else { + for (kind, path) in &referenced { + hashes.push(if *kind == AppRunnableKind::Script { windmill_common::get_latest_script_hash(db, path, w_id).await? + } else { + None }); } let mut tx = user_db.clone().begin(authed).await?; - for ((is_flow, path), hash) in referenced.into_iter().zip(hashes) { - let readable = if is_flow { - sqlx::query_scalar!( + for ((kind, path), hash) in referenced.into_iter().zip(hashes) { + let readable = match kind { + AppRunnableKind::Flow => sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM flow WHERE workspace_id = $1 AND path = $2)", w_id, path, ) .fetch_one(&mut *tx) .await? - .unwrap_or(false) - } else { - match hash { + .unwrap_or(false), + AppRunnableKind::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(&mut *tx) + .await? + .unwrap_or(false), + AppRunnableKind::Script => match hash { Some(hash) => sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND hash = $2)", w_id, @@ -2526,17 +2543,21 @@ async fn require_runnables_readable( .await? .unwrap_or(false), None => false, - } + }, }; // One message for every cause: telling "no runnable version" apart from "not readable" // would reveal that a path the caller cannot read exists. if !readable { - return Err(Error::PermissionDenied(if is_flow { - format!("Flow {path} does not exist or is not readable by you") - } else { - format!( + return Err(Error::PermissionDenied(match kind { + AppRunnableKind::Flow => { + format!("Flow {path} does not exist or is not readable by you") + } + AppRunnableKind::Agent => { + format!("Agent {path} does not exist or is not readable by you") + } + AppRunnableKind::Script => format!( "Script {path} does not exist, is not readable by you, or has not finished deploying" - ) + ), })); } } @@ -2620,14 +2641,7 @@ async fn create_app_raw<'a>( Path(w_id): Path, multipart: Multipart, ) -> Result<(StatusCode, String)> { - check_operator_can_build( - &db, - &w_id, - authed.is_operator, - BuilderKind::Apps, - "create apps", - ) - .await?; + check_operator_can_build(&db, &w_id, &authed, BuilderKind::Apps, "create apps").await?; if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, @@ -2691,11 +2705,7 @@ async fn create_app( Path(w_id): Path, Json(app): Json, ) -> Result<(StatusCode, String)> { - if authed.is_operator { - return Err(Error::NotAuthorized( - "Operators cannot create apps for security reasons".to_string(), - )); - } + require_full_apps(&db, &w_id, &authed, "create low-code apps").await?; let path = app.path.clone(); if let RuleCheckResult::Blocked(msg) = check_deploy_rules( @@ -2804,7 +2814,7 @@ async fn create_app_internal<'a>( if raw_app { validate_raw_app_path_keys(&app.value.0)?; } - if authed.is_operator { + if caller_capabilities(&db, w_id, &authed).await?.apps == AuthorLevel::Compose { validate_operator_composed_app( &authed, &db, @@ -3123,21 +3133,16 @@ async fn delete_app( Extension(webhook): Extension, Path((w_id, path)): Path<(String, StripPath)>, ) -> Result { - check_operator_can_build( - &db, - &w_id, - authed.is_operator, - BuilderKind::Apps, - "delete apps", - ) - .await?; + let apps_level = + check_operator_can_build(&db, &w_id, &authed, BuilderKind::Apps, "delete apps").await?; let path = path.to_path(); check_scopes(&authed, || format!("apps:write:{}", path))?; // One endpoint deletes both kinds, and builder rights only cover full-code apps. - if authed.is_operator && deployed_app_kind(&user_db, &authed, &w_id, path).await? != Some(true) + if apps_level == AuthorLevel::Compose + && deployed_app_kind(&user_db, &authed, &w_id, path).await? != Some(true) { return Err(Error::PermissionDenied( - "Operators with builder rights can only delete full-code apps".to_string(), + "Your role can only delete full-code apps".to_string(), )); } @@ -3330,14 +3335,14 @@ async fn update_app( let path = path.to_path(); // The deploy panel saves a deployed full-code app's access and frontend scopes here. A // builder may change those settings; the app itself only through `update_app_raw`. - if authed.is_operator { - check_operator_can_build(&db, &w_id, true, BuilderKind::Apps, "update apps").await?; + let apps_level = + check_operator_can_build(&db, &w_id, &authed, BuilderKind::Apps, "update apps").await?; + if apps_level == AuthorLevel::Compose { if ns.value.is_some() || deployed_app_kind(&user_db, &authed, &w_id, path).await? != Some(true) { return Err(Error::PermissionDenied( - "Operators with builder rights can only change the settings of full-code apps" - .to_string(), + "Your role can only change the settings of full-code apps".to_string(), )); } } @@ -3358,7 +3363,7 @@ async fn update_app( let opath = path.to_string(); let db2 = db.clone(); // Settings-only for an operator, of an app checked above to be full-code. - let raw_app = authed.is_operator; + let raw_app = apps_level == AuthorLevel::Compose; let (new_tx, npath, v_id) = update_app_internal(authed, db, user_db, &w_id, path, raw_app, ns, None).await?; new_tx.commit().await?; @@ -3393,11 +3398,7 @@ async fn update_app_raw_source( // next to it: this path compiles caller-supplied sources with a bundler job on a worker, which // is arbitrary code execution. Builders lose nothing: the browser and the CLI both bundle // locally and deploy through the multipart endpoints. - if authed.is_operator { - return Err(Error::NotAuthorized( - "Operators cannot update apps for security reasons".to_string(), - )); - } + require_full_apps(&db, &w_id, &authed, "build apps on a worker").await?; let path = path.to_path(); check_scopes(&authed, || format!("apps:write:{}", path))?; @@ -3554,11 +3555,7 @@ async fn create_app_raw_source( Json(mut app): Json, ) -> Result<(StatusCode, String)> { // Closed to builders too, for the reason on `update_app_raw_source`. - if authed.is_operator { - return Err(Error::NotAuthorized( - "Operators cannot create apps for security reasons".to_string(), - )); - } + require_full_apps(&db, &w_id, &authed, "build apps on a worker").await?; let path = app.path.clone(); check_scopes(&authed, || format!("apps:write:{}", path))?; @@ -3719,14 +3716,8 @@ async fn update_app_raw<'a>( Path((w_id, path)): Path<(String, StripPath)>, multipart: Multipart, ) -> JsonResult { - check_operator_can_build( - &db, - &w_id, - authed.is_operator, - BuilderKind::Apps, - "update apps", - ) - .await?; + let apps_level = + check_operator_can_build(&db, &w_id, &authed, BuilderKind::Apps, "update apps").await?; if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, @@ -3744,10 +3735,11 @@ async fn update_app_raw<'a>( check_scopes(&authed, || format!("apps:write:{}", path))?; // `update_app_internal` only compares kinds when a new value is deployed, so a // policy-only update would otherwise let a builder rewrite a low-code app's policy. - if authed.is_operator && deployed_app_kind(&user_db, &authed, &w_id, path).await? != Some(true) + if apps_level == AuthorLevel::Compose + && deployed_app_kind(&user_db, &authed, &w_id, path).await? != Some(true) { return Err(Error::PermissionDenied( - "Operators with builder rights can only update full-code apps".to_string(), + "Your role can only update full-code apps".to_string(), )); } let opath = path.to_string(); @@ -3817,7 +3809,7 @@ async fn update_app_internal<'a>( } } - if authed.is_operator { + if caller_capabilities(&db, w_id, &authed).await?.apps == AuthorLevel::Compose { // 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; @@ -4289,6 +4281,12 @@ pub struct ExecuteApp { /// inline-script (raw_code, preview) execution so `wmill app dev` resolves /// those imports from not-yet-deployed local content instead of deployed. pub temp_script_refs: Option>, + /// Preview mode only: run as this identity instead of the caller, as the deployed app + /// would in publisher mode. See `jobs::preview_on_behalf_of`. + pub on_behalf_of: Option, + /// Preview mode only: the inline runnable's `viewer_data_access` policy setting, which run + /// mode reads from the deployed policy instead. + pub viewer_data_access: Option, } fn digest(code: &str) -> String { @@ -4545,23 +4543,24 @@ async fn execute_component( let previews_hub = payload.path.as_deref().is_some_and(|p| { p.strip_prefix("script/") .or_else(|| p.strip_prefix("flow/")) + .or_else(|| p.strip_prefix("agent/")) .unwrap_or(p) .starts_with("hub/") }); - if authed.is_operator { - let msg = "Operators cannot run preview jobs for security reasons".to_string(); - if !operator_builder_rights(&db, &w_id).await?.apps { - return Err(Error::NotAuthorized(msg)); - } - if payload.raw_code.is_some() || previews_hub { - return Err(Error::PermissionDenied(msg)); - } + let caps = caller_capabilities(&db, &w_id, authed).await?; + if !caps.code + && (caps.apps == AuthorLevel::None || payload.raw_code.is_some() || previews_hub) + { + return Err(Error::PermissionDenied( + "Your role cannot run preview jobs in this workspace".to_string(), + )); } check_scopes(authed, || format!("jobs:run"))?; if let Some(p) = payload.path.as_deref() { let runnable_path = p .strip_prefix("script/") .or_else(|| p.strip_prefix("flow/")) + .or_else(|| p.strip_prefix("agent/")) .unwrap_or(p); require_path_read_access_for_preview(authed, &Some(runnable_path.to_string()))?; } @@ -4602,6 +4601,7 @@ async fn execute_component( force_viewer_allow_user_resources, force_viewer_sensitive_inputs, force_viewer_delete_after_secs, + viewer_data_access, .. } => ( &Policy { stated_execution_mode: Some(ExecutionMode::Viewer), ..Default::default() }, @@ -4612,8 +4612,9 @@ async fn execute_component( delete_after_secs: force_viewer_delete_after_secs, sensitive_inputs: force_viewer_sensitive_inputs.unwrap_or_default(), tag: None, - // A preview runs as the caller, whose roles these are either way. - viewer_data_access: false, + // Never for a job token: see `run_preview_script`. + viewer_data_access: opt_authed.as_ref().is_some_and(|a| a.job_id.is_none()) + && viewer_data_access.unwrap_or(false), }, None, ), @@ -4666,34 +4667,104 @@ async fn execute_component( // `app_script` id. Its resolved `rawscript/` key must be present // in the policy triggerables below — it must never resolve via the // Viewer default fallback. Without `id` the caller supplies the code - // verbatim; with `id` it selects any `app_script` row by number (no - // app/workspace scoping), so both let a caller run code the publisher - // never pinned for this app. + // verbatim, which lets a caller run code the publisher never pinned + // for this app; with `id` the row must also belong to this app. let is_inline_raw_code = payload.raw_code.is_some(); + // The policy key covers only an inline script's own code, not the lock and modules + // its `app_script` row runs with, so it cannot tell the deployed row from an older + // one (or from id-less code, which runs with neither). Once the deployed version has + // its lite value, a deployed row's code runs only through that row, and id-less code + // only as the database studio's runtime-built queries, which have no row. Those are + // limited to the languages it emits: the pin covers content, not language, and the + // policy also pins frontend scripts, which must never run as e.g. bash. + // Checked on every run: a deploy moves it. + if let Some(raw_code) = payload.raw_code.as_ref() { + let id_less_sql = payload.id.is_none() + && matches!( + raw_code.language, + ScriptLang::Postgresql + | ScriptLang::Mysql + | ScriptLang::Mssql + | ScriptLang::Snowflake + | ScriptLang::Bigquery + | ScriptLang::DuckDb + ); + let id_less_sha256 = id_less_sql + .then(|| format!("{:x}", Sha256::digest(raw_code.content.as_bytes()))); + let deployed = sqlx::query!( + "SELECT l.id IS NOT NULL as \"has_lite!\", + CASE WHEN $3::bigint IS NULL THEN NOT EXISTS ( + SELECT 1 FROM app_script s + WHERE s.app = a.id AND s.code_sha256 = $4::text + AND jsonb_path_exists(l.value, '$.**.inlineScript.id ? (@ == $id)', + jsonb_build_object('id', s.id))) + ELSE coalesce(jsonb_path_exists(l.value, '$.**.inlineScript.id ? (@ == $id)', + jsonb_build_object('id', $3::bigint)), false) + END as \"may_run!\" + FROM app a + LEFT JOIN app_version_lite l ON l.id = a.versions[array_upper(a.versions, 1)] + WHERE a.path = $1 AND a.workspace_id = $2", + path, + &w_id, + payload.id, + id_less_sha256, + ) + .fetch_optional(&db) + .await?; + let may_run = payload.id.is_some() || id_less_sql; + if deployed.is_some_and(|d| d.has_lite && !(may_run && d.may_run)) { + return Err(Error::NotAuthorized(format!( + "This runnable is not part of the deployed version of app {path}, reload the app" + ))); + } + } + // Compute the path for the triggerables map: // - flow: `flow/` // - script: `script/` // - inline script: `rawscript/` + let app_script_key: Arc; let path = match &payload { // flow or script: just use the `payload.path`. ExecuteApp { path: Some(path), .. } => path, // inline script: without entry in the `app_script` table. ExecuteApp { raw_code: Some(raw_code), id: None, .. } => &digest(&raw_code.content), // inline script: with an entry in the `app_script` table. + // Scoped to this app: the policy key covers only the entrypoint's code, while the + // row also carries the modules (and lock) the job runs, so another app's row with + // the same entrypoint would run its own modules under this app's identity. ExecuteApp { raw_code: Some(_), id: Some(id), .. } => { - let cache = cache::anon!({ u64 => Arc } in "appscriptpath" <= 10000); - // `id` is unique, cache the result. - &*sqlx::query_scalar!( - "SELECT format('rawscript/%s', code_sha256) as \"path!: String\" - FROM app_script WHERE id = $1 LIMIT 1", - id - ) - .fetch_one(&db) - .map_err(Into::::into) - .map_ok(Arc::new) - .cached(cache, *id as u64) - .await? + lazy_static! { + static ref APP_SCRIPT_KEY_CACHE: cache::Cache<(String, String, i64), Arc> = + cache::Cache::new(10000); + } + let cache_key = (w_id.to_string(), path.to_string(), *id); + app_script_key = match APP_SCRIPT_KEY_CACHE.get(&cache_key) { + Some(key) => key, + None => { + let key = sqlx::query_scalar!( + "SELECT format('rawscript/%s', s.code_sha256) as \"path!: String\" + FROM app_script s JOIN app a ON a.id = s.app + WHERE s.id = $1 AND a.path = $2 AND a.workspace_id = $3", + id, + path, + &w_id, + ) + .fetch_optional(&db) + .await? + .map(Arc::new) + .ok_or_else(|| { + Error::NotAuthorized(format!( + "App script {id} does not belong to app {path}" + )) + })?; + // A row never changes app, so a hit stays valid. + APP_SCRIPT_KEY_CACHE.insert(cache_key, key.clone()); + key + } + }; + &**app_script_key } _ => unreachable!(), }; @@ -4802,8 +4873,19 @@ async fn execute_component( } } - let (username, permissioned_as, email) = + let (username, mut permissioned_as, mut email) = get_on_behalf_details_from_policy_and_authed(&policy, &opt_authed, &w_id, &db).await?; + let preview_obo = match opt_authed.as_ref().filter(|_| is_preview) { + Some(authed) => { + crate::jobs::preview_on_behalf_of(authed, payload.on_behalf_of.as_deref(), &w_id, &db) + .await? + } + None => None, + }; + if let Some(obo) = preview_obo.as_ref() { + permissioned_as = obo.permissioned_as.clone(); + email = obo.email.clone(); + } let resolved_delete_secs = resolve_delete_after_secs(None, policy_triggerables.delete_after_secs); @@ -4814,6 +4896,36 @@ async fn execute_component( // the app identity. Drop it; the legitimate value rides in `extra`. payload.args.remove("_TEMP_SCRIPT_REFS"); + // 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 + // run is a single one, as on the direct agent endpoint. + let agent_path = payload + .path + .as_deref() + .and_then(|p| p.strip_prefix("agent/")) + .map(str::to_string); + let agent_memory_id = if agent_path.is_some() { + let memory_id = payload + .args + .remove("memory_id") + .map(|v| { + serde_json::from_str::>(v.get()) + .map_err(|_| Error::BadRequest("memory_id must be a string".to_string())) + }) + .transpose()? + .flatten() + .filter(|m| !m.trim().is_empty()); + payload.args.retain(|k, _| { + matches!( + k.as_str(), + "user_message" | "user_attachments" | "previous_messages" + ) + }); + memory_id + } else { + None + }; + let (mut args, job_id) = build_args( policy, policy_triggerables, @@ -4852,9 +4964,64 @@ async fn execute_component( } .filter(|t| !t.is_empty()) }; + if agent_path.is_some() { + #[cfg(feature = "enterprise")] + crate::jobs::check_license_key_valid().await?; + let has_attachments = !crate::agent_runs::run_attachments(&args.args)?.is_empty(); + match opt_authed.as_ref() { + // The agent reads the files with the app's token: a viewer attaches only what they + // could download themselves. + Some(_viewer) if has_attachments => { + #[cfg(all(feature = "parquet", feature = "private"))] + crate::agent_runs::check_attachments_readable(&db, _viewer, &w_id, &args.args) + .await?; + } + None if has_attachments => { + return Err(Error::BadRequest( + "an anonymous viewer cannot send files to an agent".to_string(), + )); + } + _ => {} + } + } + // Filed under the viewer, who must therefore be kept past the push below. + let agent_viewer = agent_path.as_ref().and(opt_authed.clone()); + let mut agent_run_path = None; + let component = payload.component.clone(); let (job_payload, tag, _runnable_on_behalf_of) = match (payload.path, payload.raw_code, payload.id) { + (Some(_), None, None) if agent_path.is_some() => { + let agent = agent_path.as_deref().unwrap_or_default(); + // Read as the identity the app runs as: reading the agent is what allows running + // it. The app's identity is also the run's, never the agent's own on-behalf-of. + let run_as = windmill_api_auth::fetch_api_authed_from_permissioned_as( + permissioned_as.clone(), + email.clone(), + &w_id, + &db, + None, + ) + .await?; + let turn = crate::agent_runs::agent_turn( + &run_as, + &user_db, + &w_id, + agent, + agent_memory_id.is_some(), + ) + .await?; + agent_run_path = Some(turn.run_path.clone()); + ( + JobPayload::RawFlow { + value: turn.flow_value, + path: Some(turn.run_path), + restarted_from: None, + }, + None, + None, + ) + } // flow or script: (Some(path), None, None) => { get_payload_tag_from_prefixed_path(&path, &db, &w_id).await? @@ -4914,16 +5081,16 @@ async fn execute_component( .await?; } } - // Identity is already resolved to the requesting user in preview mode (the - // policy is forced to `ExecutionMode::Viewer`, so the job runs as the - // caller). The enqueue stays root-isolated as before — switching the insert - // to user-RLS is not what contains the bypass (the auth guards above are) - // and would add unnecessary breakage risk to the legitimate editor flow. + // In preview mode the identity is the caller (the policy is forced to + // `ExecutionMode::Viewer`), or the gated `on_behalf_of` resolved above. The + // enqueue stays root-isolated — switching the insert to user-RLS is not what + // contains the bypass (the auth guards above are) and would add unnecessary + // breakage risk to the legitimate editor flow. let tx = PushIsolationLevel::IsolatedRoot(db.clone()); // An app component runs on-behalf of the APP identity (resolved above), never // the referenced runnable's own `on_behalf_of` — else a Viewer-mode app could - // execute as that identity and a preview would run as it, not the caller. + // execute as that identity and a preview would run as it, not as the editor chose. // (Direct `/jobs/run` still honors a runnable's `on_behalf_of`.) let (email, permissioned_as) = (email.as_str(), permissioned_as); @@ -4932,8 +5099,8 @@ async fn execute_component( // Stamp app-origination (trigger_kind='app' + trigger=), the signal // the deployed-app S3 provenance gate trusts (unforgeable via `/jobs/run`). - // Deployed runs only: a preview runs as the caller and is read back as the caller - // (viewer-scoped), so it must never be app-provenanced (else it could forge one). + // Deployed runs only: a preview runs request-supplied code and is read back as the + // caller (viewer-scoped), so it must never be app-provenanced (else it could forge one). let app_trigger = (!is_preview).then(|| TriggerMetadata::new(Some(path.to_string()), JobTriggerKind::App)); @@ -4941,6 +5108,7 @@ async fn execute_component( Some(authed) => windmill_api_auth::caller_scope_ceiling(&db, authed).await?, None => None, }; + let preview_obo_author = preview_obo.as_ref().and(opt_authed.clone()); let (uuid, mut tx) = push( &db, tx, @@ -4980,6 +5148,11 @@ async fn execute_component( ) .await?; + if let (Some(obo), Some(authed)) = (preview_obo.as_ref(), preview_obo_author.as_ref()) { + crate::jobs::audit_preview_on_behalf_of(&mut tx, authed, &w_id, uuid, Some(path), obo) + .await?; + } + if policy_triggerables.viewer_data_access && is_inline { sqlx::query!( "UPDATE job_perms SET viewer_data_access = true WHERE job_id = $1", @@ -4989,6 +5162,44 @@ async fn execute_component( .await?; } + if let (Some(run_path), Some(memory_id)) = (agent_run_path.as_deref(), agent_memory_id) { + // A signed-in viewer's turn is filed under them: nobody else may continue their + // conversation. An anonymous visitor's id is derived per app so that it can name no + // signed-in user's conversation (a bare UUID is used as is). Either way the + // conversation's row lock refuses a turn while one runs, and retention collects its + // memory with its messages. + let (owner, is_admin, memory_id) = match agent_viewer.as_ref() { + Some(viewer) => (viewer.username.clone(), viewer.is_admin, memory_id), + None => ( + windmill_common::flow_conversations::ANONYMOUS_CONVERSATION_OWNER.to_string(), + false, + windmill_common::flow_conversations::memory_key( + &w_id, + run_path, + &format!("anonymous:{path}:{memory_id}"), + ) + .to_string(), + ), + }; + let run_query = RunJobQuery { memory_id: Some(memory_id), ..Default::default() }; + if let Some(key) = run_query.memory_key(&w_id, run_path) { + crate::jobs::set_flow_memory_id(&mut tx, uuid, key).await?; + } + crate::jobs::file_chat_turn( + &mut tx, + &owner, + is_admin, + &w_id, + run_path, + &run_query, + args.args.get("user_message"), + uuid, + is_preview, + &args.args, + ) + .await?; + } + // Apply runnable query parameters if provided if let Some(ref run_query) = payload.run_query_params { if let Some(flow_path) = flow_path.as_deref() { @@ -5169,11 +5380,7 @@ async fn upload_s3_file_from_app( let authed = opt_authed.as_ref().ok_or_else(|| { Error::NotAuthorized("App S3 preview upload requires authentication".to_string()) })?; - if authed.is_operator { - return Err(Error::NotAuthorized( - "Operators cannot run app S3 previews for security reasons".to_string(), - )); - } + require_full_apps(&db, &w_id, authed, "run app S3 previews").await?; check_scopes(authed, || format!("apps:write:{}", path.to_path()))?; Some(Policy { stated_execution_mode: Some(ExecutionMode::Viewer), @@ -5220,10 +5427,14 @@ async fn upload_s3_file_from_app( let user_db = UserDB::new(db.clone()); - let (s3_resource_opt, file_key, on_behalf_of_email, permissioned_as, username) = if policy - .as_ref() - .is_some_and(|p| p.s3_inputs.is_some()) - { + let ( + s3_resource_opt, + file_key, + on_behalf_of_email, + permissioned_as, + username, + is_workspace_storage, + ) = if policy.as_ref().is_some_and(|p| p.s3_inputs.is_some()) { let policy = policy.unwrap(); let s3_inputs = policy.s3_inputs.as_ref().unwrap(); @@ -5292,6 +5503,7 @@ async fn upload_s3_file_from_app( email, permissioned_as, username, + false, ) } else { return Err(Error::BadRequest( @@ -5320,6 +5532,7 @@ async fn upload_s3_file_from_app( email, permissioned_as, username, + false, ) } } else { @@ -5337,7 +5550,14 @@ async fn upload_s3_file_from_app( None, ) .await?; - (s3_resource_opt, file_key, email, permissioned_as, username) + ( + s3_resource_opt, + file_key, + email, + permissioned_as, + username, + true, + ) } } else { return Err(Error::BadRequest( @@ -5371,7 +5591,14 @@ async fn upload_s3_file_from_app( ) .await?; - (s3_resource_opt, file_key, email, permissioned_as, username) + ( + s3_resource_opt, + file_key, + email, + permissioned_as, + username, + true, + ) } } else { // backward compatibility (no policy) @@ -5403,6 +5630,7 @@ async fn upload_s3_file_from_app( on_behalf_of_email, permissioned_as, username, + false, ) } else { let db_with_opt_authed = DbWithOptAuthed::from_authed(&authed, db.clone(), None); @@ -5422,6 +5650,7 @@ async fn upload_s3_file_from_app( on_behalf_of_email, permissioned_as, username, + true, ) } } else { @@ -5432,7 +5661,19 @@ async fn upload_s3_file_from_app( let s3_resource = s3_resource_opt.ok_or(Error::internal_err( "No files storage resource defined at the workspace level".to_string(), ))?; - let s3_client = windmill_object_store::build_object_store_client(&s3_resource).await?; + // The quota sizes an overwrite by the fork's own copy: a fork's write never replaces the + // ancestor's object its reads fall through to. + type Client = std::sync::Arc; + let (s3_client, _quota_client): (Client, Client) = if is_workspace_storage { + let store = + windmill_object_store::fork_store::workspace_fork_store(&db, &w_id, None, &s3_resource) + .await?; + let own_only = store.own_only(); + (store, own_only) + } else { + let client = windmill_object_store::build_object_store_client(&s3_resource).await?; + (client.clone(), client) + }; let options = Attributes::from_iter(vec![ ( @@ -5465,7 +5706,8 @@ async fn upload_s3_file_from_app( .get(http::header::CONTENT_LENGTH) .and_then(|h| h.to_str().ok()) .and_then(|s| s.parse::().ok()); - let budget = ce_upload_budget(&db, &w_id, &s3_client, &file_key, content_length).await?; + let budget = + ce_upload_budget(&db, &w_id, &_quota_client, &file_key, content_length).await?; (Some(budget.max_size), budget.existing_size) } else { (None, 0) @@ -5547,6 +5789,7 @@ async fn delete_s3_file_from_app( ) .await?; + let is_workspace_storage = s3_resource_path.is_none(); let s3_resource = if let Some(s3_resource_path) = s3_resource_path { let db_with_opt_authed = DbWithOptAuthed::from_authed(&on_behalf_authed, db.clone(), Some(user_db.clone())); @@ -5575,7 +5818,12 @@ async fn delete_s3_file_from_app( ))? }; - let s3_client = windmill_object_store::build_object_store_client(&s3_resource).await?; + let s3_client = if is_workspace_storage { + windmill_object_store::fork_store::workspace_object_store(&db, &w_id, None, &s3_resource) + .await? + } else { + windmill_object_store::build_object_store_client(&s3_resource).await? + }; s3_client.delete(&path).await.map_err(|err| { tracing::error!("Error deleting file: {:?}", err); @@ -5827,11 +6075,7 @@ async fn download_s3_file_from_app( let authed = opt_authed.as_ref().ok_or_else(|| { Error::NotAuthorized("App S3 preview download requires authentication".to_string()) })?; - if authed.is_operator { - return Err(Error::NotAuthorized( - "Operators cannot run app S3 previews for security reasons".to_string(), - )); - } + require_full_apps(&db, &w_id, authed, "run app S3 previews").await?; check_scopes(authed, || format!("apps:write:{}", path))?; Some(serde_json::from_str::>(&force_viewer_allowed_s3_keys).unwrap_or_default()) } else { @@ -6415,6 +6659,7 @@ async fn build_args( } } + let mut untrusted = HashMap::new(); for (k, v) in args { if safe_args.contains_key(&k) { continue; @@ -6428,31 +6673,16 @@ async fn build_args( k.to_string(), ctx_value(prop, policy, authed, w_id, db).await?, ); - } else if !arg_str.contains("\"$var:") && !arg_str.contains("\"$res:") { - safe_args.insert(k.to_string(), v); } else { - safe_args.insert( - k.to_string(), - RawValue::from_string( - arg_str - .replace( - "$var:", - "The following variable has been omitted for security reasons: ", - ) - .replace( - "$res:", - "The following resource has been omitted for security reasons, to allow it, toggle: 'Allow resources from users' on that field input: ", - ), - ) - .map_err(|e| { - Error::internal_err(format!( - "failed to remove sensitive variable(s)/resource(s) with error: {}", - e - )) - })?, - ); + untrusted.insert(k, v); } } + safe_args.extend( + omit_untrusted_interpolation( + untrusted, + "The following resource has been omitted for security reasons, to allow it, toggle: 'Allow resources from users' on that field input: ", + ), + ); for k in sensitive_inputs { let Some(v) = safe_args.get(k) else { continue }; let raw = v.get(); @@ -6706,8 +6936,8 @@ mod embed_token_tests { "POST", ), ("/api/w/test/jobs/restart/f/some-uuid", "POST"), - // `users:read` is presented to the viewer as "read your identity", so - // the workspace member directory must stay out of reach. + // `users:read` reaches whoami only: the workspace member directory must + // stay out of reach. ("/api/w/test/users/list", "GET"), ("/api/w/test/users/list_usage", "GET"), ("/api/w/test/users/username_to_email/admin", "GET"), @@ -6718,6 +6948,15 @@ mod embed_token_tests { "SDK token should deny {method} {path}" ); } + + // Whoami needs no declared scope. + let jobs_only = [ + "jobs:run".to_string(), + windmill_api_auth::scopes::RAW_APP_SDK_SENTINEL.to_string(), + ]; + assert!( + check_scopes_for_route(Some(&jobs_only[..]), "/api/w/test/users/whoami", "GET").is_ok() + ); } /// The token carries path-scoped `apps:run:` and `apps:read:` @@ -6823,7 +7062,7 @@ mod embed_token_tests { #[cfg(test)] mod operator_app_tests { - use super::{check_operator_composed_app, BuilderAppWrite, Policy}; + use super::{check_operator_composed_app, AppRunnableKind, BuilderAppWrite, Policy}; use windmill_common::error::Result; use windmill_common::worker::to_raw_value; @@ -6841,7 +7080,10 @@ mod operator_app_tests { }}) } - fn composed_app(value: serde_json::Value, policy: &mut Policy) -> Result> { + fn composed_app( + value: serde_json::Value, + policy: &mut Policy, + ) -> Result> { let value = to_raw_value(&value); check_operator_composed_app(BuilderAppWrite::Deploy, true, Some(&value), Some(policy)) } @@ -6859,7 +7101,10 @@ mod operator_app_tests { let referenced = composed_app(clean.clone(), &mut policy).unwrap(); assert_eq!(policy.sandbox, Some(true)); // The runnables the policy authorizes are handed back for the caller to authorize. - assert_eq!(referenced, vec![(false, "f/x/s".to_string())]); + assert_eq!( + referenced, + vec![(AppRunnableKind::Script, "f/x/s".to_string())] + ); // A hub reference is unreviewed code, refused like it is in a flow. let mut policy = builder_policy( @@ -6896,12 +7141,20 @@ mod operator_app_tests { let mut policy = builder_policy(serde_json::json!({})); assert_eq!( composed_app(path_runnable("f/x/s", "script"), &mut policy).unwrap(), - vec![(false, "f/x/s".to_string())] + vec![(AppRunnableKind::Script, "f/x/s".to_string())] ); let mut policy = builder_policy(serde_json::json!({})); assert_eq!( composed_app(path_runnable("f/x/f", "flow"), &mut policy).unwrap(), - vec![(true, "f/x/f".to_string())] + vec![(AppRunnableKind::Flow, "f/x/f".to_string())] + ); + // An agent is reported from both surfaces, for the caller to check it can read it. + let mut policy = builder_policy( + serde_json::json!({"a:agent/f/x/bot": {"static_inputs": {}, "one_of_inputs": {}}}), + ); + assert_eq!( + composed_app(path_runnable("f/x/bot", "agent"), &mut policy).unwrap(), + vec![(AppRunnableKind::Agent, "f/x/bot".to_string())] ); let mut policy = builder_policy(serde_json::json!({})); assert!(composed_app(path_runnable("hub/1/x", "hubscript"), &mut policy).is_err()); @@ -6918,7 +7171,7 @@ mod operator_app_tests { ); assert_eq!( composed_app(clean.clone(), &mut policy).unwrap(), - vec![(false, "f/x/s".to_string())] + vec![(AppRunnableKind::Script, "f/x/s".to_string())] ); // `Viewer` mode makes `execute_component` accept any script/flow path, triggerables or diff --git a/backend/windmill-api/src/apps_raw_bundler.ts b/backend/windmill-api/src/apps_raw_bundler.ts index 965a9289b5..47460be3a6 100644 --- a/backend/windmill-api/src/apps_raw_bundler.ts +++ b/backend/windmill-api/src/apps_raw_bundler.ts @@ -135,7 +135,7 @@ export async function main( const nonEmpty = (v: unknown) => typeof v === 'string' && v.length > 0 // The prefixes `execute_component` resolves a run against; anything else is a // grant no run can match. - const RUN_TYPES = ['script', 'flow', 'hubscript'] + const RUN_TYPES = ['script', 'flow', 'hubscript', 'agent'] const malformed = Object.entries(runnables ?? {}) .filter(([, r]) => r != null) .filter(([, r]) => { diff --git a/backend/windmill-api/src/args.rs b/backend/windmill-api/src/args.rs index 7e676989b2..6c1c9824bd 100644 --- a/backend/windmill-api/src/args.rs +++ b/backend/windmill-api/src/args.rs @@ -139,13 +139,13 @@ impl RawWebhookArgs { get_random_file_name, get_workspace_s3_resource, upload_file_internal, }; use futures::TryStreamExt; - use windmill_object_store::build_object_store_client; + use windmill_object_store::fork_store::workspace_object_store; use windmill_object_store::object_store_reexports::{Attribute, Attributes}; let (_, s3_resource) = get_workspace_s3_resource(authed, db, None, w_id, None).await?; if let Some(s3_resource) = s3_resource { - let s3_client = build_object_store_client(&s3_resource).await?; + let s3_client = workspace_object_store(db, w_id, None, &s3_resource).await?; let mut parts: Vec<(String, MultipartValue)> = Vec::new(); diff --git a/backend/windmill-api/src/db.rs b/backend/windmill-api/src/db.rs index db99a07c92..f0ab31b2c9 100644 --- a/backend/windmill-api/src/db.rs +++ b/backend/windmill-api/src/db.rs @@ -113,6 +113,12 @@ lazy_static::lazy_static! { (20260909163047, include_str!( "../../migrations/20260909163047_workspace_delete_cascade_indexes.up.sql" ).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY").replace("DROP INDEX", "DROP INDEX CONCURRENTLY")), + (20261009173546, include_str!( + "../../migrations/20261009173546_agent_owner_prefix_index.up.sql" + ).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY")), + (20261015114712, include_str!( + "../../migrations/20261015114712_queue_workspace_scheduled_for_index.up.sql" + ).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY").replace("DROP INDEX", "DROP INDEX CONCURRENTLY")), // The widening fails every query the previous release's still-running // servers prepared on the column; `variables::widen_variable_value` does it // once they are gone. diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index 636a801830..8d3d27eb4a 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -16,6 +16,7 @@ use axum::{ use serde::{Deserialize, Serialize}; use windmill_api_flows::flows::validate_operator_flow; use windmill_audit::{audit_oss::audit_log, ActionKind}; +use windmill_common::roles::AuthorLevel; use windmill_common::{ db::UserDB, error::{Error, Result}, @@ -24,7 +25,7 @@ use windmill_common::{ users::resolve_username_to_email, utils::{check_proper_path, require_admin, strip_json_nul}, variables::{build_crypt, encrypt}, - workspaces::{operator_builder_rights, BuilderKind}, + workspaces::operator_can_draft, }; pub fn workspaced_service() -> Router { @@ -110,12 +111,6 @@ async fn list_drafts( Path(w_id): Path, Query(query): Query, ) -> Result>> { - // Without builder rights an operator has no drafts of their own (they can't write any, see - // `require_can_write_path`), so this list is always empty for them. They can still READ some - // collaborators' drafts via `/drafts/get`. - if authed.is_operator && !operator_builder_rights(&db, &w_id).await?.any() { - return Ok(Json(vec![])); - } let all_users = query.all_users.unwrap_or(false); // Only honor `compare_to_workspace` when it is genuinely this workspace's // parent (a fork comparing against its source). Any other value is dropped @@ -476,7 +471,10 @@ async fn update_draft( } } - if authed.is_operator && kind == UserDraftItemKind::Flow { + // A role that composes but does not author in full is held to the composition-only rules on + // its drafts as on its deploys. + let caps = windmill_common::roles::caller_capabilities(&db, &w_id, &authed).await?; + if caps.flows == AuthorLevel::Compose && kind == UserDraftItemKind::Flow { if let Some(value) = &req.value { #[derive(Deserialize)] struct FlowDraft { @@ -503,7 +501,7 @@ async fn update_draft( } // A raw-app draft carries the app value and its policy side by side. - if authed.is_operator && kind == UserDraftItemKind::RawApp { + if caps.apps == AuthorLevel::Compose && kind == UserDraftItemKind::RawApp { if let Some(value) = &req.value { let draft: serde_json::Value = serde_json::from_str(&strip_json_nul(value.0.get())) .map_err(|e| Error::BadRequest(format!("Invalid app draft: {e}")))?; @@ -1437,8 +1435,7 @@ fn table_for_kind(kind: UserDraftItemKind) -> Option<&'static str> { } /// Resolves to `Ok(())` if `authed` may SAVE a draft at `path`. Operators are -/// rejected, except for flow drafts in a workspace that granted them builder -/// rights. Two layers: +/// rejected, except for a kind they could deploy (`operator_can_draft`). Two layers: /// 1. Claim-based namespace rules (admin, own `u/`, member `g/`, writable /// `f/`) — mirror what RLS reads from the same JWT claims, and are the /// ENTIRE check for draft-only paths (no deployed row for RLS to use). @@ -1455,26 +1452,17 @@ pub(crate) async fn require_can_write_path( if authed.is_admin { return Ok(()); } - // Operators are read-only and never WRITE drafts, except of a kind the workspace granted them - // the matching builder right for: a flows-only workspace must not get raw-app drafts through - // here. Read access is deliberately asymmetric: - // `require_can_read_path` has no operator block, so an operator can still READ a draft they - // can read via `/drafts/get`, mirroring their read access to deployed content. Intended. - if authed.is_operator { - let allowed = match kind { - UserDraftItemKind::Flow => Some(BuilderKind::Flows), - UserDraftItemKind::RawApp => Some(BuilderKind::Apps), - _ => None, - }; - let granted = match allowed { - Some(kind) => operator_builder_rights(db, w_id).await?.has(kind), - None => false, - }; - if !granted { - return Err(Error::PermissionDenied( - "operators cannot save drafts".to_string(), - )); - } + // Operators WRITE drafts only of a kind they could deploy: resources and variables always, + // flows and full-code apps under their builder right, schedules and triggers under the manage + // rights. The kind has to be checked, or one right would open drafts of kinds it says nothing + // about. Read access is deliberately asymmetric: `require_can_read_path` has no operator block, + // so an operator can still READ a draft they can read via `/drafts/get`, mirroring their read + // access to deployed content. Intended. + if !operator_can_draft(db, w_id, authed, kind).await? { + return Err(Error::PermissionDenied(format!( + "your role cannot save {} drafts in this workspace", + kind.as_str() + ))); } // Cheap claim-based namespace checks first: they evaluate the same JWT // claims RLS reads, so the outcome matches the policies while sparing the diff --git a/backend/windmill-api/src/favorite.rs b/backend/windmill-api/src/favorite.rs index d3e3456e0f..86f3377b39 100644 --- a/backend/windmill-api/src/favorite.rs +++ b/backend/windmill-api/src/favorite.rs @@ -32,6 +32,7 @@ pub enum FavoriteKind { #[allow(non_camel_case_types)] Raw_App, Asset, + Agent, } #[derive(Deserialize)] pub struct Favorite { diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 1a7a073732..c5dd374c7a 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -42,6 +42,7 @@ use windmill_common::jobs::{ use windmill_common::jobs::{ InlineScriptTarget, RunInlinePreviewScriptFnParams, RunInlineScriptFnParams, }; +use windmill_common::roles::AuthorLevel; use windmill_common::runnable_settings::{ ConcurrencySettings, ConcurrencySettingsWithCustom, DebouncingSettings, }; @@ -115,7 +116,9 @@ use windmill_common::{ add_virtual_items_if_necessary, resolve_maybe_value, ApprovalSkin, FlowModule, FlowValue, Suspend, }, - jobs::{script_path_to_payload, CompletedJob, JobKind, JobPayload, QueuedJob, RawCode}, + jobs::{ + script_path_to_payload, CompletedJob, JobKind, JobPayload, OnBehalfOf, QueuedJob, RawCode, + }, oauth2::HmacSha256, query_builders, scripts::{ScriptHash, ScriptLang}, @@ -6736,6 +6739,12 @@ struct Preview { /// preview job resolves those imports from not-yet-deployed local content /// (uploaded to raw_script_temp) instead of the deployed script. temp_script_refs: Option>, + /// Run the preview as this identity (an email or a `u/`/`g/` principal) instead of the + /// caller. See `preview_on_behalf_of`. + on_behalf_of: Option, + /// Data table and object storage access as the caller rather than as the job's identity, + /// as a full-code app's inline runnable flagged `viewer_data_access` does for its viewer. + viewer_data_access: Option, } #[cfg(feature = "run_inline")] @@ -6768,6 +6777,8 @@ struct PreviewFlow { /// each flow step so inline-script relative imports resolve from /// not-yet-deployed local content instead of the deployed script. temp_script_refs: Option>, + /// See `Preview::on_behalf_of`. + on_behalf_of: Option, } #[derive(Debug, Deserialize)] @@ -7048,6 +7059,7 @@ async fn batch_rerun_handle_job( ) .await; if let Ok((uuid, _, _, _)) = result { + resolve_batch_rerun_source(authed, db, user_db, w_id, job.id, uuid).await; return Ok(uuid.to_string()); } } @@ -7081,6 +7093,7 @@ async fn batch_rerun_handle_job( .map(|r| r.0) }; if let Ok(uuid) = result { + resolve_batch_rerun_source(authed, db, user_db, w_id, job.id, uuid).await; return Ok(uuid.to_string()); } } @@ -7091,6 +7104,68 @@ async fn batch_rerun_handle_job( )) } +/// Marks a failed or canceled source of a batch re-run as handled, with the re-run as its note. +/// This is the only moment the link exists (the re-run is pushed with no back-pointer), and it +/// is what lets "hide resolved" exclude already re-run jobs from the next batch. Never fails the +/// re-run, which is already queued: a source that cannot be resolved is logged and left as is. +async fn resolve_batch_rerun_source( + authed: &ApiAuthed, + db: &DB, + user_db: &UserDB, + w_id: &str, + source: Uuid, + rerun: Uuid, +) { + if !role_can_code(db, w_id, authed).await.unwrap_or(false) { + return; + } + let result: error::Result<()> = async { + let mut tx = user_db.clone().begin(authed).await?; + let tags = get_scope_tags(authed); + let (resolved_by, _) = resolution_attribution(authed, None); + // Same authorization and eligibility as `resolve_completed_jobs`. DO NOTHING keeps a + // resolution someone already explained. + let resolved = sqlx::query_scalar!( + "INSERT INTO job_resolution (job_id, workspace_id, resolved_by, note, automatic) + SELECT c.id, c.workspace_id, $4, $5, false + FROM v2_job_completed c + JOIN v2_job j ON j.id = c.id + WHERE c.id = $1 + AND c.workspace_id = $2 + AND ($3::TEXT[] IS NULL OR j.tag = ANY($3)) + AND c.status IN ('failure', 'canceled') + AND j.flow_step_id IS NULL + ON CONFLICT (job_id) DO NOTHING + RETURNING job_id", + source, + w_id, + tags.as_ref().map(|v| v.as_slice()) as Option<&[&str]>, + resolved_by, + format!("Re-run as {rerun}"), + ) + .fetch_optional(&mut *tx) + .await?; + if resolved.is_some() { + audit_log( + &mut *tx, + authed, + "jobs.resolve", + ActionKind::Update, + w_id, + Some(&source.to_string()), + None, + ) + .await?; + } + tx.commit().await?; + Ok(()) + } + .await; + if let Err(e) = result { + tracing::error!("Could not resolve job {source} after re-running it as {rerun}: {e}"); + } +} + pub async fn run_flow_by_path( authed: ApiAuthed, Extension(db): Extension, @@ -8916,20 +8991,138 @@ async fn operator_may_run_datatable_query( .unwrap_or(false)) } -/// The refusal an operator gets from a preview route. Inside a job the caller never ran a -/// preview themselves, so name the one thing the job's token may do. +/// Whether the caller's role may run code of its own: previews, dependency jobs, inline dynamic +/// selects, resolving jobs. +async fn role_can_code(db: &DB, w_id: &str, authed: &ApiAuthed) -> error::Result { + Ok( + windmill_common::roles::caller_capabilities(db, w_id, authed) + .await? + .code, + ) +} + +/// The refusal a member whose role cannot code gets from a preview route. Inside a job the caller +/// never ran a preview themselves, so name the one thing the job's token may do. fn operator_preview_refusal(job_id: Option) -> error::Error { let reason = if job_id.is_some() { - "Operators cannot run preview jobs for security reasons: from inside a job, an \ - operator may only run a wmill.datatable() query while that job is running" + "Your role cannot run preview jobs in this workspace: from inside a job, a member \ + whose role cannot code may only run a wmill.datatable() query while that job is running" } else { - "Operators cannot run preview jobs for security reasons" + "Your role cannot run preview jobs in this workspace" }; // 403, not 401: the frontend reads a 401 as a dead session and logs the user out, and the // flow editor builders open reaches this route. error::Error::PermissionDenied(reason.to_string()) } +/// The identity a preview asks to run as, or `None` when it runs as the caller. +/// +/// A preview is request-supplied code, so running it as someone else is impersonation, not +/// "running their runnable": only those who may set a runnable's `on_behalf_of` on deploy +/// (admins and `wm_deployers`) may ask for it. Naming oneself is always allowed. Operators are +/// refused even in `wm_deployers`: the datatable-query exemption lets them reach this route +/// without being able to deploy anything. +pub(crate) async fn preview_on_behalf_of( + authed: &ApiAuthed, + requested: Option<&str>, + w_id: &str, + db: &DB, +) -> error::Result> { + let Some(requested) = requested.filter(|s| !s.is_empty()) else { + return Ok(None); + }; + let own = username_to_permissioned_as(&authed.username); + if requested == own || requested == authed.email { + return Ok(None); + } + if !role_can_code(db, w_id, authed).await? + || !windmill_common::can_preserve_on_behalf_of(authed) + { + return Err(Error::PermissionDenied( + "Only admins and members of wm_deployers can run a preview on behalf of another user" + .to_string(), + )); + } + let permissioned_as = + windmill_common::resolve_on_behalf_of(None, Some(requested), true, authed, w_id, db) + .await? + .unwrap_or(own.clone()); + if permissioned_as == own { + return Ok(None); + } + let email = + windmill_common::users::get_email_from_permissioned_as_uncached(&permissioned_as, w_id, db) + .await?; + Ok(Some(OnBehalfOf { email, permissioned_as })) +} + +/// Audits the impersonation and opens the job's own logs with it, so whoever reads the run sees +/// who it ran as and who asked, not only the identity columns. +pub(crate) async fn audit_preview_on_behalf_of( + tx: &mut Transaction<'_, Postgres>, + authed: &ApiAuthed, + w_id: &str, + job_id: Uuid, + path: Option<&str>, + obo: &OnBehalfOf, +) -> error::Result<()> { + let mut params = HashMap::from([("on_behalf_of", obo.permissioned_as.as_str())]); + if let Some(path) = path { + params.insert("path", path); + } + audit_log( + &mut **tx, + authed, + "jobs.run.preview_on_behalf_of", + ActionKind::Execute, + w_id, + Some(&job_id.to_string()), + Some(params), + ) + .await?; + sqlx::query!( + "INSERT INTO job_logs (logs, job_id, workspace_id) VALUES ($1, $2, $3) ON CONFLICT (job_id) DO UPDATE SET logs = concat(job_logs.logs, EXCLUDED.logs)", + format!( + "Preview run on behalf of {} ({}), requested by {}\n", + obo.permissioned_as, obo.email, authed.email + ), + job_id, + w_id, + ) + .execute(&mut **tx) + .await?; + Ok(()) +} + +/// The push identity and isolation level for a preview: the caller's own, or the requested +/// `on_behalf_of`'s, pushed through the root pool like a deployed `on_behalf_of` run. +fn preview_push_identity( + authed: &ApiAuthed, + user_db: &UserDB, + db: &DB, + obo: Option<&OnBehalfOf>, +) -> ( + String, + String, + Option, + PushIsolationLevel<'static>, +) { + match obo { + Some(obo) => ( + obo.email.clone(), + obo.permissioned_as.clone(), + None, + PushIsolationLevel::IsolatedRoot(db.clone()), + ), + None => ( + authed.email.clone(), + username_to_permissioned_as(&authed.username), + Some(authed.clone().into()), + PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()), + ), + } +} + async fn run_preview_script( authed: ApiAuthed, Extension(db): Extension, @@ -8940,7 +9133,7 @@ async fn run_preview_script( ) -> error::Result<(StatusCode, String)> { #[cfg(feature = "enterprise")] check_license_key_valid().await?; - if authed.is_operator { + if !role_can_code(&db, &w_id, &authed).await? { // A deferred run would outlive the running job the exemption keys off. if run_query.get_scheduled_for(&db).await?.is_some() || !operator_may_run_datatable_query( @@ -8964,7 +9157,13 @@ async fn run_preview_script( require_path_read_access_for_preview(&authed, &preview.path)?; let scheduled_for = run_query.get_scheduled_for(&db).await?; let tag = run_query.tag.clone().or(preview.tag.clone()); - let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()); + let obo = preview_on_behalf_of(&authed, preview.on_behalf_of.as_deref(), &w_id, &db).await?; + let (email, permissioned_as, push_authed, tx) = + preview_push_identity(&authed, &user_db, &db, obo.as_ref()); + let audited_path = preview.path.clone(); + // A job token's end user is whoever ran the calling job, who never consented to that job's + // code reading data as them: only a direct caller, reading as themselves, may ask for it. + let viewer_data_access = authed.job_id.is_none() && preview.viewer_data_access.unwrap_or(false); let preview_args = preview.args.unwrap_or_default(); let mut extra = HashMap::new(); @@ -9010,8 +9209,8 @@ async fn run_preview_script( }, push_args, authed.display_username(), - &authed.email, - username_to_permissioned_as(&authed.username), + &email, + permissioned_as, authed.token_prefix.as_deref(), authed.username_override.as_deref(), scheduled_for, @@ -9028,7 +9227,7 @@ async fn run_preview_script( run_query.timeout, None, None, - Some(&authed.clone().into()), + push_authed.as_ref(), false, end_user_email, authed.trigger_or_fallback(None), @@ -9036,6 +9235,19 @@ async fn run_preview_script( scope_ceiling.as_deref(), ) .await?; + let mut tx = tx; + if let Some(obo) = obo.as_ref() { + audit_preview_on_behalf_of(&mut tx, &authed, &w_id, uuid, audited_path.as_deref(), obo) + .await?; + } + if viewer_data_access { + sqlx::query!( + "UPDATE job_perms SET viewer_data_access = true WHERE job_id = $1", + uuid + ) + .execute(&mut *tx) + .await?; + } tx.commit().await?; Ok((StatusCode::CREATED, uuid.to_string())) @@ -9052,7 +9264,7 @@ async fn run_inline_preview_script( // Same arbitrary-code class as run_preview_script, and every worker and standalone // server exposes this route, so an operator is refused on the same terms. A // narrowly-scoped token must not escape its scope through inline preview either. - if authed.is_operator + if !role_can_code(&db, &w_id, &authed).await? && !operator_may_run_datatable_query( &db, &w_id, @@ -9066,6 +9278,7 @@ async fn run_inline_preview_script( return Err(operator_preview_refusal(job_id)); } check_scopes(&authed, || format!("jobs:run"))?; + windmill_queue::tags::check_inline_run_allowed(&w_id, &db).await?; if let Some(job_id) = job_id { register_potential_assets_on_inline_execution(job_id, &w_id, &preview); } @@ -9084,7 +9297,6 @@ async fn run_inline_preview_script( end_user_email, viewer_data_access, lang: preview.language, - job_dir: "".to_string(), worker_name: "".to_string(), worker_dir: "".to_string(), client: AuthedClient { @@ -9186,6 +9398,7 @@ async fn run_inline_script_inner( args: Option>>, user_db: Option, ) -> error::Result { + windmill_queue::tags::check_inline_run_allowed(&w_id, &db).await?; // An inline run executes with the caller's own token, so it cannot honour a script's // `job_token_scopes`: such a script only runs as a job. A path is resolved here, once, and // the version checked is the version run. @@ -9235,7 +9448,6 @@ async fn run_inline_script_inner( permissioned_as: username_to_permissioned_as(&authed.username), permissioned_as_email: authed.email.clone(), end_user_email, - job_dir: "".to_string(), worker_name: "".to_string(), worker_dir: "".to_string(), client: AuthedClient { @@ -9358,10 +9570,8 @@ async fn run_bundle_preview_script( Query(run_query): Query, mut multipart: axum::extract::Multipart, ) -> error::Result<(StatusCode, String)> { - if authed.is_operator { - return Err(error::Error::NotAuthorized( - "Operators cannot run preview jobs for security reasons".to_string(), - )); + if !role_can_code(&db, &w_id, &authed).await? { + return Err(operator_preview_refusal(None)); } // Bundle preview runs arbitrary, request-supplied code; require the broad jobs:run // scope so a narrowly-scoped token cannot escape its scope. See run_preview_script. @@ -9387,7 +9597,11 @@ async fn run_bundle_preview_script( let scheduled_for = run_query.get_scheduled_for(&db).await?; let tag = run_query.tag.clone().or(preview.tag.clone()); - let ltx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()); + let obo = + preview_on_behalf_of(&authed, preview.on_behalf_of.as_deref(), &w_id, &db).await?; + let (email, permissioned_as, push_authed, ltx) = + preview_push_identity(&authed, &user_db, &db, obo.as_ref()); + let audited_path = preview.path.clone(); let args = preview.args.unwrap_or_default(); @@ -9435,8 +9649,8 @@ async fn run_bundle_preview_script( }), push_args, authed.display_username(), - &authed.email, - username_to_permissioned_as(&authed.username), + &email, + permissioned_as, authed.token_prefix.as_deref(), authed.username_override.as_deref(), scheduled_for, @@ -9453,7 +9667,7 @@ async fn run_bundle_preview_script( run_query.timeout, None, None, - Some(&authed.clone().into()), + push_authed.as_ref(), false, end_user_email, authed.trigger_or_fallback(None), @@ -9461,6 +9675,18 @@ async fn run_bundle_preview_script( scope_ceiling.as_deref(), ) .await?; + let mut ntx = ntx; + if let Some(obo) = obo.as_ref() { + audit_preview_on_behalf_of( + &mut ntx, + &authed, + &w_id, + uuid, + audited_path.as_deref(), + obo, + ) + .await?; + } job_id = Some(uuid); tx = Some(ntx); } @@ -9552,9 +9778,9 @@ async fn push_dependencies_job( req: RunDependenciesRequest, ) -> error::Result { check_dependencies_scope(authed, &req.entrypoint)?; - if authed.is_operator { - return Err(error::Error::NotAuthorized( - "Operators cannot run dependencies jobs for security reasons".to_string(), + if !role_can_code(db, w_id, authed).await? { + return Err(error::Error::PermissionDenied( + "Your role cannot run dependencies jobs in this workspace".to_string(), )); } @@ -9675,10 +9901,10 @@ async fn push_flow_dependencies_job( req: RunFlowDependenciesRequest, ) -> error::Result { check_dependencies_scope(authed, &req.path)?; - check_operator_can_build( + let flows_level = check_operator_can_build( db, w_id, - authed.is_operator, + authed, BuilderKind::Flows, "run dependencies jobs", ) @@ -9686,7 +9912,7 @@ async fn push_flow_dependencies_job( // The dependency job locks whatever inline code this request carries, on a worker. A // composition-only flow has none, so validating here costs a builder nothing and keeps the // lock step from becoming the way to run code the write path refuses. - if authed.is_operator { + if flows_level == AuthorLevel::Compose { validate_operator_composed_flow(&req.flow_value, &None, authed, db, user_db, w_id).await?; // The job rewrites bookkeeping stored under `req.path` (dependency map, asset usages, // lock error) even with `skip_flow_update`, so a builder must be able to write there. @@ -9971,17 +10197,22 @@ async fn add_batch_jobs( let language = language.unwrap_or(ScriptLang::Deno); - let tag = if let Some(dedicated_worker) = dedicated_worker { + let tag = match (dedicated_worker, batch_info.tag) { // Same rule as `push`: a dedicated worker would run it with its own unscoped token. - if dedicated_worker && path.is_some() && job_token_scopes.is_none() { + (Some(true), _) if path.is_some() && job_token_scopes.is_none() => { windmill_common::worker::dedicated_worker_tag(&w_id, &path.clone().unwrap()) - } else { - format!("{}", language.as_str()) } - } else if let Some(tag) = batch_info.tag { - tag - } else { - format!("{}", language.as_str()) + (None, Some(tag)) => { + windmill_queue::tags::apply_dev_workspace_suffix(tag, &w_id, &db).await? + } + _ => { + windmill_queue::tags::apply_dev_workspace_suffix( + language.as_str().to_string(), + &w_id, + &db, + ) + .await? + } }; let mut tx = user_db.begin(&authed).await?; @@ -10097,14 +10328,9 @@ async fn run_preview_flow_job( Query(run_query): Query, Json(raw_flow): Json, ) -> error::Result<(StatusCode, String)> { - check_operator_can_build( - &db, - &w_id, - authed.is_operator, - BuilderKind::Flows, - "run preview jobs", - ) - .await?; + let flows_level = + check_operator_can_build(&db, &w_id, &authed, BuilderKind::Flows, "run preview jobs") + .await?; // Flow preview runs an arbitrary, request-supplied flow definition; require the broad // jobs:run scope so a narrowly-scoped token cannot escape its scope. See run_preview_script. check_scopes(&authed, || format!("jobs:run"))?; @@ -10118,7 +10344,7 @@ async fn run_preview_flow_job( } // A builder must be able to test what it composes, but the submitted value is not the stored // one: without this the preview is a way to run inline code the write path refuses. - if authed.is_operator { + if flows_level == AuthorLevel::Compose { validate_operator_composed_flow( &raw_flow.value, &raw_flow.tag, @@ -10138,7 +10364,9 @@ async fn run_preview_flow_job( } let scheduled_for = run_query.get_scheduled_for(&db).await?; let tag = run_query.tag.clone().or(raw_flow.tag.clone()); - let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()); + let obo = preview_on_behalf_of(&authed, raw_flow.on_behalf_of.as_deref(), &w_id, &db).await?; + let (email, permissioned_as, push_authed, tx) = + preview_push_identity(&authed, &user_db, &db, obo.as_ref()); let chat_input_enabled = raw_flow.value.chat_input_enabled.unwrap_or(false); let flow_path = raw_flow.path.clone().unwrap_or_default(); @@ -10171,8 +10399,8 @@ async fn run_preview_flow_job( }, PushArgs::from(&flow_args), authed.display_username(), - &authed.email, - username_to_permissioned_as(&authed.username), + &email, + permissioned_as, authed.token_prefix.as_deref(), authed.username_override.as_deref(), scheduled_for, @@ -10189,7 +10417,7 @@ async fn run_preview_flow_job( None, None, None, - Some(&authed.clone().into()), + push_authed.as_ref(), false, end_user_email, authed.trigger_or_fallback(None), @@ -10198,6 +10426,11 @@ async fn run_preview_flow_job( ) .await?; + if let Some(obo) = obo.as_ref() { + let audited_path = Some(flow_path.as_str()).filter(|p| !p.is_empty()); + audit_preview_on_behalf_of(&mut tx, &authed, &w_id, uuid, audited_path, obo).await?; + } + // Set memory_id if provided (for agent memory) if let Some(memory_id) = run_query.memory_key(&w_id, &flow_path) { set_flow_memory_id(&mut tx, uuid, memory_id).await?; @@ -10263,7 +10496,7 @@ async fn run_dynamic_select( if matches!( request.runnable_ref, DynamicSelectRunnableRef::Inline { .. } - ) && authed.is_operator + ) && !role_can_code(&db, &w_id, &authed).await? { return Err(operator_preview_refusal(None)); } @@ -12346,14 +12579,16 @@ fn resolution_attribution<'a>( (None, None) } -fn check_resolution_request( +async fn check_resolution_request( authed: &ApiAuthed, + db: &DB, + w_id: &str, job_ids: &[Uuid], note: Option<&str>, ) -> error::Result<()> { - if authed.is_operator { - return Err(error::Error::NotAuthorized( - "Operators cannot resolve jobs".to_string(), + if !role_can_code(db, w_id, authed).await? { + return Err(error::Error::PermissionDenied( + "Your role cannot resolve jobs in this workspace".to_string(), )); } if job_ids.len() > MAX_RESOLUTION_BATCH { @@ -12373,23 +12608,24 @@ fn check_resolution_request( Ok(()) } -/// Marks failed jobs as handled. Returns the ids actually affected: an id that is not -/// visible to the caller, carries an out-of-scope tag, or did not fail is silently +/// Marks failed or canceled jobs as handled. Returns the ids actually affected: an id that is +/// not visible to the caller, carries an out-of-scope tag, or did not fail nor get canceled is silently /// absent rather than an error, so a bulk selection never fails as a whole. async fn resolve_completed_jobs( authed: ApiAuthed, + Extension(db): Extension, Extension(user_db): Extension, Path(w_id): Path, Json(req): Json, ) -> error::JsonResult> { - check_resolution_request(&authed, &req.job_ids, req.note.as_deref())?; + check_resolution_request(&authed, &db, &w_id, &req.job_ids, req.note.as_deref()).await?; let mut tx = user_db.begin(&authed).await?; let tags = get_scope_tags(&authed); let (resolved_by, typed_note) = resolution_attribution(&authed, req.note.as_deref()); let system_note = req.superseded_by.map(|_| SUPERSEDED_NOTE); // The join on v2_job is what authorizes this write: v2_job_completed has RLS // disabled, so v2_job's policies are the only thing scoping rows to the caller. - // `status = 'failure'` keeps the invariant that only a failure can be resolved. + // Only a failed or canceled run can be resolved: a success has nothing to handle. let resolved = sqlx::query_scalar!( "INSERT INTO job_resolution (job_id, workspace_id, resolved_by, note, automatic) SELECT c.id, c.workspace_id, $4, COALESCE($5, $7), false @@ -12398,7 +12634,7 @@ async fn resolve_completed_jobs( WHERE c.id = ANY($1) AND c.workspace_id = $2 AND ($3::TEXT[] IS NULL OR j.tag = ANY($3)) - AND c.status = 'failure' + AND c.status IN ('failure', 'canceled') -- Resolution is a top-level triage state: a step resolved on its own -- would render orange inside a flow whose status is still red. AND j.flow_step_id IS NULL @@ -12464,11 +12700,12 @@ async fn resolve_completed_jobs( async fn unresolve_completed_jobs( authed: ApiAuthed, + Extension(db): Extension, Extension(user_db): Extension, Path(w_id): Path, Json(req): Json, ) -> error::JsonResult> { - check_resolution_request(&authed, &req.job_ids, None)?; + check_resolution_request(&authed, &db, &w_id, &req.job_ids, None).await?; let mut tx = user_db.begin(&authed).await?; let tags = get_scope_tags(&authed); let unresolved = sqlx::query_scalar!( diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 92291bd1ea..446a585d35 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -9,8 +9,6 @@ use crate::db::ApiAuthed; #[cfg(feature = "embedding")] use crate::embeddings::load_embeddings_db; -#[cfg(feature = "oauth2")] -use crate::oauth2_oss::SlackVerifier; #[cfg(feature = "smtp")] use crate::smtp_server_oss::SmtpServer; use axum::middleware::from_fn_with_state; @@ -77,6 +75,7 @@ mod ai_sessions; #[cfg(feature = "parquet")] pub use ai_sessions::sweep_expired_ai_session_backups; mod ai_shared_artifacts; +mod ai_shared_sessions; mod apps; mod apps_raw_bundle; pub use apps::invalidate_app_policy_cache; @@ -163,6 +162,9 @@ mod scim_oss; mod scripts; mod secret_backend_ext; mod service_logs; +// Answering in Slack is only reached from the EE Slack event handlers. +#[cfg_attr(not(all(feature = "oauth2", feature = "private")), allow(dead_code))] +mod slack_agents; mod slack_approvals; #[cfg(all(feature = "smtp", feature = "private"))] pub mod smtp_server_ee; @@ -209,6 +211,7 @@ mod workspaces; #[cfg(feature = "private")] pub mod workspaces_ee; mod workspaces_export; +mod workspace_settings_document; #[cfg(feature = "mcp")] mod mcp_tools; @@ -245,10 +248,6 @@ pub use windmill_oauth::OAUTH_CLIENTS; #[cfg(feature = "oauth2")] lazy_static::lazy_static! { - pub static ref SLACK_SIGNING_SECRET: Option = std::env::var("SLACK_SIGNING_SECRET") - .ok() - .map(|x| SlackVerifier::new(x).unwrap()); - // Comma-separated Slack v2 OAuth bot scopes requested when connecting a workspace. // Must be a subset of the bot scopes declared in the Slack app manifest. Default matches // Windmill's recommended manifest at docs.windmill.dev/docs/misc/setup_oauth. @@ -256,6 +255,11 @@ lazy_static::lazy_static! { .unwrap_or_else(|_| "commands,chat:write,chat:write.public,channels:join,files:write,app_mentions:read,im:history,im:read".to_string()); } +/// Added to `SLACK_OAUTH_SCOPES` when a workspace connects with AI agents enabled: whether a +/// message's sender is a person of the team and, for agents that run as their asker, their email, +/// and the channel picker. Opt-in, as a Slack app must declare every scope it is asked for. +pub const SLACK_AGENT_SCOPES: &[&str] = &["users:read", "users:read.email", "channels:read"]; + // Compliance with cloud events spec. pub async fn add_webhook_allowed_origin( req: axum::extract::Request, @@ -566,14 +570,14 @@ pub async fn run_server( // Setup MCP server #[allow(unused_variables)] - let (mcp_router, gateway_mcp_router, mcp_cancellation_token) = { + let (mcp_router, gateway_mcp_router, gateway_session_router, mcp_cancellation_token) = { #[cfg(feature = "mcp")] if server_mode || mcp_mode { use mcp::{ add_www_authenticate_header, add_www_authenticate_header_gateway, extract_workspace_from_token, reject_token_query_param, }; - let (mcp_router, mcp_cancellation_token) = setup_mcp_server( + let (mcp_router, session_router, mcp_cancellation_token) = setup_mcp_server( db.clone(), user_db, _base_internal_url.clone(), @@ -595,17 +599,32 @@ pub async fn run_server( .layer(axum::middleware::from_fn( add_www_authenticate_header_gateway, )); + // The workspace server. Its tools name their workspace; the token bounds which. + let gateway_session_router = session_router + .route_layer(from_extractor::()) + .layer(axum::middleware::from_fn(extract_workspace_from_token)) + .layer(axum::middleware::from_fn(reject_token_query_param)) + .layer(axum::middleware::from_fn( + add_www_authenticate_header_gateway, + )) + .layer(Extension(mcp::McpEndpoint("/api/mcp"))); ( workspaced_mcp_router, gateway_mcp_router, + gateway_session_router, Some(mcp_cancellation_token), ) } else { - (Router::new(), Router::new(), None) + (Router::new(), Router::new(), Router::new(), None) } #[cfg(not(feature = "mcp"))] - (Router::new(), Router::new(), Option::<()>::None) + ( + Router::new(), + Router::new(), + Router::new(), + Option::<()>::None, + ) }; // Workers block on this before pulling their first job, so it is released ahead of @@ -618,15 +637,15 @@ pub async fn run_server( } tokio::task::yield_now().await; - let mcp_list_tools_service = { + let (mcp_list_tools_service, mcp_toolset_service) = { #[cfg(feature = "mcp")] { - mcp::list_tools_service() + (mcp::list_tools_service(), mcp::toolset_service()) } #[cfg(not(feature = "mcp"))] { - Router::new() + (Router::new(), Router::new()) } }; @@ -680,6 +699,7 @@ pub async fn run_server( .nest("/embeddings", embeddings::workspaced_service()) .nest("/favorites", favorite::workspaced_service()) .nest("/flows", flows::workspaced_service()) + .nest("/slack_agents", slack_agents::workspaced_service()) .nest("/runnables", runnables::workspaced_service()) .nest( "/workspace_dependencies", @@ -843,6 +863,7 @@ pub async fn run_server( .nest("/docs", docs::global_service()) .nest("/indexer", indexer_oss::management_service()) .nest("/mcp/w/{workspace_id}/list_tools", mcp_list_tools_service) + .nest("/mcp/toolset", mcp_toolset_service) .nest("/db_health", db_health::global_service()) .nest("/health/detailed", health::detailed_service()) .nest( @@ -923,6 +944,10 @@ pub async fn run_server( .layer(from_extractor::()) .layer(cors.clone()), ) + .nest( + "/w/{workspace_id}/ai_u/shared_sessions", + ai_shared_sessions::workspaced_unauthed_service(), + ) .layer(from_extractor::()) // Deprecated, here for backwards compatibility: user should use /mcp/w/{workspace_id}/mcp instead .nest( @@ -934,6 +959,7 @@ pub async fn run_server( mcp_router.clone().layer(mcp_cors.clone()), ) .nest("/mcp/gateway", gateway_mcp_router.layer(mcp_cors.clone())) + .merge(gateway_session_router.layer(mcp_cors.clone())) .nest("/agent_workers", { #[cfg(feature = "agent_worker_server")] { @@ -1171,6 +1197,16 @@ pub async fn run_server( get(|| async { axum::http::StatusCode::NOT_FOUND }) } }) + .route("/.well-known/oauth-protected-resource/api/mcp", { + #[cfg(feature = "mcp")] + { + get(mcp::oauth_server::gateway_session_protected_resource_metadata) + } + #[cfg(not(feature = "mcp"))] + { + get(|| async { axum::http::StatusCode::NOT_FOUND }) + } + }) // JWKS endpoint for HashiCorp Vault JWT authentication (must be outside /api prefix) .route("/.well-known/jwks.json", { #[cfg(all(feature = "private", feature = "enterprise", feature = "openidconnect"))] @@ -1244,6 +1280,9 @@ pub async fn run_server( if let Some(name) = name.as_ref() { tracing::info!("server starting for name={name}"); } + #[cfg(feature = "mcp")] + mcp::register_router(&_base_internal_url, Some(app.clone())); + let listener = listener.tap_io(move |tcp_stream| { let _ = tcp_stream.set_nodelay(!server_mode); }); @@ -1281,7 +1320,10 @@ pub async fn run_server( } }); - server.await?; + let served = server.await; + #[cfg(feature = "mcp")] + mcp::register_router(&_base_internal_url, None); + served?; let forks = &windmill_api_workspaces::workspaces::BACKGROUND_FORKS; forks.close(); if !forks.is_empty() { diff --git a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs index 73b1fd1792..8ff3e9f5c2 100644 --- a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs +++ b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs @@ -56,6 +56,46 @@ pub fn all_tools() -> Vec { query_field_renames: None, body_field_renames: None, }, + EndpointTool { + name: Cow::Borrowed("getWorkspaceSettingsDocument"), + description: Cow::Borrowed("get the workspace settings document: The workspace's settings in the layout of the settings.yaml that `wmill sync` writes. Admin only"), + instructions: Cow::Borrowed(""), + path: Cow::Borrowed("/w/{workspace}/workspaces/settings_document"), + method: Cow::Borrowed("GET"), + path_params_schema: None, + query_params_schema: None, + body_schema: None, + query_field_renames: None, + body_field_renames: None, + }, + EndpointTool { + name: Cow::Borrowed("applyWorkspaceSettingsDocument"), + description: Cow::Borrowed("apply a workspace settings document: Applies a settings document, each setting through the endpoint that owns it. Admin only"), + instructions: Cow::Borrowed("Send only the top-level keys to change, each whole as getWorkspaceSettingsDocument returns it: a key the document omits is left as it is, and a key set to null clears it. Keep `__SENSITIVE_AND_UNCHANGED__` for a secret you were not given. Call with dry_run true first, show the user the changes and apply only once they agree."), + path: Cow::Borrowed("/w/{workspace}/workspaces/settings_document"), + method: Cow::Borrowed("POST"), + path_params_schema: None, + query_params_schema: None, + body_schema: Some(serde_json::json!({ + "type": "object", + "properties": { + "document": { + "type": "object", + "description": "The settings to apply, in the layout of the document. A secret given as `__SENSITIVE_AND_UNCHANGED__` keeps its stored value; one prefixed with `$encrypted:` is decrypted with the workspace key." + }, + "dry_run": { + "description": "Only compute the changes, without applying them.", + "type": "boolean" + } + }, + "required": [ + "document" + ], + "minProperties": 1 +})), + query_field_renames: None, + body_field_renames: None, + }, EndpointTool { name: Cow::Borrowed("listDataMetrics"), description: Cow::Borrowed("list declared measures and dimensions on DuckLake tables: Call this before writing any aggregate query over a DuckLake table. A declared measure is the canonical definition of that number, and reproducing it yourself will silently disagree with it (a `revenue` measure typically excludes refunds or test rows). Filter by `table` for one table's declarations, or by `path_prefix` (e.g. `f/analytics`) for everything declared under a folder; omit both to browse the whole catalog. Results are keyset-paged: a full page may mean more remain, so continue with the `cursor_*` params rather than assuming a measure does not exist. Use each returned `expr` verbatim, and when a measure has a `filter` write it as `expr FILTER (WHERE filter)` so measures with different predicates can share one GROUP BY. If a number you need has no declared measure, write your own aggregate as usual. Results are limited to declarations whose producing script the caller can read"), @@ -559,6 +599,10 @@ pub fn all_tools() -> Vec { "include_draft_only": { "type": "boolean", "description": "When true, append per-user draft resources whose path has\nno deployed resource. Synthesized rows carry\n`draft_only: true`.\n" + }, + "starred_only": { + "type": "boolean", + "description": "Only the `ai_agent` resources the caller starred" } }, "required": [] @@ -1296,7 +1340,7 @@ is, a different one moves it there and archives the old path"), "items": { "type": "string" }, - "description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true — an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write).\n" + "description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true — an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write). Every such token can call whoami without consent, whether `users:read` is declared or not.\n" } } } @@ -1413,7 +1457,7 @@ is, a different one moves it there and archives the old path"), "items": { "type": "string" }, - "description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true — an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write).\n" + "description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true — an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write). Every such token can call whoami without consent, whether `users:read` is declared or not.\n" } } }, @@ -1802,7 +1846,7 @@ is, a different one moves it there and archives the old path"), }, "resolved": { "type": "boolean", - "description": "filter on whether a failure has been marked as handled. true keeps only resolved failures, false hides them" + "description": "filter on whether a failed or canceled run has been marked as handled. true keeps only resolved runs, false hides them" }, "is_flow_step": { "type": "boolean", diff --git a/backend/windmill-api/src/mcp/core.rs b/backend/windmill-api/src/mcp/core.rs index 8f3962da13..d31234b585 100644 --- a/backend/windmill-api/src/mcp/core.rs +++ b/backend/windmill-api/src/mcp/core.rs @@ -13,7 +13,8 @@ use windmill_mcp::common::types::{ FlowInfo, HubScriptInfo, ResourceInfo, ResourceType, SchemaType, ScriptInfo, WorkspaceInfo, }; use windmill_mcp::server::{ - BackendResult, EndpointTool, ErrorData, McpBackend, McpRequest, PathFilter, + BackendResult, CallToolResult, EndpointTool, ErrorData, McpBackend, McpRequest, PathFilter, + SessionAccess, Tool, }; use crate::auth::AuthCache; @@ -23,10 +24,11 @@ use crate::jobs::{ }; use super::auto_generated_endpoints::all_tools; +use super::session; use super::utils::{ build_query_string, build_request_body, create_http_request, get_hub_script_schema, - get_item_schema, get_items, get_resources, get_resources_types, get_scripts_from_hub, - parse_response_body, prepare_push_args, substitute_path_params, + get_item_paths, get_item_schema, get_items, get_resources, get_resources_types, + get_scripts_from_hub, parse_response_body, prepare_push_args, substitute_path_params, }; use std::sync::Arc; @@ -34,8 +36,8 @@ use std::time::Duration; use tokio_util::sync::CancellationToken; use windmill_mcp::server::{ - LocalSessionManager, McpToken, MultiWorkspaceMcp, Runner, StreamableHttpServerConfig, - StreamableHttpService, + LocalSessionManager, McpToken, MultiWorkspaceMcp, NoEndpointTools, Runner, SessionRunner, + StreamableHttpServerConfig, StreamableHttpService, }; use windmill_mcp::WorkspaceId; @@ -64,6 +66,32 @@ pub struct WindmillBackend { } impl WindmillBackend { + /// The instructions only read, so they are built at the token's own read-only flag. + fn session_ctx<'a>(&'a self, auth: &'a ApiAuthed, w_id: &'a str) -> session::Ctx<'a> { + let access = if auth.read_only { + SessionAccess::ReadOnly + } else { + SessionAccess::Full + }; + self.session_ctx_with(auth, w_id, access) + } + + fn session_ctx_with<'a>( + &'a self, + auth: &'a ApiAuthed, + w_id: &'a str, + access: SessionAccess, + ) -> session::Ctx<'a> { + session::Ctx { + auth, + w_id, + base_internal_url: &self.base_internal_url, + access, + origin: None, + jwt: Default::default(), + } + } + pub fn new( db: DB, user_db: UserDB, @@ -118,6 +146,27 @@ impl McpBackend for WindmillBackend { .map_err(|e| ErrorData::internal_error(e.message, None)) } + async fn list_runnable_paths( + &self, + auth: &ApiAuthed, + workspace_id: &str, + item_type: &str, + favorites_only: bool, + path_filter: Option>, + ) -> BackendResult> { + let scope_type = if favorites_only { "favorites" } else { "all" }; + get_item_paths( + &self.user_db, + auth, + workspace_id, + scope_type, + item_type, + path_filter, + ) + .await + .map_err(|e| ErrorData::internal_error(e.message, None)) + } + async fn list_resource_types( &self, auth: &ApiAuthed, @@ -221,6 +270,7 @@ impl McpBackend for WindmillBackend { args: Value, request: &McpRequest<'_>, ) -> BackendResult { + windmill_common::feature_usage::log_feature_usage("mcp", "runnable_tool_call", "script"); let push_args = prepare_push_args(&self.db, workspace_id, path, false, args, request) .await .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; @@ -248,6 +298,7 @@ impl McpBackend for WindmillBackend { args: Value, request: &McpRequest<'_>, ) -> BackendResult { + windmill_common::feature_usage::log_feature_usage("mcp", "runnable_tool_call", "flow"); let push_args = prepare_push_args(&self.db, workspace_id, path, true, args, request) .await .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; @@ -274,6 +325,12 @@ impl McpBackend for WindmillBackend { endpoint_tool: &EndpointTool, args: Value, ) -> BackendResult { + // Whether the generated endpoint tools are used at all, and which ones. + windmill_common::feature_usage::log_feature_usage( + "mcp", + "endpoint_tool_call", + &endpoint_tool.name, + ); let args_map = match &args { Value::Object(map) => map, _ => { @@ -400,8 +457,57 @@ impl McpBackend for WindmillBackend { fn all_endpoint_tools(&self) -> Vec { all_tools() } + + async fn session_instructions( + &self, + auth: &ApiAuthed, + workspace_id: &str, + ) -> BackendResult { + Ok(session::session_instructions(&self.session_ctx(auth, workspace_id)).await) + } + + fn generic_session_instructions(&self) -> String { + session::generic_session_instructions() + } + + async fn workspace_context(&self, auth: &ApiAuthed, workspace_id: &str) -> String { + session::workspace_context(&self.session_ctx(auth, workspace_id)).await + } + + fn session_tools(&self, access: SessionAccess) -> Vec { + session::session_tools(access) + } + + async fn call_session_tool( + &self, + auth: &ApiAuthed, + workspace_id: &str, + name: &str, + args: Value, + access: SessionAccess, + origin: Option<&str>, + ) -> BackendResult { + let ctx = session::Ctx { origin, ..self.session_ctx_with(auth, workspace_id, access) }; + session::call_session_tool(&ctx, name, args).await + } } +/// Set on a request whose token is scoped to a selection of scripts, flows and endpoints +/// rather than `mcp:all`: on `/api/mcp`, the tools server answers it. +#[derive(Clone, Copy)] +pub struct McpSelection; + +/// MCP scopes without `mcp:all`: favorites, chosen scripts, flows, endpoints or hub apps. +fn scoped_to_selection(scopes: &[String]) -> bool { + let mut mcp = scopes.iter().filter(|scope| scope.starts_with("mcp:")); + mcp.clone().next().is_some() && !mcp.any(|scope| scope == "mcp:all") +} + +/// The path of a workspace-less MCP URL, when it is not the tools server's +/// `/api/mcp/gateway`: each URL is its own OAuth protected resource. +#[derive(Clone, Copy)] +pub struct McpEndpoint(pub &'static str); + /// Extract workspace ID from path and store it in request extensions pub async fn extract_and_store_workspace_id( Path(params): Path, @@ -433,7 +539,6 @@ pub async fn add_www_authenticate_header( .body(axum::body::Body::from("Missing workspace_id in request")) .unwrap(); }; - let response = next.run(request).await; // Only add header to 401 Unauthorized responses @@ -520,27 +625,32 @@ pub async fn extract_workspace_from_token( .fetch_optional(&db) .await { - Ok(Some(row)) => match row.workspace_id { - Some(workspace_id) => { - request - .extensions_mut() - .insert(GatewayWorkspaceId(workspace_id.clone())); - request.extensions_mut().insert(WorkspaceId(workspace_id)); + Ok(Some(row)) => { + if scoped_to_selection(row.scopes.as_deref().unwrap_or_default()) { + request.extensions_mut().insert(McpSelection); } - None => { - // Only enter multi-workspace mode for genuine MCP tokens; a - // full-privilege global token without mcp scope is rejected - // by the runner's mcp-scope check anyway. - let is_mcp = row - .scopes - .as_deref() - .is_some_and(|s| s.iter().any(|scope| scope.starts_with("mcp:"))); - if is_mcp { - request.extensions_mut().insert(MultiWorkspaceMcp); - request.extensions_mut().insert(McpToken(token)); + match row.workspace_id { + Some(workspace_id) => { + request + .extensions_mut() + .insert(GatewayWorkspaceId(workspace_id.clone())); + request.extensions_mut().insert(WorkspaceId(workspace_id)); + } + None => { + // Only enter multi-workspace mode for genuine MCP tokens; a + // full-privilege global token without mcp scope is rejected + // by the runner's mcp-scope check anyway. + let is_mcp = row + .scopes + .as_deref() + .is_some_and(|s| s.iter().any(|scope| scope.starts_with("mcp:"))); + if is_mcp { + request.extensions_mut().insert(MultiWorkspaceMcp); + request.extensions_mut().insert(McpToken(token)); + } } } - }, + } Ok(None) => {} Err(e) => { tracing::error!("Gateway token workspace lookup failed: {}", e); @@ -559,11 +669,16 @@ pub async fn add_www_authenticate_header_gateway( use axum::http::StatusCode; use windmill_common::BASE_URL; + let endpoint = request + .extensions() + .get::() + .map_or("/api/mcp/gateway", |e| e.0); + let response = next.run(request).await; if response.status() == StatusCode::UNAUTHORIZED { let base_url = BASE_URL.load(); - let resource_url = format!("{}/api/mcp/gateway", base_url); + let resource_url = format!("{}{}", base_url, endpoint); let www_authenticate = format!("Bearer resource=\"{}\"", resource_url); let (mut parts, body) = response.into_parts(); @@ -585,11 +700,12 @@ pub async fn setup_mcp_server( user_db: UserDB, base_internal_url: String, auth_cache: Arc, -) -> anyhow::Result<(Router, CancellationToken)> { +) -> anyhow::Result<(Router, Router, CancellationToken)> { let cancellation_token = CancellationToken::new(); let session_manager = Arc::new(LocalSessionManager::default()); let backend = WindmillBackend::new(db, user_db, base_internal_url, auth_cache); + let session_runner = SessionRunner::new(backend.clone()); let runner = Runner::new(backend); let service_config = StreamableHttpServerConfig::default() @@ -611,11 +727,38 @@ pub async fn setup_mcp_server( // much smaller default, which would 413 them with no way to raise it. .with_max_request_body_bytes(*crate::REQUEST_SIZE_LIMIT.read().await); + let session_service = StreamableHttpService::new( + move || Ok(session_runner.clone()), + session_manager.clone(), + service_config.clone(), + ); let service = StreamableHttpService::new(move || Ok(runner.clone()), session_manager, service_config); - let router = Router::new().route_service("/", service); - Ok((router, cancellation_token)) + let router = Router::new().route_service("/", service.clone()); + // `/api/mcp` serves both servers: a token scoped to a selection of scripts, flows and + // endpoints gets the tools server, any other the session's toolset. At `/mcp` itself, + // beside the `/mcp/...` routes, so it is merged rather than nested. + let session_router = Router::new().route( + "/mcp", + axum::routing::any(move |mut request: Request| { + let (service, session_service) = (service.clone(), session_service.clone()); + async move { + use tower::ServiceExt; + let response = if request.extensions().get::().is_some() { + request.extensions_mut().insert(NoEndpointTools); + service.oneshot(request).await + } else { + session_service.oneshot(request).await + }; + match response { + Ok(response) => response.into_response(), + Err(never) => match never {}, + } + } + }), + ); + Ok((router, session_router, cancellation_token)) } /// HTTP handler to list MCP tools as JSON @@ -628,3 +771,62 @@ async fn list_mcp_tools_handler() -> JsonResult> { pub fn list_tools_service() -> Router { Router::new().route("/", get(list_mcp_tools_handler)) } + +#[derive(serde::Deserialize)] +struct ToolsetQuery { + access: Option, +} + +#[derive(serde::Serialize)] +struct ToolsetEntry { + name: String, + description: String, +} + +/// The tools `/api/mcp` offers an `mcp:all` token at each access level, for the token +/// form to show: the server's own filter, so the list cannot drift from it. +async fn list_toolset_handler( + axum::extract::Query(query): axum::extract::Query, +) -> JsonResult> { + let access = match query.access.as_deref() { + Some("read") => SessionAccess::ReadOnly, + Some("read_run") => SessionAccess::ReadAndRun, + _ => SessionAccess::Full, + }; + Ok(Json( + session::session_tools(access) + .into_iter() + .map(|tool| ToolsetEntry { + name: tool.name.to_string(), + description: tool.description.map(|d| d.to_string()).unwrap_or_default(), + }) + .collect(), + )) +} + +pub fn toolset_service() -> Router { + Router::new().route("/", get(list_toolset_handler)) +} + +#[cfg(test)] +mod tests { + use super::scoped_to_selection; + + #[test] + fn only_a_token_without_mcp_all_is_a_selection() { + let selection = |scopes: &[&str]| { + scoped_to_selection(&scopes.iter().map(|s| s.to_string()).collect::>()) + }; + assert!(selection(&["mcp:favorites"])); + assert!(selection(&["mcp:scripts:f/x/*", "mcp:endpoints:listJobs"])); + assert!(selection(&["mcp:all:f/x/*"])); + assert!(!selection(&["mcp:all"])); + assert!(!selection(&[ + "mcp:all", + "mcp:read_run", + "mcp:workspaces:a,b" + ])); + assert!(!selection(&[])); + assert!(!selection(&["variables:read"])); + } +} diff --git a/backend/windmill-api/src/mcp/mod.rs b/backend/windmill-api/src/mcp/mod.rs index 5545d59a9f..20c5532b29 100644 --- a/backend/windmill-api/src/mcp/mod.rs +++ b/backend/windmill-api/src/mcp/mod.rs @@ -5,6 +5,7 @@ mod auto_generated_endpoints; mod core; +mod session; mod utils; // Re-export only what's needed externally @@ -12,5 +13,6 @@ pub mod oauth_server; pub use core::{ add_www_authenticate_header, add_www_authenticate_header_gateway, extract_and_store_workspace_id, extract_workspace_from_token, list_tools_service, - reject_token_query_param, setup_mcp_server, + reject_token_query_param, setup_mcp_server, toolset_service, McpEndpoint, }; +pub(crate) use session::register_router; diff --git a/backend/windmill-api/src/mcp/oauth_server.rs b/backend/windmill-api/src/mcp/oauth_server.rs index d7c8c35586..de07647305 100644 --- a/backend/windmill-api/src/mcp/oauth_server.rs +++ b/backend/windmill-api/src/mcp/oauth_server.rs @@ -201,7 +201,7 @@ struct AuthorizationCode { code: String, client_id: String, user_email: String, - workspace_id: String, + workspace_id: Option, scopes: Vec, redirect_uri: String, code_challenge: Option, @@ -216,7 +216,7 @@ struct RefreshTokenRow { access_token_hash: String, client_id: String, user_email: String, - workspace_id: String, + workspace_id: Option, scopes: Vec, token_family: sqlx::types::Uuid, created_at: chrono::DateTime, @@ -232,6 +232,9 @@ fn supported_scopes() -> Vec { "mcp:scripts:*".to_string(), "mcp:flows:*".to_string(), "mcp:endpoints:*".to_string(), + "mcp:read_only".to_string(), + "mcp:read_run".to_string(), + "mcp:runnables_as_tools".to_string(), ] } @@ -365,18 +368,59 @@ pub async fn oauth_token( Extension(db): Extension, Form(req): Form, ) -> std::result::Result, OAuthTokenError> { - match req.grant_type.as_str() { - "authorization_code" => handle_authorization_code_grant(&db, &req).await, - "refresh_token" => handle_refresh_token_grant(&db, &req).await, - _ => Err(OAuthTokenError::unsupported_grant_type( - "Supported grant types: authorization_code, refresh_token", - )), + let db_error = |e: sqlx::Error| { + tracing::error!("Database error issuing MCP OAuth token: {}", e); + OAuthTokenError::server_error("Database error") + }; + let owner = match req.grant_type.as_str() { + "authorization_code" => sqlx::query_scalar!( + "SELECT user_email FROM mcp_oauth_server_code WHERE code = $1", + req.code + ) + .fetch_optional(&db) + .await + .map_err(db_error)?, + "refresh_token" => sqlx::query_scalar!( + "SELECT user_email FROM mcp_oauth_refresh_token WHERE refresh_token = $1", + req.refresh_token.as_deref().unwrap_or_default() + ) + .fetch_optional(&db) + .await + .map_err(db_error)?, + _ => { + return Err(OAuthTokenError::unsupported_grant_type( + "Supported grant types: authorization_code, refresh_token", + )) + } + }; + let mut tx = db.begin().await.map_err(db_error)?; + if let Some(email) = &owner { + crate::users::lock_mcp_oauth_grants(&mut tx, email, false) + .await + .map_err(db_error)?; + } + let res = if req.grant_type == "authorization_code" { + handle_authorization_code_grant(&mut tx, &req).await + } else { + handle_refresh_token_grant(&mut tx, &req).await + }; + // Committed on error too, so a rejected exchange still consumes its code or refresh token. + let committed = tx.commit().await; + match (res, committed) { + (Ok(res), Ok(())) => Ok(res), + (Err(e), committed) => { + if let Err(ce) = committed { + tracing::error!("Failed to commit rejected MCP OAuth exchange: {}", ce); + } + Err(e) + } + (Ok(_), Err(e)) => Err(db_error(e)), } } /// Handle authorization_code grant type async fn handle_authorization_code_grant( - db: &DB, + tx: &mut sqlx::PgConnection, req: &TokenRequest, ) -> std::result::Result, OAuthTokenError> { let auth_code = match sqlx::query_as!( @@ -387,7 +431,7 @@ async fn handle_authorization_code_grant( code_challenge, code_challenge_method", req.code ) - .fetch_optional(db) + .fetch_optional(&mut *tx) .await { Ok(Some(code)) => code, @@ -458,7 +502,7 @@ async fn handle_authorization_code_grant( &scopes, auth_code.workspace_id, ) - .execute(db) + .execute(&mut *tx) .await .map_err(|e| { tracing::error!("Failed to create access token: {}", e); @@ -471,7 +515,7 @@ async fn handle_authorization_code_grant( } // Create refresh token — store the hash of the access token so we can delete it later - let refresh_token_result = sqlx::query!( + sqlx::query!( "INSERT INTO mcp_oauth_refresh_token (refresh_token, access_token_hash, client_id, user_email, workspace_id, scopes, token_family, expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7, now() + ($8 || ' seconds')::interval)", @@ -484,29 +528,25 @@ async fn handle_authorization_code_grant( token_family, MCP_OAUTH_REFRESH_TOKEN_EXPIRATION_SECS.to_string(), ) - .execute(db) - .await; - - let refresh_token_value = match refresh_token_result { - Ok(_) => Some(refresh_token), - Err(e) => { - tracing::error!("Failed to create refresh token: {}", e); - None // Don't include invalid refresh token - } - }; + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Failed to create refresh token: {}", e); + OAuthTokenError::server_error("Failed to create refresh token") + })?; Ok(Json(TokenResponse { access_token, token_type: "Bearer".to_string(), expires_in: MCP_OAUTH_TOKEN_EXPIRATION_SECS, scope: Some(scopes.join(" ")), - refresh_token: refresh_token_value, + refresh_token: Some(refresh_token), })) } /// Handle refresh_token grant type with token rotation and theft detection async fn handle_refresh_token_grant( - db: &DB, + tx: &mut sqlx::PgConnection, req: &TokenRequest, ) -> std::result::Result, OAuthTokenError> { let refresh_token_value = req @@ -533,7 +573,7 @@ async fn handle_refresh_token_grant( refresh_token_value, req.client_id ) - .fetch_optional(db) + .fetch_optional(&mut *tx) .await { Ok(Some(row)) => row, @@ -544,7 +584,7 @@ async fn handle_refresh_token_grant( WHERE refresh_token = $1 AND used_at IS NOT NULL", refresh_token_value ) - .fetch_optional(db) + .fetch_optional(&mut *tx) .await { tracing::warn!("Refresh token reuse detected, revoking family {:?}", family); @@ -552,7 +592,7 @@ async fn handle_refresh_token_grant( "UPDATE mcp_oauth_refresh_token SET revoked = TRUE WHERE token_family = $1", family ) - .execute(db) + .execute(&mut *tx) .await; } return Err(OAuthTokenError::invalid_grant("Invalid refresh token")); @@ -564,16 +604,16 @@ async fn handle_refresh_token_grant( }; // Delete old access token using the stored hash - if let Err(e) = sqlx::query!( + sqlx::query!( "DELETE FROM token WHERE token_hash = $1", token_row.access_token_hash ) - .execute(db) + .execute(&mut *tx) .await - { + .map_err(|e| { tracing::error!("Failed to delete old access token: {}", e); - // Non-fatal, continue with token creation - } + OAuthTokenError::server_error("Database error") + })?; // Generate new tokens let new_access_token = rd_string(32); @@ -605,7 +645,7 @@ async fn handle_refresh_token_grant( &scopes, token_row.workspace_id, ) - .execute(db) + .execute(&mut *tx) .await .map_err(|e| { tracing::error!("Failed to create new access token: {}", e); @@ -618,7 +658,7 @@ async fn handle_refresh_token_grant( } // Create new refresh token (same token family for tracking) — store hash of access token - if let Err(e) = sqlx::query!( + sqlx::query!( "INSERT INTO mcp_oauth_refresh_token (refresh_token, access_token_hash, client_id, user_email, workspace_id, scopes, token_family, expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7, now() + ($8 || ' seconds')::interval)", @@ -631,12 +671,12 @@ async fn handle_refresh_token_grant( token_row.token_family, MCP_OAUTH_REFRESH_TOKEN_EXPIRATION_SECS.to_string(), ) - .execute(db) + .execute(&mut *tx) .await - { + .map_err(|e| { tracing::error!("Failed to create new refresh token: {}", e); - // Access token was created, return success without refresh token - } + OAuthTokenError::server_error("Failed to create refresh token") + })?; Ok(Json(TokenResponse { access_token: new_access_token, @@ -758,10 +798,31 @@ async fn oauth_authorize_inner( } /// Shared approve logic — validates form, checks membership, stores auth code. +async fn ensure_member(db: &DB, workspace_id: &str, email: &str) -> Result<()> { + let is_member = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2 AND NOT disabled)", + workspace_id, + email + ) + .fetch_one(db) + .await + .map_err(|e| Error::InternalErr(format!("Database error: {}", e)))? + .unwrap_or(false); + + if !is_member { + return Err(Error::NotAuthorized(format!( + "User is not a member of workspace '{workspace_id}'" + ))); + } + Ok(()) +} + +/// `workspace_id` is the workspace the minted token is bound to; without one, the token +/// reaches what its scopes allow (`mcp:workspaces:`), or every workspace of its owner. async fn oauth_approve_inner( db: &DB, authed: &ApiAuthed, - workspace_id: &str, + workspace_id: Option<&str>, form: ApprovalForm, ) -> Result> { // The code approved here is exchanged for a database token carrying only this @@ -771,21 +832,8 @@ async fn oauth_approve_inner( // the gateway approve route reach the exchange through here. forbid_elevated_job_token(db, &authed.email, authed.job_id).await?; - // Verify user is a member of the workspace - let is_member = sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2 AND NOT disabled)", - workspace_id, - authed.email - ) - .fetch_one(db) - .await - .map_err(|e| Error::InternalErr(format!("Database error: {}", e)))? - .unwrap_or(false); - - if !is_member { - return Err(Error::NotAuthorized( - "User is not a member of this workspace".to_string(), - )); + if let Some(workspace_id) = workspace_id { + ensure_member(db, workspace_id, &authed.email).await?; } // Verify client exists and redirect_uri is registered @@ -908,7 +956,7 @@ pub async fn workspaced_oauth_approve( authed: ApiAuthed, Json(form): Json, ) -> Result> { - oauth_approve_inner(&db, &authed, &workspace_id, form).await + oauth_approve_inner(&db, &authed, Some(&workspace_id), form).await } /// PKCE validation (S256 only) @@ -990,6 +1038,16 @@ pub async fn gateway_protected_resource_metadata() -> Json Json { + let base_url = BASE_URL.load(); + Json(build_protected_resource_metadata( + "/api/mcp", + "/api/mcp/gateway/oauth/server", + &base_url, + )) +} + /// GET /api/mcp/gateway/oauth/server/authorize — gateway mode (no workspace in path) pub async fn gateway_oauth_authorize( Extension(db): Extension, @@ -998,23 +1056,44 @@ pub async fn gateway_oauth_authorize( oauth_authorize_inner(&db, params, None).await } -/// POST /api/mcp/gateway/oauth/server/approve — workspace_id comes from JSON body +/// POST /api/mcp/gateway/oauth/server/approve — the workspaces come from the JSON body pub async fn gateway_oauth_approve( Extension(db): Extension, authed: ApiAuthed, Json(form): Json, ) -> Result> { - if form.workspace_id.is_empty() { - return Err(Error::BadRequest("workspace_id is required".to_string())); + let mut scope = form.scope; + let mut workspace_ids = form.workspace_ids; + if workspace_ids.is_empty() { + workspace_ids.extend(form.workspace_id); } + let workspace_id = if form.all_workspaces { + None + } else { + match workspace_ids.as_slice() { + [] => { + return Err(Error::BadRequest( + "Pick at least one workspace, or all of them".to_string(), + )) + } + [only] => Some(only.as_str()), + several => { + for workspace_id in several { + ensure_member(&db, workspace_id, &authed.email).await?; + } + scope.push_str(&format!(" mcp:workspaces:{}", several.join(","))); + None + } + } + }; oauth_approve_inner( &db, &authed, - &form.workspace_id, + workspace_id, ApprovalForm { client_id: form.client_id, redirect_uri: form.redirect_uri, - scope: form.scope, + scope, state: form.state, code_challenge: form.code_challenge, code_challenge_method: form.code_challenge_method, @@ -1023,7 +1102,8 @@ pub async fn gateway_oauth_approve( .await } -/// Gateway approval form — same as ApprovalForm but includes workspace_id +/// Gateway approval form — same as ApprovalForm, plus the workspaces the token reaches: +/// one binds the token to it, several are listed in its scopes. #[derive(Debug, Deserialize)] pub struct GatewayApprovalForm { pub client_id: String, @@ -1032,7 +1112,14 @@ pub struct GatewayApprovalForm { pub state: String, pub code_challenge: String, pub code_challenge_method: String, - pub workspace_id: String, + #[serde(default)] + pub workspace_ids: Vec, + /// The single workspace a consent page loaded before `workspace_ids` existed sends. + #[serde(default)] + pub workspace_id: Option, + /// Every workspace the user is a member of, including ones they join later. + #[serde(default)] + pub all_workspaces: bool, } // ── Router constructors ── diff --git a/backend/windmill-api/src/mcp/session/apps.rs b/backend/windmill-api/src/mcp/session/apps.rs new file mode 100644 index 0000000000..66ad43de47 --- /dev/null +++ b/backend/windmill-api/src/mcp/session/apps.rs @@ -0,0 +1,1282 @@ +//! Raw app drafts: files, backend runnables and their test runs. + +use std::cmp::Ordering; +use std::collections::HashSet; +use std::sync::LazyLock; + +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; + +use super::runs::finish_run; +use super::workspace::{ + api_message, app_source_to_draft_value, deployed_item_exists, find_and_replace, get_deployed, + get_draft, item_exists, normalize_app_draft, path_enc, persist_draft, Persist, +}; +use super::{ + bool_arg, js_head, js_len, matches_glob, opt_str_arg, pretty, str_arg, truthy, Ctx, ToolResult, +}; + +// `init_app` is dispatched below but stays out of this list until `gen/app_templates.json` +// is emitted by the generator. +pub(super) const TOOLS: &[&str] = &[ + "init_app", + "read_app_file", + "search_app", + "write_app_file", + "delete_app_file", + "patch_app_file", + "write_app_runnable", + "delete_app_runnable", + "test_run_app_runnable", +]; + +pub(super) async fn call(ctx: &Ctx<'_>, name: &str, args: &Value) -> ToolResult { + match name { + "init_app" => init_app(ctx, args).await, + "read_app_file" => read_app_file(ctx, args).await, + "search_app" => search_app(ctx, args).await, + "write_app_file" => write_app_file(ctx, args).await, + "delete_app_file" => delete_app_file(ctx, args).await, + "patch_app_file" => patch_app_file(ctx, args).await, + "write_app_runnable" => write_app_runnable(ctx, args).await, + "delete_app_runnable" => delete_app_runnable(ctx, args).await, + "test_run_app_runnable" => test_run_app_runnable(ctx, args).await, + _ => unreachable!("{name} is not a apps tool"), + } +} + +const GENERATED_FILE: &str = "/wmill.d.ts"; + +const READ_DEFAULT_LINE_LIMIT: i64 = 1500; +const READ_CHAR_BUDGET: usize = 50_000; + +const SEARCH_DEFAULT_MAX_MATCHES: i64 = 100; +const SEARCH_MAX_MATCHES_CEILING: i64 = 200; +const SEARCH_MAX_LINE_CHARS: usize = 200; +const SEARCH_TOTAL_CHAR_BUDGET: usize = 12_000; +const SEARCH_CONTEXT_LINES: usize = 2; + +const MAX_TOOL_ERROR_LENGTH: usize = 2000; + +/// `{templates: {: {: content}}, starter_runnables: {: runnable}}`, +/// the frontend's `FRAMEWORK_TEMPLATES` and `STARTER_RUNNABLES`. +static APP_TEMPLATES: LazyLock = LazyLock::new(|| { + serde_json::from_str(include_str!("gen/app_templates.json")) + .expect("gen/app_templates.json is valid") +}); + +// --------------------------------------------------------------------------- +// JS-shaped helpers +// --------------------------------------------------------------------------- + +fn entries(value: &Value, key: &str) -> Map { + value + .get(key) + .and_then(Value::as_object) + .cloned() + .unwrap_or_default() +} + +/// The object under `key`, created (or replacing a non-object) when missing. +fn object_mut<'a>(value: &'a mut Value, key: &str) -> &'a mut Map { + let slot = &mut value[key]; + if !slot.is_object() { + *slot = json!({}); + } + slot.as_object_mut().expect("just made an object") +} + +fn text_of<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + value.get(key).and_then(Value::as_str) +} + +fn is_inline(runnable: &Value) -> bool { + matches!(text_of(runnable, "type"), Some("inline" | "runnableByName")) +} + +fn is_by_path(runnable: &Value) -> bool { + matches!(text_of(runnable, "type"), Some("path" | "runnableByPath")) +} + +// --------------------------------------------------------------------------- +// File targets +// --------------------------------------------------------------------------- + +enum Target { + Frontend(String), + Backend { file_path: String, key: String, extension: &'static str }, +} + +impl Target { + fn file_path(&self) -> &str { + match self { + Target::Frontend(file_path) | Target::Backend { file_path, .. } => file_path, + } + } +} + +fn resolve_target(raw: &str) -> Target { + let trimmed = raw.trim(); + let backend = trimmed.strip_prefix("backend/").and_then(|rest| { + let (key, extension) = rest + .strip_suffix("/main.ts") + .map(|key| (key, "ts")) + .or_else(|| rest.strip_suffix("/main.py").map(|key| (key, "py")))?; + (!key.is_empty() && !key.contains('/')).then_some((key, extension)) + }); + match backend { + Some((key, extension)) => { + Target::Backend { file_path: trimmed.to_string(), key: key.to_string(), extension } + } + None if trimmed.starts_with('/') => Target::Frontend(trimmed.to_string()), + None => Target::Frontend(format!("/{trimmed}")), + } +} + +fn assert_not_generated(file_path: &str) -> Result<(), String> { + if file_path == GENERATED_FILE { + return Err(format!( + "\"{file_path}\" is generated automatically from backend runnables and cannot be modified directly." + )); + } + Ok(()) +} + +fn inline_extension(runnable: &Value) -> &'static str { + let language = runnable.get("inlineScript").and_then(|s| s.get("language")); + if language.and_then(Value::as_str) == Some("python3") { + "py" + } else { + "ts" + } +} + +/// The inline script behind a `backend//main.` target, and its runnable. +fn inline_runnable_content<'a>( + value: &'a Value, + key: &str, + extension: &str, + app_path: &str, +) -> Result<(&'a str, &'a Value), String> { + let runnable = value + .get("runnables") + .and_then(|r| r.get(key)) + .filter(|r| truthy(Some(r))) + .ok_or_else(|| format!("Backend runnable \"{key}\" not found in app \"{app_path}\"."))?; + if !is_inline(runnable) { + return Err(format!( + "Runnable \"{key}\" is not inline. Use read_workspace_item on the referenced {} instead.", + text_of(runnable, "runType").unwrap_or("item") + )); + } + let expected = inline_extension(runnable); + if extension != expected { + return Err(format!( + "Runnable \"{key}\" language is {expected}. Use backend/{key}/main.{expected}." + )); + } + let content = runnable + .get("inlineScript") + .and_then(|s| text_of(s, "content")) + .unwrap_or_default(); + Ok((content, runnable)) +} + +// --------------------------------------------------------------------------- +// Loading and saving the draft +// --------------------------------------------------------------------------- + +struct Loaded { + value: Value, + last_sync: Option, +} + +/// The caller's draft of the app, else the deployed app shaped as one. +async fn load_app(ctx: &Ctx<'_>, path: &str) -> Result { + let draft = get_draft(ctx, "raw_app", path).await?; + let mut last_sync = None; + if let Some(draft) = draft { + if draft.value.get("files").is_some() { + return Ok(Loaded { value: draft.value, last_sync: draft.created_at }); + } + last_sync = draft.created_at; + } + let app = get_deployed(ctx, "app", None, path) + .await + .map_err(|e| api_message(&e).to_string())?; + // Only an explicit false: an app known by its draft alone carries no version, so no + // `raw_app`, and must not read as low-code. + if app.get("raw_app") == Some(&Value::Bool(false)) { + return Err(format!( + "\"{path}\" is a low-code app. This chat only edits code-based apps — open it in the app editor instead." + )); + } + Ok(Loaded { value: app_source_to_draft_value(&app, Some(&app)), last_sync }) +} + +/// What the backend requires of a draft from an operator with builder rights: sandboxed, +/// never in viewer mode, and running as nobody but its builder. +async fn conform_builder_policy(ctx: &Ctx<'_>, value: &mut Value) { + if !ctx.auth.is_operator { + return; + } + let Ok(memberships) = ctx.get("/workspaces/users").await else { + return; + }; + let builder = memberships + .get("workspaces") + .and_then(Value::as_array) + .and_then(|list| list.iter().find(|w| text_of(w, "id") == Some(ctx.w_id))) + .and_then(|w| w.get("operator_settings")) + .and_then(|s| s.get("builder_apps")) + == Some(&Value::Bool(true)); + if !builder { + return; + } + let policy = object_mut(value, "policy"); + policy.insert("sandbox".into(), json!(true)); + if policy.get("execution_mode").and_then(Value::as_str) == Some("viewer") { + policy.insert("execution_mode".into(), json!("publisher")); + } + let username = &ctx.auth.username; + let this_user = if username.contains('@') { + username.clone() + } else { + format!("u/{username}") + }; + let differs = |key: &str, own: &str| { + policy + .get(key) + .is_some_and(|v| !v.is_null() && v.as_str() != Some(own)) + }; + if differs("on_behalf_of", &this_user) || differs("on_behalf_of_email", &ctx.auth.email) { + policy.shift_remove("on_behalf_of"); + policy.shift_remove("on_behalf_of_email"); + } +} + +/// Save the draft and report: the success JSON, or the conflict / failed-save JSON. +async fn save_app( + ctx: &Ctx<'_>, + path: &str, + mut value: Value, + last_sync: Option<&str>, + message: String, + warning: Option, +) -> ToolResult { + conform_builder_policy(ctx, &mut value).await; + let value = normalize_app_draft(&value); + match persist_draft(ctx, "app", path, None, &value, last_sync, false).await? { + Persist::Failed(text) => Ok(text), + Persist::Saved(_) => { + let mut out = Map::new(); + out.insert("success".into(), json!(true)); + out.insert("message".into(), json!(message)); + out.insert( + "preview_url".into(), + json!(ctx.page_url(&format!( + "/apps_raw/edit/{path}?view=preview&nomenubar=true" + ))), + ); + if let Some(warning) = warning { + out.insert("warning".into(), json!(warning)); + } + Ok(pretty(&Value::Object(out))) + } + } +} + +fn sha256_hex(text: &str) -> String { + hex::encode(Sha256::digest(text.as_bytes())) +} + +/// Rebuild `policy.triggerables_v2` from the runnables: the key names what a runnable +/// may execute (an inline script by the hash of its code), the entry which of its inputs +/// the caller cannot set. +fn recompute_policy(value: &mut Value) { + let mut triggerables = Map::new(); + for (id, runnable) in entries(value, "runnables") { + let target = if is_inline(&runnable) { + let content = runnable + .get("inlineScript") + .and_then(|s| text_of(s, "content")) + .unwrap_or_default(); + format!("rawscript/{}", sha256_hex(content)) + } else if is_by_path(&runnable) { + let run_type = match text_of(&runnable, "runType") { + Some("hubscript") => "script", + Some(other) => other, + None => "undefined", + }; + format!( + "{run_type}/{}", + text_of(&runnable, "path").unwrap_or("undefined") + ) + } else { + continue; + }; + let fields = entries(&runnable, "fields"); + let flagged = |flag: &str| -> Vec<&String> { + fields + .iter() + .filter(|(_, field)| truthy(field.get(flag))) + .map(|(name, _)| name) + .collect() + }; + let static_inputs: Map = fields + .iter() + .filter(|(_, field)| text_of(field, "type") == Some("static")) + .filter_map(|(name, field)| Some((name.clone(), field.get("value")?.clone()))) + .collect(); + let mut entry = Map::new(); + entry.insert("static_inputs".into(), Value::Object(static_inputs)); + entry.insert("one_of_inputs".into(), json!({})); + entry.insert( + "allow_user_resources".into(), + json!(flagged("allowUserResources")), + ); + if let Some(secs) = runnable + .get("delete_after_secs") + .filter(|s| s.as_f64().is_some_and(|s| s >= 0.0)) + { + entry.insert("delete_after_secs".into(), secs.clone()); + } + let sensitive = flagged("sensitive"); + if !sensitive.is_empty() { + entry.insert("sensitive_inputs".into(), json!(sensitive)); + } + if let Some(tag) = runnable + .get("inlineScript") + .and_then(|s| s.get("tag")) + .filter(|tag| truthy(Some(tag))) + { + entry.insert("tag".into(), tag.clone()); + } + // The server honors it on inline runnables only, whose code is deployed with the app. + if is_inline(&runnable) && runnable.get("viewer_data_access") == Some(&Value::Bool(true)) { + entry.insert("viewer_data_access".into(), json!(true)); + } + triggerables.insert(format!("{id}:{target}"), Value::Object(entry)); + } + let policy = object_mut(value, "policy"); + policy.insert("triggerables_v2".into(), Value::Object(triggerables)); + if !truthy(policy.get("execution_mode")) { + policy.insert("execution_mode".into(), json!("publisher")); + } +} + +// --------------------------------------------------------------------------- +// init_app +// --------------------------------------------------------------------------- + +/// A table reference canonicalized to `/` or `/:
`. +/// A bare name is a table of the app's own schema. +fn app_table_ref(datatable: &str, schema: Option<&str>, reference: &str) -> String { + if reference.contains('/') { + return reference.to_string(); + } + let separator = if reference.contains(':') { ':' } else { '.' }; + let (schema, table) = match reference.split_once(separator) { + Some((schema, table)) => (Some(schema), table), + None => (schema, reference), + }; + match schema { + _ if table.is_empty() => datatable.to_string(), + None | Some("" | "public") => format!("{datatable}/{table}"), + Some(schema) => format!("{datatable}/{schema}:{table}"), + } +} + +async fn init_app(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let framework = str_arg(args, "framework")?; + let template = APP_TEMPLATES["templates"] + .get(framework) + .ok_or_else(|| format!("Invalid framework \"{framework}\""))?; + + if get_draft(ctx, "raw_app", path).await?.is_some() { + return Err(format!( + "A draft for app \"{path}\" already exists. Use write_app_file / write_app_runnable to modify it, or delete the existing draft first." + )); + } + if item_exists(ctx, "app", None, path).await? { + return Err(format!( + "An app already exists at \"{path}\". Use read_workspace_item + write_app_file / write_app_runnable to modify it." + )); + } + + let starters = &APP_TEMPLATES["starter_runnables"]; + let mut value = Map::new(); + if let Some(summary) = opt_str_arg(args, "summary") { + value.insert("summary".into(), json!(summary)); + } + value.insert("files".into(), template.clone()); + value.insert("runnables".into(), starters.clone()); + value.insert("policy".into(), json!({ "sandbox": true })); + let data = args.get("data").filter(|d| truthy(Some(d))); + let schema = data.and_then(|d| text_of(d, "schema")); + let datatable = data + .and_then(|d| text_of(d, "datatable")) + .unwrap_or_default(); + if let Some(data) = data { + let tables: Vec = data + .get("tables") + .and_then(Value::as_array) + .map(|tables| { + tables + .iter() + .filter_map(Value::as_str) + .map(|reference| app_table_ref(datatable, schema, reference)) + .collect() + }) + .unwrap_or_default(); + let mut out = Map::new(); + out.insert("tables".into(), json!(tables)); + out.insert("datatable".into(), json!(datatable)); + if let Some(schema) = schema { + out.insert("schema".into(), json!(schema)); + } + value.insert("data".into(), Value::Object(out)); + } + let mut value = Value::Object(value); + recompute_policy(&mut value); + + let starter_keys: Vec<&str> = starters + .as_object() + .map(|s| s.keys().map(String::as_str).collect()) + .unwrap_or_default(); + let data_note = match schema.filter(|s| !s.is_empty()) { + Some(schema) => format!(" Its data config names schema \"{schema}\" of data table \"{datatable}\" without creating it: if it does not exist yet, create it before the tables that go in it."), + None => String::new(), + }; + let message = format!( + "Initialized a per-user draft app \"{path}\" from the {framework} template (saved server-side, not a deployed workspace item). The template is a demo tour with starter runnables {}: replace its UI and delete the runnables the app does not need (delete_app_runnable). Use write_app_file / write_app_runnable to evolve it.{data_note} Open preview_url in a browser the user can see from this conversation if you have one, and otherwise give them the link: its live preview follows every later edit.", + starter_keys.join(", ") + ); + save_app(ctx, path, value, None, message, None).await +} + +// --------------------------------------------------------------------------- +// read_app_file +// --------------------------------------------------------------------------- + +fn format_file_read(content: &str, offset: Option, limit: Option) -> String { + let lines: Vec<&str> = content.split('\n').collect(); + let total = lines.len(); + let requested_start = offset.unwrap_or(1); + let start = (requested_start.saturating_sub(1).max(0) as usize).min(total); + let line_limit = limit.unwrap_or(READ_DEFAULT_LINE_LIMIT).max(0) as usize; + let end = start.saturating_add(line_limit).min(total); + let window = lines[start..end].join("\n"); + let window_chars = js_len(&window); + let char_truncated = window_chars > READ_CHAR_BUDGET; + let body = js_head(&window, READ_CHAR_BUDGET); + + if requested_start > total as i64 { + return format!("offset {requested_start} is past the end of the file ({total} lines)."); + } + if start == 0 && end == total && !char_truncated { + return body.to_string(); + } + let mut label = format!("lines {}-{end} of {total}", start + 1); + let mut more = String::new(); + if char_truncated { + label.push_str(&format!( + ", truncated to the first {READ_CHAR_BUDGET} of {window_chars} chars" + )); + more = format!(" Reached the {READ_CHAR_BUDGET}-char limit; re-read with a smaller limit (fewer lines). If a single line exceeds the limit the file is likely minified and not readable this way."); + } else if end < total { + more = format!( + " Call read_app_file again with offset={} to continue.", + end + 1 + ); + } + format!("{label}.{more}\n\n{body}") +} + +async fn read_app_file(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let target = resolve_target(str_arg(args, "file_path")?); + let value = load_app(ctx, path).await?.value; + let content = match &target { + Target::Frontend(file_path) => value + .get("files") + .and_then(|files| text_of(files, file_path)) + .ok_or_else(|| format!("Frontend file \"{file_path}\" not found in app \"{path}\"."))?, + Target::Backend { key, extension, .. } => { + inline_runnable_content(&value, key, extension, path)?.0 + } + }; + let int = |name: &str| args.get(name).and_then(Value::as_i64); + Ok(format_file_read(content, int("offset"), int("limit"))) +} + +// --------------------------------------------------------------------------- +// search_app +// --------------------------------------------------------------------------- + +/// Frontend files (the generated one aside) and inline runnables, each under the path +/// `read_app_file` takes. +fn searchable_files(value: &Value) -> Vec<(String, String)> { + let mut files: Vec<(String, String)> = entries(value, "files") + .into_iter() + .filter(|(file_path, _)| file_path != GENERATED_FILE) + .filter_map(|(file_path, content)| Some((file_path, content.as_str()?.to_string()))) + .collect(); + for (key, runnable) in entries(value, "runnables") { + let Some(content) = runnable + .get("inlineScript") + .and_then(|s| text_of(s, "content")) + else { + continue; + }; + files.push(( + format!("backend/{key}/main.{}", inline_extension(&runnable)), + content.to_string(), + )); + } + files +} + +// ponytail: approximates `localeCompare` (punctuation, then digits, then letters, case +// ignored until a tie) rather than full ICU collation; only the order files are listed in +// depends on it. +fn locale_compare(a: &str, b: &str) -> Ordering { + let key = |text: &str| -> Vec<(u8, char)> { + text.chars() + .flat_map(char::to_lowercase) + .map(|c| { + let class = if c.is_alphabetic() { + 2 + } else if c.is_numeric() { + 1 + } else { + 0 + }; + (class, c) + }) + .collect() + }; + key(a).cmp(&key(b)).then_with(|| b.cmp(a)) +} + +fn search_files( + mut files: Vec<(String, String)>, + query: &str, + file_glob: Option<&str>, + max_matches: i64, +) -> String { + let needle = query.to_lowercase(); + files.sort_by(|a, b| locale_compare(&a.0, &b.0)); + if let Some(glob) = file_glob { + files.retain(|(file_path, _)| matches_glob(file_path, glob)); + } + + // (file, 1-based line, text) rows: every matching line with its context window. + let mut rows: Vec<(&str, usize, &str)> = Vec::new(); + let (mut total, mut rendered, mut file_count, mut truncated) = (0usize, 0i64, 0usize, false); + for (file_path, content) in &files { + let lines: Vec<&str> = content.split('\n').collect(); + let mut file_had_match = false; + for (i, line) in lines.iter().enumerate() { + if !line.to_lowercase().contains(&needle) { + continue; + } + total += 1; + file_had_match = true; + if rendered >= max_matches { + truncated = true; + continue; + } + rendered += 1; + let lo = i.saturating_sub(SEARCH_CONTEXT_LINES); + let hi = (i + SEARCH_CONTEXT_LINES).min(lines.len() - 1); + for j in lo..=hi { + rows.push((file_path, j + 1, lines[j])); + } + } + if file_had_match { + file_count += 1; + } + } + + if total == 0 { + return format!( + "No matches. Try a broader or differently-spelled term{}.", + if file_glob.is_some() { + ", or drop the file_glob" + } else { + "" + } + ); + } + + let plural = |n: usize, one: &str, many: &str| { + if n == 1 { + one.to_string() + } else { + many.to_string() + } + }; + let header = format!( + "{total} {} in {file_count} {}{}", + plural(total, "match", "matches"), + plural(file_count, "file", "files"), + if truncated { + format!(" (showing the first {max_matches}; narrow with file_glob or a more specific query)") + } else { + String::new() + } + ); + + let mut spent = js_len(&header); + let mut out = vec![header]; + let mut current_file = ""; + let mut budget_hit = false; + let mut seen = HashSet::new(); + for (file_path, line, text) in rows { + if !seen.insert((file_path, line)) { + continue; + } + let text = if js_len(text) > SEARCH_MAX_LINE_CHARS { + format!("{}… [line truncated]", js_head(text, SEARCH_MAX_LINE_CHARS)) + } else { + text.to_string() + }; + let new_file = file_path != current_file; + let row = if new_file { + format!("{file_path}\n {line}: {text}") + } else { + format!(" {line}: {text}") + }; + if spent + js_len(&row) + 1 > SEARCH_TOTAL_CHAR_BUDGET { + budget_hit = true; + break; + } + if new_file { + current_file = file_path; + } + spent += js_len(&row) + 1; + out.push(row); + } + if budget_hit { + out.push( + "… output truncated at the context budget — narrow with file_glob or a more specific query." + .to_string(), + ); + } + out.join("\n") +} + +async fn search_app(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let query = str_arg(args, "query")?; + if query.is_empty() { + return Err("search_app requires a non-empty query.".to_string()); + } + let value = load_app(ctx, path).await?.value; + let max_matches = args + .get("max_matches") + .and_then(Value::as_i64) + .unwrap_or(SEARCH_DEFAULT_MAX_MATCHES) + .min(SEARCH_MAX_MATCHES_CEILING); + let file_glob = opt_str_arg(args, "file_glob").filter(|glob| !glob.is_empty()); + Ok(search_files( + searchable_files(&value), + query, + file_glob, + max_matches, + )) +} + +// --------------------------------------------------------------------------- +// File writes +// --------------------------------------------------------------------------- + +async fn write_app_file(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let content = str_arg(args, "content")?; + let Target::Frontend(file_path) = resolve_target(str_arg(args, "file_path")?) else { + return Err("write_app_file only writes frontend files. Use write_app_runnable to set inline backend script content.".to_string()); + }; + assert_not_generated(&file_path)?; + + let Loaded { mut value, last_sync } = load_app(ctx, path).await?; + object_mut(&mut value, "files").insert(file_path.clone(), json!(content)); + let message = format!("Updated draft app \"{path}\" with frontend file \"{file_path}\"."); + save_app(ctx, path, value, last_sync.as_deref(), message, None).await +} + +async fn delete_app_file(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let Target::Frontend(file_path) = resolve_target(str_arg(args, "file_path")?) else { + return Err("delete_app_file only deletes frontend files. Use delete_app_runnable for backend runnables.".to_string()); + }; + assert_not_generated(&file_path)?; + + let Loaded { mut value, last_sync } = load_app(ctx, path).await?; + if object_mut(&mut value, "files") + .shift_remove(&file_path) + .is_none() + { + return Err(format!( + "Frontend file \"{file_path}\" not found in app \"{path}\"." + )); + } + let message = format!("Removed \"{file_path}\" from draft app \"{path}\"."); + save_app(ctx, path, value, last_sync.as_deref(), message, None).await +} + +async fn patch_app_file(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let old = str_arg(args, "old_string")?; + let new = str_arg(args, "new_string")?; + let replace_all = bool_arg(args, "replace_all"); + let target = resolve_target(str_arg(args, "file_path")?); + if let Target::Frontend(file_path) = &target { + assert_not_generated(file_path)?; + } + + let Loaded { mut value, last_sync } = load_app(ctx, path).await?; + let label = format!("{} content", target.file_path()); + match &target { + Target::Frontend(file_path) => { + let current = value + .get("files") + .and_then(|files| text_of(files, file_path)) + .ok_or_else(|| { + format!("Frontend file \"{file_path}\" not found in app \"{path}\".") + })?; + let updated = find_and_replace(current, old, new, replace_all, &label)?; + object_mut(&mut value, "files").insert(file_path.clone(), json!(updated)); + } + Target::Backend { key, extension, .. } => { + let (current, runnable) = inline_runnable_content(&value, key, extension, path)?; + let updated = find_and_replace(current, old, new, replace_all, &label)?; + let language = runnable + .get("inlineScript") + .and_then(|s| s.get("language")) + .filter(|l| !l.is_null()) + .cloned() + .unwrap_or_else(|| json!(if *extension == "py" { "python3" } else { "bun" })); + let mut runnable = runnable.clone(); + runnable["inlineScript"] = json!({ "language": language, "content": updated }); + object_mut(&mut value, "runnables").insert(key.clone(), runnable); + } + } + let message = format!( + "Patched \"{}\" in draft app \"{path}\".", + target.file_path() + ); + save_app(ctx, path, value, last_sync.as_deref(), message, None).await +} + +// --------------------------------------------------------------------------- +// Runnable writes +// --------------------------------------------------------------------------- + +/// The stored runnable for a `write_app_runnable` input. Converting between kinds drops +/// the other kind's fields, and a path runnable's `schema` survives only while its target +/// is unchanged: it describes the item pointed at. `viewer_data_access` is inline-only. +fn build_persisted_runnable(input: &Value, existing: Option<&Value>) -> Result { + let name = str_arg(input, "name")?; + let kind = str_arg(input, "type")?; + let existing = existing.and_then(Value::as_object); + let fields = match input.get("staticInputs").and_then(Value::as_object) { + Some(static_inputs) => Value::Object( + static_inputs + .iter() + .map(|(k, v)| { + ( + k.clone(), + json!({ "type": "static", "value": v, "fieldType": "object" }), + ) + }) + .collect(), + ), + None => existing + .and_then(|e| e.get("fields")) + .filter(|f| !f.is_null()) + .cloned() + .unwrap_or_else(|| json!({})), + }; + let mut out = existing.cloned().unwrap_or_default(); + let carried_as_viewer = out.shift_remove("viewer_data_access") == Some(Value::Bool(true)); + for key in ["runType", "path", "inlineScript"] { + out.shift_remove(key); + } + out.insert("name".into(), json!(name)); + + if kind == "inline" { + let inline = input + .get("inlineScript") + .filter(|s| truthy(Some(s))) + .ok_or("inlineScript is required when runnable type is \"inline\".")?; + out.insert("type".into(), json!("inline")); + out.insert( + "inlineScript".into(), + json!({ "content": inline.get("content"), "language": inline.get("language") }), + ); + let viewer_data_access = input + .get("viewerDataAccess") + .and_then(Value::as_bool) + .unwrap_or(carried_as_viewer); + if viewer_data_access { + out.insert("viewer_data_access".into(), json!(true)); + } + out.insert("fields".into(), fields); + return Ok(Value::Object(out)); + } + + let path = text_of(input, "path") + .filter(|p| !p.is_empty()) + .ok_or("path is required when runnable type is \"script\", \"flow\", or \"hubscript\".")?; + let same_target = existing.is_some_and(|e| { + matches!( + e.get("type").and_then(Value::as_str), + Some("path" | "runnableByPath") + ) && e.get("runType").and_then(Value::as_str) == Some(kind) + && e.get("path").and_then(Value::as_str) == Some(path) + }); + let schema = existing + .filter(|_| same_target) + .and_then(|e| e.get("schema")) + .filter(|s| !s.is_null()) + .cloned() + .unwrap_or_else(|| json!({})); + out.insert("type".into(), json!("path")); + out.insert("runType".into(), json!(kind)); + out.insert("path".into(), json!(path)); + out.insert("fields".into(), fields); + out.insert("schema".into(), schema); + Ok(Value::Object(out)) +} + +/// Whether the script or flow a path runnable names is deployed. None when it names +/// neither, or the probe failed: the answer only annotates a write. +async fn path_target_deployed(ctx: &Ctx<'_>, runnable: &Value) -> Option { + let kind = text_of(runnable, "runType").filter(|k| matches!(*k, "script" | "flow"))?; + let path = text_of(runnable, "path").filter(|p| !p.is_empty())?; + deployed_item_exists(ctx, kind, None, path).await.ok() +} + +async fn write_app_runnable(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let key = str_arg(args, "key")?; + let input = args + .get("runnable") + .filter(|r| r.is_object()) + .ok_or("Missing required object argument `runnable`")?; + + let Loaded { mut value, last_sync } = load_app(ctx, path).await?; + let existing = value.get("runnables").and_then(|r| r.get(key)); + let persisted = build_persisted_runnable(input, existing)?; + let warning = match path_target_deployed(ctx, &persisted).await { + Some(false) => Some(format!( + "This runnable points at an item that is NOT deployed (\"{key}\" → {} \"{}\"), so it fails at runtime — a path runnable runs the deployed item, never a draft. Offer to deploy just that item with deploy_workspace_item; the app itself does not need deploying, since the preview runs its draft.", + text_of(&persisted, "runType").unwrap_or_default(), + text_of(&persisted, "path").unwrap_or_default(), + )), + _ => None, + }; + object_mut(&mut value, "runnables").insert(key.to_string(), persisted); + recompute_policy(&mut value); + let message = format!("Updated draft app \"{path}\" with runnable \"{key}\"."); + save_app(ctx, path, value, last_sync.as_deref(), message, warning).await +} + +async fn delete_app_runnable(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let key = str_arg(args, "key")?; + let Loaded { mut value, last_sync } = load_app(ctx, path).await?; + if object_mut(&mut value, "runnables") + .shift_remove(key) + .is_none() + { + return Err(format!( + "Backend runnable \"{key}\" not found in app \"{path}\"." + )); + } + recompute_policy(&mut value); + let message = format!("Removed runnable \"{key}\" from draft app \"{path}\"."); + save_app(ctx, path, value, last_sync.as_deref(), message, None).await +} + +// --------------------------------------------------------------------------- +// test_run_app_runnable +// --------------------------------------------------------------------------- + +/// The `execute_component` body that runs a runnable the way the editor preview does. +fn execute_component_body(app_path: &str, key: &str, runnable: &Value, args: &Value) -> Value { + let fields = entries(runnable, "fields"); + let mut test_args = args.as_object().cloned().unwrap_or_default(); + let mut static_fields = Map::new(); + let mut sensitive = Vec::new(); + for (name, field) in &fields { + match text_of(field, "type") { + Some("static") => { + if let Some(value) = field.get("value") { + static_fields.insert(name.clone(), value.clone()); + } + } + // Filled by the server from `$ctx:`; left out, the argument is missing. + Some("ctx") => { + if let Some(prop) = text_of(field, "ctx").filter(|p| !p.is_empty()) { + test_args.insert(name.clone(), json!(format!("$ctx:{prop}"))); + } + } + Some("user") if truthy(field.get("sensitive")) => sensitive.push(name.clone()), + _ => {} + } + } + + let mut body = Map::new(); + body.insert("component".into(), json!(key)); + body.insert("args".into(), Value::Object(test_args)); + // Sent even when empty: its presence is what puts the server in preview mode, which + // is what lets inline draft code run at all. + body.insert( + "force_viewer_static_fields".into(), + Value::Object(static_fields), + ); + // The server encrypts a queued argument only when it is named here. + if !sensitive.is_empty() { + body.insert("force_viewer_sensitive_inputs".into(), json!(sensitive)); + } + if is_inline(runnable) { + if let Some(inline) = runnable.get("inlineScript").filter(|s| truthy(Some(s))) { + let id = inline.get("id").filter(|id| !id.is_null()); + let mut raw_code = Map::new(); + raw_code.insert( + "content".into(), + if id.is_some() { + json!("") + } else { + inline.get("content").cloned().unwrap_or_default() + }, + ); + raw_code.insert( + "language".into(), + inline + .get("language") + .filter(|l| !l.is_null()) + .cloned() + .unwrap_or_else(|| json!("")), + ); + raw_code.insert("path".into(), json!(format!("{app_path}/{key}"))); + match id { + Some(id) => { + body.insert("id".into(), id.clone()); + } + None => { + if let Some(lock) = inline.get("lock") { + raw_code.insert("lock".into(), lock.clone()); + } + } + } + if let Some(cache_ttl) = inline.get("cache_ttl") { + raw_code.insert("cache_ttl".into(), cache_ttl.clone()); + } + if let Some(tag) = inline.get("tag").filter(|tag| truthy(Some(tag))) { + raw_code.insert("tag".into(), tag.clone()); + } + body.insert("raw_code".into(), Value::Object(raw_code)); + } + } else if is_by_path(runnable) { + let run_type = match text_of(runnable, "runType") { + Some("hubscript") | None => "script", + Some(other) => other, + }; + body.insert( + "path".into(), + json!(format!( + "{run_type}/{}", + text_of(runnable, "path").unwrap_or_default() + )), + ); + } + Value::Object(body) +} + +async fn test_run_app_runnable(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let key = str_arg(args, "key")?; + let value = load_app(ctx, path).await?.value; + let runnables = entries(&value, "runnables"); + let Some(runnable) = runnables.get(key).filter(|r| truthy(Some(r))) else { + let known: Vec<&str> = runnables.keys().map(String::as_str).collect(); + return Err(format!( + "App \"{path}\" has no backend runnable \"{key}\".{}", + if known.is_empty() { + String::new() + } else { + format!(" Available runnables: {}.", known.join(", ")) + } + )); + }; + + let body = execute_component_body( + path, + key, + runnable, + args.get("args").unwrap_or(&Value::Null), + ); + let id = ctx + .request( + "POST", + &ctx.ws(&format!("apps_u/execute_component/{}", path_enc(path))), + Some(body), + ) + .await + .map_err(|e| { + let message = api_message(&e); + let message = if js_len(message) > MAX_TOOL_ERROR_LENGTH { + format!("{}... (truncated)", js_head(message, MAX_TOOL_ERROR_LENGTH)) + } else { + message.to_string() + }; + format!("Failed to execute test run: {message}") + })?; + let id = id.trim().trim_matches('"').to_string(); + + // A path runnable aimed at a flow queues a flow job, whose steps are worth a look. + let is_flow = text_of(runnable, "runType") == Some("flow"); + let label = format!("{path} / {key}"); + finish_run(ctx, Ok(id), &label, is_flow, "test", args).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn targets_resolve_to_frontend_files_or_inline_runnables() { + assert!(matches!(resolve_target(" App.tsx "), Target::Frontend(p) if p == "/App.tsx")); + assert!(matches!( + resolve_target("backend/a/main.py"), + Target::Backend { key, extension: "py", .. } if key == "a" + )); + // Not the virtual file of a runnable: a frontend path like any other. + assert!(matches!( + resolve_target("backend/a/b/main.ts"), + Target::Frontend(p) if p == "/backend/a/b/main.ts" + )); + } + + #[test] + fn file_reads_are_whole_or_annotated_windows() { + assert_eq!(format_file_read("a\nb", None, None), "a\nb"); + assert_eq!( + format_file_read("a\nb\nc", Some(2), Some(1)), + "lines 2-2 of 3. Call read_app_file again with offset=3 to continue.\n\nb" + ); + assert_eq!( + format_file_read("a\nb\nc", Some(2), None), + "lines 2-3 of 3.\n\nb\nc" + ); + assert_eq!( + format_file_read("a", Some(5), None), + "offset 5 is past the end of the file (1 lines)." + ); + let long = "x".repeat(READ_CHAR_BUDGET + 1); + assert!(format_file_read(&long, None, None).starts_with( + "lines 1-1 of 1, truncated to the first 50000 of 50001 chars. Reached the 50000-char limit;" + )); + } + + #[test] + fn search_caps_matching_lines_and_dedupes_context() { + let files = vec![ + ( + "/b.ts".to_string(), + "one\nFoo()\nfoo()\nfour\nfive\nsix".to_string(), + ), + ("/App.tsx".to_string(), "foo".to_string()), + ]; + assert_eq!( + search_files(files.clone(), "foo", None, 100), + "3 matches in 2 files\n/App.tsx\n 1: foo\n/b.ts\n 1: one\n 2: Foo()\n 3: foo()\n 4: four\n 5: five" + ); + assert_eq!( + search_files(files.clone(), "foo", Some("*.ts"), 1), + "2 matches in 1 file (showing the first 1; narrow with file_glob or a more specific query)\n/b.ts\n 1: one\n 2: Foo()\n 3: foo()\n 4: four" + ); + assert_eq!( + search_files(files, "nope", Some("*.ts"), 100), + "No matches. Try a broader or differently-spelled term, or drop the file_glob." + ); + } + + #[test] + fn globs_match_the_name_or_the_full_path() { + assert!(matches_glob("/components/Button.tsx", "*.tsx")); + assert!(!matches_glob("/components/Button.tsx", "/*.tsx")); + assert!(matches_glob("/components/Button.tsx", "/components/*")); + assert!(matches_glob("backend/a/main.ts", "backend/**")); + assert!(!matches_glob("/a.tsx", "?.ts")); + } + + #[test] + fn runnable_kind_changes_drop_the_other_kinds_fields() { + let existing = json!({ + "name": "old", "type": "path", "runType": "script", "path": "u/a/s", + "fields": { "x": { "type": "static", "value": 1 } }, "schema": { "k": 1 } + }); + let same = build_persisted_runnable( + &json!({ "name": "n", "type": "script", "path": "u/a/s" }), + Some(&existing), + ) + .unwrap(); + assert_eq!(same["schema"], json!({ "k": 1 })); + assert_eq!(same["fields"], existing["fields"]); + + let retargeted = build_persisted_runnable( + &json!({ "name": "n", "type": "flow", "path": "u/a/s" }), + Some(&existing), + ) + .unwrap(); + assert_eq!(retargeted["schema"], json!({})); + + let inline = build_persisted_runnable( + &json!({ + "name": "n", "type": "inline", "staticInputs": { "y": 2 }, + "inlineScript": { "language": "bun", "content": "c" } + }), + Some(&existing), + ) + .unwrap(); + assert!(inline.get("path").is_none() && inline.get("runType").is_none()); + assert_eq!( + inline["fields"], + json!({ "y": { "type": "static", "value": 2, "fieldType": "object" } }) + ); + assert_eq!( + build_persisted_runnable(&json!({ "name": "n", "type": "inline" }), None), + Err("inlineScript is required when runnable type is \"inline\".".to_string()) + ); + } + + #[test] + fn viewer_data_access_is_set_kept_or_cleared_on_inline_runnables_only() { + let inline = |viewer: Value| { + let mut input = json!({ + "name": "n", "type": "inline", + "inlineScript": { "language": "bun", "content": "c" } + }); + if !viewer.is_null() { + input["viewerDataAccess"] = viewer; + } + input + }; + let set = build_persisted_runnable(&inline(json!(true)), None).unwrap(); + assert_eq!(set["viewer_data_access"], json!(true)); + let kept = build_persisted_runnable(&inline(Value::Null), Some(&set)).unwrap(); + assert_eq!(kept["viewer_data_access"], json!(true)); + let cleared = build_persisted_runnable(&inline(json!(false)), Some(&set)).unwrap(); + assert!(cleared.get("viewer_data_access").is_none()); + let by_path = build_persisted_runnable( + &json!({ "name": "n", "type": "script", "path": "u/a/s", "viewerDataAccess": true }), + Some(&set), + ) + .unwrap(); + assert!(by_path.get("viewer_data_access").is_none()); + + let mut value = json!({ "runnables": { "a": set, "b": { + "type": "path", "runType": "script", "path": "u/a/s", "viewer_data_access": true + } } }); + recompute_policy(&mut value); + let triggerables = &value["policy"]["triggerables_v2"]; + let flagged: Vec<&String> = triggerables + .as_object() + .unwrap() + .iter() + .filter(|(_, e)| e["viewer_data_access"] == json!(true)) + .map(|(k, _)| k) + .collect(); + assert_eq!(flagged.len(), 1); + assert!(flagged[0].starts_with("a:rawscript/")); + } + + #[test] + fn policy_keys_inline_runnables_by_the_hash_of_their_code() { + let mut value = json!({ + "runnables": { + "a": { + "type": "inline", "inlineScript": { "content": "", "language": "bun" }, + "fields": { + "s": { "type": "static", "value": 1 }, + "u": { "type": "user", "sensitive": true } + } + }, + "b": { "type": "path", "runType": "hubscript", "path": "hub/1/x" } + }, + "policy": { "on_behalf_of": "u/x", "triggerables_v2": { "stale": {} } } + }); + recompute_policy(&mut value); + assert_eq!( + value["policy"], + json!({ + "on_behalf_of": "u/x", + "triggerables_v2": { + "a:rawscript/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855": { + "static_inputs": { "s": 1 }, "one_of_inputs": {}, + "allow_user_resources": [], "sensitive_inputs": ["u"] + }, + "b:script/hub/1/x": { + "static_inputs": {}, "one_of_inputs": {}, "allow_user_resources": [] + } + }, + "execution_mode": "publisher" + }) + ); + } + + #[test] + fn preview_body_fills_static_ctx_and_sensitive_inputs() { + let runnable = json!({ + "type": "inline", + "inlineScript": { "content": "code", "language": "bun" }, + "fields": { + "s": { "type": "static", "value": 1 }, + "who": { "type": "ctx", "ctx": "username" }, + "pw": { "type": "user", "sensitive": true } + } + }); + assert_eq!( + execute_component_body("u/a/app", "k", &runnable, &json!({ "x": 1 })), + json!({ + "component": "k", + "args": { "x": 1, "who": "$ctx:username" }, + "force_viewer_static_fields": { "s": 1 }, + "force_viewer_sensitive_inputs": ["pw"], + "raw_code": { "content": "code", "language": "bun", "path": "u/a/app/k" } + }) + ); + let by_path = json!({ "type": "path", "runType": "flow", "path": "u/a/f" }); + assert_eq!( + execute_component_body("u/a/app", "k", &by_path, &Value::Null), + json!({ + "component": "k", "args": {}, "force_viewer_static_fields": {}, + "path": "flow/u/a/f" + }) + ); + } + + #[test] + fn table_refs_are_canonicalized_against_the_apps_schema() { + assert_eq!( + app_table_ref("main", Some("shop"), "orders"), + "main/shop:orders" + ); + assert_eq!(app_table_ref("main", None, "orders"), "main/orders"); + assert_eq!( + app_table_ref("main", Some("shop"), "public.orders"), + "main/orders" + ); + assert_eq!( + app_table_ref("main", Some("shop"), "crm:leads"), + "main/crm:leads" + ); + assert_eq!(app_table_ref("main", Some("shop"), "other/x"), "other/x"); + } +} diff --git a/backend/windmill-api/src/mcp/session/data.rs b/backend/windmill-api/src/mcp/session/data.rs new file mode 100644 index 0000000000..665647c7ce --- /dev/null +++ b/backend/windmill-api/src/mcp/session/data.rs @@ -0,0 +1,969 @@ +//! Data tables, DuckLake and database resource schemas. + +use std::sync::LazyLock; + +use regex::Regex; +use serde_json::{json, Map, Value}; + +use super::runs::{backgrounded_summary, run_script_preview, wait_budget, wait_job}; +use super::workspace::{api_message, path_enc}; +use super::{array, enc, js_str, non_null, opt_str_arg, pretty, str_arg, truthy, Ctx, ToolResult}; + +pub(super) const TOOLS: &[&str] = &[ + "list_datatables", + "get_datatable_table_schema", + "exec_datatable_sql", + "list_data_metrics", + "list_ducklakes", + "get_db_schema", + "list_datatable_migrations", + "create_datatable_migration", + "run_datatable_migrations", + "rollback_datatable_migration", +]; + +pub(super) async fn call(ctx: &Ctx<'_>, name: &str, args: &Value) -> ToolResult { + match name { + "list_datatables" => Ok(list_datatables(ctx, args) + .await + .unwrap_or_else(|e| format!("Error listing datatables: {e}"))), + "get_datatable_table_schema" => get_datatable_table_schema(ctx, args).await, + "exec_datatable_sql" => exec_datatable_sql(ctx, args).await, + "list_data_metrics" => list_data_metrics(ctx, args).await, + "list_ducklakes" => Ok(list_ducklakes(ctx) + .await + .unwrap_or_else(|e| format!("Error listing DuckLake catalogs: {e}"))), + "get_db_schema" => get_db_schema(ctx, args).await, + "list_datatable_migrations" => list_datatable_migrations(ctx, args).await, + "create_datatable_migration" => create_datatable_migration(ctx, args).await, + "run_datatable_migrations" => run_datatable_migrations(ctx, args).await, + "rollback_datatable_migration" => rollback_datatable_migration(ctx, args).await, + _ => unreachable!("{name} is not a data tool"), + } +} + +const NO_DATATABLES_CONFIGURED_MESSAGE: &str = "No datatables are configured in this workspace. Datatable operations (querying data, creating or altering tables) are blocked until a datatable exists. Datatables are not created by SQL — the user must set one up in the workspace settings (Workspace settings → Data Tables) first. Do not call exec_datatable_sql or assume a \"main\" datatable exists; instead tell the user this is a required prerequisite and ask them to configure a datatable in their workspace settings."; + +const NO_DUCKLAKES_CONFIGURED_MESSAGE: &str = "No DuckLake catalogs are configured in this workspace. A data pipeline that materializes DuckLake tables or reads/writes S3 assets cannot run until object storage and a DuckLake catalog are set up. You can still draft the pipeline scripts, but tell the user how to enable it by role: a workspace admin adds object storage under Workspace settings → Object Storage (S3/Azure/GCS), then a DuckLake catalog on top of it; a user without admin rights should ask a workspace admin. Do not assume a default 'main' DuckLake exists."; + +const NO_DATA_METRICS_NOTE: &str = "Nothing matched. That does not establish that nothing is declared: declarations whose producing script you cannot read are omitted from this list, not flagged. You may write your own aggregate, but tell the user you found no declared measure you can read rather than stating none is declared."; + +const DEFAULT_DATA_METRICS_LIMIT: u64 = 200; +/// Rows of a SELECT handed to the model. +const MAX_ROWS: usize = 100; +/// Seconds a query is awaited before it is left running for `get_run`. +const MAX_SCHEMA_LENGTH: usize = 350_000; + +/// The worker's error for a data table that is not configured in the workspace. +static DATATABLE_NOT_FOUND: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)datatable\s+\S+\s+not found").expect("valid regex")); + +fn datatable_not_configured_message(name: &str) -> String { + format!( + "Datatable \"{name}\" is not configured in this workspace, so this operation cannot run. \ + Datatables are not created by SQL — they must be set up first by the user in the workspace settings \ + (Workspace settings → Data Tables) before any table can be queried or created. \ + This is a required, blocking prerequisite: do not retry on this or another name. \ + Tell the user they need to configure a datatable (e.g. named \"{name}\") in their workspace settings, then try again." + ) +} + +/// The optional `role` argument, held to the rule the server applies to a role name. +fn role_arg(args: &Value) -> Result, String> { + let role = opt_str_arg(args, "role"); + match role { + Some(r) + if r.is_empty() + || r.len() > 63 + || !r + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') => + { + Err(format!( + "Invalid `role` '{r}': only letters, digits, '_' and '-' are allowed (1 to 63 characters)" + )) + } + _ => Ok(role), + } +} + +/// `path?k=v&...` over the parameters that are set. +fn with_query(path: String, params: &[(&str, Option<&str>)]) -> String { + let query: Vec = params + .iter() + .filter_map(|(k, v)| v.map(|v| format!("{k}={}", enc(v)))) + .collect(); + if query.is_empty() { + path + } else { + format!("{path}?{}", query.join("&")) + } +} + +async fn list_datatables(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let role = role_arg(args)?; + let datatable_name = opt_str_arg(args, "datatable_name"); + if role.is_some() && datatable_name.is_none() { + return Err("`role` needs `datatable_name`, the datatable it is a role of".to_string()); + } + let path = with_query( + ctx.ws("workspaces/list_datatable_tables"), + &[ + ("datatable_name", datatable_name), + ("role_for", role.and(datatable_name)), + ("role", role), + ], + ); + let metadata = ctx.get(&path).await?; + let metadata = array(Some(&metadata)); + if metadata.is_empty() { + return Ok(NO_DATATABLES_CONFIGURED_MESSAGE.to_string()); + } + let listed: Vec = metadata + .iter() + .map(|d| { + let mut out = Map::new(); + out.insert("datatable_name".into(), d["datatable_name"].clone()); + out.insert("schemas".into(), d["schemas"].clone()); + if truthy(d.get("error")) { + out.insert("error".into(), d["error"].clone()); + } + if truthy(d.get("permissioned")) { + for key in ["usable_roles", "default_role"] { + if let Some(v) = d.get(key) { + out.insert(key.into(), v.clone()); + } + } + } + Value::Object(out) + }) + .collect(); + Ok(pretty(&Value::Array(listed))) +} + +async fn get_datatable_table_schema(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let datatable_name = str_arg(args, "datatable_name")?; + let schema_name = str_arg(args, "schema_name")?; + let table_name = str_arg(args, "table_name")?; + let role = role_arg(args)?; + let path = with_query( + ctx.ws("workspaces/get_datatable_table_schema"), + &[ + ("datatable_name", Some(datatable_name)), + ("schema_name", Some(schema_name)), + ("table_name", Some(table_name)), + ("role", role), + ], + ); + Ok(match ctx.get(&path).await { + Ok(schema) => pretty(&json!({ + "datatable_name": datatable_name, + "schema_name": schema_name, + "table_name": table_name, + "columns": schema["columns"], + })), + Err(e) if DATATABLE_NOT_FOUND.is_match(&e) => { + datatable_not_configured_message(datatable_name) + } + Err(e) => format!("Error getting table schema: {e}"), + }) +} + +async fn list_ducklakes(ctx: &Ctx<'_>) -> ToolResult { + let ducklakes = ctx.get(&ctx.ws("workspaces/list_ducklakes")).await?; + if ducklakes.as_array().is_none_or(Vec::is_empty) { + return Ok(NO_DUCKLAKES_CONFIGURED_MESSAGE.to_string()); + } + Ok(pretty(&json!({ "ducklakes": ducklakes }))) +} + +async fn list_data_metrics(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let limit = match args.get("limit") { + None | Some(Value::Null) => DEFAULT_DATA_METRICS_LIMIT, + Some(v) => v + .as_u64() + .filter(|n| (1..=1000).contains(n)) + .ok_or("`limit` must be an integer between 1 and 1000")?, + }; + let table = opt_str_arg(args, "table"); + let path = with_query( + ctx.ws("data_metrics/list"), + &[ + ("table", table), + ("path_prefix", opt_str_arg(args, "path_prefix")), + ("per_page", Some(&limit.to_string())), + ], + ); + let page = ctx.get(&path).await?; + let metrics = page.get("metrics").cloned().unwrap_or_else(|| json!([])); + let note = if truthy(page.get("next_cursor")) { + Some(format!("More declarations exist beyond the first {limit}. Re-call with table/path_prefix to target what you are looking for, or with a higher limit (max 1000) for the rest of the list, rather than concluding a measure is undeclared.")) + } else if metrics.as_array().is_some_and(Vec::is_empty) { + // The endpoint expands a table filter only once it contains a `/`, so a bare + // table name matches nothing whatever is declared. + let bare = table.map(|t| t.replacen("ducklake://", "", 1)); + Some(match (table, bare) { + (Some(table), Some(bare)) if !bare.is_empty() && !bare.contains('/') => format!("Nothing matched `{table}`: a table filter must name its lake, as `/{bare}`. Re-call with the lake before concluding anything about what is declared."), + _ => NO_DATA_METRICS_NOTE.to_string(), + }) + } else { + None + }; + let mut out = Map::new(); + out.insert("metrics".into(), metrics); + if let Some(note) = note { + out.insert("note".into(), note.into()); + } + Ok(pretty(&Value::Object(out))) +} + +async fn exec_datatable_sql(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let name = str_arg(args, "datatable_name")?; + let sql = str_arg(args, "sql")?; + let role = role_arg(args)?; + let failed = |e: String| format!("Failed to execute test run: {e}"); + + let database = match role { + None => format!("datatable://{name}"), + // A stored name holding a `?` is read whole by the server, so a role cannot follow it. + Some(role) if name.contains('?') => { + return Err(failed(format!("Data table '{name}' has a '?' in its name, so it can only be used here as its default role. Rename it to connect as role '{role}'."))); + } + Some(role) => format!("datatable://{name}?role={role}"), + }; + let id = run_script_preview( + ctx, + None, + "postgresql", + sql, + json!({ "database": database }), + ) + .await + .map_err(failed)?; + + match wait_job(ctx, &id, wait_budget(args), false).await { + Some(job) => Ok(format_datatable_sql_completion(&job, name)), + None => Ok(backgrounded_summary(&id, &format!("SQL · {name}"))), + } +} + +fn format_datatable_sql_completion(job: &Value, datatable_name: &str) -> String { + if job["success"] == true { + let rows = array(job.get("result")); + let row_count = rows.len(); + return pretty(&if row_count > MAX_ROWS { + json!({ + "success": true, + "rowCount": row_count, + "result": &rows[..MAX_ROWS], + "note": format!("Showing first {MAX_ROWS} of {row_count} rows"), + }) + } else { + json!({ "success": true, "rowCount": row_count, "result": rows }) + }); + } + let error = match job.get("result") { + None => json!("Unknown error"), + Some(result) => match result.pointer("/error/message") { + Some(message) if !message.is_null() => message.clone(), + _ => match result { + Value::String(_) => result.clone(), + other => Value::String(other.to_string()), + }, + }, + }; + let error = match error.as_str() { + Some(raw) if DATATABLE_NOT_FOUND.is_match(raw) => { + Value::String(datatable_not_configured_message(datatable_name)) + } + _ => error, + }; + json!({ "success": false, "error": error }).to_string() +} + +/// The optional `version` argument: a migration's timestamp. +fn version_arg(args: &Value) -> Result, String> { + match args.get("version") { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_i64() + .or_else(|| v.as_f64().filter(|f| f.fract() == 0.0).map(|f| f as i64)) + .map(Some) + .ok_or_else(|| "`version` must be an integer".to_string()), + } +} + +/// `workspaces//`, the shape of every migration route. +fn migrations_path(ctx: &Ctx<'_>, endpoint: &str, datatable_name: &str) -> String { + ctx.ws(&format!("workspaces/{endpoint}/{}", enc(datatable_name))) +} + +async fn migrations_status(ctx: &Ctx<'_>, datatable_name: &str) -> Result { + ctx.get(&migrations_path( + ctx, + "datatable_migrations_status", + datatable_name, + )) + .await + .map_err(|e| api_message(&e).to_string()) +} + +/// `POST` without a body: applying and rolling back take their target from the query. +async fn post_migrations(ctx: &Ctx<'_>, path: &str) -> Result { + let text = ctx + .request("POST", path, None) + .await + .map_err(|e| api_message(&e).to_string())?; + serde_json::from_str(&text).map_err(|e| format!("unexpected response: {e}")) +} + +fn migration_label(version: &Value, name: &Value) -> String { + format!("{} ({})", js_str(version), js_str(name)) +} + +fn labels(migrations: Option<&Value>) -> String { + array(migrations) + .iter() + .map(|m| migration_label(&m["version"], &m["name"])) + .collect::>() + .join(", ") +} + +fn migration_status(status: &Value) -> &'static str { + match status.as_str() { + Some("ran") => "applied", + Some("not_run") => "pending", + _ => "unknown", + } +} + +/// The rest of a leading comment line whose first word is `keyword`: an annotation attempt. +fn annotation_attempt<'a>(line: &'a str, keyword: &str) -> Option<&'a str> { + let body = line.strip_prefix("--")?.trim_start(); + let after = body + .get(..keyword.len()) + .filter(|w| w.eq_ignore_ascii_case(keyword)) + .map(|_| &body[keyword.len()..])?; + match after.chars().next() { + Some(c) if !(c.is_whitespace() || c == ':' || c == '=') => None, + _ => Some(after), + } +} + +/// The lines of `sql`'s leading comment block, where the server reads its annotations. +fn leading_block(sql: &str) -> impl Iterator { + sql.split('\n') + .map(str::trim) + .filter(|l| !l.is_empty()) + .take_while(|l| l.starts_with("--")) +} + +/// `sql` with every `keyword` attempt of its leading comment block removed, and `line`, if +/// any, on top. +fn with_annotation(sql: &str, keyword: &str, line: Option) -> String { + let lines: Vec<&str> = sql.split('\n').collect(); + let leading = lines + .iter() + .position(|l| !l.trim().is_empty() && !l.trim().starts_with("--")) + .unwrap_or(lines.len()); + let rest = lines[..leading] + .iter() + .filter(|l| annotation_attempt(l.trim(), keyword).is_none()) + .chain(&lines[leading..]) + .skip_while(|l| l.trim().is_empty()) + .map(|l| l.to_string()); + line.into_iter().chain(rest).collect::>().join("\n") +} + +/// `sql` declaring `role`: the server reads the role a migration runs as from a `-- role ` +/// annotation in its leading comment block, so any attempt there is replaced by one on top. +fn with_migration_role(sql: &str, role: Option<&str>) -> String { + match role { + Some(role) => with_annotation(sql, "role", Some(format!("-- role {role}"))), + None => sql.to_string(), + } +} + +fn is_annotatable_schema_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 63 + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '$' | '-')) +} + +/// The scope `sql`'s `-- schemas a, b` annotation declares, lowercased, as the server's +/// `parse_migration_schemas` reads it. None for the whole database, which is also what a +/// malformed annotation stands for here. +fn declared_schemas(sql: &str) -> Option> { + let after = leading_block(sql).find_map(|l| annotation_attempt(l, "schemas"))?; + let rest = after.trim_start(); + let rest = rest.strip_prefix([':', '=']).unwrap_or(rest).trim(); + let rest = rest.strip_suffix(';').unwrap_or(rest); + let mut schemas: Vec = Vec::new(); + for name in rest.split(',').map(str::trim) { + if !is_annotatable_schema_name(name) { + return None; + } + let name = name.to_ascii_lowercase(); + if !schemas.contains(&name) { + schemas.push(name); + } + } + Some(schemas) +} + +/// Whether a listed migration's `schemas` overlaps `own`; an absent scope is the whole database. +fn scopes_overlap(theirs: &Value, own: Option<&[String]>) -> bool { + match (theirs.as_array(), own) { + (Some(theirs), Some(own)) => theirs + .iter() + .any(|s| own.iter().any(|o| s.as_str() == Some(o))), + _ => true, + } +} + +/// The optional `schemas` argument, held to the rule the server applies to a scope name. +fn schemas_arg(args: &Value) -> Result>, String> { + let Some(value) = non_null(args.get("schemas")) else { + return Ok(None); + }; + let names = value + .as_array() + .ok_or("`schemas` must be an array of schema names")?; + names + .iter() + .map(|name| match name.as_str() { + Some(name) if is_annotatable_schema_name(name) => Ok(name.to_ascii_lowercase()), + _ => Err(format!( + "Invalid schema name '{}' in `schemas`: only letters, digits, '_', '$' and '-' are allowed (1 to 63 characters)", + js_str(name) + )), + }) + .collect::>() + .map(Some) +} + +async fn list_datatable_migrations(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let datatable_name = str_arg(args, "datatable_name")?; + let version = version_arg(args)?; + let status = match migrations_status(ctx, datatable_name).await { + Ok(status) => status, + Err(e) => return Ok(format!("Error listing migrations: {e}")), + }; + if !truthy(status.get("enabled")) { + return Ok(format!("Migrations are not enabled on datatable \"{datatable_name}\": its schema is changed with plain SQL and there is no history to list. Enabling them is a workspace admin's decision, made in Workspace settings → Data Tables.")); + } + let migrations = array(status.get("migrations")); + let Some(version) = version else { + let mut out = Map::new(); + out.insert( + "migrations".into(), + migrations + .iter() + .map(|m| { + json!({ + "version": m["timestamp"], + "name": m["name"], + "status": migration_status(&m["status"]), + "schemas": non_null(m.get("schemas")).cloned().unwrap_or_else(|| json!("whole database")), + }) + }) + .collect(), + ); + if truthy(status.get("error")) { + out.insert("error".into(), status["error"].clone()); + } + return Ok(pretty(&Value::Object(out))); + }; + let Some(migration) = migrations + .iter() + .find(|m| m["timestamp"].as_i64() == Some(version)) + else { + return Ok(format!( + "No migration {version} on datatable \"{datatable_name}\"" + )); + }; + let mut out = Map::new(); + out.insert("version".into(), version.into()); + out.insert("name".into(), migration["name"].clone()); + out.insert( + "status".into(), + migration_status(&migration["status"]).into(), + ); + out.insert("code_up".into(), migration["code_up"].clone()); + if let Some(code_down) = non_null(migration.get("code_down")) { + out.insert("code_down".into(), code_down.clone()); + } + Ok(pretty(&Value::Object(out))) +} + +/// Saves the definition and answers its version, or the message the tool returns instead. +async fn save_migration( + ctx: &Ctx<'_>, + datatable_name: &str, + version: Option, + mut definition: Map, +) -> Result, String> { + let Some(version) = version else { + let created = ctx + .post( + &migrations_path(ctx, "create_datatable_migration", datatable_name), + Value::Object(definition), + ) + .await?; + return created["timestamp"] + .as_i64() + .map(Ok) + .ok_or_else(|| "the created migration has no version".to_string()); + }; + // The server inserts at a version it does not know, which would wedge a new migration + // into the middle of the history, and stores a down left out as none. + let status = migrations_status(ctx, datatable_name).await?; + let existing = array(status.get("migrations")) + .iter() + .find(|m| m["timestamp"].as_i64() == Some(version)); + let Some(existing) = existing.filter(|m| m["status"] == "not_run") else { + return Ok(Err(format!("Migration {version} is not a pending migration of datatable \"{datatable_name}\", so it cannot be rewritten. Omit `version` to add a new migration."))); + }; + definition.insert("timestamp".into(), version.into()); + if let Some(code_down) = non_null(existing.get("code_down")) { + definition + .entry("code_down") + .or_insert_with(|| code_down.clone()); + } + ctx.post( + &migrations_path(ctx, "upsert_datatable_migration", datatable_name), + Value::Object(definition), + ) + .await?; + Ok(Ok(version)) +} + +/// The labels of the migrations before `version`, in a scope overlapping `scope`, that are +/// not applied yet. +async fn earlier_pending( + ctx: &Ctx<'_>, + datatable_name: &str, + version: i64, + scope: Option<&[String]>, +) -> Result, String> { + let status = migrations_status(ctx, datatable_name).await?; + let earlier: Vec<&Value> = array(status.get("migrations")) + .iter() + .filter(|m| m["timestamp"].as_i64().is_some_and(|t| t < version)) + .filter(|m| scopes_overlap(&m["schemas"], scope)) + .collect(); + // Unreadable is not pending: reporting it so would send the user to apply migrations + // that already ran. + if truthy(status.get("error")) { + return Err(js_str(&status["error"])); + } + if earlier.iter().any(|m| m["status"] == "unknown") { + return Err("the applied migrations could not be read".to_string()); + } + Ok(earlier + .into_iter() + .filter(|m| m["status"] == "not_run") + .map(|m| migration_label(&m["timestamp"], &m["name"])) + .collect()) +} + +async fn create_datatable_migration(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let datatable_name = str_arg(args, "datatable_name")?; + let name = str_arg(args, "name")?; + if name.is_empty() + || !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Err(format!( + "Invalid `name` '{name}': only letters, digits, '_' and '-' are allowed" + )); + } + let role = role_arg(args)?; + let version = version_arg(args)?; + let code_up = str_arg(args, "code_up")?; + // What the caller declares, else what `code_up` does; none is the whole database. + let scope = schemas_arg(args)? + .or_else(|| declared_schemas(code_up)) + .unwrap_or_default(); + let header = (!scope.is_empty()).then(|| format!("-- schemas {}", scope.join(", "))); + + let mut definition = Map::new(); + definition.insert("name".into(), name.into()); + definition.insert( + "code_up".into(), + with_annotation(&with_migration_role(code_up, role), "schemas", header).into(), + ); + if let Some(code_down) = opt_str_arg(args, "code_down") { + // A rollback has to run as the role that created what it drops. + definition.insert( + "code_down".into(), + with_migration_role(code_down, role).into(), + ); + } + let version = match save_migration(ctx, datatable_name, version, definition).await { + Ok(Ok(version)) => version, + Ok(Err(message)) => return Ok(message), + Err(e) => return Ok(format!("Error saving migration: {}", api_message(&e))), + }; + let label = format!("{version} ({name})"); + if args.get("apply") == Some(&Value::Bool(false)) { + return Ok(format!( + "Saved migration {label} as pending. It is not applied yet." + )); + } + + // Applying it alone would run it ahead of migrations it may depend on. + let own_scope = (!scope.is_empty()).then_some(scope.as_slice()); + let earlier = match earlier_pending(ctx, datatable_name, version, own_scope).await { + Ok(earlier) => earlier, + Err(e) => return Ok(format!("Saved migration {label} as pending, but could not check what else is pending, so it was not applied: {e}")), + }; + if !earlier.is_empty() { + return Ok(format!("Saved migration {label} as pending without applying it: earlier migrations are not applied yet ({}) and it may depend on them. Tell the user, then apply them all in order with run_datatable_migrations.", earlier.join(", "))); + } + + let run = format!( + "{}?only={version}", + migrations_path(ctx, "run_datatable_migrations", datatable_name) + ); + Ok(match post_migrations(ctx, &run).await { + Ok(_) => format!("Applied migration {label} on datatable \"{datatable_name}\"."), + Err(e) => format!("Migration {label} is saved but failed to apply: {e}. It stays pending and blocks later migrations. If nothing of it was applied, fix it by calling this tool again with version {version}; otherwise, or to drop it, tell the user, who can edit or delete it in Workspace settings → Data Tables."), + }) +} + +async fn run_datatable_migrations(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let datatable_name = str_arg(args, "datatable_name")?; + let path = migrations_path(ctx, "run_datatable_migrations", datatable_name); + Ok(match post_migrations(ctx, &path).await { + Ok(result) => { + let applied = array(result.get("applied")); + if applied.is_empty() { + format!("No pending migration to apply on datatable \"{datatable_name}\".") + } else { + format!( + "Applied {} migration(s) on datatable \"{datatable_name}\": {}.", + applied.len(), + labels(result.get("applied")) + ) + } + } + Err(e) => format!("Error applying migrations: {e}. Migrations before the failing one may have been applied: list them to see."), + }) +} + +async fn rollback_datatable_migration(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let datatable_name = str_arg(args, "datatable_name")?; + let version = version_arg(args)?.ok_or("Missing required number argument `version`")?; + let path = format!( + "{}?only={version}", + migrations_path(ctx, "rollback_datatable_migrations", datatable_name) + ); + Ok(match post_migrations(ctx, &path).await { + Ok(result) if array(result.get("rolled_back")).is_empty() => format!("Migration {version} is not applied on datatable \"{datatable_name}\", so there is nothing to roll back."), + Ok(result) => format!( + "Rolled back migration {} on datatable \"{datatable_name}\". It is pending again.", + labels(result.get("rolled_back")) + ), + Err(e) => format!("Error rolling back: {e}"), + }) +} + +const POSTGRESQL_SCHEMA_QUERY: &str = " +SELECT table_name, column_name, udt_name, column_default, is_nullable, nsp.nspname AS table_schema FROM information_schema.columns +RIGHT JOIN pg_namespace nsp ON table_schema = nsp.nspname WHERE nsp.nspname NOT IN ('information_schema', 'pg_toast', 'pg_catalog') +AND NOT starts_with(nsp.nspname, 'pg_') AND has_schema_privilege(nsp.oid, 'USAGE')"; +const MYSQL_SCHEMA_QUERY: &str = "SELECT DATABASE() AS default_db_name, TABLE_SCHEMA, TABLE_NAME, DATA_TYPE, COLUMN_NAME, COLUMN_DEFAULT FROM information_schema.columns WHERE table_schema = DATABASE() OR table_schema NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys', '_vt');"; +const SNOWFLAKE_SCHEMA_QUERY: &str = "select TABLE_SCHEMA, TABLE_NAME, DATA_TYPE, COLUMN_NAME, COLUMN_DEFAULT, IS_NULLABLE from information_schema.columns where table_schema != 'INFORMATION_SCHEMA'"; +const MSSQL_SCHEMA_QUERY: &str = "select TABLE_SCHEMA, TABLE_NAME, DATA_TYPE, COLUMN_NAME, COLUMN_DEFAULT from information_schema.columns where table_schema != 'sys'"; +const BIGQUERY_SCHEMA_SCRIPT: &str = r#"import { BigQuery } from '@google-cloud/bigquery@7.5.0'; +export async function main(args: bigquery) { +const bq = new BigQuery({ + credentials: args +}) +const [datasets] = await bq.getDatasets(); +if (!datasets) return {} +const schema = {} as any +let queries = datasets.map(dataset => ` + SELECT + table_name, + '${dataset.id}' as dataset, + ARRAY_AGG(STRUCT( + if(is_nullable = 'YES', true, false) AS required, + column_name AS name, + data_type AS type, + if(column_default = 'NULL', null, column_default) AS \`default\` + ) ORDER BY ordinal_position) + AS schema + FROM \`${dataset.id}\`.INFORMATION_SCHEMA.COLUMNS + GROUP BY table_name` +) +let query = queries.join('\nUNION ALL \n') +const [rows] = await bq.query(query) +for (const row of rows) { + schema[row.dataset] ??= {} + schema[row.dataset][row.table_name] = {} + for (const col of row.schema) { + const colName = col.name + delete col.name + if (col.default === null) { + delete col.default + } + schema[row.dataset][row.table_name][colName] = col + } +} +return schema +}"#; + +/// How long a schema introspection is awaited. +const DB_SCHEMA_WAIT_SECS: u64 = 120; + +async fn get_db_schema(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = opt_str_arg(args, "resourcePath") + .filter(|p| !p.is_empty()) + .ok_or("Database path not provided")?; + let resource = ctx + .get(&ctx.ws(&format!("resources/get/{}", path_enc(path)))) + .await?; + let resource_type = resource["resource_type"].as_str().unwrap_or_default(); + let not_found = || "Database not found".to_string(); + let (language, content, arg_name) = match resource_type { + "postgresql" => ("postgresql", POSTGRESQL_SCHEMA_QUERY, "database"), + "mysql" => ("mysql", MYSQL_SCHEMA_QUERY, "database"), + "ms_sql_server" | "mssql" => ("mssql", MSSQL_SCHEMA_QUERY, "database"), + "snowflake" | "snowflake_oauth" => ("snowflake", SNOWFLAKE_SCHEMA_QUERY, "database"), + "bigquery" => ("bun", BIGQUERY_SCHEMA_SCRIPT, "args"), + _ => return Err(not_found()), + }; + let id = run_script_preview( + ctx, + None, + language, + content, + json!({ arg_name: format!("$res:{path}") }), + ) + .await + .map_err(|_| not_found())?; + let job = wait_job(ctx, &id, DB_SCHEMA_WAIT_SECS, false) + .await + .ok_or_else(|| { + format!( + "The schema query did not finish within {DB_SCHEMA_WAIT_SECS} seconds (job {id})" + ) + })?; + if job["success"] != true { + return Err(not_found()); + } + let mut stringified = + stringify_db_schema(resource_type, &job["result"]).ok_or_else(not_found)?; + if stringified.chars().count() > MAX_SCHEMA_LENGTH { + stringified = stringified + .chars() + .take(MAX_SCHEMA_LENGTH) + .collect::() + + "..."; + } + Ok(format!( + "DATABASE SCHEMA (each column is in the format [name, type, required, default?]):\n{stringified}" + )) +} + +/// The object under `key`, created empty where a JavaScript object would first get it. +fn slot(map: &mut Map, key: String) -> &mut Map { + map.entry(key) + .or_insert_with(|| json!({})) + .as_object_mut() + .expect("only objects are inserted") +} + +/// The introspection result as `{schema: {table: [[name, type, required, default?]]}}`, +/// narrowed to the default schema's tables when the database has one. +fn stringify_db_schema(resource_type: &str, result: &Value) -> Option { + let mut schemas: Map = Map::new(); + let column = |name: String, ty: &Value, required: Value, default: Option<&Value>| { + let mut col = vec![Value::String(name), ty.clone(), required]; + if truthy(default) { + col.extend(default.cloned()); + } + Value::Array(col) + }; + if resource_type == "bigquery" { + for (dataset, tables) in result.as_object()? { + let dataset = slot(&mut schemas, dataset.clone()); + for (table, columns) in tables.as_object()? { + let table = slot(dataset, table.clone()); + for (name, col) in columns.as_object()? { + let entry = column( + name.clone(), + &col["type"], + col["required"].clone(), + col.get("default"), + ); + table.insert(name.clone(), entry); + } + } + } + } else { + for row in result.as_array()? { + let (schema, table, name, ty, required, default) = match resource_type { + "postgresql" => ( + "table_schema", + "table_name", + "column_name", + "udt_name", + row["is_nullable"] == "NO", + row.get("column_default"), + ), + // `required` reads IS_NULLABLE the way the session does: "YES" is true. + "snowflake" | "snowflake_oauth" => ( + "TABLE_SCHEMA", + "TABLE_NAME", + "COLUMN_NAME", + "DATA_TYPE", + row["IS_NULLABLE"] == "YES", + row.get("COLUMN_DEFAULT"), + ), + // These queries select neither nullability nor a default the session reads. + _ => ( + "TABLE_SCHEMA", + "TABLE_NAME", + "COLUMN_NAME", + "DATA_TYPE", + false, + None, + ), + }; + let schema = slot(&mut schemas, js_str(&row[schema])); + // A schema with no table comes back as one row of nulls. + if resource_type == "postgresql" && !truthy(row.get(table)) && !truthy(row.get(name)) { + continue; + } + let name = js_str(&row[name]); + let entry = column(name.clone(), &row[ty], json!(required), default); + slot(schema, js_str(&row[table])).insert(name, entry); + } + } + + for table in schemas.values_mut().filter_map(Value::as_object_mut) { + for columns in table.values_mut() { + if let Some(by_name) = columns.as_object_mut() { + *columns = Value::Array( + std::mem::take(by_name) + .into_iter() + .map(|(_, c)| c) + .collect(), + ); + } + } + } + let default_schema = ["public", "PUBLIC", "dbo"] + .into_iter() + .find(|k| schemas.contains_key(*k)) + .or_else(|| { + (resource_type == "mysql" && schemas.len() == 1) + .then(|| schemas.keys().next().map(String::as_str)) + .flatten() + }) + .map(str::to_string); + Some(match default_schema { + Some(key) => schemas[&key].to_string(), + None => Value::Object(schemas).to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn postgres_schema_is_compact_and_narrowed_to_public() { + let rows = json!([ + { "table_schema": "empty", "table_name": null, "column_name": null }, + { "table_schema": "public", "table_name": "t", "column_name": "id", "udt_name": "int4", "is_nullable": "NO", "column_default": "nextval('s')" }, + { "table_schema": "public", "table_name": "t", "column_name": "name", "udt_name": "text", "is_nullable": "YES", "column_default": null }, + { "table_schema": "other", "table_name": "u", "column_name": "x", "udt_name": "bool", "is_nullable": "YES", "column_default": "" }, + ]); + assert_eq!( + stringify_db_schema("postgresql", &rows).unwrap(), + r#"{"t":[["id","int4",true,"nextval('s')"],["name","text",false]]}"# + ); + let rows = json!([rows[0], rows[3]]); + assert_eq!( + stringify_db_schema("postgresql", &rows).unwrap(), + r#"{"empty":{},"other":{"u":[["x","bool",false]]}}"# + ); + assert_eq!(stringify_db_schema("postgresql", &json!({})), None); + } + + #[test] + fn migration_role_replaces_a_leading_annotation_only() { + assert_eq!(with_migration_role("BEGIN;\nEND;", None), "BEGIN;\nEND;"); + assert_eq!( + with_migration_role( + "\n-- note\n--ROLE: old;\n-- roles kept\nBEGIN;\n-- role below\nEND;", + Some("app_rw") + ), + "-- role app_rw\n-- note\n-- roles kept\nBEGIN;\n-- role below\nEND;" + ); + } + + #[test] + fn migration_scope_is_read_from_the_leading_block_and_overlaps() { + let sql = "-- role r\n--SCHEMAS: Sales, billing, sales;\nBEGIN;\n-- schemas other\nEND;"; + let scope = declared_schemas(sql).unwrap(); + assert_eq!(scope, ["sales", "billing"]); + assert_eq!(declared_schemas("BEGIN;\n-- schemas a\nEND;"), None); + assert_eq!(declared_schemas("-- schemas a b\nBEGIN;"), None); + assert_eq!(declared_schemas("-- schemasx a\nBEGIN;"), None); + assert_eq!( + with_annotation( + &with_migration_role(sql, Some("w")), + "schemas", + Some("-- schemas sales".into()) + ), + "-- schemas sales\n-- role w\nBEGIN;\n-- schemas other\nEND;" + ); + assert_eq!( + with_annotation(sql, "schemas", None), + "-- role r\nBEGIN;\n-- schemas other\nEND;" + ); + + assert!(scopes_overlap(&json!(["billing", "x"]), Some(&scope))); + assert!(!scopes_overlap(&json!(["x"]), Some(&scope))); + assert!(scopes_overlap(&Value::Null, Some(&scope))); + assert!(scopes_overlap(&json!(["x"]), None)); + } + + #[test] + fn sql_completion_caps_rows_and_rewrites_the_missing_datatable_error() { + let rows: Vec = (0..101).map(|i| json!({ "i": i })).collect(); + let out: Value = serde_json::from_str(&format_datatable_sql_completion( + &json!({ "success": true, "result": rows }), + "main", + )) + .unwrap(); + assert_eq!(out["rowCount"], 101); + assert_eq!(out["result"].as_array().unwrap().len(), 100); + assert_eq!(out["note"], "Showing first 100 of 101 rows"); + + assert_eq!( + format_datatable_sql_completion(&json!({ "success": true, "result": [] }), "main"), + "{\n \"success\": true,\n \"rowCount\": 0,\n \"result\": []\n}" + ); + assert_eq!( + format_datatable_sql_completion( + &json!({ "success": false, "result": { "error": { "message": "boom" } } }), + "main" + ), + r#"{"success":false,"error":"boom"}"# + ); + let missing = format_datatable_sql_completion( + &json!({ "success": false, "result": { "error": { "message": "datatable main not found" } } }), + "main", + ); + assert!(missing.contains("Datatable \\\"main\\\" is not configured")); + } +} diff --git a/backend/windmill-api/src/mcp/session/deploy.rs b/backend/windmill-api/src/mcp/session/deploy.rs new file mode 100644 index 0000000000..e508a16174 --- /dev/null +++ b/backend/windmill-api/src/mcp/session/deploy.rs @@ -0,0 +1,2121 @@ +//! Deploying and deleting workspace items, and diffing drafts against what is deployed. + +use std::sync::LazyLock; + +use futures::{stream, StreamExt}; +use regex::Regex; +use serde_json::{json, Map, Value}; + +use super::flows::drop_stale_step_locks; +use super::workspace::{ + api_message, app_source_to_draft_value, delete_draft, deployed_item_exists, draft_kind, + get_deployed, get_draft_item, item_exists, item_type_arg, path_enc, service, unified_patch, + ITEM_TYPES, TRIGGER_KINDS, +}; +use super::{ + array, bool_arg, is_not_found, js_str, matches_glob, non_null, opt_str_arg, pretty, str_arg, + truthy, Ctx, ToolResult, +}; + +pub(super) const TOOLS: &[&str] = &["deploy_workspace_item", "delete_workspace_item", "diff"]; + +pub(super) async fn call(ctx: &Ctx<'_>, name: &str, args: &Value) -> ToolResult { + match name { + "deploy_workspace_item" => deploy_item(ctx, args).await, + "delete_workspace_item" => delete_item(ctx, args).await, + "diff" => diff(ctx, args).await, + _ => unreachable!("{name} is not a deploy tool"), + } +} + +/// An object without `keys`; `{}` for anything that is not an object. +fn without(value: &Value, keys: &[&str]) -> Value { + let mut out = value.as_object().cloned().unwrap_or_default(); + for key in keys { + out.shift_remove(*key); + } + Value::Object(out) +} + +fn uint_arg(args: &Value, name: &str) -> Option { + args.get(name) + .and_then(Value::as_u64) + .map(|n| usize::try_from(n).unwrap_or(usize::MAX)) +} + +// ============================================================================ +// deploy_workspace_item +// ============================================================================ + +fn assert_draft_based_on_latest( + item_type: &str, + path: &str, + base: Option<&Value>, + head: Option<&Value>, + force: bool, +) -> Result<(), String> { + let (Some(base), Some(head)) = (base.filter(|v| !v.is_null()), head.filter(|v| !v.is_null())) + else { + return Ok(()); + }; + if force || base == head { + return Ok(()); + } + Err(format!( + "This {item_type} draft \"{path}\" was started from an older deployed version (forked from {}, \ + latest is {}). Deploying now would overwrite the version deployed since. Call rebase_draft to \ + discard the stale draft and get your changes back as a diff, then re-apply them (the new draft \ + re-bases onto the latest version) and deploy. To deploy as-is and replace the newer version, call \ + deploy_workspace_item again with force: true.", + js_str(base), + js_str(head) + )) +} + +/// An editor draft of a script or flow can stage trigger edits, which the editors deploy +/// through per-kind forms. Deploying the item alone deletes the draft and those edits +/// with it, so it is refused instead. +fn refuse_staged_triggers(item_type: &str, path: &str, draft: &Value) -> Result<(), String> { + let staged = draft + .get("draft_triggers") + .and_then(Value::as_array) + .is_some_and(|triggers| triggers.iter().any(|t| truthy(t.get("draftConfig")))); + if staged { + return Err(format!( + "Draft {item_type} \"{path}\" also stages trigger changes made in the Windmill editor, which \ + cannot be deployed from here. Deploy it from the editor so they are not lost." + )); + } + Ok(()) +} + +async fn deploy_script(ctx: &Ctx<'_>, path: &str, message: Option<&str>) -> Result<(), String> { + let body_of = |e: String| api_message(&e).to_string(); + let row = ctx + .get(&ctx.ws(&format!("scripts/get/p/{}?get_draft=true", path_enc(path)))) + .await + .map_err(body_of)?; + let draft = non_null(row.get("draft")).unwrap_or(&row); + refuse_staged_triggers("script", path, draft)?; + let mut body = without( + draft, + &[ + "draft_triggers", + "draft_only", + "parent_hash", + "deployment_message", + "preserve_on_behalf_of", + // A draft seeded from the deployed script carries its lock, and the server skips + // the dependency job whenever one is set. + "lock", + "lock_error_logs", + ], + ); + body["path"] = non_null(draft.get("path")).cloned().unwrap_or(json!(path)); + if let Some(hash) = row.get("hash") { + body["parent_hash"] = hash.clone(); + } + if let Some(message) = message { + body["deployment_message"] = json!(message); + } + // Without the flag the server resets the identity to the deploying user. + if truthy(draft.get("on_behalf_of_email")) { + body["preserve_on_behalf_of"] = json!(true); + } + ctx.post(&ctx.ws("scripts/create"), body) + .await + .map_err(body_of)?; + Ok(()) +} + +async fn deploy_flow( + ctx: &Ctx<'_>, + path: &str, + draft_only: bool, + message: Option<&str>, +) -> Result<(), String> { + let body_of = |e: String| api_message(&e).to_string(); + let row = ctx + .get(&ctx.ws(&format!("flows/get/{}?get_draft=true", path_enc(path)))) + .await + .map_err(body_of)?; + let draft = non_null(row.get("draft")).unwrap_or(&row); + refuse_staged_triggers("flow", path, draft)?; + let or_empty = |key: &str| non_null(draft.get(key)).cloned().unwrap_or(json!("")); + let mut body = Map::new(); + body.insert( + "path".into(), + non_null(draft.get("draft_path")) + .or_else(|| non_null(draft.get("path"))) + .cloned() + .unwrap_or(json!(path)), + ); + body.insert("summary".into(), or_empty("summary")); + body.insert("description".into(), or_empty("description")); + for key in [ + "value", + "schema", + "tag", + "dedicated_worker", + "ws_error_handler_muted", + "visible_to_runner_only", + "on_behalf_of_email", + "on_behalf_of", + "labels", + "job_token_scopes", + ] { + if let Some(value) = draft.get(key) { + body.insert(key.into(), value.clone()); + } + } + if let Some(Value::Object(value)) = body.get_mut("value") { + // With nothing deployed the server fills `value` from the draft itself. + let deployed = row + .get("value") + .filter(|_| !draft_only && !truthy(row.get("no_deployed"))); + drop_stale_step_locks(value, deployed); + } + if truthy(draft.get("on_behalf_of_email")) { + body.insert("preserve_on_behalf_of".into(), json!(true)); + } + if let Some(message) = message { + body.insert("deployment_message".into(), json!(message)); + } + // A draft-only flow has no flow row to update. + let route = if draft_only { + "flows/create".to_string() + } else { + format!("flows/update/{}", path_enc(path)) + }; + ctx.post(&ctx.ws(&route), Value::Object(body)) + .await + .map_err(body_of)?; + Ok(()) +} + +async fn upsert( + ctx: &Ctx<'_>, + item_type: &str, + trigger_kind: Option<&str>, + path: &str, + body: Value, +) -> Result<(), String> { + let service = service(item_type, trigger_kind)?; + let route = if item_exists(ctx, item_type, trigger_kind, path).await? { + format!("{service}/update/{}", path_enc(path)) + } else { + format!("{service}/create") + }; + ctx.post(&ctx.ws(&route), body).await?; + Ok(()) +} + +/// An agent's draft never carries the identity it runs as, and without the flag the server +/// resets it to the deploying user, so an update restates the deployed one. +async fn deploy_resource(ctx: &Ctx<'_>, path: &str, draft: &Value) -> Result<(), String> { + if !item_exists(ctx, "resource", None, path).await? { + ctx.post(&ctx.ws("resources/create"), draft.clone()).await?; + return Ok(()); + } + let deployed = get_deployed(ctx, "resource", None, path).await?; + let mut body = draft.clone(); + let agent_obo = deployed + .get("value") + .and_then(|value| value.get("on_behalf_of")) + .filter(|obo| { + deployed.get("resource_type").and_then(Value::as_str) == Some("ai_agent") + && truthy(Some(obo)) + }); + if let (Some(obo), Some(body)) = (agent_obo, body.as_object_mut()) { + let mut value = body + .get("value") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + value.insert("on_behalf_of".into(), obo.clone()); + body.insert("value".into(), Value::Object(value)); + body.insert("preserve_on_behalf_of".into(), json!(true)); + } + ctx.post( + &ctx.ws(&format!("resources/update/{}", path_enc(path))), + body, + ) + .await?; + Ok(()) +} + +/// Deploys a variable draft; the note says when a secret's stored value was left alone. +async fn deploy_variable( + ctx: &Ctx<'_>, + path: &str, + draft: &Value, +) -> Result, String> { + let is_secret = truthy(draft.get("is_secret")); + let stages_no_value = draft.get("value").and_then(Value::as_str) == Some(""); + if !item_exists(ctx, "variable", None, path).await? { + if is_secret && stages_no_value { + return Err(format!( + "Draft variable \"{}\" is secret but stages no value, so it cannot be created. Run write_variable with its value first.", + draft.get("path").map(js_str).unwrap_or_default() + )); + } + ctx.post(&ctx.ws("variables/create"), draft.clone()).await?; + return Ok(None); + } + // A draft never carries a secret's or an OAuth-managed value, so `''` there means + // "none staged": sending it would blank the stored value or wipe a rotating token. + let protected = is_secret + || 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 { + without(draft, &["value"]) + } else { + draft.clone() + }; + ctx.post( + &ctx.ws(&format!("variables/update/{}", path_enc(path))), + body, + ) + .await?; + Ok((is_secret && keep_stored).then(|| { + "Its secret value was left unchanged, because the draft staged only metadata. If a new value was meant to be set, run write_variable again with it.".to_string() + })) +} + +/// A path runnable runs the DEPLOYED item at its path, so one pointing at a draft-only +/// script or flow fails at runtime. +async fn undeployed_runnable_targets(ctx: &Ctx<'_>, runnables: &Map) -> Vec { + let mut targets = Vec::new(); + for (key, runnable) in runnables { + let kind = runnable.get("type").and_then(Value::as_str); + let target_type = if matches!(kind, Some("path" | "runnableByPath")) { + runnable.get("runType").and_then(Value::as_str) + } else { + kind + }; + let (Some(target_type @ ("script" | "flow")), Some(path)) = ( + target_type, + runnable + .get("path") + .and_then(Value::as_str) + .filter(|p| !p.is_empty()), + ) else { + continue; + }; + // Advisory: a failed probe must not block the deploy it annotates. + if let Ok(false) = deployed_item_exists(ctx, target_type, None, path).await { + targets.push(format!("\"{key}\" → {target_type} \"{path}\"")); + } + } + targets +} + +async fn guest_access_is_live(ctx: &Ctx<'_>) -> bool { + let (Ok(usage), Ok(settings)) = ( + ctx.get(&ctx.ws("workspaces/guest_usage")).await, + ctx.get(&ctx.ws("workspaces/get_public_settings")).await, + ) else { + return false; + }; + truthy(usage.get("available")) + && truthy(usage.get("instance_enabled")) + && truthy(settings.get("guest_access_enabled")) +} + +/// Deploys a raw app draft from its sources: the server bundles them on a worker and +/// derives the policy's triggerables from the runnables. Returns the note for the result. +async fn deploy_app( + ctx: &Ctx<'_>, + path: &str, + item: &Value, + draft: &Value, + message: Option<&str>, + force: bool, +) -> Result, String> { + if let Some(base) = non_null(item.get("parentVersionId")) { + let head = if item_exists(ctx, "app", None, path).await? { + get_deployed(ctx, "app", None, path) + .await? + .get("versions") + .and_then(Value::as_array) + .and_then(|versions| versions.last()) + .cloned() + } else { + None + }; + assert_draft_based_on_latest("app", path, Some(base), head.as_ref(), force)?; + } + let object_or_empty = |key: &str| { + draft + .get(key) + .and_then(Value::as_object) + .cloned() + .unwrap_or_default() + }; + let runnables = object_or_empty("runnables"); + let mut policy = object_or_empty("policy"); + if !truthy(policy.get("execution_mode")) { + policy.insert("execution_mode".into(), json!("publisher")); + } + + let mut notes = Vec::new(); + let undeployed = undeployed_runnable_targets(ctx, &runnables).await; + if !undeployed.is_empty() { + notes.push(format!( + "These backend runnables point at items that are NOT deployed, so they fail at runtime: \ + {}. Deploy those items too, and tell the user the app is not working until they are.", + undeployed.join(", ") + )); + } + // What a runnable runs as is the server's to decide, so the note names who can + // reach the app and never an identity. + match policy.get("execution_mode").and_then(Value::as_str) { + Some("anonymous") => notes.push( + "This app is deployed as anonymous: its backend runnables are now reachable by anyone \ + with the URL, without logging in. Tell the user plainly what is now reachable and by whom." + .to_string(), + ), + // `guest` is stored whether or not a guest is admitted: silent unless it is live. + Some("guest") if guest_access_is_live(ctx).await => notes.push( + "This app is deployed as guest: anyone the instance's identity provider authenticates \ + can now open it and run its backend runnables, member of this workspace or not. Tell \ + the user plainly what is now reachable and by whom." + .to_string(), + ), + _ => {} + } + + // An editor-created draft is parked at a synthetic storage key, its chosen path in + // the stored draft. Only a missing item justifies falling back to the storage path. + let target = match ctx + .get(&ctx.ws(&format!( + "apps/get/p/{}?get_draft=true&raw_app=true", + path_enc(path) + ))) + .await + { + Ok(row) => non_null(row.get("draft")) + .and_then(|d| non_null(d.get("draft_path")).or_else(|| non_null(d.get("path")))) + .or_else(|| non_null(row.get("draft_path"))) + .and_then(Value::as_str) + .unwrap_or(path) + .to_string(), + Err(e) if is_not_found(&e) => path.to_string(), + Err(e) => return Err(e), + }; + + let mut app = Map::new(); + app.insert("path".into(), json!(target)); + app.insert( + "value".into(), + json!({ + "files": object_or_empty("files"), + "runnables": runnables, + "data": non_null(draft.get("data")).cloned().unwrap_or_else(|| json!({ "tables": [] })), + }), + ); + app.insert( + "summary".into(), + non_null(draft.get("summary")) + .or_else(|| non_null(item.get("summary"))) + .cloned() + .unwrap_or(json!("")), + ); + if truthy(policy.get("on_behalf_of")) { + app.insert("preserve_on_behalf_of".into(), json!(true)); + } + app.insert("policy".into(), Value::Object(policy)); + if let Some(message) = message { + app.insert("deployment_message".into(), json!(message)); + } + if item_exists(ctx, "app", None, &target).await? { + // `custom_path` stays out of an update: the server keeps it when absent, and + // sending it takes admin rights. + ctx.post( + &ctx.ws(&format!("apps/update_raw_source/{}", path_enc(&target))), + Value::Object(app), + ) + .await?; + } else { + if let Some(custom_path) = draft.get("custom_path") { + app.insert("custom_path".into(), custom_path.clone()); + } + ctx.post(&ctx.ws("apps/create_raw_source"), Value::Object(app)) + .await?; + } + Ok((!notes.is_empty()).then(|| notes.join(" "))) +} + +fn mutation_result( + message: String, + item_type: &str, + path: &str, + trigger_kind: Option<&str>, + url: Option, +) -> String { + let mut out = Map::new(); + out.insert("success".into(), json!(true)); + out.insert("message".into(), json!(message)); + out.insert("type".into(), json!(item_type)); + out.insert("path".into(), json!(path)); + if let Some(trigger_kind) = trigger_kind { + out.insert("triggerKind".into(), json!(trigger_kind)); + } + if let Some(url) = url { + out.insert("url".into(), json!(url)); + } + pretty(&Value::Object(out)) +} + +/// Where a deployed item is seen in Windmill. +fn deployed_page(item_type: &str, path: &str, value: &Value) -> Option { + Some(match item_type { + "script" => format!("/scripts/get/{path}"), + "flow" => format!("/flows/get/{path}"), + "app" => format!("/apps_raw/get/{path}"), + "variable" => format!("/variables#{path}"), + "resource" if super::workspace::is_agent(value) => format!("/agents/get/{path}"), + "resource" => format!("/resources#/resource/{path}"), + "schedule" => format!("/schedules#{path}"), + _ => return None, + }) +} + +async fn deploy_item(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let item_type = item_type_arg(args)?; + let path = str_arg(args, "path")?; + let trigger_kind = opt_str_arg(args, "trigger_kind"); + let message = opt_str_arg(args, "deployment_message"); + let force = bool_arg(args, "force"); + if item_type == "trigger" && trigger_kind.is_none() { + return Err("trigger_kind is required when deploying a trigger.".to_string()); + } + let no_draft = || format!("No draft found for {item_type} \"{path}\"."); + let kind = draft_kind(item_type, trigger_kind).ok_or_else(no_draft)?; + let (item, draft) = get_draft_item(ctx, item_type, path, trigger_kind) + .await? + .ok_or_else(no_draft)?; + let value = item + .get("value") + .ok_or_else(|| format!("Draft {item_type} \"{path}\" has no value to deploy."))?; + + let mut note = None; + match item_type { + "script" => { + let head = if item_exists(ctx, "script", None, path).await? { + get_deployed(ctx, "script", None, path) + .await? + .get("hash") + .cloned() + } else { + None + }; + assert_draft_based_on_latest( + "script", + path, + item.get("parentHash"), + head.as_ref(), + force, + )?; + deploy_script(ctx, path, message).await?; + } + "flow" => { + let exists = item_exists(ctx, "flow", None, path).await?; + let head = if exists { + get_deployed(ctx, "flow", None, path) + .await? + .get("version_id") + .cloned() + } else { + None + }; + assert_draft_based_on_latest( + "flow", + path, + item.get("parentVersionId"), + head.as_ref(), + force, + )?; + deploy_flow(ctx, path, !exists, message).await?; + } + "variable" => note = deploy_variable(ctx, path, value).await?, + "resource" => deploy_resource(ctx, path, value).await?, + "app" => note = deploy_app(ctx, path, &item, value, message, force).await?, + _ => upsert(ctx, item_type, trigger_kind, path, value.clone()).await?, + } + + // The baseline keeps a draft saved since the read above from being deleted undeployed. + delete_draft(ctx, &kind, path, draft.created_at.as_deref()).await?; + + let note = note.map(|note| format!(" {note}")).unwrap_or_default(); + Ok(mutation_result( + format!("Deployed draft {item_type} \"{path}\" to the workspace. Draft removed.{note}"), + item_type, + path, + trigger_kind, + deployed_page(item_type, path, value).map(|page| ctx.page_url(&page)), + )) +} + +// ============================================================================ +// delete_workspace_item +// ============================================================================ + +async fn delete_item(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let item_type = item_type_arg(args)?; + let path = str_arg(args, "path")?; + let trigger_kind = opt_str_arg(args, "trigger_kind"); + if item_type == "trigger" && trigger_kind.is_none() { + return Err("trigger_kind is required when deleting a trigger.".to_string()); + } + // Undoing a draft is a discard, not a delete: say so before the delete route 404s. + if !deployed_item_exists(ctx, item_type, trigger_kind, path).await? { + let draft = get_draft_item(ctx, item_type, path, trigger_kind).await?; + return Err(if draft.is_some() { + format!( + "No deployed {item_type} at \"{path}\" — it only exists as a draft, so there is nothing to delete \ + from the workspace. Call discard_local_draft with the same arguments to remove the draft." + ) + } else { + format!("No {item_type} at \"{path}\": neither a deployed item nor a draft. Nothing to delete.") + }); + } + + let path_in_url = path_enc(path); + if item_type == "script" { + ctx.request( + "POST", + &ctx.ws(&format!("scripts/delete/p/{path_in_url}")), + None, + ) + .await?; + } else { + ctx.delete(&ctx.ws(&format!( + "{}/delete/{path_in_url}", + service(item_type, trigger_kind)? + ))) + .await?; + } + if let Some(kind) = draft_kind(item_type, trigger_kind) { + delete_draft(ctx, &kind, path, None).await?; + } + Ok(mutation_result( + format!( + "Deleted {item_type} \"{path}\" from the workspace. Any matching draft was also cleared." + ), + item_type, + path, + trigger_kind, + None, + )) +} + +// ============================================================================ +// Stable YAML: what the session's `yaml` library prints for +// `stringify(value, { sortMapEntries: true, aliasDuplicateObjects: false, lineWidth: 0 })`, +// so a diff reads the same here as in a session. +// ============================================================================ + +static YAML_CONTROL_CHARS: LazyLock = + LazyLock::new(|| Regex::new(r"[\x00-\x08\x0b-\x1f\x7f-\u{9f}]").expect("valid regex")); +static YAML_NOT_PLAIN: LazyLock = LazyLock::new(|| { + Regex::new( + r#"^[\n\t ,\[\]{}#&*!|>'"%@`]|^[?-]$|^[?-][ \t]|[\n:][ \t]|[ \t]\n|[\n\t ]#|[\n\t :]$"#, + ) + .expect("valid regex") +}); +/// Plain scalars the core schema would read back as null, a boolean or a number. +static YAML_NOT_A_STRING: LazyLock = LazyLock::new(|| { + Regex::new(concat!( + r"^(?:~|[Nn]ull|NULL)?$", + r"|^(?:[Tt]rue|TRUE|[Ff]alse|FALSE)$", + r"|^0o[0-7]+$|^[-+]?[0-9]+$|^0x[0-9a-fA-F]+$", + r"|^(?:[-+]?\.(?:inf|Inf|INF)|\.nan|\.NaN|\.NAN)$", + r"|^[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)[eE][-+]?[0-9]+$", + r"|^[-+]?(?:\.[0-9]+|[0-9]+\.[0-9]*)$", + )) + .expect("valid regex") +}); +static YAML_DOCUMENT_MARKER: LazyLock = + LazyLock::new(|| Regex::new(r"(?m)^(%|---|\.\.\.)").expect("valid regex")); + +/// Null object properties are dropped so `field: null` never diffs against an absent +/// field; array elements are kept so positions stay aligned. +fn prune_nulls(value: &Value) -> Value { + match value { + Value::Array(items) => Value::Array(items.iter().map(prune_nulls).collect()), + Value::Object(map) => Value::Object( + map.iter() + .filter(|(_, v)| !v.is_null()) + .map(|(k, v)| (k.clone(), prune_nulls(v))) + .collect(), + ), + other => other.clone(), + } +} + +fn to_stable_yaml(value: Option<&Value>) -> String { + match value { + None | Some(Value::Null) => String::new(), + Some(value) => yaml_node(&prune_nulls(value), "") + "\n", + } +} + +fn yaml_node(value: &Value, indent: &str) -> String { + let nested = format!("{indent} "); + match value { + Value::Null => "null".to_string(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => yaml_number(n), + Value::String(s) => yaml_string(s, indent, false), + Value::Array(items) if items.is_empty() => "[]".to_string(), + Value::Object(map) if map.is_empty() => "{}".to_string(), + Value::Array(items) => items + .iter() + .map(|item| format!("- {}", yaml_node(item, &nested))) + .collect::>() + .join(&format!("\n{indent}")), + Value::Object(map) => { + let mut entries: Vec<(&String, &Value)> = map.iter().collect(); + entries.sort_by(|a, b| a.0.cmp(b.0)); + entries + .into_iter() + .map(|(key, value)| { + let key = yaml_string(key, &nested, true); + let rendered = yaml_node(value, &nested); + let is_block = match value { + Value::Array(items) => !items.is_empty(), + Value::Object(map) => !map.is_empty(), + _ => false, + }; + if is_block { + format!("{key}:\n{nested}{rendered}") + } else { + format!("{key}: {rendered}") + } + }) + .collect::>() + .join(&format!("\n{indent}")) + } + } +} + +// ponytail: prints what Rust prints for a float. JS switches to exponent notation +// below 1e-6 and from 1e21, and rounds integers past 2^53; port Number#toString if a +// diff of such numbers ever has to match a session's byte for byte. +fn yaml_number(n: &serde_json::Number) -> String { + match n.as_f64() { + Some(f) if !n.is_i64() && !n.is_u64() && f.fract() == 0.0 && f.abs() < 1e21 => { + format!("{f:.0}") + } + _ => n.to_string(), + } +} + +fn yaml_string(value: &str, indent: &str, implicit_key: bool) -> String { + if YAML_CONTROL_CHARS.is_match(value) { + return yaml_double_quoted(value, indent, implicit_key); + } + let multiline = value.contains('\n'); + if implicit_key && multiline { + return yaml_quoted(value, indent, implicit_key); + } + if multiline { + return yaml_block(value, indent); + } + let top_level_key = implicit_key && indent.len() == 2; + if YAML_NOT_PLAIN.is_match(value) + || YAML_NOT_A_STRING.is_match(value) + || (top_level_key && YAML_DOCUMENT_MARKER.is_match(value)) + { + return yaml_quoted(value, indent, implicit_key); + } + value.to_string() +} + +fn yaml_quoted(value: &str, indent: &str, implicit_key: bool) -> String { + let single = value.contains('"') && !value.contains('\'') && !value.contains('\n'); + if single { + format!("'{}'", value.replace('\'', "''")) + } else { + yaml_double_quoted(value, indent, implicit_key) + } +} + +/// Lines of a double-quoted scalar shorter than this stay on one line, newlines escaped. +const YAML_MIN_MULTILINE_LENGTH: usize = 40; + +fn yaml_double_quoted(value: &str, indent: &str, implicit_key: bool) -> String { + let json: Vec = serde_json::to_string(value) + .unwrap_or_default() + .chars() + .collect(); + let at = |i: usize| json.get(i).copied(); + let mut out = String::new(); + let mut start = 0; + let mut i = 0; + while i < json.len() { + let mut ch = json[i]; + // A space before a line break would be folded away unless escaped. + if ch == ' ' && at(i + 1) == Some('\\') && at(i + 2) == Some('n') { + out.extend(&json[start..i]); + out.push_str("\\ "); + i += 1; + start = i; + ch = '\\'; + } + if ch == '\\' { + match at(i + 1) { + Some('u') => { + out.extend(&json[start..i]); + let code: String = json[i + 2..(i + 6).min(json.len())].iter().collect(); + match code.as_str() { + "0000" => out.push_str("\\0"), + "0007" => out.push_str("\\a"), + "000b" => out.push_str("\\v"), + "001b" => out.push_str("\\e"), + _ if code.starts_with("00") => out.push_str(&format!("\\x{}", &code[2..])), + _ => out.push_str(&format!("\\u{code}")), + } + i += 5; + start = i + 1; + } + Some('n') => { + if implicit_key + || at(i + 2) == Some('"') + || json.len() < YAML_MIN_MULTILINE_LENGTH + { + i += 1; + } else { + // A real line break; the reader folds the first one away. + out.extend(&json[start..i]); + out.push_str("\n\n"); + while at(i + 2) == Some('\\') + && at(i + 3) == Some('n') + && at(i + 4) != Some('"') + { + out.push('\n'); + i += 2; + } + out.push_str(indent); + if at(i + 2) == Some(' ') { + out.push('\\'); + } + i += 1; + start = i + 1; + } + } + _ => i += 1, + } + } + i += 1; + } + if start == 0 { + return json.into_iter().collect(); + } + out.extend(&json[start.min(json.len())..]); + out +} + +/// Appends `indent` after every run of line breaks; `inner_only` leaves a run that +/// ends the text alone. +fn indent_after_breaks(text: &str, indent: &str, inner_only: bool) -> String { + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + out.push(ch); + if ch == '\n' && chars.peek() != Some(&'\n') && !(inner_only && chars.peek().is_none()) { + out.push_str(indent); + } + } + out +} + +/// A literal block scalar, so multi-line text (code most of all) diffs line by line. +fn yaml_block(value: &str, indent: &str) -> String { + let is_space = |c: char| matches!(c, '\n' | '\t' | ' '); + let body_len = value.trim_end_matches(is_space).len(); + let (mut body, mut end) = value.split_at(body_len); + // A block cannot end in whitespace-only text after its last line break. + if end + .rfind('\n') + .is_some_and(|last_break| last_break + 1 < end.len()) + { + return yaml_quoted(value, indent, false); + } + let chomp = match end.find('\n') { + None => "-", + Some(first_break) if body.is_empty() || first_break != end.len() - 1 => "+", + Some(_) => "", + }; + end = end.strip_suffix('\n').unwrap_or(end); + let end = indent_after_breaks(end, indent, true); + + // Leading spaces need an explicit indentation indicator; leading line breaks are + // kept ahead of the body. + let lead_len = body.len() - body.trim_start_matches([' ', '\n']).len(); + let lead = &body[..lead_len]; + let starts_with_space = lead.contains(' '); + let start = lead.rfind('\n').map(|i| &lead[..=i]).unwrap_or(""); + body = &body[start.len()..]; + let indicator = match (starts_with_space, indent.is_empty()) { + (false, _) => "", + (true, true) => "1", + (true, false) => "2", + }; + format!( + "|{indicator}{chomp}\n{indent}{}{}{end}", + indent_after_breaks(start, indent, false), + indent_after_breaks(body, indent, false), + ) +} + +// ============================================================================ +// Patches +// ============================================================================ + +fn line_count(patch: &str) -> usize { + if patch.is_empty() { + 0 + } else { + patch.split('\n').count() + } +} + +/// Unified patch between two sides of an item, `""` when they render the same. +fn yaml_patch(before: Option<&Value>, after: Option<&Value>, labels: (&str, &str)) -> String { + let (before, after) = (to_stable_yaml(before), to_stable_yaml(after)); + if before == after { + return String::new(); + } + unified_patch(labels.0, labels.1, &before, &after, 3) +} + +/// Unified patch between two raw files; an absent side reads as empty. +fn text_patch(before: Option<&str>, after: Option<&str>, labels: (&str, &str)) -> String { + let (before, after) = (before.unwrap_or(""), after.unwrap_or("")); + if before == after { + return String::new(); + } + unified_patch(labels.0, labels.1, before, after, 3) +} + +/// Indices of the added and removed lines of a patch. The `---`/`+++` file labels only +/// occur ahead of the first hunk, while a changed source line may itself start with +/// `++` or `--`. +fn changed_line_indices(patch: &str) -> Vec { + let mut in_hunk = false; + let mut indices = Vec::new(); + for (i, line) in patch.split('\n').enumerate() { + if line.starts_with("@@") { + in_hunk = true; + } else if in_hunk && (line.starts_with('+') || line.starts_with('-')) { + indices.push(i); + } + } + indices +} + +const MAX_DIFF_PATCH_CHARS: usize = 50_000; +const DIFF_READ_DEFAULT_LINES: usize = 500; + +/// A window of a patch by lines, with a character backstop. When the backstop cuts +/// inside the window the continuation offset advances past the complete lines +/// delivered only, or the rest of the window would be skipped for good. +fn window_patch(patch: &str, offset: usize, limit: usize, max_chars: usize) -> String { + let lines: Vec<&str> = patch.split('\n').collect(); + let total = lines.len(); + if offset >= total { + return format!( + "(no lines at offset {offset} — the patch has {total} lines; call again with a smaller offset)" + ); + } + let requested_end = total.min(offset.saturating_add(limit)); + let mut body = lines[offset..requested_end].join("\n"); + let mut effective_end = requested_end; + let mut note = String::new(); + if body.chars().count() > max_chars { + body = body.chars().take(max_chars).collect(); + match body.rfind('\n') { + // One line over the whole budget: deliver its head and step past it. + None => { + effective_end = offset + 1; + note = format!("\n… [line truncated at {max_chars} chars]"); + } + Some(last_break) => { + body.truncate(last_break); + effective_end = offset + body.split('\n').count(); + note = format!("\n… window truncated at {max_chars} chars."); + } + } + } + if offset > 0 || effective_end < total { + let rest = if effective_end < total { + format!("; call diff again with offset={effective_end} for the rest") + } else { + String::new() + }; + note.push_str(&format!( + "\n(lines {}-{effective_end} of {total}{rest})", + offset + 1 + )); + } + body + note.as_str() +} + +struct FileDiff { + status: &'static str, + patch: String, + line_count: usize, +} + +#[derive(Default)] +struct DiffParts { + /// The whole-value patch, or the config-only patch of a multi-file app. + patch: String, + /// Changed files of a multi-file app, by name. + files: Option>, + line_count: usize, + has_changes: bool, +} + +/// A map of string contents marks a multi-file app value. +fn app_files(value: Option<&Value>) -> Option<&Map> { + value? + .get("files")? + .as_object() + .filter(|files| !files.is_empty() && files.values().all(Value::is_string)) +} + +/// Per-file text patches plus a config-only patch for a multi-file app, one whole-value +/// YAML patch for anything else. +fn diff_parts(before: Option<&Value>, after: Option<&Value>, labels: (&str, &str)) -> DiffParts { + let (before_files, after_files) = (app_files(before), app_files(after)); + if before_files.is_none() && after_files.is_none() { + let patch = yaml_patch(before, after, labels); + return DiffParts { + line_count: line_count(&patch), + has_changes: !patch.is_empty(), + patch, + files: None, + }; + } + let mut names: Vec<&String> = before_files + .into_iter() + .chain(after_files) + .flat_map(|files| files.keys()) + .collect(); + names.sort(); + names.dedup(); + fn content<'a>(files: Option<&'a Map>, name: &str) -> Option<&'a str> { + files?.get(name)?.as_str() + } + let mut files = Vec::new(); + let mut total = 0; + for name in names { + let (old, new) = (content(before_files, name), content(after_files, name)); + let patch = text_patch(old, new, labels); + // An empty file appearing or disappearing yields no patch, but its presence + // is a change in its own right. + if patch.is_empty() && old.is_some() == new.is_some() { + continue; + } + let line_count = line_count(&patch); + total += line_count; + let status = match (old, new) { + (None, _) => "added", + (_, None) => "deleted", + _ => "modified", + }; + files.push((name.clone(), FileDiff { status, patch, line_count })); + } + let config = |side: Option<&Value>| { + side.map(|v| { + if v.is_object() { + without(v, &["files"]) + } else { + v.clone() + } + }) + }; + let patch = yaml_patch(config(before).as_ref(), config(after).as_ref(), labels); + DiffParts { + line_count: total + line_count(&patch), + has_changes: !files.is_empty() || !patch.is_empty(), + patch, + files: Some(files), + } +} + +// ============================================================================ +// The two sides of a draft +// ============================================================================ + +/// The per-user markers a `get_draft=true` read adds beside the deployed item. +const OVERLAY_KEYS: &[&str] = &[ + "draft", + "is_draft", + "draft_saved_at", + "draft_base", + "no_deployed", + "other_drafts_users", +]; + +/// Server-managed script fields: identical noise, or recomputed at every deploy. +const SCRIPT_RUNTIME_KEYS: &[&str] = &[ + "workspace_id", + "hash", + "parent_hash", + "parent_hashes", + "created_at", + "created_by", + "archived", + "deleted", + "extra_perms", + "lock", + "lock_error_logs", + "starred", + "has_draft", + "draft_only", + "assets", + "marked", +]; + +/// Runtime fields of a schedule or trigger, which the workspace comparison ignores too. +const TRIGGER_RUNTIME_KEYS: &[&str] = &[ + "workspace_id", + "edited_by", + "edited_at", + "email", + "error", + "enabled", + "mode", + "server_id", + "last_server_ping", + "extra_perms", + "permissioned_as", + "subscription_id", + "push_auth_config", +]; + +struct DraftSides { + deployed: Value, + draft: Value, + /// False when the read carried no draft, and the item's own value stood in for it. + has_draft: bool, + /// True when the item was never deployed. + no_deployed: bool, +} + +/// A raw app on the flat draft shape, without the inline-script locks the server +/// recomputes at every deploy. +fn canonical_raw_app(source: &Value, path: Option<&Value>) -> Value { + let mut value = app_source_to_draft_value(source, None); + if let Some(runnables) = value.get_mut("runnables").and_then(Value::as_object_mut) { + for runnable in runnables.values_mut() { + if let Some(inline) = runnable + .get_mut("inlineScript") + .and_then(Value::as_object_mut) + { + inline.shift_remove("lock"); + } + } + } + // A staged rename changes where a deploy lands the app, so the path is compared too. + if let (Some(path), Some(value)) = (path, value.as_object_mut()) { + value.insert("path".into(), path.clone()); + } + value +} + +/// A low-code app draft split into its grid and the metadata the editor mirrors into +/// it: `(value, summary, draft_path)`. +fn classic_app_parts(json: Option<&Value>) -> (Value, Option, Option) { + let Some(Value::Object(obj)) = json else { + return (json.cloned().unwrap_or(Value::Null), None, None); + }; + // A legacy draft wraps the grid; a bare app always carries `grid` at the top. + let inner = obj.get("value").filter(|v| v.get("grid").is_some()); + if let (false, Some(inner)) = (obj.contains_key("grid"), inner) { + let (value, summary, draft_path) = classic_app_parts(Some(inner)); + let outer = |key: &str| obj.get(key).filter(|v| !v.is_null()).cloned(); + return ( + value, + outer("summary").or(summary), + outer("draft_path").or(draft_path), + ); + } + let mut value = obj.clone(); + value.shift_remove("parent_version"); + let draft_path = value.shift_remove("draft_path").filter(|v| !v.is_null()); + let summary = value.shift_remove("summary").filter(|v| !v.is_null()); + (Value::Object(value), summary, draft_path) +} + +/// A variable, resource, schedule or trigger on one field set, whether it comes as the +/// deployed row or as the editor state a draft stores. +fn canonical_drawer_value(kind: &str, raw: &Value, is_draft: bool) -> Value { + if !raw.is_object() { + return json!({}); + } + let or_empty = + |value: Option<&Value>| value.filter(|v| !v.is_null()).cloned().unwrap_or(json!("")); + let ws_specific = if is_draft { + "wsSpecific" + } else { + "ws_specific" + }; + let mut out = Map::new(); + match kind { + "variable" => { + let empty = json!({}); + let variable = if is_draft { + non_null(raw.get("variable")).unwrap_or(&empty) + } else { + raw + }; + let is_secret = truthy(variable.get("is_secret")); + out.insert( + "value".into(), + if is_secret { + json!("") + } else { + or_empty(variable.get("value")) + }, + ); + out.insert("is_secret".into(), json!(is_secret)); + out.insert("description".into(), or_empty(variable.get("description"))); + } + "resource" => { + let key = if is_draft { "args" } else { "value" }; + let mut value = non_null(raw.get(key)).cloned().unwrap_or(json!({})); + // An agent's identity is in its deployed value and never in a draft: not an edit. + if raw.get("resource_type").and_then(Value::as_str) == Some("ai_agent") { + if let Some(value) = value.as_object_mut() { + value.shift_remove("on_behalf_of"); + } + } + out.insert("value".into(), value); + out.insert("description".into(), or_empty(raw.get("description"))); + if let Some(resource_type) = non_null(raw.get("resource_type")) { + out.insert("resource_type".into(), resource_type.clone()); + } + } + _ => return without(raw, TRIGGER_RUNTIME_KEYS), + } + if let Some(labels) = non_null(raw.get("labels")) { + out.insert("labels".into(), labels.clone()); + } + if let Some(ws_specific) = non_null(raw.get(ws_specific)) { + out.insert("ws_specific".into(), ws_specific.clone()); + } + Value::Object(out) +} + +/// The deployed value and the draft value of an item, each normalised so that only a +/// real change differs. `kind` is a draft kind; an item that is neither deployed nor +/// drafted is the API's 404. +async fn draft_sides(ctx: &Ctx<'_>, kind: &str, path: &str) -> Result { + let path_in_url = path_enc(path); + let route = match kind { + "script" => format!("scripts/get/p/{path_in_url}?get_draft=true"), + "flow" => format!("flows/get/{path_in_url}?get_draft=true"), + // The server resolves a never-deployed app's draft kind from `raw_app`. + "app" | "raw_app" => format!( + "apps/get/p/{path_in_url}?get_draft=true&raw_app={}", + kind == "raw_app" + ), + "variable" => format!("variables/get/{path_in_url}?decrypt_secret=false&get_draft=true"), + "resource" => format!("resources/get/{path_in_url}?get_draft=true"), + "trigger_schedule" => format!("schedules/get/{path_in_url}?get_draft=true"), + _ => { + let trigger_kind = kind + .strip_prefix("trigger_") + .filter(|k| TRIGGER_KINDS.contains(k)) + .ok_or_else(|| format!("Draft diff not supported for kind {kind}"))?; + format!("{trigger_kind}_triggers/get/{path_in_url}?get_draft=true") + } + }; + Ok(sides_of_row(kind, &ctx.get(&ctx.ws(&route)).await?)) +} + +/// Splits a `get_draft=true` read into its two sides. +fn sides_of_row(kind: &str, row: &Value) -> DraftSides { + let draft = non_null(row.get("draft")); + let has_draft = draft.is_some(); + let no_deployed = row.get("no_deployed") == Some(&Value::Bool(true)); + let deployed = without(row, OVERLAY_KEYS); + let (deployed, draft) = match kind { + "script" => ( + without(&deployed, SCRIPT_RUNTIME_KEYS), + without(draft.unwrap_or(&deployed), SCRIPT_RUNTIME_KEYS), + ), + // The base a draft pinned differs from the head when it is stale: not a change. + "flow" => ( + without(&deployed, &["version_id"]), + without(draft.unwrap_or(&deployed), &["version_id"]), + ), + "app" | "raw_app" + if kind == "raw_app" || row.get("raw_app") == Some(&Value::Bool(true)) => + { + let row_path = row.get("path"); + let staged_path = draft + .and_then(|d| non_null(d.get("draft_path"))) + .or(row_path); + ( + canonical_raw_app(row, row_path), + canonical_raw_app(draft.unwrap_or(row), staged_path), + ) + } + "app" => { + let row_summary = non_null(row.get("summary")).cloned().unwrap_or(json!("")); + let deployed_parts = classic_app_parts(row.get("value")); + let value = deployed_parts.0.clone(); + let (draft_value, summary, draft_path) = match draft { + Some(draft) => classic_app_parts(Some(draft)), + None => deployed_parts, + }; + ( + json!({ "summary": row_summary, "value": value, "path": row.get("path") }), + json!({ + "summary": summary.unwrap_or(row_summary.clone()), + "value": draft_value, + "path": draft_path.or_else(|| row.get("path").cloned()), + }), + ) + } + _ => ( + canonical_drawer_value(kind, &deployed, false), + canonical_drawer_value(kind, draft.unwrap_or(&deployed), true), + ), + }; + DraftSides { deployed, draft, has_draft, no_deployed } +} + +const VARIABLE_VALUE_PLACEHOLDER: &str = ""; +const VARIABLE_VALUE_CHANGED_PLACEHOLDER: &str = ""; +const VARIABLE_MASKED_NOTE: &str = "Note: variable values are never shown in chat — the diff marks whether the value changed without revealing it.\n\n"; +const SECRET_UNCOMPARABLE_NOTE: &str = "Note: this is a SECRET variable — its value is never shown and cannot be compared, so it may ALSO have changed beyond what this diff shows.\n\n"; + +/// A variable's value never reaches a tool result, secret or not: both sides get a +/// placeholder that still shows WHETHER a readable value changed. The flag is set for a +/// secret, whose sides are never comparable — the deployed value is not decrypted and a +/// draft holds a marker — so equal sides prove nothing. +fn mask_variable_sides(before: &mut Option, after: &mut Option) -> bool { + let object = |side: &Option| side.as_ref().is_some_and(Value::is_object); + let both = object(before) && object(after); + let is_secret = |side: &Option| { + side.as_ref().and_then(|v| v.get("is_secret")) == Some(&Value::Bool(true)) + }; + let secret = is_secret(before) || is_secret(after); + let value = |side: &Option| side.as_ref().and_then(|v| v.get("value")).cloned(); + let changed = !secret && both && value(before) != value(after); + if let Some(Value::Object(before)) = before { + before.insert("value".into(), json!(VARIABLE_VALUE_PLACEHOLDER)); + } + if let Some(Value::Object(after)) = after { + let placeholder = if changed { + VARIABLE_VALUE_CHANGED_PLACEHOLDER + } else { + VARIABLE_VALUE_PLACEHOLDER + }; + after.insert("value".into(), json!(placeholder)); + } + secret && both +} + +// ============================================================================ +// diff +// ============================================================================ + +/// One of the caller's drafts, as the drafts listing reports it. +struct DraftRow { + kind: String, + /// The path the draft is stored under. + path: String, + /// The path the item is known by: a staged name when there is one. + display_path: String, + /// The item type and trigger kind a tool addresses the draft by, if any does. + addressing: Option<(&'static str, Option<&'static str>)>, +} + +fn addressing_of(kind: &str) -> Option<(&'static str, Option<&'static str>)> { + Some(match kind { + "script" => ("script", None), + "flow" => ("flow", None), + "resource" => ("resource", None), + "variable" => ("variable", None), + "app" | "raw_app" => ("app", None), + "trigger_schedule" => ("schedule", None), + _ => { + let trigger_kind = kind.strip_prefix("trigger_")?; + let trigger_kind = TRIGGER_KINDS.iter().find(|k| **k == trigger_kind)?; + ("trigger", Some(*trigger_kind)) + } + }) +} + +/// The caller's drafts, and how many drafts other users hold in the workspace. +async fn list_draft_rows(ctx: &Ctx<'_>) -> Result<(Vec, usize), String> { + let rows = ctx.get(&ctx.ws("drafts/list?all_users=true")).await?; + let rows = array(Some(&rows)); + let mine: Vec = rows + .iter() + .filter(|row| row.get("mine") != Some(&Value::Bool(false))) + .filter_map(|row| { + let kind = row.get("kind")?.as_str()?; + let path = row.get("path")?.as_str()?; + let display_path = row + .get("draft_path") + .and_then(Value::as_str) + .filter(|p| !p.is_empty()) + .unwrap_or(path); + Some(DraftRow { + kind: kind.to_string(), + path: path.to_string(), + display_path: display_path.to_string(), + addressing: addressing_of(kind), + }) + }) + .collect(); + let others = rows.len() - mine.len(); + Ok((mine, others)) +} + +/// A draft's computed diff against what is deployed. +#[derive(Default)] +struct Materialized { + parts: DiffParts, + /// Variable content was masked: the patch marks a value change without showing it. + value_masked: bool, + value_uncomparable: bool, + no_deployed: bool, + error: Option, +} + +async fn materialize(ctx: &Ctx<'_>, kind: &str, path: &str) -> Materialized { + match draft_sides(ctx, kind, path).await { + Ok(sides) => materialized_of(kind, sides), + Err(e) => { + let error = if is_not_found(&e) { + "item not found (the draft may reference a deleted item)".to_string() + } else { + e + }; + Materialized { error: Some(error), ..Default::default() } + } + } +} + +fn materialized_of(kind: &str, sides: DraftSides) -> Materialized { + let mut before = (!sides.no_deployed).then_some(sides.deployed); + let mut after = Some(sides.draft); + let value_masked = kind == "variable"; + let value_uncomparable = value_masked && mask_variable_sides(&mut before, &mut after); + Materialized { + parts: diff_parts(before.as_ref(), after.as_ref(), ("deployed", "draft")), + value_masked, + value_uncomparable, + no_deployed: sides.no_deployed, + error: None, + } +} + +/// What an item read prints for a multi-file app: one file's patch when `file` is +/// given, otherwise the per-file summary plus the config changes. +fn render_files( + files: &[(String, FileDiff)], + config_patch: &str, + file: Option<&str>, + offset: usize, + limit: usize, +) -> ToolResult { + let window = |patch: &str| window_patch(patch, offset, limit, MAX_DIFF_PATCH_CHARS); + if let Some(requested) = file { + // App files are keyed with a leading slash: the slash-less spelling and a + // unique base name are accepted too. + let suffix = format!("/{}", requested.strip_prefix('/').unwrap_or(requested)); + let by_suffix: Vec<_> = files.iter().filter(|(n, _)| n.ends_with(&suffix)).collect(); + let resolved = files + .iter() + .find(|(name, _)| name == requested) + .or_else(|| { + files + .iter() + .find(|(name, _)| *name == format!("/{requested}")) + }) + .or_else(|| (by_suffix.len() == 1).then(|| by_suffix[0])); + let Some((name, file_diff)) = resolved else { + let names: Vec<&str> = files.iter().map(|(name, _)| name.as_str()).collect(); + let changed = if names.is_empty() { + "(none)".to_string() + } else { + names.join(", ") + }; + return Err(format!( + "No changes in file \"{requested}\". Changed files: {changed}." + )); + }; + if file_diff.patch.is_empty() { + return Ok(format!( + "File \"{name}\" was {} with empty content.", + file_diff.status + )); + } + return Ok(window(&file_diff.patch)); + } + let mut sections = Vec::new(); + if files.is_empty() { + sections.push("No file contents changed.".to_string()); + } else { + let lines: Vec = files + .iter() + .map(|(name, file_diff)| { + let size = match (file_diff.status, file_diff.line_count) { + ("deleted", _) => String::new(), + (_, 0) => " (empty file)".to_string(), + (_, n) => format!(" ({n} diff lines)"), + }; + format!("- {name} — {}{size}", file_diff.status) + }) + .collect(); + sections.push(format!( + "{} file(s) changed:\n{}\nRead one with file=\"\".", + lines.len(), + lines.join("\n") + )); + } + if !config_patch.is_empty() { + sections.push(format!("Config changes:\n{}", window(config_patch))); + } + Ok(sections.join("\n\n")) +} + +async fn diff_item(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let non_empty = |name: &str| opt_str_arg(args, name).filter(|s| !s.is_empty()); + let (Some(item_type), Some(path)) = (non_empty("type"), non_empty("path")) else { + return Err("type is required when path is provided.".to_string()); + }; + if !ITEM_TYPES.contains(&item_type) { + return Err(format!("Invalid type \"{item_type}\"")); + } + let kind = draft_kind(item_type, opt_str_arg(args, "trigger_kind")) + .ok_or("trigger_kind is required when type is trigger.")?; + let file = opt_str_arg(args, "file"); + let offset = uint_arg(args, "offset").unwrap_or(0); + let limit = uint_arg(args, "limit").unwrap_or(DIFF_READ_DEFAULT_LINES); + + // The listing is what knows a draft parked under a synthetic key by the name staged + // for it, and which of the two app kinds a draft at this path is. + let kinds: &[&str] = if item_type == "app" { + &["raw_app", "app"] + } else { + &[kind.as_str()] + }; + let (rows, _) = list_draft_rows(ctx).await?; + let listed = kinds + .iter() + .find_map(|kind| rows.iter().find(|r| r.kind == *kind && r.path == path)) + .or_else(|| { + rows.iter().find(|r| { + kinds.contains(&r.kind.as_str()) && (r.display_path == path || r.path == path) + }) + }); + + let mut caveat = ""; + let (parts, no_deployed, value_uncomparable) = match listed { + Some(row) => { + let m = materialize(ctx, &row.kind, &row.path).await; + if let Some(error) = m.error { + return Err(format!("Could not diff {item_type} \"{path}\": {error}")); + } + if m.value_masked { + caveat = if m.value_uncomparable { + SECRET_UNCOMPARABLE_NOTE + } else { + VARIABLE_MASKED_NOTE + }; + } + (m.parts, m.no_deployed, m.value_uncomparable) + } + // Not listed: no draft at all, nothing at the path, or a draft the listing + // does not show. The item's own read settles which. + None => { + let sides = draft_sides(ctx, &kind, path).await.map_err(|e| { + if is_not_found(&e) { + format!( + "No {item_type} found at \"{path}\" — it has neither a deployed version nor a draft." + ) + } else { + e + } + })?; + if !sides.no_deployed && !sides.has_draft { + return Ok(format!( + "No draft exists for {item_type} \"{path}\" — the deployed version is current." + )); + } + let mut before = (!sides.no_deployed).then_some(sides.deployed); + let mut after = Some(sides.draft); + let mut value_uncomparable = false; + if kind == "variable" { + value_uncomparable = mask_variable_sides(&mut before, &mut after); + caveat = if value_uncomparable { + SECRET_UNCOMPARABLE_NOTE + } else { + VARIABLE_MASKED_NOTE + }; + } + let patch = yaml_patch(before.as_ref(), after.as_ref(), ("deployed", "draft")); + ( + DiffParts { patch, ..Default::default() }, + sides.no_deployed, + value_uncomparable, + ) + } + }; + + let changed_files = parts.files.as_ref().map_or(0, Vec::len); + if parts.patch.is_empty() && changed_files == 0 { + // A secret is masked on both ends: an empty patch cannot prove it unchanged. + let message = if value_uncomparable { + format!( + "No visible changes for {item_type} \"{path}\" — but a secret's value cannot be compared and may have been updated in the draft." + ) + } else { + format!("Draft matches the deployed version of {item_type} \"{path}\" — no changes.") + }; + return Ok(format!("{caveat}{message}")); + } + let header = if no_deployed { + format!("{item_type} \"{path}\" has no deployed version yet — the entire draft is new.\n\n") + } else { + format!("Draft changes vs deployed for {item_type} \"{path}\":\n\n") + }; + let body = match &parts.files { + Some(files) => render_files(files, &parts.patch, file, offset, limit)?, + None if file.is_some() => { + return Err(format!( + "file only applies to multi-file apps; {item_type} \"{path}\" diffs as a single document — call again without file." + )) + } + None => window_patch(&parts.patch, offset, limit, MAX_DIFF_PATCH_CHARS), + }; + Ok(format!("{caveat}{header}{body}")) +} + +/// Patches computed per index read, so a workspace with hundreds of drafts still +/// answers; the rest report as pending and compute on an item read. +const EAGER_MATERIALIZE_CAP: usize = 50; +const FETCH_CONCURRENCY: usize = 6; +const DIFF_INDEX_DEFAULT_ITEMS: usize = 50; +const DIFF_INDEX_MAX_ITEMS: usize = 100; +const DIFF_INDEX_MAX_FILE_CHILDREN: usize = 20; + +struct IndexEntry { + row: DraftRow, + materialized: Option, +} + +/// Every draft of the caller with its diff, computed for the first `cap` addressable ones. +async fn diff_index_entries(ctx: &Ctx<'_>, cap: usize) -> Result<(Vec, usize), String> { + let (rows, others) = list_draft_rows(ctx).await?; + let eager: Vec = (0..rows.len()) + .filter(|i| rows[*i].addressing.is_some()) + .take(cap) + .collect(); + // Built ahead of the stream: a closure borrowing `rows` inside it does not make a + // `Send` future. + let fetches: Vec<_> = eager + .iter() + .map(|i| materialize(ctx, &rows[*i].kind, &rows[*i].path)) + .collect(); + let computed: Vec = stream::iter(fetches) + .buffered(FETCH_CONCURRENCY) + .collect() + .await; + let mut entries: Vec = rows + .into_iter() + .map(|row| IndexEntry { row, materialized: None }) + .collect(); + for (i, materialized) in eager.into_iter().zip(computed) { + entries[i].materialized = Some(materialized); + } + Ok((entries, others)) +} + +fn index_line(entry: &IndexEntry) -> String { + let row = &entry.row; + let Some((item_type, trigger_kind)) = row.addressing else { + return format!( + "- {} draft \"{}\" — not addressable in this chat", + row.kind, row.display_path + ); + }; + let label = match trigger_kind { + Some(trigger_kind) => format!("{trigger_kind} trigger"), + None => item_type.to_string(), + }; + let name = format!("{label} \"{}\"", row.display_path); + let Some(m) = &entry.materialized else { + return format!("- {name} — draft present (diff not computed yet; read it with type+path)"); + }; + let lines = m.parts.line_count; + match ( + &m.error, + m.no_deployed, + m.parts.has_changes, + m.value_uncomparable, + ) { + (Some(error), ..) => format!("- {name} — diff failed: {error}"), + (None, true, ..) => format!("- {name} — new, never deployed ({lines} lines)"), + (None, false, true, true) => { + format!("- {name} — modified ({lines} diff lines; secret value may also differ)") + } + (None, false, true, false) => format!("- {name} — modified ({lines} diff lines)"), + (None, false, false, true) => { + format!("- {name} — no visible changes (secret value cannot be compared; may differ)") + } + (None, false, false, false) => format!("- {name} — draft matches deployed"), + } +} + +/// The indented per-file rows under a multi-file app's index line. +fn file_children_lines(entry: &IndexEntry) -> Vec { + let Some(parts) = entry.materialized.as_ref().map(|m| &m.parts) else { + return Vec::new(); + }; + let Some(files) = &parts.files else { + return Vec::new(); + }; + let mut lines: Vec = files + .iter() + .take(DIFF_INDEX_MAX_FILE_CHILDREN) + .map(|(name, file_diff)| { + let size = match (file_diff.status, file_diff.line_count) { + ("deleted", _) => String::new(), + (_, 0) => " (empty file)".to_string(), + (_, n) => format!(" ({n} lines)"), + }; + format!(" · {name} — {}{size}", file_diff.status) + }) + .collect(); + if files.len() > DIFF_INDEX_MAX_FILE_CHILDREN { + lines.push(format!( + " · … {} more files", + files.len() - DIFF_INDEX_MAX_FILE_CHILDREN + )); + } + if !parts.patch.is_empty() { + lines.push(format!( + " · (config) — modified ({} lines)", + line_count(&parts.patch) + )); + } + lines +} + +async fn diff_index(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let (entries, others) = diff_index_entries(ctx, EAGER_MATERIALIZE_CAP).await?; + let types: Vec<&str> = args + .get("types") + .and_then(Value::as_array) + .map(|types| types.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + let prefix = opt_str_arg(args, "path_prefix").filter(|p| !p.is_empty()); + let matching: Vec<&IndexEntry> = entries + .iter() + .filter(|e| { + types.is_empty() + || e.row + .addressing + .is_some_and(|(item_type, _)| types.contains(&item_type)) + }) + .filter(|e| { + prefix.is_none_or(|prefix| { + e.row.display_path.starts_with(prefix) || e.row.path.starts_with(prefix) + }) + }) + .collect(); + let total = matching.len(); + let limit = uint_arg(args, "limit") + .unwrap_or(DIFF_INDEX_DEFAULT_ITEMS) + .min(DIFF_INDEX_MAX_ITEMS); + let shown = &matching[..total.min(limit)]; + + let filtered = !types.is_empty() || prefix.is_some(); + let mut lines = vec![if total > 0 { + format!("{total} draft(s) vs deployed:") + } else if filtered && !entries.is_empty() { + format!( + "No drafts match your filters ({} draft(s) exist in the workspace).", + entries.len() + ) + } else { + "No drafts in this workspace — nothing differs from the deployed state.".to_string() + }]; + for entry in shown { + lines.push(index_line(entry)); + lines.extend(file_children_lines(entry)); + } + if total > shown.len() { + lines.push(format!( + "Showing {} of {total} drafts — narrow with types/path_prefix or raise limit.", + shown.len() + )); + } + if others > 0 { + lines.push(format!( + "{others} draft(s) by other users exist in this workspace (not shown — drafts are per-user)." + )); + } + Ok(lines.join("\n")) +} + +const DIFF_SEARCH_DEFAULT_MAX_MATCHES: usize = 50; +const DIFF_SEARCH_MAX_MATCHES_CEILING: usize = 200; +const SEARCH_CONTEXT_LINES: usize = 2; +const SEARCH_MAX_LINE_CHARS: usize = 200; +const SEARCH_TOTAL_CHAR_BUDGET: usize = 12_000; + +/// Literal, case-insensitive search over the changed lines of every draft's diff. +async fn diff_search(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let query = opt_str_arg(args, "search").unwrap_or_default(); + if query.is_empty() { + return Err("search requires a non-empty string.".to_string()); + } + if opt_str_arg(args, "against") == Some("parent_workspace") { + return Err(parent_comparison_unavailable(ctx).await); + } + let (entries, _) = diff_index_entries(ctx, usize::MAX).await?; + let file_glob = opt_str_arg(args, "file_glob").filter(|g| !g.is_empty()); + let max_matches = uint_arg(args, "max_matches") + .unwrap_or(DIFF_SEARCH_DEFAULT_MAX_MATCHES) + .min(DIFF_SEARCH_MAX_MATCHES_CEILING); + Ok(search_entries(&entries, query, file_glob, max_matches)) +} + +fn search_entries( + entries: &[IndexEntry], + query: &str, + file_glob: Option<&str>, + max_matches: usize, +) -> String { + // `(subject, patch)`: an item path, or `/` for a file inside an app. + let mut units: Vec<(String, &str)> = Vec::new(); + let mut failed: Vec<&str> = Vec::new(); + for entry in entries { + let Some(m) = &entry.materialized else { + continue; + }; + let path = entry.row.display_path.as_str(); + // A diff that could not be computed has no patch: reporting "no matches" for + // it would present an incomplete search as a definitive one. + if m.error.is_some() { + failed.push(path); + continue; + } + for (name, file_diff) in m.parts.files.iter().flatten() { + units.push(( + format!("{path}/{}", name.trim_start_matches('/')), + &file_diff.patch, + )); + } + if !m.parts.patch.is_empty() { + units.push((path.to_string(), &m.parts.patch)); + } + } + let mut warning = String::new(); + if !failed.is_empty() { + let one = failed.len() == 1; + warning = format!( + "\nWarning: {} could not be computed and {} NOT searched (matches may be missing): {}. Retry, or read the item{} directly for the error.", + if one { "this diff" } else { "these diffs" }, + if one { "was" } else { "were" }, + failed.join(", "), + if one { "" } else { "s" } + ); + } + if let Some(glob) = file_glob { + units.retain(|(subject, _)| matches_glob(subject, glob)); + } + // ponytail: byte order, where the session sorts with the locale's collation; the + // two only differ in which diff's matches come first. + units.sort_by(|a, b| a.0.cmp(&b.0)); + + let needle = query.to_lowercase(); + // `(unit, line index)` of every line to print: the matches and their context. + let mut rows: Vec<(usize, usize)> = Vec::new(); + let mut unit_lines: Vec> = Vec::new(); + let (mut total, mut rendered, mut subjects) = (0, 0, 0); + for (unit, (_, patch)) in units.iter().enumerate() { + let lines: Vec<&str> = patch.split('\n').collect(); + let mut unit_matched = false; + for i in changed_line_indices(patch) { + if !lines[i].to_lowercase().contains(&needle) { + continue; + } + total += 1; + unit_matched = true; + if rendered >= max_matches { + continue; + } + rendered += 1; + let last = (i + SEARCH_CONTEXT_LINES).min(lines.len() - 1); + rows.extend((i.saturating_sub(SEARCH_CONTEXT_LINES)..=last).map(|j| (unit, j))); + } + subjects += usize::from(unit_matched); + unit_lines.push(lines); + } + if total == 0 { + return format!( + "No changed lines match. Try a broader or differently-spelled term{}.{warning}", + if file_glob.is_some() { + ", or drop the file_glob" + } else { + "" + } + ); + } + + let plural = |n: usize| if n == 1 { "" } else { "s" }; + let header = format!( + "{total} changed line{} match in {subjects} diff{}{}", + plural(total), + plural(subjects), + if total > rendered { + format!( + " (showing the first {max_matches}; narrow with file_glob or a more specific query)" + ) + } else { + String::new() + } + ); + let mut spent = header.chars().count(); + let mut out = vec![header]; + let mut current_unit = None; + let mut seen = std::collections::HashSet::new(); + for row @ (unit, line) in rows { + if !seen.insert(row) { + continue; + } + let text = unit_lines[unit][line]; + let text = if text.chars().count() > SEARCH_MAX_LINE_CHARS { + let head: String = text.chars().take(SEARCH_MAX_LINE_CHARS).collect(); + format!("{head}… [line truncated]") + } else { + text.to_string() + }; + let subject = if current_unit == Some(unit) { + String::new() + } else { + format!("{}\n", units[unit].0) + }; + let row = format!("{subject} {}: {text}", line + 1); + let cost = row.chars().count() + 1; + if spent + cost > SEARCH_TOTAL_CHAR_BUDGET { + out.push( + "… output truncated at the context budget — narrow with file_glob or a more specific query." + .to_string(), + ); + break; + } + current_unit = Some(unit); + spent += cost; + out.push(row); + } + out.join("\n") + warning.as_str() +} + +/// Why `against="parent_workspace"` fails. A tool reaches the API with a credential +/// that is only valid in its own workspace, and the comparison reads the parent's +/// items under the caller's rights there. +async fn parent_comparison_unavailable(ctx: &Ctx<'_>) -> String { + let unavailable = "Comparing a fork with its parent workspace is not available on this server. Use diff without 'against' to compare drafts vs deployed versions, or the fork's compare page in Windmill for the comparison with the parent."; + let Ok(user) = ctx.get("/workspaces/users").await else { + return unavailable.to_string(); + }; + let workspace = user + .get("workspaces") + .and_then(Value::as_array) + .and_then(|workspaces| { + workspaces + .iter() + .find(|w| w.get("id").and_then(Value::as_str) == Some(ctx.w_id)) + }); + match workspace { + Some(workspace) if non_null(workspace.get("parent_workspace_id")).is_none() => format!( + "Workspace \"{}\" is not a fork — it has no parent workspace to compare against. Use diff without 'against' to compare drafts vs deployed versions.", + ctx.w_id + ), + _ => unavailable.to_string(), + } +} + +async fn diff(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + if args.get("search").is_some_and(|s| !s.is_null()) { + return diff_search(ctx, args).await; + } + if opt_str_arg(args, "against") == Some("parent_workspace") { + return Err(parent_comparison_unavailable(ctx).await); + } + if args.get("path").is_some_and(|p| !p.is_null()) { + diff_item(ctx, args).await + } else { + diff_index(ctx, args).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn yaml_matches_the_session_emitter() { + // Expected texts are what the session's `yaml` library prints for these values. + let scalars = json!({ "s": [ + "true", "~", "12", "1e3", "yes", "-", "- a", "a: b", "a:b", "#x", "a #b", "x ", + "\"q\"", "it's", "say \"hi\"", "@at", "a,b", "[x]", "?q", "---", " lead", + "0 0 12 * * *", "$encrypted:abc==", "", null, 1.0, -2.5e-3, [], {}, [[1, 2], ["a"]] + ], "gone": null }); + assert_eq!( + to_stable_yaml(Some(&scalars)), + "s:\n - \"true\"\n - \"~\"\n - \"12\"\n - \"1e3\"\n - yes\n - \"-\"\n - \"- a\"\n - \"a: b\"\n - a:b\n - \"#x\"\n - \"a #b\"\n - \"x \"\n - '\"q\"'\n - it's\n - say \"hi\"\n - \"@at\"\n - a,b\n - \"[x]\"\n - ?q\n - ---\n - \" lead\"\n - 0 0 12 * * *\n - $encrypted:abc==\n - \"\"\n - null\n - 1\n - -0.0025\n - []\n - {}\n - - - 1\n - 2\n - - a\n" + ); + + let blocks = json!({ + "content": "export async function main() {\n return 1\n}\n", + "one": "a\nb", "keep": "a\n\n", "keeps": "a\n\n\nb\n\n\n", + "lead": " indented\nnext", "leadnl": "\n\nx\n", "blank": "a\n\n\nb", + "wsend": "a\n ", "onlynl": "\n", "nlnl": "\n\n", "crlf": "a\r\nb\r\n", "ctrl": "a\u{7}b", + "longcrlf": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\r\nbbbbbbbbbbbbbbbbbbbbbbbb\r\n\r\n cccc \r\nd", + "z": " \nfoo", "x": "a\n\n \nb\n", + "seq": [{ "a": "x\ny\n", "b": [{ "c": "p\nq" }] }, "m\nn"], + }); + assert_eq!( + to_stable_yaml(Some(&blocks)), + "blank: |-\n a\n\n\n b\ncontent: |\n export async function main() {\n return 1\n }\ncrlf: \"a\\r\\nb\\r\\n\"\nctrl: \"a\\ab\"\nkeep: |+\n a\n\nkeeps: |+\n a\n\n\n b\n\n\nlead: |2-\n indented\n next\nleadnl: |\n \n\n x\nlongcrlf: \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\\r\n\n bbbbbbbbbbbbbbbbbbbbbbbb\\r\n\n \\r\n\n \\ cccc \\r\n\n d\"\nnlnl: |+\n \n\none: |-\n a\n b\nonlynl: |+\n \nseq:\n - a: |\n x\n y\n b:\n - c: |-\n p\n q\n - |-\n m\n n\nwsend: \"a\\n \"\nx: |\n a\n\n \n b\nz: |2-\n \n foo\n" + ); + + let keys = json!({ + "": 1, "12": 1, "true": 1, "a b": 1, "a:b": 1, "a: b": 1, "#k": 1, "-": 1, + "x\ny": 1, "say \"hi\"": 1, "$schema": 1, "/App.tsx": 1, "---": 1, "~": 1, + }); + assert_eq!( + to_stable_yaml(Some(&keys)), + "\"\": 1\n\"#k\": 1\n$schema: 1\n\"-\": 1\n\"---\": 1\n/App.tsx: 1\n\"12\": 1\na b: 1\n\"a: b\": 1\na:b: 1\nsay \"hi\": 1\n\"true\": 1\n\"x\\ny\": 1\n\"~\": 1\n" + ); + assert_eq!(to_stable_yaml(None), ""); + assert_eq!(to_stable_yaml(Some(&json!({}))), "{}\n"); + } + + #[test] + fn yaml_patch_ignores_key_order_and_null_fields() { + let labels = ("deployed", "draft"); + let patch = |a: Value, b: Value| yaml_patch(Some(&a), Some(&b), labels); + assert_eq!(patch(json!({"a": 1, "b": 2}), json!({"b": 2, "a": 1})), ""); + assert_eq!(patch(json!({"a": 1, "tag": null}), json!({"a": 1})), ""); + assert!(patch(json!({"a": 1, "tag": "prod"}), json!({"a": 1})).contains("-tag: prod")); + + let code = patch( + json!({"content": "line1\nline2\nline3\n", "language": "bun"}), + json!({"content": "line1\nchanged\nline3\n", "language": "bun"}), + ); + assert!(code.contains("- line2") && code.contains("+ changed")); + assert!(!code.contains("- line1")); + + let new = yaml_patch(None, Some(&json!({"summary": "new item"})), labels); + assert!(new.contains("+summary: new item")); + } + + #[test] + fn window_patch_resumes_at_the_first_undelivered_line() { + let patch = (0..20) + .map(|i| format!("line-{i:02}")) + .collect::>() + .join("\n"); + // The cap fits five complete lines. + assert_eq!( + window_patch(&patch, 0, 20, 40), + "line-00\nline-01\nline-02\nline-03\nline-04\n… window truncated at 40 chars.\n(lines 1-5 of 20; call diff again with offset=5 for the rest)" + ); + assert!(window_patch(&patch, 5, 20, 40).starts_with("line-05\n")); + assert!(window_patch("a\nb", 10, 5, 100).contains("no lines at offset 10")); + + let long = format!("{}\nafter", "x".repeat(100)); + assert!(window_patch(&long, 0, 10, 40).contains("offset=1")); + assert_eq!(window_patch(&long, 1, 10, 40), "after\n(lines 2-2 of 2)"); + } + + #[test] + fn changed_lines_exclude_the_file_labels_only() { + let patch = text_patch( + Some("a\n--counter\nb\n"), + Some("a\n++counter\nb\n"), + ("deployed", "draft"), + ); + let lines: Vec<&str> = patch.split('\n').collect(); + let changed: Vec<&str> = changed_line_indices(&patch) + .into_iter() + .map(|i| lines[i]) + .collect(); + assert_eq!(changed, ["---counter", "+++counter"]); + } + + #[test] + fn variable_values_are_masked_on_both_sides() { + let mut before = Some(json!({"value": "old-plaintext", "is_secret": false})); + let mut after = Some(json!({"value": "new-plaintext", "is_secret": false})); + assert!(!mask_variable_sides(&mut before, &mut after)); + let patch = yaml_patch(before.as_ref(), after.as_ref(), ("deployed", "draft")); + assert!(!patch.contains("plaintext") && patch.contains("(changed)")); + + // Equal masked sides prove nothing about a secret. + let mut before = Some(json!({"value": "", "is_secret": true})); + let mut after = before.clone(); + assert!(mask_variable_sides(&mut before, &mut after)); + assert_eq!(before, after); + } + + #[test] + fn multi_file_apps_split_into_file_patches_and_a_config_patch() { + let before = json!({ + "summary": "Dash", + "files": { "src/App.tsx": "a\nb\nc\n", "old.js": "legacy\n", "gone.css": "", "kept.ts": "same\n" }, + }); + let after = json!({ + "summary": "Dash v2", + "files": { "src/App.tsx": "a\nB\nc\n", "new.ts": "fresh\n", "added.ts": "", "kept.ts": "same\n" }, + }); + let parts = diff_parts(Some(&before), Some(&after), ("deployed", "draft")); + let files = parts.files.unwrap(); + let statuses: Vec<(&str, &str, usize)> = files + .iter() + .map(|(name, f)| (name.as_str(), f.status, f.line_count)) + .collect(); + assert_eq!( + statuses, + [ + ("added.ts", "added", 0), + ("gone.css", "deleted", 0), + ("new.ts", "added", 6), + ("old.js", "deleted", 6), + ("src/App.tsx", "modified", 9), + ] + ); + assert!(parts.patch.contains("+summary: Dash v2") && !parts.patch.contains("App.tsx")); + assert!(parts.has_changes); + + let same = diff_parts(Some(&before), Some(&before), ("deployed", "draft")); + assert!(!same.has_changes && same.files.unwrap().is_empty()); + } + + #[test] + fn globs_match_file_names_or_full_paths() { + assert!(matches_glob("f/dash/main/src/App.tsx", "*.tsx")); + assert!(matches_glob("f/dash/main/src/App.tsx", "f/dash/**")); + assert!(!matches_glob("f/dash/main/src/App.tsx", "f/dash/*.tsx")); + assert!(!matches_glob("f/dash/main", "*.tsx")); + } +} diff --git a/backend/windmill-api/src/mcp/session/flows.rs b/backend/windmill-api/src/mcp/session/flows.rs new file mode 100644 index 0000000000..6911552c41 --- /dev/null +++ b/backend/windmill-api/src/mcp/session/flows.rs @@ -0,0 +1,1184 @@ +//! Flow drafts: the compact view, structural patches and inline module code. +//! +//! The model never sees a rawscript body inside flow JSON: the compact view shows +//! `inline_script.` in its place, and a write resolves each such placeholder +//! back to a body (an existing module's, or an empty one for the module's own id). + +use std::collections::{HashMap, HashSet}; + +use serde_json::{json, Map, Value}; +use windmill_types::flows::FlowModule; + +use super::workspace::{ + compact_flow, empty_schema, find_and_replace, finish_draft_write, load_flow, persist_draft, + write_base, Persist, FLOW_VALUE_SETTINGS_KEYS, +}; +use super::{js_len, non_null, opt_str_arg, pretty, str_arg, truthy, Ctx, ToolResult}; + +pub(super) const TOOLS: &[&str] = &[ + "write_flow", + "patch_flow_json", + "read_flow_module_code", + "set_flow_module_code", +]; + +pub(super) async fn call(ctx: &Ctx<'_>, name: &str, args: &Value) -> ToolResult { + match name { + "write_flow" => write_flow(ctx, args).await, + "patch_flow_json" => patch_flow_json(ctx, args).await, + "read_flow_module_code" => read_flow_module_code(ctx, args).await, + "set_flow_module_code" => set_flow_module_code(ctx, args).await, + _ => unreachable!("{name} is not a flows tool"), + } +} + +const STRUCTURAL_KEYS: &[&str] = &[ + "modules", + "schema", + "preprocessor_module", + "failure_module", + "groups", + "notes", +]; +const SPECIAL_MODULES: [(&str, &str); 2] = [ + ("preprocessor_module", "preprocessor"), + ("failure_module", "failure"), +]; +const NOTE_COLORS: &[&str] = &[ + "yellow", "blue", "green", "purple", "pink", "orange", "red", "cyan", "lime", "gray", +]; +/// Names the flow editor reserves, which an agent tool cannot answer to. +const RESERVED_TOOL_NAMES: &[&str] = &[ + "do", + "bg", + "ctx", + "state", + "if", + "else", + "for", + "delete", + "while", + "new", + "in", + "failure", + "preprocessor", + "__wm_agent_root", + "as", + "Input", + "Result", + "Trigger", + "__wm_web_search", +]; +const PROVIDER_SHAPE: &str = r#"{ "type": "static", "value": { "kind": "", "resource": "$res:", "model": "" } }"#; + +fn module_id(module: &Value) -> &str { + module.get("id").and_then(Value::as_str).unwrap_or_default() +} + +fn value_type(value: &Map) -> &str { + value + .get("type") + .and_then(Value::as_str) + .unwrap_or_default() +} + +fn module_type(module: &Value) -> &str { + module + .get("value") + .and_then(Value::as_object) + .map(value_type) + .unwrap_or_default() +} + +/// The module id an `inline_script.` placeholder names. +fn placeholder_ref(content: &str) -> Option<&str> { + content + .strip_prefix("inline_script.") + .filter(|id| !id.is_empty() && !id.contains(['\n', '\r', '\u{2028}', '\u{2029}'])) +} + +fn content_of(value: &Map) -> Option<&str> { + value.get("content").and_then(Value::as_str) +} + +/// Every rawscript the compact view shows as a placeholder, with its owner's id: the +/// modules of loops and branches, and an AI agent's own rawscript tools (not what those +/// tools nest). +fn visit_scripts(modules: Option<&mut Value>, f: &mut dyn FnMut(&str, &mut Map)) { + let Some(Value::Array(modules)) = modules else { + return; + }; + for module in modules { + let id = module_id(module).to_string(); + let Some(value) = module.get_mut("value").and_then(Value::as_object_mut) else { + continue; + }; + let kind = value_type(value).to_string(); + match kind.as_str() { + "rawscript" => f(&id, value), + "forloopflow" | "whileloopflow" => visit_scripts(value.get_mut("modules"), f), + "branchone" | "branchall" => { + if let Some(Value::Array(branches)) = value.get_mut("branches") { + for branch in branches { + visit_scripts(branch.get_mut("modules"), f); + } + } + if kind == "branchone" { + visit_scripts(value.get_mut("default"), f); + } + } + "aiagent" => { + let Some(Value::Array(tools)) = value.get_mut("tools") else { + continue; + }; + for tool in tools { + let tool_id = module_id(tool).to_string(); + let Some(tool_value) = tool.get_mut("value").and_then(Value::as_object_mut) + else { + continue; + }; + let is_flow_module = + tool_value.get("tool_type").and_then(Value::as_str) == Some("flowmodule"); + if is_flow_module && value_type(tool_value) == "rawscript" { + f(&tool_id, tool_value); + } + } + } + _ => {} + } + } +} + +/// `visit_scripts` over a flow value: its modules, then its preprocessor and failure +/// modules when they are rawscripts. +fn visit_flow_scripts( + flow: &mut Map, + f: &mut dyn FnMut(&str, &mut Map), +) { + visit_scripts(flow.get_mut("modules"), f); + for (key, _) in SPECIAL_MODULES { + let Some(module) = flow.get_mut(key) else { + continue; + }; + let id = module_id(module).to_string(); + if let Some(value) = module.get_mut("value").and_then(Value::as_object_mut) { + if value_type(value) == "rawscript" { + f(&id, value); + } + } + } +} + +/// Every module of a tree, parents first, an AI agent's tools included as modules +/// (`in_tool` is set from there down). +fn walk(modules: Option<&mut Value>, in_tool: bool, f: &mut dyn FnMut(&mut Value, bool)) { + let Some(Value::Array(modules)) = modules else { + return; + }; + for module in modules { + f(module, in_tool); + let Some(value) = module.get_mut("value").and_then(Value::as_object_mut) else { + continue; + }; + let kind = value_type(value).to_string(); + match kind.as_str() { + "forloopflow" | "whileloopflow" => walk(value.get_mut("modules"), in_tool, f), + "branchone" | "branchall" => { + if kind == "branchone" { + walk(value.get_mut("default"), in_tool, f); + } + if let Some(Value::Array(branches)) = value.get_mut("branches") { + for branch in branches { + walk(branch.get_mut("modules"), in_tool, f); + } + } + } + "aiagent" => walk(value.get_mut("tools"), true, f), + _ => {} + } + } +} + +/// `walk` over a flow value: its modules, then its preprocessor and failure modules. +fn walk_flow(flow: &mut Map, f: &mut dyn FnMut(&mut Value, bool)) { + walk(flow.get_mut("modules"), false, f); + for (key, _) in SPECIAL_MODULES { + if let Some(module) = flow.get_mut(key).filter(|m| !m.is_null()) { + let mut single = Value::Array(vec![module.take()]); + walk(Some(&mut single), false, f); + *module = single[0].take(); + } + } +} + +/// Drops the lock of every step whose code differs from the same step in `base`: the worker +/// reuses any non-empty step lock, so a changed step keeping its lock runs against the old +/// dependencies. A step id `base` lacks (renamed or duplicated) keeps its lock only when some +/// `base` step has the same code. Without a base nothing is dropped: a copied flow's locks match. +pub(super) fn drop_stale_step_locks(value: &mut Map, base: Option<&Value>) { + let Some(mut base) = base.and_then(Value::as_object).cloned() else { + return; + }; + let code = |module: &Value| module.get("value").and_then(|v| v.get("content")).cloned(); + let mut base_code_by_id = HashMap::new(); + let mut base_codes = Vec::new(); + walk_flow(&mut base, &mut |module, _| { + base_code_by_id.insert(module_id(module).to_string(), code(module)); + base_codes.push(code(module)); + }); + walk_flow(value, &mut |module, _| { + let (id, content) = (module_id(module).to_string(), code(module)); + let Some(step) = module.get_mut("value").and_then(Value::as_object_mut) else { + return; + }; + if step.get("lock").is_none_or(Value::is_null) { + return; + } + let unchanged = match base_code_by_id.get(&id) { + Some(base_code) => *base_code == content, + None => base_codes.contains(&content), + }; + if !unchanged { + step.shift_remove("lock"); + } + }); +} + +/// Put the stored bodies back in place of the placeholders that name them. +fn restore_inline_scripts(flow: &mut Map, scripts: &HashMap) { + visit_flow_scripts(flow, &mut |_, value| { + let body = content_of(value) + .and_then(placeholder_ref) + .and_then(|id| scripts.get(id)); + if let Some(body) = body { + value.insert("content".into(), json!(body)); + } + }); +} + +fn unresolved_inline_scripts(flow: &mut Map) -> Vec { + let mut refs = Vec::new(); + visit_flow_scripts(flow, &mut |_, value| { + if let Some(id) = content_of(value).and_then(placeholder_ref) { + refs.push(id.to_string()); + } + }); + refs +} + +/// What is left of the placeholders once the stored bodies are back: one naming its own +/// module is a new body, saved empty; one naming anything else must not be saved as code. +fn finalize_inline_scripts(flow: &mut Map) -> Result<(), String> { + let mut blank = |module: &mut Value, _: bool| { + let id = module_id(module).to_string(); + let Some(value) = module.get_mut("value").and_then(Value::as_object_mut) else { + return; + }; + if value_type(value) == "rawscript" + && content_of(value).and_then(placeholder_ref) == Some(id.as_str()) + { + value.insert("content".into(), json!("")); + } + }; + walk_flow(flow, &mut blank); + let unresolved = unresolved_inline_scripts(flow); + if unresolved.is_empty() { + return Ok(()); + } + let refs = unresolved + .iter() + .map(|id| format!("\"inline_script.{id}\"")) + .collect::>() + .join(", "); + Err(format!("Unresolved inline script reference(s): {refs}. A rawscript \"content\" placeholder must be \"inline_script.\" (a new body, filled afterwards with the set-module-code tool) or reference an existing rawscript module to keep its current body. Nothing was saved.")) +} + +/// The inline scripts of a flow value that still have no body, in flow order. +fn empty_inline_script_ids(flow: &Map) -> Vec { + let mut scripts: Vec<(String, String)> = Vec::new(); + visit_flow_scripts(&mut flow.clone(), &mut |id, value| { + let Some(content) = content_of(value) else { + return; + }; + match scripts.iter_mut().find(|(known, _)| known == id) { + Some(script) => script.1 = content.to_string(), + None => scripts.push((id.to_string(), content.to_string())), + } + }); + scripts + .into_iter() + .filter(|(_, content)| content.trim().is_empty() || content.starts_with("inline_script.")) + .map(|(id, _)| id) + .collect() +} + +/// A patched compact view applied onto the flow value it was built from. The view is +/// the whole state of what it shows, so a key it no longer has is removed; fields of +/// the value it never showed are kept. +fn apply_editable( + original: Option<&Value>, + editable: &Map, + scripts: &HashMap, +) -> Map { + let mut value = original + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + value.insert( + "modules".into(), + editable + .get("modules") + .cloned() + .unwrap_or_else(|| json!([])), + ); + let optional = ["preprocessor_module", "failure_module", "groups", "notes"]; + for key in optional.iter().chain(FLOW_VALUE_SETTINGS_KEYS) { + match non_null(editable.get(*key)).cloned() { + Some(field) => value.insert((*key).into(), field), + None => value.shift_remove(*key), + }; + } + restore_inline_scripts(&mut value, scripts); + value +} + +fn json_type(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +fn validate_settings(flow: &Map) -> Result<(), String> { + let invalid = |path: &str, expected: &str, got: &Value| { + format!( + "Invalid flow setting {path}: Invalid input: expected {expected}, received {}", + json_type(got) + ) + }; + for key in FLOW_VALUE_SETTINGS_KEYS { + let Some(value) = flow.get(*key) else { + continue; + }; + let (expected, ok) = match *key { + "same_worker" + | "preserve_step_tags" + | "cache_ignore_s3_path" + | "chat_input_enabled" => ("boolean", value.is_boolean()), + "concurrency_key" | "debounce_key" | "skip_expr" | "early_return" => { + ("string", value.is_string()) + } + "debounce_args_to_accumulate" => ("array", value.is_array()), + "flow_env" => ("record", value.is_object()), + _ => ("number", value.is_number()), + }; + if !ok { + return Err(invalid(key, expected, value)); + } + for (index, entry) in value.as_array().into_iter().flatten().enumerate() { + if !entry.is_string() { + return Err(invalid(&format!("{key}.{index}"), "string", entry)); + } + } + } + Ok(()) +} + +/// What the server itself can read as a flow module: the shape it would refuse to run. +fn check_module_shape(module: &Value) -> Result<(), String> { + serde_json::from_str::(&module.to_string()) + .map_err(|e| e.to_string())? + .get_value() + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +fn tool_name_error(name: &str, siblings: &[&str]) -> Option { + if name.is_empty() { + return Some("Tool name must not be empty".into()); + } + if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + return Some("Tool name must only contain letters, numbers and underscores".into()); + } + if RESERVED_TOOL_NAMES.contains(&name) { + return Some(format!("'{name}' is a reserved name")); + } + (siblings.iter().filter(|n| **n == name).count() > 1).then(|| "Duplicate tool name".into()) +} + +/// A provider config that cannot run whatever the workspace holds. +fn provider_shape_error(provider: Option<&Value>, step_type: &str) -> Option { + let provider = match provider { + Some(Value::String(reference)) => { + return Some(format!( + "provider must be an object {PROVIDER_SHAPE}, not the resource reference string {}", + json!(reference) + )) + } + Some(Value::Object(provider)) => provider, + _ => return Some(format!("provider must be an object {PROVIDER_SHAPE}")), + }; + let text = |key: &str| { + provider + .get(key) + .and_then(Value::as_str) + .unwrap_or_default() + }; + let kind = text("kind"); + if kind.is_empty() { + return Some(format!( + "provider.kind is missing. Expected {PROVIDER_SHAPE}" + )); + } + let decision_only = ["typesafe", "cloudflare"].contains(&kind); + if step_type == "aidecision" && !decision_only && kind != "openai" { + return Some(format!("an AI decision step runs on a decision model: provider.kind must be \"typesafe\", \"cloudflare\" or \"openai\", not \"{kind}\"")); + } + if step_type != "aidecision" && decision_only { + return Some(format!("\"{kind}\" serves decision models, which answer typed questions rather than messages: use an \"aidecision\" step rather than an AI agent")); + } + if !text("resource").starts_with("$res:") { + return Some(format!("provider.resource must be a \"$res:\" reference. Expected {PROVIDER_SHAPE}")); + } + text("model") + .is_empty() + .then(|| format!("provider.model is missing. Expected {PROVIDER_SHAPE}")) +} + +fn quoted_list(ids: &[String]) -> String { + ids.iter() + .map(|id| format!("\"{id}\"")) + .collect::>() + .join(", ") +} + +/// Checks the modules array and returns every id it holds, agent tools included. +fn validate_modules(modules: &mut Value) -> Result, String> { + if !modules.is_array() { + return Err("Flow modules must be an array".into()); + } + let mut shape_errors = Vec::new(); + let mut ids = Vec::new(); + let mut providerless = Vec::new(); + let mut tool_names = Vec::new(); + let mut providers = Vec::new(); + walk(Some(modules), false, &mut |module, in_tool| { + let id = module_id(module); + // A tool is not a flow module: the agent that owns it checks its shape. + if !in_tool && shape_errors.len() < 5 { + if let Err(e) = check_module_shape(module) { + shape_errors.push(format!("Module \"{id}\": {e}")); + } + } + if !id.is_empty() { + ids.push(id.to_string()); + } + let kind = module_type(module); + if kind != "aiagent" && kind != "aidecision" { + return; + } + let value = &module["value"]; + let linked = truthy(value.get("agent")); + let provider = value + .get("input_transforms") + .and_then(|transforms| transforms.get("provider")) + .filter(|provider| truthy(Some(provider))); + if kind == "aiagent" && !linked && provider.is_none() { + providerless.push(id.to_string()); + } + + // Only a flow module tool's summary is a name the agent calls. + let named: Vec<&Value> = value + .get("tools") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|tool| { + let tool_type = tool.get("value").and_then(|v| v.get("tool_type")); + !matches!(tool_type.and_then(Value::as_str), Some("mcp" | "websearch")) + }) + .collect(); + fn name_of(tool: &Value) -> &str { + tool.get("summary") + .and_then(Value::as_str) + .unwrap_or_default() + } + let siblings: Vec<&str> = named.iter().map(|tool| name_of(tool)).collect(); + for tool in &named { + if let Some(error) = tool_name_error(name_of(tool), &siblings) { + tool_names.push(format!( + "agent \"{id}\" tool \"{}\" is named {} - {error}", + module_id(tool), + json!(name_of(tool)) + )); + } + } + + let is_static = |p: &&Value| p.get("type").and_then(Value::as_str) == Some("static"); + if let Some(provider) = provider.filter(|_| !linked).filter(is_static) { + if let Some(error) = provider_shape_error(provider.get("value"), kind) { + providers.push(format!("Step \"{id}\": {error}")); + } + } + }); + + if !shape_errors.is_empty() { + return Err(format!( + "Invalid flow modules:\n{}", + shape_errors.join("\n") + )); + } + let unique: HashSet = ids.iter().cloned().collect(); + if unique.len() != ids.len() { + return Err("Duplicate module IDs found in flow".into()); + } + if SPECIAL_MODULES.iter().any(|(_, id)| unique.contains(*id)) { + return Err("Special modules must be provided via preprocessor_module and failure_module, not inside modules".into()); + } + if !providerless.is_empty() { + return Err(format!( + "AI agent modules {} need a provider input transform, or an \"agent\" path linking them to a saved agent", + quoted_list(&providerless) + )); + } + if !tool_names.is_empty() { + return Err(format!( + "Invalid AI agent tool name(s): {}. The tool's \"summary\" is the name the agent calls it by: use underscores instead of spaces (e.g. \"search_docs\").", + tool_names.join("; ") + )); + } + if !providers.is_empty() { + return Err(format!( + "Invalid AI agent provider configuration:\n{}", + providers.join("\n") + )); + } + Ok(unique) +} + +fn check_color(color: Option<&Value>) -> Result<(), String> { + match color { + None | Some(Value::Null) => Ok(()), + Some(Value::String(color)) if NOTE_COLORS.contains(&color.as_str()) => Ok(()), + Some(_) => Err(format!("color must be one of {}", NOTE_COLORS.join(", "))), + } +} + +fn validate_groups(raw: Option<&Value>, module_ids: &HashSet) -> Result { + let groups = match raw { + None | Some(Value::Null) => return Ok(Value::Null), + Some(Value::Array(groups)) => groups, + Some(_) => return Err("Flow groups must be an array".into()), + }; + for (index, group) in groups.iter().enumerate() { + let fail = |what: String| format!("Invalid group at index {index}: {what}"); + let Value::Object(group) = group else { + return Err(fail("must be an object".into())); + }; + for key in ["start_id", "end_id"] { + let id = group.get(key).and_then(Value::as_str).unwrap_or_default(); + if id.is_empty() { + return Err(fail(format!("{key} must be a non-empty string"))); + } + } + for key in ["start_id", "end_id"] { + let id = group[key].as_str().unwrap_or_default(); + if !module_ids.contains(id) { + return Err(fail(format!( + "{key} \"{id}\" does not match any flow module" + ))); + } + } + check_color(group.get("color")).map_err(fail)?; + } + Ok(Value::Array(groups.clone())) +} + +fn number(n: f64) -> Value { + if n.fract() == 0.0 && n.abs() < 9e15 { + json!(n as i64) + } else { + json!(n) + } +} + +const NOTE_WIDTH: f64 = 275.0; +const NOTE_MIN_HEIGHT: f64 = 60.0; +/// Where a free note without a position goes: a column left of the flow. +const NOTE_COLUMN_X: f64 = -(NOTE_WIDTH + 100.0); +const NOTE_GAP: f64 = 24.0; + +/// The editor shows a free note at a fixed size, so one written without a size needs a +/// box tall enough for its text (12px text, 1.4 line height, 16px padding). +fn free_note_height(text: &str) -> f64 { + let chars_per_line = ((NOTE_WIDTH - 32.0) / 6.2).floor().max(1.0); + let lines: f64 = text + .split('\n') + .map(|line| (js_len(line) as f64 / chars_per_line).ceil().max(1.0)) + .sum(); + (lines * 17.0 + 32.0).clamp(NOTE_MIN_HEIGHT, 600.0) +} + +/// Checks the notes and fills in what the editor needs of each: a type, a color and, +/// for a free note, the size and position without which it cannot be moved. +fn validate_notes(raw: Option<&Value>, module_ids: &HashSet) -> Result { + let notes = match raw { + None | Some(Value::Null) => return Ok(Value::Null), + Some(Value::Array(notes)) => notes, + Some(_) => return Err("Flow notes must be an array".into()), + }; + let mut seen = HashSet::new(); + let mut stack_y = 0.0_f64; + let mut out = Vec::new(); + for (index, note) in notes.iter().enumerate() { + let fail = |what: String| format!("Invalid note at index {index}: {what}"); + let Value::Object(note) = note else { + return Err(fail("must be an object".into())); + }; + let id = note.get("id").and_then(Value::as_str).unwrap_or_default(); + if id.is_empty() { + return Err(fail("id must be a non-empty string".into())); + } + if !seen.insert(id) { + return Err(fail(format!("duplicate note id \"{id}\""))); + } + let Some(text) = note.get("text").and_then(Value::as_str) else { + return Err(fail("text must be a string".into())); + }; + let kind = match note.get("type") { + None | Some(Value::Null) => "free", + Some(Value::String(kind)) if kind == "free" || kind == "group" => kind.as_str(), + Some(_) => return Err(fail("type must be \"free\" or \"group\"".into())), + }; + check_color(note.get("color")).map_err(fail)?; + let pair = |key: &str, a: &str, b: &str| -> Result, String> { + let Some(value) = note.get(key).filter(|v| !v.is_null()) else { + return Ok(None); + }; + let field = |name: &str| value.get(name).and_then(Value::as_f64); + match (value.is_object(), field(a), field(b)) { + (true, Some(a), Some(b)) => Ok(Some((a, b))), + _ => Err(fail(format!( + "{key} must be an object with numeric {a} and {b}" + ))), + } + }; + let position = pair("position", "x", "y")?; + let size = pair("size", "width", "height")?; + if kind == "group" { + if let Some(contained) = note.get("contained_node_ids") { + let ids: Option> = contained + .as_array() + .and_then(|ids| ids.iter().map(Value::as_str).collect()); + let Some(ids) = ids else { + return Err(fail( + "contained_node_ids must be an array of strings".into(), + )); + }; + if let Some(id) = ids.iter().find(|id| !module_ids.contains(**id)) { + return Err(fail(format!( + "contained_node_ids \"{id}\" does not match any flow module" + ))); + } + } + } + + let mut normalized = note.clone(); + normalized.insert("type".into(), json!(kind)); + if !note.get("color").is_some_and(Value::is_string) { + normalized.insert("color".into(), json!("green")); + } + if kind == "free" { + let (width, height) = size.unwrap_or_else(|| (NOTE_WIDTH, free_note_height(text))); + if size.is_none() { + normalized.insert( + "size".into(), + json!({ "width": number(width), "height": number(height) }), + ); + } + match position { + None => { + normalized.insert( + "position".into(), + json!({ "x": number(NOTE_COLUMN_X), "y": number(stack_y) }), + ); + stack_y += height + NOTE_GAP; + } + // A placed note standing in the column pushes the next unplaced one below it. + Some((x, y)) if x < NOTE_COLUMN_X + NOTE_WIDTH && x + width > NOTE_COLUMN_X => { + stack_y = stack_y.max(y + height + NOTE_GAP); + } + Some(_) => {} + } + } + out.push(Value::Object(normalized)); + } + Ok(Value::Array(out)) +} + +/// Checks a compact flow view (`modules, schema, preprocessor_module, failure_module, +/// groups, notes` and the top-level settings) and returns it with every structural key +/// present, `null` when unset. +fn validate_editable(raw: Value) -> Result, String> { + let Value::Object(mut flow) = raw else { + return Err("Flow JSON must be an object".into()); + }; + let allowed = || STRUCTURAL_KEYS.iter().chain(FLOW_VALUE_SETTINGS_KEYS); + // An unknown key is refused rather than dropped: the patch that added it would + // otherwise report success for an edit that never lands on the flow. + let unknown: Vec<&str> = flow + .keys() + .map(String::as_str) + .filter(|key| !allowed().any(|known| known == key)) + .collect(); + if !unknown.is_empty() { + return Err(format!( + "Unknown top-level flow key(s): {}. Allowed keys: {}", + unknown.join(", "), + allowed().copied().collect::>().join(", ") + )); + } + validate_settings(&flow)?; + + let mut modules = flow.remove("modules").unwrap_or(Value::Null); + let module_ids = validate_modules(&mut modules)?; + let schema = match flow.get("schema") { + None | Some(Value::Null) => Value::Null, + Some(schema @ Value::Object(_)) => schema.clone(), + Some(_) => return Err("Flow schema must be an object or null".into()), + }; + for (key, _) in SPECIAL_MODULES { + if let Some(module) = non_null(flow.get(key)) { + check_module_shape(&module).map_err(|e| format!("Invalid {key}: {e}"))?; + } + } + let groups = validate_groups(flow.get("groups"), &module_ids)?; + let notes = validate_notes(flow.get("notes"), &module_ids)?; + for (key, id) in SPECIAL_MODULES { + let Some(module) = non_null(flow.get(key)) else { + continue; + }; + if module_id(&module) != id { + return Err(format!("Invalid {key}: id must be \"{id}\"")); + } + if !["rawscript", "script"].contains(&module_type(&module)) { + return Err(format!( + "Invalid {key}: only \"rawscript\" and \"script\" modules are supported" + )); + } + } + + let mut editable = Map::new(); + editable.insert("modules".into(), modules); + editable.insert("schema".into(), schema); + for (key, _) in SPECIAL_MODULES { + editable.insert(key.into(), flow.remove(key).unwrap_or(Value::Null)); + } + editable.insert("groups".into(), groups); + editable.insert("notes".into(), notes); + for key in FLOW_VALUE_SETTINGS_KEYS { + if let Some(setting) = flow.remove(*key) { + editable.insert((*key).into(), setting); + } + } + Ok(editable) +} + +struct FlowWrite<'a> { + path: &'a str, + summary: Option<&'a str>, + description: Option<&'a str>, + value: Map, + schema: Option, + groups: Option, + /// Keep the fields of the current flow value that `value` cannot express. + preserve_base_settings: bool, + /// Name the inline scripts left without a body in the result. + warn_empty_scripts: bool, + force: bool, +} + +async fn write_flow_draft(ctx: &Ctx<'_>, write: FlowWrite<'_>) -> ToolResult { + let FlowWrite { path, mut value, .. } = write; + let base = write_base(ctx, "flow", path, None).await?; + let existed = base.base.is_some(); + if let Some(groups) = write.groups { + value.insert("groups".into(), groups); + } + let base_flow = match base.base { + Some(Value::Object(flow)) => Some(flow), + _ => None, + }; + if write.preserve_base_settings { + let base_value = base_flow.as_ref().and_then(|flow| flow.get("value")); + for (key, field) in base_value.and_then(Value::as_object).into_iter().flatten() { + if !STRUCTURAL_KEYS.contains(&key.as_str()) && !value.contains_key(key) { + value.insert(key.clone(), field.clone()); + } + } + } + // Also done at deploy; here too because a test run of the draft reads the locks. + drop_stale_step_locks( + &mut value, + base_flow.as_ref().and_then(|flow| flow.get("value")), + ); + let empty_scripts = if write.warn_empty_scripts { + empty_inline_script_ids(&value) + } else { + Vec::new() + }; + let draft = match base_flow { + Some(mut flow) => { + flow.insert("path".into(), json!(path)); + if let Some(summary) = write.summary { + flow.insert("summary".into(), json!(summary)); + } + if let Some(description) = write.description { + flow.insert("description".into(), json!(description)); + } + flow.insert("value".into(), Value::Object(value)); + if let Some(schema) = write.schema { + flow.insert("schema".into(), schema); + } + Value::Object(flow) + } + None => json!({ + "path": path, + "summary": write.summary.unwrap_or_default(), + "description": write.description.unwrap_or_default(), + "value": value, + "schema": write.schema.unwrap_or_else(empty_schema), + "edited_by": "", + "edited_at": "", + "archived": false, + "extra_perms": {}, + }), + }; + let persisted = persist_draft( + ctx, + "flow", + path, + None, + &draft, + base.last_sync.as_deref(), + write.force, + ) + .await?; + let item = match persisted { + Persist::Saved(item) => item, + Persist::Failed(text) => return Ok(text), + }; + let result = finish_draft_write(ctx, &item, existed); + if empty_scripts.is_empty() { + return Ok(result); + } + let Ok(Value::Object(mut parsed)) = serde_json::from_str::(&result) else { + return Ok(result); + }; + let message = parsed + .get("message") + .and_then(Value::as_str) + .unwrap_or_default(); + let message = format!("{message}\n\nWarning: inline scripts {} have no code yet. Fill each one with set_flow_module_code(path, module_id, code) — do not re-send the whole flow.", quoted_list(&empty_scripts)); + parsed.insert("message".into(), json!(message)); + Ok(pretty(&Value::Object(parsed))) +} + +/// A flow-structure argument: a JSON string, or the value itself from a client that +/// did not stringify it. +fn json_arg(args: &Value, field: &str) -> Result { + let Some(Value::String(text)) = args.get(field) else { + return Ok(args.get(field).cloned().unwrap_or(Value::Null)); + }; + serde_json::from_str(text).map_err(|e| { + let hint = if ["modules", "preprocessor_module", "failure_module"].contains(&field) { + " A rawscript \"content\" string with multi-line code or quotes is the usual cause. Instead of inlining large code, create the rawscript module with empty content (\"\") and fill its body with set_flow_module_code afterwards." + } else { + "" + }; + format!("Invalid JSON for {field}: {e}{hint}") + }) +} + +async fn write_flow(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let mut raw = Map::new(); + for key in STRUCTURAL_KEYS { + raw.insert((*key).into(), json_arg(args, key)?); + } + let mut editable = validate_editable(Value::Object(raw))?; + + // A placeholder keeps the body the current flow has for that module, so a rewrite + // does not have to send code it is not changing. + if !unresolved_inline_scripts(&mut editable).is_empty() { + let current = write_base(ctx, "flow", path, None).await?.base; + if let Some(value) = current.as_ref().and_then(|flow| flow.get("value")) { + restore_inline_scripts(&mut editable, &compact_flow(value, None).1); + } + finalize_inline_scripts(&mut editable)?; + } + + let schema = non_null(editable.get("schema")).cloned(); + let groups = non_null(editable.get("groups")).cloned(); + editable.remove("schema"); + editable.retain(|_, field| !field.is_null()); + write_flow_draft( + ctx, + FlowWrite { + path, + summary: opt_str_arg(args, "summary"), + description: opt_str_arg(args, "description"), + value: editable, + schema, + groups, + preserve_base_settings: true, + warn_empty_scripts: true, + force: args.get("override").and_then(Value::as_bool) == Some(true), + }, + ) + .await +} + +async fn patch_flow_json(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let base = load_flow(ctx, path).await?; + let (compact, scripts) = compact_flow( + base.flow.get("value").unwrap_or(&Value::Null), + base.flow.get("schema"), + ); + let updated = find_and_replace( + &compact.to_string(), + str_arg(args, "old_string")?, + str_arg(args, "new_string")?, + args.get("replace_all").and_then(Value::as_bool) == Some(true), + "compact flow JSON", + )?; + let parsed: Value = serde_json::from_str(&updated) + .map_err(|e| format!("Invalid JSON after replacement: {e}"))?; + let editable = validate_editable(parsed)?; + let mut value = apply_editable(base.flow.get("value"), &editable, &scripts); + finalize_inline_scripts(&mut value)?; + write_flow_draft( + ctx, + FlowWrite { + path, + summary: base.summary.as_deref(), + description: None, + value, + schema: non_null(editable.get("schema")).cloned(), + groups: non_null(editable.get("groups")).cloned(), + preserve_base_settings: false, + warn_empty_scripts: true, + force: false, + }, + ) + .await +} + +async fn read_flow_module_code(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let (path, module_id) = (str_arg(args, "path")?, str_arg(args, "module_id")?); + let base = load_flow(ctx, path).await?; + let (_, mut scripts) = compact_flow(base.flow.get("value").unwrap_or(&Value::Null), None); + scripts.remove(module_id).ok_or_else(|| format!("Module \"{module_id}\" is not an inline rawscript in flow \"{path}\". (Path runnables, branches, and loops have no inline script content; use patch_flow_json to inspect them.)")) +} + +async fn set_flow_module_code(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let (path, module_id) = (str_arg(args, "path")?, str_arg(args, "module_id")?); + let code = str_arg(args, "code")?; + let base = load_flow(ctx, path).await?; + let (compact, mut scripts) = compact_flow( + base.flow.get("value").unwrap_or(&Value::Null), + base.flow.get("schema"), + ); + if !scripts.contains_key(module_id) { + return Err(format!("Module \"{module_id}\" is not an inline rawscript in flow \"{path}\". Use patch_flow_json or write_flow for structural changes.")); + } + scripts.insert(module_id.to_string(), code.to_string()); + let editable = compact.as_object().cloned().unwrap_or_default(); + write_flow_draft( + ctx, + FlowWrite { + path, + summary: base.summary.as_deref(), + description: None, + value: apply_editable(base.flow.get("value"), &editable, &scripts), + schema: non_null(base.flow.get("schema")).cloned(), + groups: non_null(base.flow.get("groups")).cloned(), + preserve_base_settings: false, + warn_empty_scripts: false, + force: false, + }, + ) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + + fn raw(id: &str, content: &str) -> Value { + json!({ "id": id, "value": { "type": "rawscript", "content": content, "language": "bun", "input_transforms": {} } }) + } + + fn content<'a>(flow: &'a Map, pointer: &str) -> &'a str { + Value::pointer(&flow["modules"], pointer) + .and_then(|module| module["value"]["content"].as_str()) + .unwrap() + } + + #[test] + fn placeholders_resolve_to_an_existing_body_or_an_empty_new_one() { + let scripts = HashMap::from([("a".to_string(), "A".to_string())]); + let mut flow = json!({ + "modules": [ + raw("a", "inline_script.a"), + raw("copy", "inline_script.a"), + { "id": "loop", "value": { "type": "forloopflow", "modules": [raw("new", "inline_script.new")] } }, + raw("multiline", "inline_script.a\nmore"), + ], + "failure_module": raw("failure", "inline_script.failure"), + }) + .as_object() + .cloned() + .unwrap(); + restore_inline_scripts(&mut flow, &scripts); + finalize_inline_scripts(&mut flow).unwrap(); + assert_eq!(content(&flow, "/0"), "A"); + assert_eq!(content(&flow, "/1"), "A"); + assert_eq!(content(&flow, "/2/value/modules/0"), ""); + assert_eq!(content(&flow, "/3"), "inline_script.a\nmore"); + assert_eq!(flow["failure_module"]["value"]["content"], ""); + assert_eq!( + empty_inline_script_ids(&flow), + ["new", "multiline", "failure"] + ); + + let mut dangling = json!({ "modules": [raw("b", "inline_script.gone")] }) + .as_object() + .cloned() + .unwrap(); + restore_inline_scripts(&mut dangling, &scripts); + let error = finalize_inline_scripts(&mut dangling).unwrap_err(); + assert!( + error.starts_with("Unresolved inline script reference(s): \"inline_script.gone\". ") + ); + } + + #[test] + fn only_steps_whose_code_changed_lose_their_lock() { + let locked = |id: &str, content: &str| { + let mut module = raw(id, content); + module["value"]["lock"] = json!("lock"); + module + }; + let base = json!({ "modules": [raw("edited", "A"), raw("kept", "B")] }); + let mut value = json!({ + "modules": [locked("edited", "B"), locked("kept", "B"), locked("renamed", "B"), locked("new", "C")], + "failure_module": locked("failure", "F"), + }) + .as_object() + .cloned() + .unwrap(); + let unlocked = value.clone(); + drop_stale_step_locks(&mut value, None); + assert_eq!(value, unlocked); + + drop_stale_step_locks(&mut value, Some(&base)); + let has_lock = |module: &Value| module["value"].get("lock").is_some(); + let kept: Vec = value["modules"] + .as_array() + .unwrap() + .iter() + .map(has_lock) + .collect(); + assert_eq!(kept, [false, true, true, false]); + assert!(!has_lock(&value["failure_module"])); + } + + #[test] + fn a_patch_of_the_compact_view_keeps_bodies_and_unsets_removed_settings() { + let value = json!({ + "modules": [raw("a", "A"), raw("b", "B")], + "same_worker": true, + "untouched": 1, + }); + let (compact, scripts) = compact_flow(&value, None); + let patched = compact + .to_string() + .replace(",\"same_worker\":true", "") + .replace("\"id\":\"b\"", "\"id\":\"c\""); + let editable = validate_editable(serde_json::from_str(&patched).unwrap()).unwrap(); + let mut applied = apply_editable(Some(&value), &editable, &scripts); + finalize_inline_scripts(&mut applied).unwrap(); + assert_eq!(content(&applied, "/0"), "A"); + // Renamed, so its placeholder still names the body it had. + assert_eq!(content(&applied, "/1"), "B"); + assert_eq!(applied.get("same_worker"), None); + assert_eq!(applied["untouched"], 1); + assert_eq!(applied.get("groups"), None); + } + + #[test] + fn validation_reports_what_the_editor_would_refuse() { + let check = |flow: Value| validate_editable(flow).unwrap_err(); + assert_eq!( + check(json!({ "modules": [raw("a", ""), raw("a", "")] })), + "Duplicate module IDs found in flow" + ); + assert!(check(json!({ "modules": [], "bogus": 1 })) + .starts_with("Unknown top-level flow key(s): bogus. Allowed keys: modules, schema,")); + assert_eq!( + check(json!({ "modules": [], "same_worker": "yes" })), + "Invalid flow setting same_worker: Invalid input: expected boolean, received string" + ); + assert!( + check(json!({ "modules": [{ "id": "a", "value": { "type": "rawscript" } }] })) + .starts_with("Invalid flow modules:\nModule \"a\": ") + ); + assert_eq!( + check(json!({ "modules": [], "failure_module": raw("oops", "") })), + "Invalid failure_module: id must be \"failure\"" + ); + let agent = |tools: Value| { + json!({ "modules": [{ "id": "agent", "value": { + "type": "aiagent", + "tools": tools, + "input_transforms": { "provider": { "type": "static", "value": { "kind": "openai", "resource": "$res:u/a/b", "model": "m" } } }, + } }] }) + }; + let tool = |summary: &str| { + let mut tool = raw("t", ""); + tool["summary"] = json!(summary); + tool["value"]["tool_type"] = json!("flowmodule"); + tool + }; + assert!(check(agent(json!([tool("search docs")]))).starts_with( + "Invalid AI agent tool name(s): agent \"agent\" tool \"t\" is named \"search docs\" - Tool name must only contain letters, numbers and underscores. " + )); + validate_editable(agent(json!([tool("search_docs")]))).unwrap(); + } + + #[test] + fn notes_get_the_geometry_the_editor_needs() { + let notes = validate_notes( + Some(&json!([ + { "id": "n1", "text": "hello\nworld" }, + { "id": "n2", "text": "", "color": "red", "position": { "x": 5, "y": 6 } }, + { "id": "n3", "text": "x" }, + ])), + &HashSet::new(), + ) + .unwrap(); + assert_eq!( + notes[0], + json!({ "id": "n1", "text": "hello\nworld", "type": "free", "color": "green", "size": { "width": 275, "height": 66 }, "position": { "x": -375, "y": 0 } }) + ); + assert_eq!(notes[1]["color"], "red"); + assert_eq!(notes[1]["position"], json!({ "x": 5, "y": 6 })); + assert_eq!(notes[2]["position"], json!({ "x": -375, "y": 90 })); + } +} diff --git a/backend/windmill-api/src/mcp/session/gen/app_templates.json b/backend/windmill-api/src/mcp/session/gen/app_templates.json new file mode 100644 index 0000000000..02c1fa35b5 --- /dev/null +++ b/backend/windmill-api/src/mcp/session/gen/app_templates.json @@ -0,0 +1,123 @@ +{ + "templates": { + "react19": { + "/index.tsx": "\nimport React from 'react'\n\nimport { createRoot } from 'react-dom/client'\nimport App from './App'\n\nconst root = createRoot(document.getElementById('root')!);\nroot.render();\n", + "/App.tsx": "import React, { useState } from 'react'\nimport { backend, backendAsync, getJob, streamJob } from './wmill'\nimport './index.css'\n\n// Windmill injects the viewer (absent for anonymous visitors) and the workspace.\nconst ctx = (window as any).ctx ?? {}\nconst viewer: { name?: string; username?: string } | undefined = ctx.ctx\nconst firstName = (viewer?.name || viewer?.username || '').split(' ')[0]\n\nconst sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))\n\nfunction errorMessage(e: any): string {\n return e?.message ?? (typeof e === 'string' ? e : JSON.stringify(e))\n}\n\nfunction Card(props: {\n step: string\n title: string\n description: string\n code: string\n children: React.ReactNode\n}) {\n return (\n
\n
\n {props.step}\n
\n

{props.title}

\n

{props.description}

\n
\n
\n
{props.children}
\n
\n        {props.code}\n      
\n
\n )\n}\n\nfunction GreetDemo() {\n const [name, setName] = useState(firstName || 'world')\n const [result, setResult] = useState<{ message: string; ran_at: string }>()\n const [error, setError] = useState()\n const [loading, setLoading] = useState(false)\n\n async function run() {\n setLoading(true)\n setError(undefined)\n try {\n setResult(await backend.greet({ name }))\n } catch (e) {\n setError(errorMessage(e))\n }\n setLoading(false)\n }\n\n return (\n \n {\n e.preventDefault()\n run()\n }}\n >\n setName(e.target.value)} placeholder=\"Your name\" />\n \n \n
\n {error ? (\n {error}\n ) : result ? (\n <>\n {result.message}\n Ran at {new Date(result.ran_at).toLocaleTimeString()}\n \n ) : (\n The result shows up here.\n )}\n
\n \n )\n}\n\ntype Stats = { words: number; characters: number; sentences: number; top_words: string[] }\n\nfunction StatsDemo() {\n const [text, setText] = useState(\"Windmill turns scripts into apps. Write the frontend in React or Svelte, and the backend in TypeScript, Python or any other supported language. Scripts run on workers, and the app calls them like functions.\")\n const [stats, setStats] = useState()\n const [error, setError] = useState()\n const [loading, setLoading] = useState(false)\n\n async function run() {\n setLoading(true)\n setError(undefined)\n try {\n setStats(await backend.word_stats({ text }))\n } catch (e) {\n setError(errorMessage(e))\n }\n setLoading(false)\n }\n\n return (\n \n \n
\n \n
\n {#if statsError}\n
{statsError}
\n {/if}\n {#if stats}\n
\n
{stats.words}words
\n
{stats.sentences}sentences
\n
{stats.characters}characters
\n
\n {#each stats.top_words as word (word)}\n {word}\n {/each}\n
\n
\n {/if}\n \n
const stats = await backend.word_stats({ text })
\n \n\n
\n {@render header('3', 'Run jobs in the background', 'Start long jobs without blocking the UI, then poll their status. Start several at once.')}\n
\n
\n \n
\n {#if jobsError}\n
{jobsError}
\n {/if}\n
    \n {#each jobs as job (job.id)}\n
  • \n {job.status}\n {job.id.slice(-8)}\n {job.seconds}s\n
    \n
    \n
    \n
  • \n {:else}\n
  • No jobs yet.
  • \n {/each}\n
\n
\n
const id = await backendAsync.slow_task({ seconds })\nconst job = await getJob(id)
\n
\n\n
\n {@render header('4', 'Stream results', 'A runnable can yield its output bit by bit. Render it as it arrives, like an LLM answer.')}\n
\n
\n \n
\n
\n {#if streamError}\n {streamError}\n {:else if streamed || streaming}\n {streamed}{#if streaming}{/if}\n {:else}\n Streamed text shows up here.\n {/if}\n
\n
\n
await streamJob(id, (u) => append(u.new_result_stream))
\n
\n \n\n", + "/index.css": "/* The app renders inside an iframe whose host page has its own theme the\n iframe cannot read, so paint an opaque surface here: without one the host's\n background shows through and dark text lands on it unreadable. */\n:root {\n color-scheme: light;\n --bg: #f4f5f8;\n --card: #ffffff;\n --sunken: #efeff4;\n --border: #e2e3eb;\n --fg: #3d4758;\n --fg-strong: #1d2430;\n --muted: #718096;\n --accent: #5074f6;\n --accent-hover: #2c5beb;\n --accent-soft: #e8ebfb;\n --success: #15803d;\n --success-soft: #dcfce7;\n --error: #b91c1c;\n --error-soft: #fef2f2;\n --warning: #a16207;\n --warning-soft: #fef9c3;\n}\n\n@media (prefers-color-scheme: dark) {\n :root {\n color-scheme: dark;\n --bg: #242832;\n --card: #2e3441;\n --sunken: #272c35;\n --border: #3b4252;\n --fg: #d4d7dd;\n --fg-strong: #eeeff2;\n --muted: #a9b0ba;\n --accent: #7085db;\n --accent-hover: #5670d5;\n --accent-soft: #343d5c;\n --success: #86efac;\n --success-soft: #1f3a2b;\n --error: #fca5a5;\n --error-soft: #431a1a;\n --warning: #fde68a;\n --warning-soft: #3d3419;\n }\n}\n\n* {\n box-sizing: border-box;\n}\n\nbody {\n margin: 0;\n min-height: 100vh;\n background: var(--bg);\n color: var(--fg);\n font-family: Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;\n font-size: 14px;\n line-height: 1.5;\n -webkit-font-smoothing: antialiased;\n}\n\nh1,\nh2 {\n margin: 0;\n color: var(--fg-strong);\n}\n\ncode,\npre {\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n}\n\n.muted {\n color: var(--muted);\n}\n\n.error {\n color: var(--error);\n}\n\n.app {\n max-width: 1040px;\n margin: 0 auto;\n padding: 40px 20px 56px;\n}\n\n.hero {\n margin-bottom: 28px;\n}\n\n.hero h1 {\n font-size: 1.75rem;\n font-weight: 650;\n letter-spacing: -0.01em;\n margin: 8px 0 6px;\n}\n\n.hero p {\n max-width: 620px;\n margin: 0;\n}\n\n.eyebrow {\n display: inline-block;\n padding: 2px 10px;\n border-radius: 999px;\n background: var(--accent-soft);\n color: var(--accent);\n font-size: 12px;\n font-weight: 600;\n}\n\n.grid {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr));\n gap: 16px;\n}\n\n.card {\n display: flex;\n flex-direction: column;\n background: var(--card);\n border: 1px solid var(--border);\n border-radius: 10px;\n box-shadow: 0 1px 2px rgb(16 24 40 / 0.04);\n overflow: hidden;\n}\n\n.card-header {\n display: flex;\n gap: 12px;\n padding: 18px 18px 0;\n}\n\n.card-header h2 {\n font-size: 15px;\n font-weight: 600;\n}\n\n.card-header p {\n margin: 2px 0 0;\n font-size: 13px;\n}\n\n.card-step {\n flex: none;\n display: grid;\n place-items: center;\n width: 26px;\n height: 26px;\n border-radius: 7px;\n background: var(--accent-soft);\n color: var(--accent);\n font-size: 13px;\n font-weight: 700;\n}\n\n.card-body {\n flex: 1;\n display: flex;\n flex-direction: column;\n gap: 12px;\n padding: 16px 18px 18px;\n}\n\n.snippet {\n margin: 0;\n padding: 10px 18px;\n border-top: 1px solid var(--border);\n background: var(--sunken);\n color: var(--muted);\n font-size: 12px;\n white-space: pre-wrap;\n}\n\n.row {\n display: flex;\n gap: 8px;\n}\n\ninput,\ntextarea {\n flex: 1;\n min-width: 0;\n padding: 7px 10px;\n border: 1px solid var(--border);\n border-radius: 6px;\n background: var(--card);\n color: var(--fg-strong);\n font: inherit;\n resize: vertical;\n}\n\ninput:focus,\ntextarea:focus {\n outline: 2px solid var(--accent);\n outline-offset: -1px;\n}\n\nbutton {\n padding: 7px 14px;\n border: none;\n border-radius: 6px;\n background: var(--accent);\n color: #fff;\n font: inherit;\n font-weight: 500;\n cursor: pointer;\n transition: background 0.15s;\n}\n\nbutton:hover:not(:disabled) {\n background: var(--accent-hover);\n}\n\nbutton:disabled {\n opacity: 0.6;\n cursor: default;\n}\n\n.output {\n min-height: 44px;\n padding: 11px 12px;\n border-radius: 6px;\n background: var(--sunken);\n word-break: break-word;\n}\n\n.output.error {\n background: var(--error-soft);\n}\n\n.output.stream {\n min-height: 96px;\n white-space: pre-wrap;\n}\n\n.caret {\n display: inline-block;\n width: 7px;\n height: 1em;\n margin-left: 2px;\n vertical-align: text-bottom;\n background: var(--accent);\n animation: blink 1s steps(1) infinite;\n}\n\n@keyframes blink {\n 50% {\n opacity: 0;\n }\n}\n\n.stats {\n display: grid;\n grid-template-columns: repeat(3, 1fr);\n gap: 8px;\n}\n\n.stats > div {\n display: flex;\n flex-direction: column;\n padding: 10px 12px;\n border-radius: 6px;\n background: var(--sunken);\n}\n\n.stats strong {\n font-size: 20px;\n color: var(--fg-strong);\n}\n\n.stats span {\n font-size: 12px;\n}\n\n.stats .tags {\n grid-column: 1 / -1;\n flex-direction: row;\n flex-wrap: wrap;\n gap: 6px;\n padding: 0;\n background: none;\n}\n\n.tag {\n padding: 2px 8px;\n border-radius: 999px;\n background: var(--accent-soft);\n color: var(--accent);\n}\n\n.jobs {\n display: flex;\n flex-direction: column;\n gap: 6px;\n margin: 0;\n padding: 0;\n list-style: none;\n}\n\n.jobs li {\n display: flex;\n align-items: center;\n gap: 10px;\n padding: 8px 10px;\n border-radius: 6px;\n background: var(--sunken);\n font-size: 13px;\n}\n\n.pill {\n min-width: 64px;\n padding: 1px 8px;\n border-radius: 999px;\n font-size: 12px;\n font-weight: 600;\n text-align: center;\n}\n\n.pill.queued {\n background: var(--warning-soft);\n color: var(--warning);\n}\n\n.pill.running {\n background: var(--accent-soft);\n color: var(--accent);\n}\n\n.pill.success {\n background: var(--success-soft);\n color: var(--success);\n}\n\n.pill.failure {\n background: var(--error-soft);\n color: var(--error);\n}\n\n.bar {\n flex: 1;\n height: 4px;\n border-radius: 999px;\n background: var(--border);\n overflow: hidden;\n}\n\n.bar-fill {\n width: 0;\n height: 100%;\n border-radius: inherit;\n background: var(--accent);\n}\n\n.bar-fill.running {\n animation: fill linear forwards;\n}\n\n.bar-fill.success {\n width: 100%;\n background: var(--success);\n}\n\n.bar-fill.failure {\n width: 100%;\n background: var(--error);\n}\n\n@keyframes fill {\n to {\n width: 95%;\n }\n}\n", + "/package.json": "{\n \"dependencies\": {\n \"svelte\": \"^5.56.8\"\n }\n}" + } + }, + "starter_runnables": { + "greet": { + "name": "greet", + "fields": { + "username": { + "type": "ctx", + "ctx": "username" + } + }, + "type": "inline", + "inlineScript": { + "content": "// import * as wmill from \"windmill-client\"\n\n// `username` is a context field: the app fills it with the viewer's username.\n// Direct API callers can pass any value, so don't use it for access control.\nexport async function main(name: string, username: string) {\n return {\n message: `Hello ${name}! ${username ? `You are signed in as ${username}.` : 'You are not signed in.'}`,\n ran_at: new Date().toISOString()\n }\n}\n", + "language": "bun", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "name": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "username": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "name", + "username" + ], + "type": "object" + } + } + }, + "word_stats": { + "name": "word_stats", + "fields": {}, + "type": "inline", + "inlineScript": { + "content": "import re\nfrom collections import Counter\n\n\ndef main(text: str):\n words = re.findall(r\"[a-z']+\", text.lower())\n top = Counter(w for w in words if len(w) > 4).most_common(5)\n return {\n \"words\": len(words),\n \"characters\": len(text),\n \"sentences\": len(re.findall(r\"[.!?]+\", text)),\n \"top_words\": [word for word, _ in top],\n }\n", + "language": "python3", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "text": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + } + } + }, + "slow_task": { + "name": "slow_task", + "fields": {}, + "type": "inline", + "inlineScript": { + "content": "export async function main(seconds: number) {\n await new Promise((resolve) => setTimeout(resolve, seconds * 1000))\n return `Done after ${seconds}s`\n}\n", + "language": "bun", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "seconds": { + "default": null, + "description": "", + "originalType": "number", + "type": "number" + } + }, + "required": [ + "seconds" + ], + "type": "object" + } + } + }, + "stream_text": { + "name": "stream_text", + "fields": {}, + "type": "inline", + "inlineScript": { + "content": "// Returning an async generator streams each yielded chunk to the caller.\nexport async function main() {\n const text =\n 'Every word of this text is yielded by a Bun script as soon as it is ready, ' +\n 'and the app renders it the moment it arrives. Use the same pattern to ' +\n 'stream an LLM answer, a long report or live logs.'\n return (async function* () {\n for (const word of text.split(' ')) {\n await new Promise((resolve) => setTimeout(resolve, 60))\n yield word + ' '\n }\n })()\n}\n", + "language": "bun", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": {}, + "required": [], + "type": "object" + } + } + } + } +} diff --git a/backend/windmill-api/src/mcp/session/gen/instructions.json b/backend/windmill-api/src/mcp/session/gen/instructions.json new file mode 100644 index 0000000000..796bdb7ffd --- /dev/null +++ b/backend/windmill-api/src/mcp/session/gen/instructions.json @@ -0,0 +1,237 @@ +{ + "index": { + "script": { + "": "script", + "python3": "script.python3", + "deno": "script.deno", + "go": "script.go", + "bash": "script.bash", + "powershell": "script.powershell", + "postgresql": "script.postgresql", + "mysql": "script.mysql", + "bigquery": "script.bigquery", + "snowflake": "script.snowflake", + "mssql": "script.mssql", + "oracledb": "script.oracledb", + "graphql": "script.graphql", + "nativets": "script.nativets", + "bun": "script.bun", + "php": "script.php", + "rust": "script.rust", + "ansible": "script.ansible", + "csharp": "script.csharp", + "nu": "script.nu", + "java": "script.java", + "ruby": "script.ruby", + "rlang": "script.rlang", + "duckdb": "script.duckdb", + "bunnative": "script.bunnative", + "dbt": "script.dbt" + }, + "flow": { + "": "flow", + "python3": "flow", + "deno": "flow", + "go": "flow", + "bash": "flow", + "powershell": "flow", + "postgresql": "flow", + "mysql": "flow", + "bigquery": "flow", + "snowflake": "flow", + "mssql": "flow", + "oracledb": "flow", + "graphql": "flow", + "nativets": "flow", + "bun": "flow", + "php": "flow", + "rust": "flow", + "ansible": "flow", + "csharp": "flow", + "nu": "flow", + "java": "flow", + "ruby": "flow", + "rlang": "flow", + "duckdb": "flow", + "bunnative": "flow", + "dbt": "flow" + }, + "resource": { + "": "resource", + "python3": "resource", + "deno": "resource", + "go": "resource", + "bash": "resource", + "powershell": "resource", + "postgresql": "resource", + "mysql": "resource", + "bigquery": "resource", + "snowflake": "resource", + "mssql": "resource", + "oracledb": "resource", + "graphql": "resource", + "nativets": "resource", + "bun": "resource", + "php": "resource", + "rust": "resource", + "ansible": "resource", + "csharp": "resource", + "nu": "resource", + "java": "resource", + "ruby": "resource", + "rlang": "resource", + "duckdb": "resource", + "bunnative": "resource", + "dbt": "resource" + }, + "app": { + "": "app", + "python3": "app.python3", + "deno": "app", + "go": "app", + "bash": "app", + "powershell": "app", + "postgresql": "app", + "mysql": "app", + "bigquery": "app", + "snowflake": "app", + "mssql": "app", + "oracledb": "app", + "graphql": "app", + "nativets": "app", + "bun": "app", + "php": "app", + "rust": "app", + "ansible": "app", + "csharp": "app", + "nu": "app", + "java": "app", + "ruby": "app", + "rlang": "app", + "duckdb": "app", + "bunnative": "app", + "dbt": "app" + }, + "datatable": { + "": "datatable", + "python3": "datatable.python3", + "deno": "datatable", + "go": "datatable.go", + "bash": "datatable.go", + "powershell": "datatable.go", + "postgresql": "datatable.go", + "mysql": "datatable.go", + "bigquery": "datatable.go", + "snowflake": "datatable.go", + "mssql": "datatable.go", + "oracledb": "datatable.go", + "graphql": "datatable.go", + "nativets": "datatable", + "bun": "datatable", + "php": "datatable.go", + "rust": "datatable.go", + "ansible": "datatable.go", + "csharp": "datatable.go", + "nu": "datatable.go", + "java": "datatable.go", + "ruby": "datatable.go", + "rlang": "datatable.go", + "duckdb": "datatable.go", + "bunnative": "datatable", + "dbt": "datatable.go" + }, + "pipeline": { + "": "pipeline", + "python3": "pipeline", + "deno": "pipeline", + "go": "pipeline", + "bash": "pipeline", + "powershell": "pipeline", + "postgresql": "pipeline", + "mysql": "pipeline", + "bigquery": "pipeline", + "snowflake": "pipeline", + "mssql": "pipeline", + "oracledb": "pipeline", + "graphql": "pipeline", + "nativets": "pipeline", + "bun": "pipeline", + "php": "pipeline", + "rust": "pipeline", + "ansible": "pipeline", + "csharp": "pipeline", + "nu": "pipeline", + "java": "pipeline", + "ruby": "pipeline", + "rlang": "pipeline", + "duckdb": "pipeline", + "bunnative": "pipeline", + "dbt": "pipeline" + }, + "workspace_settings": { + "": "workspace_settings", + "python3": "workspace_settings", + "deno": "workspace_settings", + "go": "workspace_settings", + "bash": "workspace_settings", + "powershell": "workspace_settings", + "postgresql": "workspace_settings", + "mysql": "workspace_settings", + "bigquery": "workspace_settings", + "snowflake": "workspace_settings", + "mssql": "workspace_settings", + "oracledb": "workspace_settings", + "graphql": "workspace_settings", + "nativets": "workspace_settings", + "bun": "workspace_settings", + "php": "workspace_settings", + "rust": "workspace_settings", + "ansible": "workspace_settings", + "csharp": "workspace_settings", + "nu": "workspace_settings", + "java": "workspace_settings", + "ruby": "workspace_settings", + "rlang": "workspace_settings", + "duckdb": "workspace_settings", + "bunnative": "workspace_settings", + "dbt": "workspace_settings" + } + }, + "texts": { + "script": "# 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- No script language was provided. Default to `bun` only for new TypeScript scripts; if the user requested another language or you read an existing script, call get_instructions again with that language.\n\n# Windmill script authoring reference (bun)\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)\n\nBun runtime with full npm ecosystem and fastest execution. **Bun is the default and preferred TypeScript runtime** — choose it for any TypeScript script unless there is a major reason to use Deno for that specific use-case.\n\n## Structure\n\nExport a single **async** function called `main`:\n\n```typescript\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. Libraries are installed automatically.\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\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```typescript\nimport Stripe from \"stripe\";\nimport { someFunction } from \"some-package\";\n```\n\n## Prefer `//native` when the runtime allows it\n\nIf a script only needs `fetch` and the JavaScript standard library — including when it uses `windmill-client` — prefer making it a **native** script: add `//native` as the first line and write it with the `write-script-bunnative` skill. Native scripts run on a lightweight V8 isolate, start faster, and parallelize heavily. `windmill-client` works on the native worker (its calls go over `fetch`), so needing the Windmill client is **not** a reason to avoid `//native`. Use the regular `bun` language only when the code (or a dependency) needs Node/Bun runtime APIs — `node:*` modules, the filesystem, child processes, or native addons.\n\n## Windmill Client\n\nImport the windmill client for platform interactions:\n\n```typescript\nimport * as wmill from \"windmill-client\";\n```\n\n**Prefer `windmill-client` over raw `fetch` for anything that talks to Windmill** — reading resources/variables/states, running scripts and flows, S3 object operations, etc. It handles auth, the workspace, and the base URL for you, so you don't hand-roll URLs or tokens. Reserve `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 to use instead of guessing or falling back to `fetch`.\n\n## Preprocessor Scripts\n\nFor preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter:\n\n```typescript\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\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\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.python3": "# 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 (python3)\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# Python\n\n## Structure\n\nThe script must contain at least one function called `main`:\n\n```python\ndef main(param1: str, param2: int):\n # Your code here\n return {\"result\": param1, \"count\": param2}\n```\n\nDo not call the main function. Libraries are installed automatically.\n\n## Resource Types\n\nOn Windmill, credentials and configuration are stored in resources and passed as parameters to main.\n\nYou need to **redefine** the type of the resources that are needed before the main function as TypedDict:\n\n```python\nfrom typing import TypedDict\n\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 database connection details\n pass\n```\n\n**Important rules:**\n\n- The resource type name must be **IN LOWERCASE**\n- Only include resource types if they are actually needed\n- If an import conflicts with a resource type name, **rename the imported object, not the type name**\n- Make sure to import TypedDict from typing **if you're using it**\n\n## Imports\n\nLibraries are installed automatically. Do not show installation instructions.\n\n```python\nimport requests\nimport pandas as pd\nfrom datetime import datetime\n```\n\nIf an import name conflicts with a resource type:\n\n```python\n# Wrong - don't rename the type\nimport stripe as stripe_lib\nclass stripe_type(TypedDict): ...\n\n# Correct - rename the import\nimport stripe as stripe_sdk\nclass stripe(TypedDict):\n api_key: str\n```\n\n## Windmill Client\n\nImport the windmill client for platform interactions:\n\n```python\nimport wmill\n```\n\nSee the SDK documentation for available methods.\n\n## Preprocessor Scripts\n\nFor preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter:\n\n```python\nfrom typing import TypedDict, Literal, Any\n\nclass Event(TypedDict):\n kind: Literal[\"webhook\", \"http\", \"websocket\", \"kafka\", \"email\", \"nats\", \"postgres\", \"sqs\", \"mqtt\", \"amqp\", \"gcp\", \"azure\"]\n body: Any\n headers: dict[str, str]\n query: dict[str, str]\n\ndef preprocessor(event: Event):\n # Transform the event into flow input parameters\n return {\n \"param1\": event[\"body\"][\"field1\"],\n \"param2\": event[\"query\"][\"id\"]\n }\n```\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations.\n\n### Receiving an S3Object as a script parameter\n\nTo accept a file from S3 as input to a script, type the parameter with `S3Object` (imported from `wmill`):\n\n```python\nimport wmill\nfrom wmill import S3Object\n\ndef main(file: S3Object):\n content = wmill.load_s3_file(file)\n # ...\n```\n\n### S3 operations\n\n```python\nimport wmill\n\n# Load file content from S3\ncontent: bytes = wmill.load_s3_file(s3object)\n\n# Load file as stream reader\nreader: BufferedReader = wmill.load_s3_file_reader(s3object)\n\n# Write file to S3\nresult: S3Object = wmill.write_s3_file(\n s3object, # Target path (or None to auto-generate)\n file_content, # bytes or BufferedReader\n s3_resource_path, # Optional: specific S3 resource\n content_type, # Optional: MIME type\n content_disposition # Optional: Content-Disposition header\n)\n```\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:///`` 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:///`` 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:///` URI string (`s3:///`\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", + "script.deno": "# 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 (deno)\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 (Deno)\n\nDeno runtime with npm support via `npm:` prefix and native Deno libraries.\n\n**Prefer Bun (`write-script-bun`) for TypeScript.** Only use Deno when the script specifically requires the Deno runtime — Deno's standard library or `deno.land` URL imports that have no npm equivalent. For all other TypeScript, use Bun instead.\n\n## Structure\n\nExport a single **async** function called `main`:\n\n```typescript\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. Libraries are installed automatically.\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\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```typescript\n// npm packages use npm: prefix\nimport Stripe from \"npm:stripe\";\nimport { someFunction } from \"npm:some-package\";\n\n// Deno standard library\nimport { serve } from \"https://deno.land/std/http/server.ts\";\n```\n\n## Windmill Client\n\nImport the windmill client for platform interactions:\n\n```typescript\nimport * as wmill from \"windmill-client\";\n```\n\n**Prefer `windmill-client` over raw `fetch` for anything that talks to Windmill** — reading resources/variables/states, running scripts and flows, S3 object operations, etc. It handles auth, the workspace, and the base URL for you. Reserve `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 guessing or falling back to `fetch`.\n\n## Preprocessor Scripts\n\nFor preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter:\n\n```typescript\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\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\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.go": "# 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 (go)\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# Go\n\n## Structure\n\nThe file package must be `inner` and export a function called `main`:\n\n```go\npackage inner\n\nfunc main(param1 string, param2 int) (map[string]interface{}, error) {\n return map[string]interface{}{\n \"result\": param1,\n \"count\": param2,\n }, nil\n}\n```\n\n**Important:**\n- Package must be `inner`\n- Return type must be `({return_type}, error)`\n- Function name is `main` (lowercase)\n\n## Return Types\n\nThe return type can be any Go type that can be serialized to JSON:\n\n```go\npackage inner\n\ntype Result struct {\n Name string `json:\"name\"`\n Count int `json:\"count\"`\n}\n\nfunc main(name string, count int) (Result, error) {\n return Result{\n Name: name,\n Count: count,\n }, nil\n}\n```\n\n## Error Handling\n\nReturn errors as the second return value:\n\n```go\npackage inner\n\nimport \"errors\"\n\nfunc main(value int) (string, error) {\n if value < 0 {\n return \"\", errors.New(\"value must be positive\")\n }\n return \"success\", nil\n}\n```\n", + "script.bash": "# 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 (bash)\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# Bash\n\n## Structure\n\nDo not include `#!/bin/bash`. Arguments are obtained as positional parameters:\n\n```bash\n# Get arguments\nvar1=\"$1\"\nvar2=\"$2\"\n\necho \"Processing $var1 and $var2\"\n\n# Return JSON by echoing to stdout\necho \"{\\\"result\\\": \\\"$var1\\\", \\\"count\\\": $var2}\"\n```\n\n**Important:**\n- Do not include shebang (`#!/bin/bash`)\n- Arguments are always strings\n- Access with `$1`, `$2`, etc.\n\n## Output\n\nThe script output is captured as the result. For structured data, output valid JSON:\n\n```bash\nname=\"$1\"\ncount=\"$2\"\n\n# Output JSON result\ncat << EOF\n{\n \"name\": \"$name\",\n \"count\": $count,\n \"timestamp\": \"$(date -Iseconds)\"\n}\nEOF\n```\n\n## Environment Variables\n\nEnvironment variables set in Windmill are available:\n\n```bash\n# Access environment variable\necho \"Workspace: $WM_WORKSPACE\"\necho \"Job ID: $WM_JOB_ID\"\n```\n", + "script.powershell": "# 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 (powershell)\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# PowerShell\n\n## Structure\n\nArguments are obtained by calling the `param` function on the first line:\n\n```powershell\nparam($Name, $Count = 0, [int]$Age)\n\n# Your code here\nWrite-Output \"Processing $Name, count: $Count, age: $Age\"\n\n# Return object\n@{\n name = $Name\n count = $Count\n age = $Age\n}\n```\n\n## Parameter Types\n\nYou can specify types for parameters:\n\n```powershell\nparam(\n [string]$Name,\n [int]$Count = 0,\n [bool]$Enabled = $true,\n [array]$Items\n)\n\n@{\n name = $Name\n count = $Count\n enabled = $Enabled\n items = $Items\n}\n```\n\n## Return Values\n\nReturn values by outputting them at the end of the script:\n\n```powershell\nparam($Input)\n\n$result = @{\n processed = $true\n data = $Input\n timestamp = Get-Date -Format \"o\"\n}\n\n$result\n```\n", + "script.postgresql": "# 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 (postgresql)\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# PostgreSQL\n\nArguments are obtained directly in the statement with `$1::{type}`, `$2::{type}`, etc.\n\nName the parameters by adding comments at the beginning of the script (without specifying the type):\n\n```sql\n-- $1 name1\n-- $2 name2 = default_value\nSELECT * FROM users WHERE name = $1::TEXT AND age > $2::INT;\n```\n\n## Receiving an S3Object as a script parameter\n\nDeclare the arg with type `(s3object)`. Windmill renders an S3 file picker for\nit, downloads the file, and binds it as a `jsonb` parameter — Parquet/CSV files\nare decoded server-side into a JSON array of records, JSON/JSONL pass through.\nConsume with `jsonb_to_recordset` (or any `jsonb` API):\n\n```sql\n-- $1 file (s3object)\nSELECT *\nFROM jsonb_to_recordset($1::jsonb) AS r(id INT, name TEXT);\n```\n\n## Streaming query results to S3\n\nAdd a `-- s3` directive at the top of the script to stream the result set to S3\ninstead of returning rows. Windmill writes the file and returns its `S3Object`\nas the script result.\n\n```sql\n-- s3 prefix=exports/users format=parquet\nSELECT id, name FROM users;\n```\n\nAll keys are optional: `prefix` (object key prefix), `storage` (named storage —\nomit to use the workspace default), `format` (`json` (default), `parquet`, or\n`csv`). Use this for large result sets — rows stream directly to S3 instead of\nbeing buffered as the script return value.\n", + "script.mysql": "# 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 (mysql)\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# MySQL\n\nArguments use `?` placeholders.\n\nName the parameters by adding comments before the statement:\n\n```sql\n-- ? name1 (text)\n-- ? name2 (int) = 0\nSELECT * FROM users WHERE name = ? AND age > ?;\n```\n\n## Receiving an S3Object as a script parameter\n\nDeclare the arg with type `(s3object)`. Windmill renders an S3 file picker for\nit, downloads the file, and binds it as JSON text — Parquet/CSV files are\ndecoded server-side into a JSON array of records, JSON/JSONL pass through.\nConsume with `JSON_TABLE`:\n\n```sql\n-- ? file (s3object)\nSELECT id, name\nFROM JSON_TABLE(?, '$[*]'\n COLUMNS (id INT PATH '$.id', name VARCHAR(200) PATH '$.name')\n) AS r;\n```\n\n## Streaming query results to S3\n\nAdd a `-- s3` directive at the top of the script to stream the result set to S3\ninstead of returning rows. Windmill writes the file and returns its `S3Object`\nas the script result.\n\n```sql\n-- s3 prefix=exports/users format=parquet\nSELECT id, name FROM users;\n```\n\nAll keys are optional: `prefix` (object key prefix), `storage` (named storage —\nomit to use the workspace default), `format` (`json` (default), `parquet`, or\n`csv`). Use this for large result sets — rows stream directly to S3 instead of\nbeing buffered as the script return value.\n", + "script.bigquery": "# 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 (bigquery)\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# BigQuery\n\nArguments use `@name` syntax.\n\nName the parameters by adding comments before the statement:\n\n```sql\n-- @name1 (string)\n-- @name2 (int64) = 0\nSELECT * FROM users WHERE name = @name1 AND age > @name2;\n```\n\n## Receiving an S3Object as a script parameter\n\nDeclare the arg with type `(s3object)`. Windmill renders an S3 file picker for\nit, downloads the file, and binds it as a `STRING` JSON parameter — Parquet/CSV\nfiles are decoded server-side into a JSON array of records, JSON/JSONL pass\nthrough. Consume with `JSON_EXTRACT_ARRAY` / `JSON_VALUE`:\n\n```sql\n-- @file (s3object)\nSELECT\n CAST(JSON_VALUE(row, '$.id') AS INT64) AS id,\n JSON_VALUE(row, '$.name') AS name\nFROM UNNEST(JSON_EXTRACT_ARRAY(@file)) AS row;\n```\n\n## Streaming query results to S3\n\nAdd a `-- s3` directive at the top of the script to stream the result set to S3\ninstead of returning rows. Windmill writes the file and returns its `S3Object`\nas the script result.\n\n```sql\n-- s3 prefix=exports/users format=parquet\nSELECT id, name FROM users;\n```\n\nAll keys are optional: `prefix` (object key prefix), `storage` (named storage —\nomit to use the workspace default), `format` (`json` (default), `parquet`, or\n`csv`). Use this for large result sets — rows stream directly to S3 instead of\nbeing buffered, bypassing the 10000-row return cap.\n", + "script.snowflake": "# 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 (snowflake)\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# Snowflake\n\nArguments use `?` placeholders.\n\nName the parameters by adding comments before the statement:\n\n```sql\n-- ? name1 (text)\n-- ? name2 (number) = 0\nSELECT * FROM users WHERE name = ? AND age > ?;\n```\n\n## Array parameters\n\nDeclare a list with a `[]` suffix on the element type (`(varchar[])`,\n`(int[])`), or `(array)` for an untyped one. Snowflake cannot bind an array\ndirectly, so Windmill binds the list as JSON text: wrap the placeholder with\n`PARSE_JSON(?)`, and cast to `ARRAY` where a function expects one.\n\n```sql\n-- ? groups (varchar[])\nSELECT o.* FROM orders o\nWHERE ARRAY_CONTAINS(o.owner_group::VARIANT, PARSE_JSON(?)::ARRAY);\n```\n\nOr join on the elements with `FLATTEN`:\n\n```sql\n-- ? groups (varchar[])\nSELECT o.* FROM orders o\nJOIN TABLE(FLATTEN(input => PARSE_JSON(?))) g ON g.value::STRING = o.owner_group;\n```\n\n## Receiving an S3Object as a script parameter\n\nDeclare the arg with type `(s3object)`. Windmill renders an S3 file picker for\nit, downloads the file, and binds it as JSON text — Parquet/CSV files are\ndecoded server-side into a JSON array of records, JSON/JSONL pass through.\nWrap the bind with `PARSE_JSON(?)` and walk it with `LATERAL FLATTEN`:\n\n```sql\n-- ? file (s3object)\nSELECT\n v.value:id::NUMBER AS id,\n v.value:name::STRING AS name\nFROM LATERAL FLATTEN(input => PARSE_JSON(?)) v;\n```\n\n## Streaming query results to S3\n\nAdd a `-- s3` directive at the top of the script to stream the result set to S3\ninstead of returning rows. Windmill writes the file and returns its `S3Object`\nas the script result.\n\n```sql\n-- s3 prefix=exports/users format=parquet\nSELECT id, name FROM users;\n```\n\nAll keys are optional: `prefix` (object key prefix), `storage` (named storage —\nomit to use the workspace default), `format` (`json` (default), `parquet`, or\n`csv`). Use this for large result sets — rows stream directly to S3 instead of\nbeing buffered, bypassing the 10000-row return cap.\n", + "script.mssql": "# 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 (mssql)\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# Microsoft SQL Server (MSSQL)\n\nArguments use `@P1`, `@P2`, etc.\n\nName the parameters by adding comments before the statement:\n\n```sql\n-- @P1 name1 (varchar)\n-- @P2 name2 (int) = 0\nSELECT * FROM users WHERE name = @P1 AND age > @P2;\n```\n\n## Receiving an S3Object as a script parameter\n\nDeclare the arg with type `(s3object)`. Windmill renders an S3 file picker for\nit, downloads the file, and binds it as `nvarchar(max)` JSON text — Parquet/CSV\nfiles are decoded server-side into a JSON array of records, JSON/JSONL pass\nthrough. Consume with `OPENJSON`:\n\n```sql\n-- @P1 file (s3object)\nSELECT id, name\nFROM OPENJSON(@P1)\nWITH (id INT, name NVARCHAR(200));\n```\n\n## Streaming query results to S3\n\nAdd a `-- s3` directive at the top of the script to stream the result set to S3\ninstead of returning rows. Windmill writes the file and returns its `S3Object`\nas the script result.\n\n```sql\n-- s3 prefix=exports/users format=parquet\nSELECT id, name FROM users;\n```\n\nAll keys are optional: `prefix` (object key prefix), `storage` (named storage —\nomit to use the workspace default), `format` (`json` (default), `parquet`, or\n`csv`). Use this for large result sets — rows stream directly to S3 instead of\nbeing buffered as the script return value.\n", + "script.oracledb": "# 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 (oracledb)\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", + "script.graphql": "# 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 (graphql)\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# GraphQL\n\n## Structure\n\nWrite GraphQL queries or mutations. Arguments can be added as query parameters:\n\n```graphql\nquery GetUser($id: ID!) {\n user(id: $id) {\n id\n name\n email\n }\n}\n```\n\n## Variables\n\nVariables are passed as script arguments and automatically bound to the query:\n\n```graphql\nquery SearchProducts($query: String!, $limit: Int = 10) {\n products(search: $query, first: $limit) {\n edges {\n node {\n id\n name\n price\n }\n }\n }\n}\n```\n\n## Mutations\n\n```graphql\nmutation CreateUser($input: CreateUserInput!) {\n createUser(input: $input) {\n id\n name\n createdAt\n }\n}\n```\n", + "script.nativets": "# 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 (nativets)\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 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.bun": "# 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 (bun)\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)\n\nBun runtime with full npm ecosystem and fastest execution. **Bun is the default and preferred TypeScript runtime** — choose it for any TypeScript script unless there is a major reason to use Deno for that specific use-case.\n\n## Structure\n\nExport a single **async** function called `main`:\n\n```typescript\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. Libraries are installed automatically.\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\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```typescript\nimport Stripe from \"stripe\";\nimport { someFunction } from \"some-package\";\n```\n\n## Prefer `//native` when the runtime allows it\n\nIf a script only needs `fetch` and the JavaScript standard library — including when it uses `windmill-client` — prefer making it a **native** script: add `//native` as the first line and write it with the `write-script-bunnative` skill. Native scripts run on a lightweight V8 isolate, start faster, and parallelize heavily. `windmill-client` works on the native worker (its calls go over `fetch`), so needing the Windmill client is **not** a reason to avoid `//native`. Use the regular `bun` language only when the code (or a dependency) needs Node/Bun runtime APIs — `node:*` modules, the filesystem, child processes, or native addons.\n\n## Windmill Client\n\nImport the windmill client for platform interactions:\n\n```typescript\nimport * as wmill from \"windmill-client\";\n```\n\n**Prefer `windmill-client` over raw `fetch` for anything that talks to Windmill** — reading resources/variables/states, running scripts and flows, S3 object operations, etc. It handles auth, the workspace, and the base URL for you, so you don't hand-roll URLs or tokens. Reserve `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 to use instead of guessing or falling back to `fetch`.\n\n## Preprocessor Scripts\n\nFor preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter:\n\n```typescript\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\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\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.php": "# 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 (php)\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# PHP\n\n## Structure\n\nThe script must start with ` $param1, \"count\" => $param2];\n}\n```\n\n## Resource Types\n\nOn Windmill, credentials and configuration are stored in resources and passed as parameters to main.\n\nYou need to **redefine** the type of the resources that are needed before the main function. Always check if the class already exists using `class_exists`:\n\n```php\n/` 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 (rust)\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# Rust\n\n## Structure\n\nThe script must contain a function called `main` with proper return type:\n\n```rust\nuse anyhow::anyhow;\nuse serde::Serialize;\n\n#[derive(Serialize, Debug)]\nstruct ReturnType {\n result: String,\n count: i32,\n}\n\nfn main(param1: String, param2: i32) -> anyhow::Result {\n Ok(ReturnType {\n result: param1,\n count: param2,\n })\n}\n```\n\n**Important:**\n- Arguments should be owned types\n- Return type must be serializable (`#[derive(Serialize)]`)\n- Return type is `anyhow::Result`\n\n## Dependencies\n\nPackages must be specified with a partial cargo.toml at the beginning of the script:\n\n```rust\n//! ```cargo\n//! [dependencies]\n//! anyhow = \"1.0.86\"\n//! reqwest = { version = \"0.11\", features = [\"json\"] }\n//! tokio = { version = \"1\", features = [\"full\"] }\n//! ```\n\nuse anyhow::anyhow;\n// ... rest of the code\n```\n\n**Note:** Serde is already included, no need to add it again.\n\n## Async Functions\n\nIf you need to handle async functions (e.g., using tokio), keep the main function sync and create the runtime inside:\n\n```rust\n//! ```cargo\n//! [dependencies]\n//! anyhow = \"1.0.86\"\n//! tokio = { version = \"1\", features = [\"full\"] }\n//! reqwest = { version = \"0.11\", features = [\"json\"] }\n//! ```\n\nuse anyhow::anyhow;\nuse serde::Serialize;\n\n#[derive(Serialize, Debug)]\nstruct Response {\n data: String,\n}\n\nfn main(url: String) -> anyhow::Result {\n let rt = tokio::runtime::Runtime::new()?;\n rt.block_on(async {\n let resp = reqwest::get(&url).await?.text().await?;\n Ok(Response { data: resp })\n })\n}\n```\n", + "script.ansible": "# 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 (ansible)\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# Ansible\n\nWindmill runs Ansible playbooks with `ansible-playbook`. A script is a single YAML\ndocument made of two parts separated by a `---` line: a Windmill **header** and one or\nmore standard Ansible **plays**.\n\n## Structure\n\n```yaml\n---\n# Windmill header: configures inventories, file resources, arguments and dependencies\nextra_vars:\n world_qualifier:\n type: string\ndependencies:\n galaxy:\n collections:\n - name: community.general\n python:\n - jmespath\n---\n# Standard Ansible plays\n- name: Echo\n hosts: 127.0.0.1\n connection: local\n tasks:\n - name: Print debug message\n debug:\n msg: \"Hello, {{ world_qualifier }} world!\"\n```\n\n## Header\n\nThe header is **not** standard Ansible — it is parsed by Windmill to build the script's\ninputs and runtime environment. Supported keys:\n\n- `extra_vars`: defines the script arguments. Each entry is passed to the playbook via\n `--extra-vars` and becomes a Jinja variable usable as `{{ name }}` in the plays. Give\n each argument a `type` (`string`, `number`, `boolean`, `object`, ...) so Windmill can\n generate the input form.\n- `inventory`: lists inventories. Use `resource_type: ansible_inventory` (optionally\n pinned with `resource: u/user/your_resource`) or `resource_type: dynamic_inventory`.\n- `files`: writes Windmill resources/variables to files before the run, e.g.\n `- resource: u/user/template` with `target: ./config.j2`, or\n `- variable: u/user/ssh_key` with `target: ./ssh_key` and `mode: '0600'`.\n- `dependencies`: `galaxy` collections/roles (installed with `ansible-galaxy`) and\n `python` pip packages available to the playbook.\n- `options`: extra `ansible-playbook` flags such as `- verbosity: vvv`.\n- `vault_password`: a Windmill variable path to use as the Ansible Vault password.\n\n## Arguments\n\nReference header `extra_vars` directly as Jinja variables in the plays:\n\n```yaml\nextra_vars:\n name:\n type: string\n count:\n type: number\n---\n- hosts: localhost\n tasks:\n - debug:\n msg: \"{{ name }} x {{ count }}\"\n```\n\n## Environment variables\n\nWindmill contextual variables are available as environment variables and read with the\n`env` lookup:\n\n```yaml\n- debug:\n msg: \"Running in workspace {{ lookup('env', 'WM_WORKSPACE') }}\"\n```\n\n## Output\n\nTo return a result, write JSON to a `result.json` file in the job directory:\n\n```yaml\n- hosts: localhost\n tasks:\n - name: Write result\n copy:\n content: \"{{ { 'ok': true, 'value': 42 } | to_json }}\"\n dest: result.json\n```\n", + "script.csharp": "# 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 (csharp)\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# C#\n\nThe script must contain a public static `Main` method inside a class:\n\n```csharp\npublic class Script\n{\n public static object Main(string name, int count)\n {\n return new { Name = name, Count = count };\n }\n}\n```\n\n**Important:**\n- Class name is irrelevant\n- Method must be `public static`\n- Return type can be `object` or specific type\n\n## NuGet Packages\n\nAdd packages using the `#r` directive at the top:\n\n```csharp\n#r \"nuget: Newtonsoft.Json, 13.0.3\"\n#r \"nuget: RestSharp, 110.2.0\"\n\nusing Newtonsoft.Json;\nusing RestSharp;\n\npublic class Script\n{\n public static object Main(string url)\n {\n var client = new RestClient(url);\n var request = new RestRequest();\n var response = client.Get(request);\n return JsonConvert.DeserializeObject(response.Content);\n }\n}\n```\n", + "script.nu": "# 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 (nu)\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", + "script.java": "# 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 (java)\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# Java\n\nThe script must contain a Main public class with a `public static main()` method:\n\n```java\npublic class Main {\n public static Object main(String name, int count) {\n java.util.Map result = new java.util.HashMap<>();\n result.put(\"name\", name);\n result.put(\"count\", count);\n return result;\n }\n}\n```\n\n**Important:**\n- Class must be named `Main`\n- Method must be `public static Object main(...)`\n- Return type is `Object` or `void`\n\n## Maven Dependencies\n\nAdd dependencies using comments at the top:\n\n```java\n//requirements:\n//com.google.code.gson:gson:2.10.1\n//org.apache.httpcomponents:httpclient:4.5.14\n\nimport com.google.gson.Gson;\n\npublic class Main {\n public static Object main(String input) {\n Gson gson = new Gson();\n return gson.fromJson(input, Object.class);\n }\n}\n```\n", + "script.ruby": "# 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 (ruby)\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", + "script.rlang": "# 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 (rlang)\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# R\n\n## Structure\n\nDefine a `main` function using `<-` or `=` assignment. Parameters become the script inputs:\n\n```r\nlibrary(dplyr)\nlibrary(jsonlite)\n\nmain <- function(x, name = \"default\", flag = TRUE) {\n df <- tibble(x = x, name = name)\n result <- df %>% mutate(greeting = paste(\"Hello\", name))\n return(toJSON(result, auto_unbox = TRUE))\n}\n```\n\n**Important:**\n- The `main` function is required\n- Use `library()` to load packages — they are resolved and installed automatically\n- `jsonlite` is always available (used internally for argument parsing)\n- Return values must be JSON-serializable\n\n## Parameters\n\nR types map to Windmill types:\n- `numeric` → float/int\n- `character` → string\n- `logical` → bool (use `TRUE`/`FALSE`)\n- `list` → object/dict\n- `NULL` → null\n\nDefault values are inferred from the function signature:\n\n```r\nmain <- function(\n name, # required string\n count = 10, # optional int, default 10\n verbose = FALSE # optional bool, default FALSE\n) {\n # ...\n}\n```\n\n## Resources and Variables\n\nUse the built-in Windmill helpers (no import needed):\n\n```r\nmain <- function() {\n # Get a variable\n api_key <- get_variable(\"f/my_folder/api_key\")\n\n # Get a resource (returns a list)\n db <- get_resource(\"f/my_folder/postgres_config\")\n host <- db$host\n port <- db$port\n\n return(list(host = host, port = port))\n}\n```\n\n## Output\n\nReturn any JSON-serializable value from `main`. The return value becomes the step result:\n\n```r\nmain <- function(x) {\n # Return a scalar\n return(x + 1)\n\n # Or a list (becomes JSON object)\n return(list(result = x + 1, status = \"ok\"))\n}\n```\n\n## Annotations\n\nControl execution behavior with comment annotations:\n\n```r\n#renv_verbose = true # Show verbose renv output during resolution\n#renv_install_verbose = true # Show verbose output during package installation\n#sandbox = true # Run in nsjail sandbox (requires nsjail)\n```\n", + "script.duckdb": "# 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 (duckdb)\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# DuckDB\n\nArguments are defined with comments and used with `$name` syntax:\n\n```sql\n-- $name (text) = default\n-- $age (integer)\nSELECT * FROM users WHERE name = $name AND age > $age;\n```\n\n## Ducklake Integration\n\nAttach Ducklake for data lake operations:\n\n```sql\n-- Main ducklake\nATTACH 'ducklake' AS dl;\n\n-- Named ducklake\nATTACH 'ducklake://my_lake' AS dl;\n\n-- Then query\nSELECT * FROM dl.schema.table;\n```\n\n## External Database Connections\n\nConnect to external databases using resources:\n\n```sql\nATTACH '$res:path/to/resource' AS db (TYPE postgres);\nSELECT * FROM db.schema.table;\n```\n\n## S3 File Operations\n\nRead files from S3 storage:\n\n```sql\n-- Default storage\nSELECT * FROM read_csv('s3:///path/to/file.csv');\n\n-- Named storage\nSELECT * FROM read_csv('s3://storage_name/path/to/file.csv');\n\n-- Parquet files\nSELECT * FROM read_parquet('s3:///path/to/file.parquet');\n\n-- JSON files\nSELECT * FROM read_json('s3:///path/to/file.json');\n```\n\n### Receiving an S3Object as a script parameter\n\nDeclare the arg with type `(s3object)`. Windmill renders an S3 file picker for it\nand binds the arg as the bare `s3://storage/key` URI, which DuckDB's reader\nfunctions consume directly:\n\n```sql\n-- $file (s3object)\nSELECT * FROM read_parquet($file);\n```\n\nWorks with any DuckDB reader: `read_csv($file)`, `read_json($file)`, etc.\n\n### Writing query results to S3\n\nDuckDB writes to S3 natively via `COPY ... TO`:\n\n```sql\nCOPY (SELECT * FROM users) TO 's3:///exports/users.parquet' (FORMAT PARQUET);\n```\n\nUse this instead of the `-- s3` streaming directive supported by the other SQL\ndialects — that directive is not available in DuckDB.\n", + "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", + "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" + } +} diff --git a/backend/windmill-api/src/mcp/session/gen/prompt.json b/backend/windmill-api/src/mcp/session/gen/prompt.json new file mode 100644 index 0000000000..b3ffea17b7 --- /dev/null +++ b/backend/windmill-api/src/mcp/session/gen/prompt.json @@ -0,0 +1,14 @@ +{ + "base": "You are working in a Windmill workspace through its MCP server.\n\nThe user sees no Windmill page next to this conversation. Tool results carry links instead: a run's page, a script, flow or agent draft's url (its editor), an app draft's preview_url (the app editor with its live preview, which these instructions call \"the preview\"), a deployed item's url. Whenever the user will want to see what you built or ran, or has to do a step in Windmill themselves, open the link in a browser they can see from this conversation if you have one, and otherwise give them the link to open.\n\nThe current user's workspace username is \"{{username}}\".\n\nUse 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.\n\nRules:\n- Draft tools create or update drafts only; they do not deploy or mutate deployed workspace items.\n- Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind.\n- Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a draft to the workspace.\n- Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language.\n- After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step with representative args before reporting that it works. These tools prefer drafts, so testing does not require deployment.\n- Default to test_run_script, test_run_flow, or test_run_step for any run request, an existing script included; they prefer drafts and need no deployment. Use run_script or run_flow only when the user names the deployed version (\"the deployed X\", \"in production\", \"for real\") — a bare \"run X\" is not that. For those two, read the item with read_workspace_item version: \"deployed\" first so the arguments match the deployed schema. test_run_script, test_run_flow, test_run_step, run_script and run_flow run with exactly the arguments you send, so fill in every argument the schema asks for. test_run_step takes the step's own inputs, not the flow's.\n\nPath conventions:\n- A workspace path starts with one of two namespaces; its trailing <name> may itself contain \"/\", so a path has three or more segments:\n - `u/{{username}}/<name>` — your personal scope. Default for ad-hoc, exploratory, or scratch work.\n - `f/<folder>/<name>` — a shared folder scope; the <folder> must already exist (a bare `f/<name>` with no folder segment is INVALID and will fail).\n- If the user supplies a fully qualified `f/<folder>/...` path, use that exact path; they have already chosen the folder. Do not ask for folder confirmation or substitute a `u/{{username}}/...` path unless a tool rejects it.\n- Default a bare name with no namespace prefix (e.g. \"create a flow called myflow\") to `u/{{username}}/<name>`. Never invent an `f/<folder>/...` path for a folder that does not exist; create one with `create_folder` only when the user explicitly asks for a new folder.\n{{folder_guidance}}\nMore rules:\n- 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.\n- An agent, as the Agents page lists it, is a resource of type `ai_agent`: read it with read_workspace_item type \"resource\". 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.\n- To undo something you created or changed in this chat, use discard_local_draft: everything you write is a draft until it is explicitly deployed, so \"delete it\" / \"never mind\" / \"remove that\" about your own work means discarding the draft (it also clears the matching open editor draft). 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.\n- Use diff to review changes — before deploying, or when the user asks what changed. It is read-only: without arguments it lists every draft in the workspace with its change status; with type+path it returns that item's unified diff (for multi-file apps, pass file to read one file's diff). In a fork, pass against=\"parent_workspace\" to compare the deployed fork with its parent workspace instead. Pass search to grep changed lines across all diffs.\n- You can never read a variable's value, secret or not, so never invent one: when editing an existing variable, omit value (and is_secret) from write_variable and pass only the fields you are actually changing. The user can reveal a value in the variable editor; you cannot, so never tell them a value is unreadable in general. \"$var:path/to/variable\" is how a resource value references a variable — it is never a variable's own value.\n- Use search_resource_types before write_resource, and get_trigger_schema before write_trigger: the trigger config fields differ per kind and are not listed in the write_trigger definition.\n- When script or raw app code needs an external npm package you are not fully familiar with, use search_npm_packages to find it and get its documentation and type definitions. Link the package documentation in your answer when you rely on it.\n- Hub scripts are prebuilt, vetted integrations for third-party services, hosted outside the workspace under `hub/<version>/<app>/<name>` paths. Check search_hub_scripts before hand-writing code against a third-party API, even when the user never mentions the hub; read a result with read_workspace_item type \"script\" and its hub path to get its code, language, and input schema. Use what you find in whichever way fits: reference the hub path directly from a flow module or app runnable when a script already does the job, copy it into a workspace draft and adapt it when it is close (note the source hub path in a comment at the top of the code), or take it as a worked example and write your own. A script that does not do what the user asked is still worth reading when it is the only example of that integration: pass its `integration` back to search_hub_scripts to list that integration's other scripts with their descriptions, or use the `suggested_integrations` a search hands back when it finds nothing.\n- Before writing your own code against an integration the hub covers, call get_hub_integration with its slug: it returns the resource type to take, its auth fields and the integration's most-used scripts, which beats inferring them from script bodies. Call it for the integration you are about to write against, whichever it is. A search marks an integration `documented` when the hub additionally holds provider knowledge checked against the live API — pagination, enums, error codes and gotchas — so read that closely where it appears rather than trusting your own memory of the API.\n- If you have a web search tool and the hub does not cover a third-party API, search for the vendor's own API documentation rather than writing its endpoints and auth from memory, and link the page you relied on. Reserve it for external APIs: search_docs answers questions about Windmill itself.\n- create_resource_type creates a resource type the workspace lacks. Show the user the schema you propose and create it once they agree.\n- Use get_db_schema with a database resource path to fetch its tables and columns before writing SQL (or a script querying that database).\n- A \"data pipeline\" is NOT a flow: it is a DAG of independent scripts in one folder, wired by storage assets (DuckLake/data tables/S3) and triggers via top-of-file `pipeline` / `on <ref>` annotation comments written in each script's comment syntax (`--` for SQL, `#` for Python/Bash, `//` for TS — a `//` line in a SQL node is a syntax error). When the user asks for a data pipeline (or to ingest/transform/materialize data across steps), call get_instructions with subject \"pipeline\" and build annotated script drafts — do not build a flow. list_pipelines lists the existing ones by folder.\n- Do the same for a raw app: run test_run_app_runnable on each backend runnable you wrote or changed before saying the app works. A bundle that compiles proves nothing about whether the runnables run. An inline runnable executes the app's draft code; a path runnable executes the DEPLOYED script/flow it names, so a path runnable aimed at something you have not deployed fails here — that failure is the point: report it and offer to deploy that one target. The app itself does not need deploying to be tested.\n- Use list_runs to find recent runs (optionally filtered by path, creator, label, or status), then get_run with a returned id to see what that run was called with, what it returned and what it logged — without starting a new test run.\n- get_run also covers what a flow run did per step — statuses and results across the whole execution tree, subflow steps and loop iterations included — and works while the flow is still running. Pass step to read one step's result in full (capped at 12k chars).\n- Keep context targeted.\n\nDocumentation:\n- Use search_docs to look up how a Windmill feature works in the official documentation (a flag, concept, function, or \"does Windmill support X\") instead of guessing about product behavior. It returns matching doc snippets with their Source URL; call read_docs_page with a Source URL to read the full page (or a section, if it returns headings). Cite the Source URL when you rely on it.\n- Complete your response with precisions about how it works based on the documentation. Also drop a link to the relevant documentation if possible.\n- If the user asks about something that you are unsure about, say that you are not sure about the answer and suggest to ask the question to the windmill team.\n- If the first search returns nothing useful, retry with different or broader keywords before giving up.\n- If the documentation does not cover the user's question, say so clearly rather than inventing an answer, and suggest asking the Windmill team.\n\nFlows:\n- read_workspace_item returns compact flow JSON. Inline script bodies appear as \"inline_script.<moduleId>\".\n- Use read_flow_module_code and set_flow_module_code for inline script bodies.\n- Use patch_flow_json for structural flow edits and write_flow for full flow rewrites.\n\nRaw apps:\n- The app tools below only work on raw (code) apps. `rawApp` says which: false is a drag-and-drop app: you can list it and read its metadata, but not read its contents, edit it or deploy it. Check it before offering to change an app.\n- read_workspace_item returns app metadata only. Use read_app_file for file and inline runnable contents.\n- A draft app is reachable by nobody; deploying is what exposes its backend runnables. deploy_workspace_item says so when the deploy widens who may open the app: anonymous means anyone with the URL, without logging in; guest means anyone the instance's identity provider authenticates, member of this workspace or not. Relay that in plain words and carry on. This is disclosure, not a gate: do not stop and ask for permission, and do not refuse the deploy. You cannot change who may open an app from chat; it is set on the app's deploy settings.\n- Use write_app_file, patch_app_file, and delete_app_file for frontend files.\n- Use write_app_runnable and delete_app_runnable for backend runnables.\n- Use init_app for a new app, with framework react19 unless the user named one or points at an app to follow: do not ask about the framework, and write the summary and pick the path (path conventions above) yourself. When the app has to store data and the user did not say where, settle the data setup with the user BEFORE init_app or any SQL (get_instructions subject \"app\" lists the questions), then pass the outcome as init_app's data.\n- Use deploy_workspace_item after explicit user deploy intent; raw app deploy bundles JS/CSS before saving.\n\nData Tables:\n- Datatables are workspace-scoped managed PostgreSQL databases, shared across the workspace (not owned by any single app). They must be configured by the user in their workspace settings (Workspace settings → Data Tables); they cannot be created via SQL.\n- Use list_datatables to discover the available datatables and their tables. Reuse an existing table rather than creating a duplicate. If list_datatables reports none, this is a blocking prerequisite — tell the user to set up a datatable in their workspace settings and stop; do not assume a \"main\" datatable exists or call exec_datatable_sql.\n- Use get_datatable_table_schema only when you need a table's column names/types; list_datatables is enough for table-list or availability summaries.\n- Use exec_datatable_sql to explore data, run queries, mutate rows, or change schema (CREATE/ALTER/DROP). Creating a table is a normal CREATE TABLE statement — it appears in list_datatables afterward, with no registration step.\n- A datatable that list_datatables reports with `migrations_enabled` keeps its schema as versioned migrations: change its schema with create_datatable_migration, never exec_datatable_sql. list_datatable_migrations reads them, run_datatable_migrations applies what is pending, rollback_datatable_migration undoes one. A migration is applied for real, not drafted, so call get_instructions with subject \"datatable\" before your first one.\n- A datatable can be under roles; list_datatables then reports its `usable_roles` and `default_role`. When working on a raw app that pins a role (`data.roles` in its raw_app.yaml), pass that role to the datatable tools, and to wmill.datatable in its runnables, so you see and change only what the app itself can. For anything else about roles, grants or a `permission denied`, call get_instructions with subject \"datatable\".\n- When writing runnable code (inline app runnables, scripts, flow modules) that reads or writes datatable data at runtime, it accesses a datatable via wmill.datatable(). Default to TypeScript (bun) unless the user asked for another language. Call get_instructions with subject \"datatable\" and language \"bun\" for the TypeScript SQL SDK reference (or language \"python3\" for Python) — it returns only that language so you get just what you need.", + "folder_guidance": { + "admin": "- As a workspace admin you can write to any existing folder. Folders here include {{writable_folders}} (you can also write to others not listed; list_workspace_items with type \"folder\" lists them all). If the user names a folder, use it; if they explicitly ask for a new folder, create it with `create_folder`; otherwise ask them which folder to use rather than guessing or creating one unprompted.\n", + "admin_no_folders": "- As a workspace admin you can write to any existing folder. If the user names a folder, use it; if they explicitly ask for a new folder, create it with `create_folder`; otherwise ask them which folder to use rather than guessing or creating one unprompted.\n", + "member": "- Folders you can write to in this workspace: {{writable_folders}}. For shared/team work, pick the one whose purpose matches the request; if none clearly fits, ask which folder to use rather than inventing a path. Use `create_folder` only when the user explicitly asks for a new folder.\n", + "member_no_folders": "- You have no shared folders you can write to in this workspace, so use `u/{{username}}/<name>`. If the user explicitly asks for a shared folder, create one with `create_folder` (you become an owner); otherwise ask before placing shared work rather than inventing an `f/<folder>/...` path.\n", + "member_readonly": "- Folders you can write to in this workspace: {{writable_folders}}. For shared/team work, pick the one whose purpose matches the request; if none clearly fits, ask which folder to use rather than inventing a path. Use `create_folder` only when the user explicitly asks for a new folder.\n- You can see but NOT write to: {{readonly_folders}} — never create or deploy items there.\n", + "member_no_folders_readonly": "- You have no shared folders you can write to in this workspace, so use `u/{{username}}/<name>`. If the user explicitly asks for a shared folder, create one with `create_folder` (you become an owner); otherwise ask before placing shared work rather than inventing an `f/<folder>/...` path.\n- You can see but NOT write to: {{readonly_folders}} — never create or deploy items there.\n" + }, + "skills_header": "\n\nSkills:\n- Skills are reusable instruction sets available in this workspace, each covering a specific kind of task. The available skills are listed below by resource path and description.\n- When a user's request matches a skill's description, call read_skill with its exact path to load the full instructions BEFORE acting, then follow them.\n", + "skill_line": "- {{skill_path}}: {{skill_description}}", + "workspace_instructions": "\n\nWORKSPACE INSTRUCTIONS (configured by a workspace admin, shared by everyone in this workspace — you cannot modify these):\n{{workspace_instructions}}" +} diff --git a/backend/windmill-api/src/mcp/session/gen/schemas.json b/backend/windmill-api/src/mcp/session/gen/schemas.json new file mode 100644 index 0000000000..f15242d1aa --- /dev/null +++ b/backend/windmill-api/src/mcp/session/gen/schemas.json @@ -0,0 +1,16 @@ +{ + "trigger": { + "http": "{\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"description\": \"The unique Windmill path for this trigger. Must be of the form `u/<user>/<path>` or `f/<folder>/<path>`. This is the trigger object path, not the HTTP route path.\",\n \"type\": \"string\"\n },\n \"script_path\": {\n \"description\": \"Path to the script or flow to execute when triggered\",\n \"type\": \"string\"\n },\n \"route_path\": {\n \"description\": \"The URL route path that will trigger this endpoint (e.g., 'api/myendpoint'). Must NOT start with a /.\",\n \"type\": \"string\"\n },\n \"workspaced_route\": {\n \"description\": \"If true, the route includes the workspace ID in the path\",\n \"type\": \"boolean\"\n },\n \"summary\": {\n \"anyOf\": [\n {\n \"description\": \"Short summary describing the purpose of this trigger\",\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"description\": {\n \"anyOf\": [\n {\n \"description\": \"Detailed description of what this trigger does\",\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"static_asset_config\": {\n \"anyOf\": [\n {\n \"description\": \"Configuration for serving static assets (s3 bucket, storage path, filename)\",\n \"type\": \"object\",\n \"properties\": {\n \"s3\": {\n \"description\": \"S3 bucket path for static assets\",\n \"type\": \"string\"\n },\n \"storage\": {\n \"description\": \"Storage path for static assets\",\n \"type\": \"string\"\n },\n \"filename\": {\n \"description\": \"Filename for the static asset\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"s3\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"is_flow\": {\n \"description\": \"True if script_path points to a flow, false if it points to a script\",\n \"type\": \"boolean\"\n },\n \"http_method\": {\n \"description\": \"HTTP method (get, post, put, delete, patch) that triggers this endpoint\",\n \"type\": \"string\",\n \"enum\": [\n \"get\",\n \"post\",\n \"put\",\n \"delete\",\n \"patch\"\n ]\n },\n \"authentication_resource_path\": {\n \"anyOf\": [\n {\n \"description\": \"Path to the resource containing authentication configuration (for api_key, basic_http, custom_script, signature methods)\",\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"is_async\": {\n \"description\": \"Deprecated, use request_type instead\",\n \"type\": \"boolean\"\n },\n \"request_type\": {\n \"description\": \"How the request is handled - 'sync' waits for result, 'async' returns job ID immediately, 'sync_sse' streams results via Server-Sent Events\",\n \"type\": \"string\",\n \"enum\": [\n \"sync\",\n \"async\",\n \"sync_sse\"\n ]\n },\n \"authentication_method\": {\n \"description\": \"How requests are authenticated - 'none' (public), 'windmill' (Windmill token), 'api_key', 'basic_http', 'custom_script', 'signature'\",\n \"type\": \"string\",\n \"enum\": [\n \"none\",\n \"windmill\",\n \"api_key\",\n \"basic_http\",\n \"custom_script\",\n \"signature\"\n ]\n },\n \"is_static_website\": {\n \"description\": \"If true, serves static files from S3/storage instead of running a script\",\n \"type\": \"boolean\"\n },\n \"wrap_body\": {\n \"description\": \"If true, wraps the request body in a 'body' parameter\",\n \"type\": \"boolean\"\n },\n \"mode\": {\n \"description\": \"job trigger mode\",\n \"type\": \"string\",\n \"enum\": [\n \"enabled\",\n \"disabled\",\n \"suspended\"\n ]\n },\n \"raw_string\": {\n \"description\": \"If true, passes the request body as a raw string instead of parsing as JSON\",\n \"type\": \"boolean\"\n },\n \"allowed_origins\": {\n \"anyOf\": [\n {\n \"description\": \"Origins allowed to call this route cross-origin, matched against the request's Origin header (ignoring case) and echoed back on a match. When set, the list governs both the preflight and the response, overriding any Access-Control-Allow-Origin the runnable returns via wm_headers. Use ['*'] to opt out of any restriction, including the http_route_default_allowed_origins instance setting. An empty list is not a configuration and resolves exactly as null does. When null, the instance setting applies, or Access-Control-Allow-Origin: * if it is unset. Ignored on a static website, which has no authentication of its own and so hands out public files: restricting which browsers may read them protects nothing while breaking cross-origin webfonts and fetches. A single-file static asset is not exempt, since it can carry an authentication_method.\",\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"error_handler_path\": {\n \"description\": \"Path to a script to run when the triggered job fails. A bare path, without the script/ or flow/ prefix a schedule error handler takes; it cannot be a flow.\",\n \"type\": \"string\"\n },\n \"error_handler_args\": {\n \"description\": \"Arguments to pass to the error handler\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n \"retry\": {\n \"description\": \"Retry configuration for failed executions\",\n \"type\": \"object\",\n \"properties\": {\n \"constant\": {\n \"description\": \"Retry with constant delay between attempts\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Seconds to wait between retries\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n }\n },\n \"additionalProperties\": false\n },\n \"exponential\": {\n \"description\": \"Retry with exponential backoff: the n-th retry waits multiplier × seconds^n\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"multiplier\": {\n \"description\": \"Multiplier for exponential backoff\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too\",\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 9007199254740991\n },\n \"random_factor\": {\n \"description\": \"Random jitter percentage (0-100) to avoid thundering herd\",\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 100\n }\n },\n \"additionalProperties\": false\n },\n \"retry_if\": {\n \"description\": \"Conditional retry based on error or result\",\n \"type\": \"object\",\n \"properties\": {\n \"expr\": {\n \"description\": \"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"expr\"\n ],\n \"additionalProperties\": false\n }\n },\n \"additionalProperties\": false\n },\n \"permissioned_as\": {\n \"description\": \"The user or group this trigger runs as. Used during deployment to preserve the original trigger owner.\",\n \"type\": \"string\"\n },\n \"preserve_permissioned_as\": {\n \"description\": \"When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it.\",\n \"type\": \"boolean\"\n },\n \"labels\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"path\",\n \"script_path\",\n \"route_path\",\n \"is_flow\",\n \"http_method\",\n \"authentication_method\",\n \"is_static_website\"\n ],\n \"additionalProperties\": false\n}", + "websocket": "{\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"description\": \"The unique Windmill path for this trigger. Must be of the form `u/<user>/<path>` or `f/<folder>/<path>`. This is the trigger object path, not the HTTP route path.\",\n \"type\": \"string\"\n },\n \"script_path\": {\n \"description\": \"Path to the script or flow to execute when a message is received\",\n \"type\": \"string\"\n },\n \"is_flow\": {\n \"description\": \"True if script_path points to a flow, false if it points to a script\",\n \"type\": \"boolean\"\n },\n \"url\": {\n \"description\": \"The WebSocket URL to connect to (can be a static URL or computed by a runnable)\",\n \"type\": \"string\"\n },\n \"mode\": {\n \"description\": \"job trigger mode\",\n \"type\": \"string\",\n \"enum\": [\n \"enabled\",\n \"disabled\",\n \"suspended\"\n ]\n },\n \"filters\": {\n \"description\": \"Filters to match incoming messages (only matching messages trigger the script). Each entry is either a leaf `{key, value}` (top-level field) or `{path, value}` (dotted path into nested objects), or a group `{any_of: [...]}` / `{all_of: [...]}` / `{none_of: [...]}` nesting more entries. Entries at the top level are combined with `filter_logic`.\",\n \"type\": \"array\",\n \"items\": {\n \"description\": \"Either a leaf filter, matching a field of the message (parsed as JSON) against a value by equality (or superset, when the value is an object or array) — addressed by `key` for a top-level field or `path` for a dotted path into nested objects — or a group nesting sub-filters under a boolean operator (`none_of` matches when none of its sub-filters do).\\n\",\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"key\": {\n \"type\": \"string\"\n },\n \"value\": {}\n },\n \"required\": [\n \"key\",\n \"value\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"description\": \"Dotted path into nested objects, e.g. `a.b.c`. Does not traverse arrays.\",\n \"type\": \"string\"\n },\n \"value\": {}\n },\n \"required\": [\n \"path\",\n \"value\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"any_of\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n }\n }\n },\n \"required\": [\n \"any_of\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"all_of\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n }\n }\n },\n \"required\": [\n \"all_of\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"none_of\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n }\n }\n },\n \"required\": [\n \"none_of\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"filter_logic\": {\n \"default\": \"and\",\n \"description\": \"Logic to apply when evaluating the top-level filters. 'and' requires all of them to match, 'or' requires any of them to match. Nested `any_of`/`all_of`/`none_of` groups carry their own logic.\",\n \"type\": \"string\",\n \"enum\": [\n \"and\",\n \"or\"\n ]\n },\n \"initial_messages\": {\n \"anyOf\": [\n {\n \"description\": \"Messages to send immediately after connecting (can be raw strings or computed by runnables)\",\n \"type\": \"array\",\n \"items\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"raw_message\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"raw_message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"runnable_result\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"description\": \"The arguments to pass to the script or flow\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n \"is_flow\": {\n \"type\": \"boolean\"\n }\n },\n \"required\": [\n \"path\",\n \"args\",\n \"is_flow\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"runnable_result\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"url_runnable_args\": {\n \"anyOf\": [\n {\n \"description\": \"Arguments to pass to the script/flow that computes the WebSocket URL\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"can_return_message\": {\n \"description\": \"If true, the script can return a message to send back through the WebSocket\",\n \"type\": \"boolean\"\n },\n \"can_return_error_result\": {\n \"description\": \"If true, error results are sent back through the WebSocket\",\n \"type\": \"boolean\"\n },\n \"heartbeat\": {\n \"anyOf\": [\n {\n \"description\": \"Optional periodic heartbeat message configuration\",\n \"type\": \"object\",\n \"properties\": {\n \"interval_secs\": {\n \"description\": \"Interval in seconds between heartbeat messages\",\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 9007199254740991\n },\n \"message\": {\n \"description\": \"Message to send as heartbeat. Use {{state}} as a placeholder for a value extracted from incoming messages (see state_field).\",\n \"type\": \"string\"\n },\n \"state_field\": {\n \"description\": \"Optional. Top-level JSON field to extract from incoming messages. The extracted value replaces {{state}} in the heartbeat message.\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"interval_secs\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"error_handler_path\": {\n \"description\": \"Path to a script to run when the triggered job fails. A bare path, without the script/ or flow/ prefix a schedule error handler takes; it cannot be a flow.\",\n \"type\": \"string\"\n },\n \"error_handler_args\": {\n \"description\": \"Arguments to pass to the error handler\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n \"retry\": {\n \"description\": \"Retry configuration for failed executions\",\n \"type\": \"object\",\n \"properties\": {\n \"constant\": {\n \"description\": \"Retry with constant delay between attempts\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Seconds to wait between retries\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n }\n },\n \"additionalProperties\": false\n },\n \"exponential\": {\n \"description\": \"Retry with exponential backoff: the n-th retry waits multiplier × seconds^n\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"multiplier\": {\n \"description\": \"Multiplier for exponential backoff\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too\",\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 9007199254740991\n },\n \"random_factor\": {\n \"description\": \"Random jitter percentage (0-100) to avoid thundering herd\",\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 100\n }\n },\n \"additionalProperties\": false\n },\n \"retry_if\": {\n \"description\": \"Conditional retry based on error or result\",\n \"type\": \"object\",\n \"properties\": {\n \"expr\": {\n \"description\": \"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"expr\"\n ],\n \"additionalProperties\": false\n }\n },\n \"additionalProperties\": false\n },\n \"permissioned_as\": {\n \"description\": \"The user or group this trigger runs as. Used during deployment to preserve the original trigger owner.\",\n \"type\": \"string\"\n },\n \"preserve_permissioned_as\": {\n \"description\": \"When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it.\",\n \"type\": \"boolean\"\n },\n \"labels\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"path\",\n \"script_path\",\n \"is_flow\",\n \"url\",\n \"filters\",\n \"can_return_message\",\n \"can_return_error_result\"\n ],\n \"additionalProperties\": false\n}", + "kafka": "{\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"description\": \"The unique Windmill path for this trigger. Must be of the form `u/<user>/<path>` or `f/<folder>/<path>`. This is the trigger object path, not the HTTP route path.\",\n \"type\": \"string\"\n },\n \"script_path\": {\n \"description\": \"Path to the script or flow to execute when a message is received\",\n \"type\": \"string\"\n },\n \"is_flow\": {\n \"description\": \"True if script_path points to a flow, false if it points to a script\",\n \"type\": \"boolean\"\n },\n \"kafka_resource_path\": {\n \"description\": \"Path to the Kafka resource containing connection configuration\",\n \"type\": \"string\"\n },\n \"group_id\": {\n \"description\": \"Kafka consumer group ID for this trigger\",\n \"type\": \"string\"\n },\n \"topics\": {\n \"description\": \"Array of Kafka topic names to subscribe to\",\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"filters\": {\n \"description\": \"Filters to match incoming messages (only matching messages trigger the script). Each entry is either a leaf `{key, value}` (top-level field) or `{path, value}` (dotted path into nested objects), or a group `{any_of: [...]}` / `{all_of: [...]}` / `{none_of: [...]}` nesting more entries. Entries at the top level are combined with `filter_logic`.\",\n \"type\": \"array\",\n \"items\": {\n \"description\": \"Either a leaf filter, matching a field of the message (parsed as JSON) against a value by equality (or superset, when the value is an object or array) — addressed by `key` for a top-level field or `path` for a dotted path into nested objects — or a group nesting sub-filters under a boolean operator (`none_of` matches when none of its sub-filters do).\\n\",\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"key\": {\n \"type\": \"string\"\n },\n \"value\": {}\n },\n \"required\": [\n \"key\",\n \"value\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"description\": \"Dotted path into nested objects, e.g. `a.b.c`. Does not traverse arrays.\",\n \"type\": \"string\"\n },\n \"value\": {}\n },\n \"required\": [\n \"path\",\n \"value\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"any_of\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n }\n }\n },\n \"required\": [\n \"any_of\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"all_of\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n }\n }\n },\n \"required\": [\n \"all_of\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"none_of\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n }\n }\n },\n \"required\": [\n \"none_of\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"filter_logic\": {\n \"default\": \"and\",\n \"description\": \"Logic to apply when evaluating the top-level filters. 'and' requires all of them to match, 'or' requires any of them to match. Nested `any_of`/`all_of`/`none_of` groups carry their own logic.\",\n \"type\": \"string\",\n \"enum\": [\n \"and\",\n \"or\"\n ]\n },\n \"auto_offset_reset\": {\n \"default\": \"latest\",\n \"description\": \"Initial offset behavior when consumer group has no committed offset.\",\n \"type\": \"string\",\n \"enum\": [\n \"latest\",\n \"earliest\"\n ]\n },\n \"auto_commit\": {\n \"default\": true,\n \"description\": \"When true (default), offsets are committed automatically after receiving each message. When false, you must manually commit offsets using the commit_offsets endpoint.\",\n \"type\": \"boolean\"\n },\n \"mode\": {\n \"description\": \"job trigger mode\",\n \"type\": \"string\",\n \"enum\": [\n \"enabled\",\n \"disabled\",\n \"suspended\"\n ]\n },\n \"error_handler_path\": {\n \"description\": \"Path to a script to run when the triggered job fails. A bare path, without the script/ or flow/ prefix a schedule error handler takes; it cannot be a flow.\",\n \"type\": \"string\"\n },\n \"error_handler_args\": {\n \"description\": \"Arguments to pass to the error handler\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n \"retry\": {\n \"description\": \"Retry configuration for failed executions\",\n \"type\": \"object\",\n \"properties\": {\n \"constant\": {\n \"description\": \"Retry with constant delay between attempts\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Seconds to wait between retries\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n }\n },\n \"additionalProperties\": false\n },\n \"exponential\": {\n \"description\": \"Retry with exponential backoff: the n-th retry waits multiplier × seconds^n\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"multiplier\": {\n \"description\": \"Multiplier for exponential backoff\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too\",\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 9007199254740991\n },\n \"random_factor\": {\n \"description\": \"Random jitter percentage (0-100) to avoid thundering herd\",\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 100\n }\n },\n \"additionalProperties\": false\n },\n \"retry_if\": {\n \"description\": \"Conditional retry based on error or result\",\n \"type\": \"object\",\n \"properties\": {\n \"expr\": {\n \"description\": \"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"expr\"\n ],\n \"additionalProperties\": false\n }\n },\n \"additionalProperties\": false\n },\n \"permissioned_as\": {\n \"description\": \"The user or group this trigger runs as. Used during deployment to preserve the original trigger owner.\",\n \"type\": \"string\"\n },\n \"preserve_permissioned_as\": {\n \"description\": \"When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it.\",\n \"type\": \"boolean\"\n },\n \"labels\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"path\",\n \"script_path\",\n \"is_flow\",\n \"kafka_resource_path\",\n \"group_id\",\n \"topics\",\n \"filters\"\n ],\n \"additionalProperties\": false\n}", + "nats": "{\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"description\": \"The unique Windmill path for this trigger. Must be of the form `u/<user>/<path>` or `f/<folder>/<path>`. This is the trigger object path, not the HTTP route path.\",\n \"type\": \"string\"\n },\n \"script_path\": {\n \"description\": \"Path to the script or flow to execute when a message is received\",\n \"type\": \"string\"\n },\n \"is_flow\": {\n \"description\": \"True if script_path points to a flow, false if it points to a script\",\n \"type\": \"boolean\"\n },\n \"nats_resource_path\": {\n \"description\": \"Path to the NATS resource containing connection configuration\",\n \"type\": \"string\"\n },\n \"use_jetstream\": {\n \"description\": \"If true, uses NATS JetStream for durable message delivery\",\n \"type\": \"boolean\"\n },\n \"stream_name\": {\n \"anyOf\": [\n {\n \"description\": \"JetStream stream name (required when use_jetstream is true)\",\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"consumer_name\": {\n \"anyOf\": [\n {\n \"description\": \"JetStream consumer name (required when use_jetstream is true)\",\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"subjects\": {\n \"description\": \"Array of NATS subjects to subscribe to\",\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"mode\": {\n \"description\": \"job trigger mode\",\n \"type\": \"string\",\n \"enum\": [\n \"enabled\",\n \"disabled\",\n \"suspended\"\n ]\n },\n \"error_handler_path\": {\n \"description\": \"Path to a script to run when the triggered job fails. A bare path, without the script/ or flow/ prefix a schedule error handler takes; it cannot be a flow.\",\n \"type\": \"string\"\n },\n \"error_handler_args\": {\n \"description\": \"Arguments to pass to the error handler\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n \"retry\": {\n \"description\": \"Retry configuration for failed executions\",\n \"type\": \"object\",\n \"properties\": {\n \"constant\": {\n \"description\": \"Retry with constant delay between attempts\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Seconds to wait between retries\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n }\n },\n \"additionalProperties\": false\n },\n \"exponential\": {\n \"description\": \"Retry with exponential backoff: the n-th retry waits multiplier × seconds^n\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"multiplier\": {\n \"description\": \"Multiplier for exponential backoff\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too\",\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 9007199254740991\n },\n \"random_factor\": {\n \"description\": \"Random jitter percentage (0-100) to avoid thundering herd\",\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 100\n }\n },\n \"additionalProperties\": false\n },\n \"retry_if\": {\n \"description\": \"Conditional retry based on error or result\",\n \"type\": \"object\",\n \"properties\": {\n \"expr\": {\n \"description\": \"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"expr\"\n ],\n \"additionalProperties\": false\n }\n },\n \"additionalProperties\": false\n },\n \"permissioned_as\": {\n \"description\": \"The user or group this trigger runs as. Used during deployment to preserve the original trigger owner.\",\n \"type\": \"string\"\n },\n \"preserve_permissioned_as\": {\n \"description\": \"When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it.\",\n \"type\": \"boolean\"\n },\n \"labels\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"path\",\n \"script_path\",\n \"is_flow\",\n \"nats_resource_path\",\n \"use_jetstream\",\n \"subjects\"\n ],\n \"additionalProperties\": false\n}", + "postgres": "{\n \"type\": \"object\",\n \"properties\": {\n \"replication_slot_name\": {\n \"description\": \"Name of the PostgreSQL logical replication slot to use\",\n \"type\": \"string\"\n },\n \"publication_name\": {\n \"description\": \"Name of the PostgreSQL publication to subscribe to for change data capture\",\n \"type\": \"string\"\n },\n \"path\": {\n \"description\": \"The unique Windmill path for this trigger. Must be of the form `u/<user>/<path>` or `f/<folder>/<path>`. This is the trigger object path, not the HTTP route path.\",\n \"type\": \"string\"\n },\n \"script_path\": {\n \"description\": \"Path to the script or flow to execute when database changes are detected\",\n \"type\": \"string\"\n },\n \"is_flow\": {\n \"description\": \"True if script_path points to a flow, false if it points to a script\",\n \"type\": \"boolean\"\n },\n \"mode\": {\n \"description\": \"job trigger mode\",\n \"type\": \"string\",\n \"enum\": [\n \"enabled\",\n \"disabled\",\n \"suspended\"\n ]\n },\n \"postgres_resource_path\": {\n \"description\": \"Path to the PostgreSQL resource containing connection configuration\",\n \"type\": \"string\"\n },\n \"publication\": {\n \"description\": \"Configuration for creating/managing the publication (tables, operations)\",\n \"type\": \"object\",\n \"properties\": {\n \"table_to_track\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"schema_name\": {\n \"type\": \"string\"\n },\n \"table_to_track\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"table_name\": {\n \"type\": \"string\"\n },\n \"columns_name\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"where_clause\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"table_name\"\n ],\n \"additionalProperties\": false\n }\n }\n },\n \"required\": [\n \"schema_name\",\n \"table_to_track\"\n ],\n \"additionalProperties\": false\n }\n },\n \"transaction_to_track\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"transaction_to_track\"\n ],\n \"additionalProperties\": false\n },\n \"error_handler_path\": {\n \"description\": \"Path to a script to run when the triggered job fails. A bare path, without the script/ or flow/ prefix a schedule error handler takes; it cannot be a flow.\",\n \"type\": \"string\"\n },\n \"error_handler_args\": {\n \"description\": \"Arguments to pass to the error handler\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n \"retry\": {\n \"description\": \"Retry configuration for failed executions\",\n \"type\": \"object\",\n \"properties\": {\n \"constant\": {\n \"description\": \"Retry with constant delay between attempts\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Seconds to wait between retries\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n }\n },\n \"additionalProperties\": false\n },\n \"exponential\": {\n \"description\": \"Retry with exponential backoff: the n-th retry waits multiplier × seconds^n\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"multiplier\": {\n \"description\": \"Multiplier for exponential backoff\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too\",\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 9007199254740991\n },\n \"random_factor\": {\n \"description\": \"Random jitter percentage (0-100) to avoid thundering herd\",\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 100\n }\n },\n \"additionalProperties\": false\n },\n \"retry_if\": {\n \"description\": \"Conditional retry based on error or result\",\n \"type\": \"object\",\n \"properties\": {\n \"expr\": {\n \"description\": \"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"expr\"\n ],\n \"additionalProperties\": false\n }\n },\n \"additionalProperties\": false\n },\n \"permissioned_as\": {\n \"description\": \"The user or group this trigger runs as. Used during deployment to preserve the original trigger owner.\",\n \"type\": \"string\"\n },\n \"preserve_permissioned_as\": {\n \"description\": \"When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it.\",\n \"type\": \"boolean\"\n },\n \"labels\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"path\",\n \"script_path\",\n \"is_flow\",\n \"postgres_resource_path\"\n ],\n \"additionalProperties\": false\n}", + "mqtt": "{\n \"type\": \"object\",\n \"properties\": {\n \"mqtt_resource_path\": {\n \"description\": \"Path to the MQTT resource containing broker connection configuration\",\n \"type\": \"string\"\n },\n \"subscribe_topics\": {\n \"description\": \"Array of MQTT topics to subscribe to, each with topic name and QoS level\",\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"qos\": {\n \"type\": \"string\",\n \"enum\": [\n \"qos0\",\n \"qos1\",\n \"qos2\"\n ]\n },\n \"topic\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"qos\",\n \"topic\"\n ],\n \"additionalProperties\": false\n }\n },\n \"client_id\": {\n \"anyOf\": [\n {\n \"description\": \"MQTT client ID for this connection\",\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"v3_config\": {\n \"anyOf\": [\n {\n \"description\": \"MQTT v3 specific configuration (clean_session)\",\n \"type\": \"object\",\n \"properties\": {\n \"clean_session\": {\n \"type\": \"boolean\"\n }\n },\n \"additionalProperties\": false\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"v5_config\": {\n \"anyOf\": [\n {\n \"description\": \"MQTT v5 specific configuration (clean_start, topic_alias_maximum, session_expiry_interval)\",\n \"type\": \"object\",\n \"properties\": {\n \"clean_start\": {\n \"type\": \"boolean\"\n },\n \"topic_alias_maximum\": {\n \"type\": \"number\"\n },\n \"session_expiry_interval\": {\n \"type\": \"number\"\n }\n },\n \"additionalProperties\": false\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"client_version\": {\n \"anyOf\": [\n {\n \"description\": \"MQTT protocol version ('v3' or 'v5')\",\n \"type\": \"string\",\n \"enum\": [\n \"v3\",\n \"v5\"\n ]\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"path\": {\n \"description\": \"The unique Windmill path for this trigger. Must be of the form `u/<user>/<path>` or `f/<folder>/<path>`. This is the trigger object path, not the HTTP route path.\",\n \"type\": \"string\"\n },\n \"script_path\": {\n \"description\": \"Path to the script or flow to execute when a message is received\",\n \"type\": \"string\"\n },\n \"is_flow\": {\n \"description\": \"True if script_path points to a flow, false if it points to a script\",\n \"type\": \"boolean\"\n },\n \"mode\": {\n \"description\": \"job trigger mode\",\n \"type\": \"string\",\n \"enum\": [\n \"enabled\",\n \"disabled\",\n \"suspended\"\n ]\n },\n \"error_handler_path\": {\n \"description\": \"Path to a script to run when the triggered job fails. A bare path, without the script/ or flow/ prefix a schedule error handler takes; it cannot be a flow.\",\n \"type\": \"string\"\n },\n \"error_handler_args\": {\n \"description\": \"Arguments to pass to the error handler\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n \"retry\": {\n \"description\": \"Retry configuration for failed executions\",\n \"type\": \"object\",\n \"properties\": {\n \"constant\": {\n \"description\": \"Retry with constant delay between attempts\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Seconds to wait between retries\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n }\n },\n \"additionalProperties\": false\n },\n \"exponential\": {\n \"description\": \"Retry with exponential backoff: the n-th retry waits multiplier × seconds^n\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"multiplier\": {\n \"description\": \"Multiplier for exponential backoff\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too\",\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 9007199254740991\n },\n \"random_factor\": {\n \"description\": \"Random jitter percentage (0-100) to avoid thundering herd\",\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 100\n }\n },\n \"additionalProperties\": false\n },\n \"retry_if\": {\n \"description\": \"Conditional retry based on error or result\",\n \"type\": \"object\",\n \"properties\": {\n \"expr\": {\n \"description\": \"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"expr\"\n ],\n \"additionalProperties\": false\n }\n },\n \"additionalProperties\": false\n },\n \"permissioned_as\": {\n \"description\": \"The user or group this trigger runs as. Used during deployment to preserve the original trigger owner.\",\n \"type\": \"string\"\n },\n \"preserve_permissioned_as\": {\n \"description\": \"When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it.\",\n \"type\": \"boolean\"\n },\n \"labels\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"mqtt_resource_path\",\n \"subscribe_topics\",\n \"path\",\n \"script_path\",\n \"is_flow\"\n ],\n \"additionalProperties\": false\n}", + "amqp": "{\n \"type\": \"object\",\n \"properties\": {\n \"amqp_resource_path\": {\n \"description\": \"Path to the AMQP resource containing broker connection configuration\",\n \"type\": \"string\"\n },\n \"queue_name\": {\n \"description\": \"Name of the queue to consume messages from\",\n \"type\": \"string\"\n },\n \"exchange\": {\n \"anyOf\": [\n {\n \"description\": \"Optional exchange binding for the consumed queue\",\n \"type\": \"object\",\n \"properties\": {\n \"exchange_name\": {\n \"description\": \"Name of the exchange to bind the consumed queue to\",\n \"type\": \"string\"\n },\n \"routing_keys\": {\n \"description\": \"Routing keys used to bind the queue to the exchange\",\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"exchange_name\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"options\": {\n \"anyOf\": [\n {\n \"description\": \"Optional consumer options (queue declaration, prefetch)\",\n \"type\": \"object\",\n \"properties\": {\n \"declare_queue\": {\n \"description\": \"Declare the queue (durable) before consuming; when false the queue is declared passively and must already exist\",\n \"type\": \"boolean\"\n },\n \"prefetch_count\": {\n \"description\": \"Maximum number of unacknowledged messages the broker delivers at once (1-65535)\",\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 65535\n }\n },\n \"additionalProperties\": false\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"path\": {\n \"description\": \"The unique Windmill path for this trigger. Must be of the form `u/<user>/<path>` or `f/<folder>/<path>`.\",\n \"type\": \"string\"\n },\n \"script_path\": {\n \"description\": \"Path to the script or flow to execute when a message is received\",\n \"type\": \"string\"\n },\n \"is_flow\": {\n \"description\": \"True if script_path points to a flow, false if it points to a script\",\n \"type\": \"boolean\"\n },\n \"mode\": {\n \"description\": \"job trigger mode\",\n \"type\": \"string\",\n \"enum\": [\n \"enabled\",\n \"disabled\",\n \"suspended\"\n ]\n },\n \"error_handler_path\": {\n \"description\": \"Path to a script to run when the triggered job fails. A bare path, without the script/ or flow/ prefix a schedule error handler takes; it cannot be a flow.\",\n \"type\": \"string\"\n },\n \"error_handler_args\": {\n \"description\": \"Arguments to pass to the error handler\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n \"retry\": {\n \"description\": \"Retry configuration for failed executions\",\n \"type\": \"object\",\n \"properties\": {\n \"constant\": {\n \"description\": \"Retry with constant delay between attempts\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Seconds to wait between retries\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n }\n },\n \"additionalProperties\": false\n },\n \"exponential\": {\n \"description\": \"Retry with exponential backoff: the n-th retry waits multiplier × seconds^n\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"multiplier\": {\n \"description\": \"Multiplier for exponential backoff\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too\",\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 9007199254740991\n },\n \"random_factor\": {\n \"description\": \"Random jitter percentage (0-100) to avoid thundering herd\",\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 100\n }\n },\n \"additionalProperties\": false\n },\n \"retry_if\": {\n \"description\": \"Conditional retry based on error or result\",\n \"type\": \"object\",\n \"properties\": {\n \"expr\": {\n \"description\": \"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"expr\"\n ],\n \"additionalProperties\": false\n }\n },\n \"additionalProperties\": false\n },\n \"permissioned_as\": {\n \"description\": \"The user or group this trigger runs as. Used during deployment to preserve the original trigger owner.\",\n \"type\": \"string\"\n },\n \"preserve_permissioned_as\": {\n \"description\": \"When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it.\",\n \"type\": \"boolean\"\n },\n \"labels\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"amqp_resource_path\",\n \"queue_name\",\n \"path\",\n \"script_path\",\n \"is_flow\"\n ],\n \"additionalProperties\": false\n}", + "sqs": "{\n \"type\": \"object\",\n \"properties\": {\n \"queue_url\": {\n \"description\": \"The full URL of the AWS SQS queue to poll for messages\",\n \"type\": \"string\"\n },\n \"aws_auth_resource_type\": {\n \"description\": \"Authentication type - 'credentials' for access key/secret, 'oidc' for OpenID Connect\",\n \"type\": \"string\",\n \"enum\": [\n \"oidc\",\n \"credentials\"\n ]\n },\n \"aws_resource_path\": {\n \"description\": \"Path to the AWS resource containing credentials or OIDC configuration\",\n \"type\": \"string\"\n },\n \"message_attributes\": {\n \"anyOf\": [\n {\n \"description\": \"Array of SQS message attribute names to include with each message\",\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"path\": {\n \"description\": \"The unique Windmill path for this trigger. Must be of the form `u/<user>/<path>` or `f/<folder>/<path>`. This is the trigger object path, not the HTTP route path.\",\n \"type\": \"string\"\n },\n \"script_path\": {\n \"description\": \"Path to the script or flow to execute when a message is received\",\n \"type\": \"string\"\n },\n \"is_flow\": {\n \"description\": \"True if script_path points to a flow, false if it points to a script\",\n \"type\": \"boolean\"\n },\n \"mode\": {\n \"description\": \"job trigger mode\",\n \"type\": \"string\",\n \"enum\": [\n \"enabled\",\n \"disabled\",\n \"suspended\"\n ]\n },\n \"error_handler_path\": {\n \"description\": \"Path to a script to run when the triggered job fails. A bare path, without the script/ or flow/ prefix a schedule error handler takes; it cannot be a flow.\",\n \"type\": \"string\"\n },\n \"error_handler_args\": {\n \"description\": \"Arguments to pass to the error handler\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n \"retry\": {\n \"description\": \"Retry configuration for failed executions\",\n \"type\": \"object\",\n \"properties\": {\n \"constant\": {\n \"description\": \"Retry with constant delay between attempts\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Seconds to wait between retries\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n }\n },\n \"additionalProperties\": false\n },\n \"exponential\": {\n \"description\": \"Retry with exponential backoff: the n-th retry waits multiplier × seconds^n\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"multiplier\": {\n \"description\": \"Multiplier for exponential backoff\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too\",\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 9007199254740991\n },\n \"random_factor\": {\n \"description\": \"Random jitter percentage (0-100) to avoid thundering herd\",\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 100\n }\n },\n \"additionalProperties\": false\n },\n \"retry_if\": {\n \"description\": \"Conditional retry based on error or result\",\n \"type\": \"object\",\n \"properties\": {\n \"expr\": {\n \"description\": \"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"expr\"\n ],\n \"additionalProperties\": false\n }\n },\n \"additionalProperties\": false\n },\n \"permissioned_as\": {\n \"description\": \"The user or group this trigger runs as. Used during deployment to preserve the original trigger owner.\",\n \"type\": \"string\"\n },\n \"preserve_permissioned_as\": {\n \"description\": \"When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it.\",\n \"type\": \"boolean\"\n },\n \"labels\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"queue_url\",\n \"aws_auth_resource_type\",\n \"aws_resource_path\",\n \"path\",\n \"script_path\",\n \"is_flow\"\n ],\n \"additionalProperties\": false\n}", + "gcp": "{\n \"description\": \"Data for creating or updating a Google Cloud Pub/Sub trigger.\",\n \"type\": \"object\",\n \"properties\": {\n \"gcp_resource_path\": {\n \"description\": \"Path to the GCP resource containing service account credentials for authentication. Omit to authenticate with the instance's application default credentials, which only workspace admins may select.\",\n \"type\": \"string\"\n },\n \"project_id\": {\n \"description\": \"GCP project the client operates in. Defaults to the project of the credentials. Topics and subscriptions given as fully qualified names are reached whatever it is.\",\n \"type\": \"string\"\n },\n \"subscription_mode\": {\n \"description\": \"The mode of subscription. 'existing' means using an existing GCP subscription, while 'create_update' involves creating or updating a new subscription.\",\n \"type\": \"string\",\n \"enum\": [\n \"existing\",\n \"create_update\"\n ]\n },\n \"topic_id\": {\n \"description\": \"Google Cloud Pub/Sub topic ID to subscribe to. Accepts a bare ID or a fully qualified name (projects/<project>/topics/<id>).\",\n \"type\": \"string\"\n },\n \"subscription_id\": {\n \"description\": \"Google Cloud Pub/Sub subscription ID. Accepts a bare ID or a fully qualified name (projects/<project>/subscriptions/<id>).\",\n \"type\": \"string\"\n },\n \"base_endpoint\": {\n \"description\": \"Base URL for push delivery endpoint.\",\n \"type\": \"string\"\n },\n \"delivery_type\": {\n \"description\": \"Delivery mode for messages. 'push' for HTTP push delivery where messages are sent to a webhook endpoint, 'pull' for polling where the trigger actively fetches messages.\",\n \"type\": \"string\",\n \"enum\": [\n \"push\",\n \"pull\"\n ]\n },\n \"delivery_config\": {\n \"anyOf\": [\n {\n \"description\": \"Configuration for push delivery mode.\",\n \"type\": \"object\",\n \"properties\": {\n \"audience\": {\n \"description\": \"The audience claim for OIDC tokens used in push authentication.\",\n \"type\": \"string\"\n },\n \"authenticate\": {\n \"description\": \"If true, push messages will include OIDC authentication tokens.\",\n \"type\": \"boolean\"\n }\n },\n \"required\": [\n \"authenticate\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"path\": {\n \"description\": \"The unique Windmill path for this trigger. Must be of the form `u/<user>/<path>` or `f/<folder>/<path>`. This is the trigger object path, not the HTTP route path.\",\n \"type\": \"string\"\n },\n \"script_path\": {\n \"description\": \"Path to the script or flow to execute when a message is received.\",\n \"type\": \"string\"\n },\n \"is_flow\": {\n \"description\": \"True if script_path points to a flow, false if it points to a script.\",\n \"type\": \"boolean\"\n },\n \"mode\": {\n \"description\": \"job trigger mode\",\n \"type\": \"string\",\n \"enum\": [\n \"enabled\",\n \"disabled\",\n \"suspended\"\n ]\n },\n \"auto_acknowledge_msg\": {\n \"description\": \"If true, automatically acknowledge messages after processing.\",\n \"type\": \"boolean\"\n },\n \"ack_deadline\": {\n \"description\": \"Time in seconds within which the message must be acknowledged. If not provided, defaults to the subscription's acknowledgment deadline (600 seconds).\",\n \"type\": \"integer\",\n \"minimum\": 10,\n \"maximum\": 600\n },\n \"error_handler_path\": {\n \"description\": \"Path to a script or flow to run when the triggered job fails.\",\n \"type\": \"string\"\n },\n \"error_handler_args\": {\n \"description\": \"Arguments to pass to the error handler.\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n \"retry\": {\n \"description\": \"Retry configuration for failed executions.\",\n \"type\": \"object\",\n \"properties\": {\n \"constant\": {\n \"description\": \"Retry with constant delay between attempts\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Seconds to wait between retries\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n }\n },\n \"additionalProperties\": false\n },\n \"exponential\": {\n \"description\": \"Retry with exponential backoff: the n-th retry waits multiplier × seconds^n\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"multiplier\": {\n \"description\": \"Multiplier for exponential backoff\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too\",\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 9007199254740991\n },\n \"random_factor\": {\n \"description\": \"Random jitter percentage (0-100) to avoid thundering herd\",\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 100\n }\n },\n \"additionalProperties\": false\n },\n \"retry_if\": {\n \"description\": \"Conditional retry based on error or result\",\n \"type\": \"object\",\n \"properties\": {\n \"expr\": {\n \"description\": \"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"expr\"\n ],\n \"additionalProperties\": false\n }\n },\n \"additionalProperties\": false\n },\n \"permissioned_as\": {\n \"description\": \"The user or group this trigger runs as. Used during deployment to preserve the original trigger owner.\",\n \"type\": \"string\"\n },\n \"preserve_permissioned_as\": {\n \"description\": \"When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it.\",\n \"type\": \"boolean\"\n },\n \"labels\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"subscription_mode\",\n \"topic_id\",\n \"path\",\n \"script_path\",\n \"is_flow\"\n ],\n \"additionalProperties\": false\n}", + "azure": "{\n \"description\": \"Data for creating or updating an Azure Event Grid trigger.\",\n \"type\": \"object\",\n \"properties\": {\n \"azure_resource_path\": {\n \"type\": \"string\"\n },\n \"azure_mode\": {\n \"description\": \"Azure Event Grid trigger mode.\",\n \"type\": \"string\",\n \"enum\": [\n \"basic_push\",\n \"namespace_push\",\n \"namespace_pull\"\n ]\n },\n \"scope_resource_id\": {\n \"type\": \"string\"\n },\n \"topic_name\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"subscription_name\": {\n \"type\": \"string\"\n },\n \"base_endpoint\": {\n \"description\": \"Base URL for push delivery endpoints (push modes only).\",\n \"type\": \"string\"\n },\n \"event_type_filters\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"path\": {\n \"description\": \"The unique Windmill path for this trigger. Must be of the form `u/<user>/<path>` or `f/<folder>/<path>`. This is the trigger object path, not the HTTP route path.\",\n \"type\": \"string\"\n },\n \"script_path\": {\n \"type\": \"string\"\n },\n \"is_flow\": {\n \"type\": \"boolean\"\n },\n \"mode\": {\n \"description\": \"job trigger mode\",\n \"type\": \"string\",\n \"enum\": [\n \"enabled\",\n \"disabled\",\n \"suspended\"\n ]\n },\n \"error_handler_path\": {\n \"type\": \"string\"\n },\n \"error_handler_args\": {\n \"description\": \"The arguments to pass to the script or flow\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n \"retry\": {\n \"description\": \"Retry configuration for failed module executions\",\n \"type\": \"object\",\n \"properties\": {\n \"constant\": {\n \"description\": \"Retry with constant delay between attempts\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Seconds to wait between retries\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n }\n },\n \"additionalProperties\": false\n },\n \"exponential\": {\n \"description\": \"Retry with exponential backoff: the n-th retry waits multiplier × seconds^n\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"multiplier\": {\n \"description\": \"Multiplier for exponential backoff\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too\",\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 9007199254740991\n },\n \"random_factor\": {\n \"description\": \"Random jitter percentage (0-100) to avoid thundering herd\",\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 100\n }\n },\n \"additionalProperties\": false\n },\n \"retry_if\": {\n \"description\": \"Conditional retry based on error or result\",\n \"type\": \"object\",\n \"properties\": {\n \"expr\": {\n \"description\": \"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"expr\"\n ],\n \"additionalProperties\": false\n }\n },\n \"additionalProperties\": false\n },\n \"permissioned_as\": {\n \"type\": \"string\"\n },\n \"preserve_permissioned_as\": {\n \"type\": \"boolean\"\n },\n \"labels\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"azure_resource_path\",\n \"azure_mode\",\n \"scope_resource_id\",\n \"subscription_name\",\n \"path\",\n \"script_path\",\n \"is_flow\"\n ],\n \"additionalProperties\": false\n}", + "email": "{\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"type\": \"string\"\n },\n \"script_path\": {\n \"type\": \"string\"\n },\n \"local_part\": {\n \"type\": \"string\"\n },\n \"workspaced_local_part\": {\n \"type\": \"boolean\"\n },\n \"is_flow\": {\n \"type\": \"boolean\"\n },\n \"error_handler_path\": {\n \"type\": \"string\"\n },\n \"error_handler_args\": {\n \"description\": \"The arguments to pass to the script or flow\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n \"retry\": {\n \"description\": \"Retry configuration for failed module executions\",\n \"type\": \"object\",\n \"properties\": {\n \"constant\": {\n \"description\": \"Retry with constant delay between attempts\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Seconds to wait between retries\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n }\n },\n \"additionalProperties\": false\n },\n \"exponential\": {\n \"description\": \"Retry with exponential backoff: the n-th retry waits multiplier × seconds^n\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"multiplier\": {\n \"description\": \"Multiplier for exponential backoff\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too\",\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 9007199254740991\n },\n \"random_factor\": {\n \"description\": \"Random jitter percentage (0-100) to avoid thundering herd\",\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 100\n }\n },\n \"additionalProperties\": false\n },\n \"retry_if\": {\n \"description\": \"Conditional retry based on error or result\",\n \"type\": \"object\",\n \"properties\": {\n \"expr\": {\n \"description\": \"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"expr\"\n ],\n \"additionalProperties\": false\n }\n },\n \"additionalProperties\": false\n },\n \"mode\": {\n \"description\": \"job trigger mode\",\n \"type\": \"string\",\n \"enum\": [\n \"enabled\",\n \"disabled\",\n \"suspended\"\n ]\n },\n \"permissioned_as\": {\n \"description\": \"The user or group this trigger runs as. Used during deployment to preserve the original trigger owner.\",\n \"type\": \"string\"\n },\n \"preserve_permissioned_as\": {\n \"description\": \"When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it.\",\n \"type\": \"boolean\"\n },\n \"labels\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"path\",\n \"script_path\",\n \"local_part\",\n \"is_flow\"\n ],\n \"additionalProperties\": false\n}" + }, + "schedule": "{\n \"type\": \"object\",\n \"properties\": {\n \"on_failure_times\": {\n \"anyOf\": [\n {\n \"description\": \"Number of consecutive failures before the on_failure handler is triggered (default 1)\",\n \"type\": \"number\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"on_failure_exact\": {\n \"anyOf\": [\n {\n \"description\": \"If true, trigger on_failure handler only on exactly N failures, not on every failure after N\",\n \"type\": \"boolean\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"on_failure_extra_args\": {\n \"anyOf\": [\n {\n \"description\": \"The arguments to pass to the script or flow\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"on_recovery_times\": {\n \"anyOf\": [\n {\n \"description\": \"Number of consecutive successes before the on_recovery handler is triggered (default 1)\",\n \"type\": \"number\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"on_recovery_extra_args\": {\n \"anyOf\": [\n {\n \"description\": \"The arguments to pass to the script or flow\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"on_success_extra_args\": {\n \"anyOf\": [\n {\n \"description\": \"The arguments to pass to the script or flow\",\n \"type\": \"object\",\n \"propertyNames\": {\n \"type\": \"string\"\n },\n \"additionalProperties\": {}\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"ws_error_handler_muted\": {\n \"description\": \"If true, the workspace-level error handler will not be triggered for this schedule's failures\",\n \"type\": \"boolean\"\n },\n \"retry\": {\n \"anyOf\": [\n {\n \"description\": \"Retry configuration for failed module executions\",\n \"type\": \"object\",\n \"properties\": {\n \"constant\": {\n \"description\": \"Retry with constant delay between attempts\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Seconds to wait between retries\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n }\n },\n \"additionalProperties\": false\n },\n \"exponential\": {\n \"description\": \"Retry with exponential backoff: the n-th retry waits multiplier × seconds^n\",\n \"type\": \"object\",\n \"properties\": {\n \"attempts\": {\n \"description\": \"Number of retry attempts\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"multiplier\": {\n \"description\": \"Multiplier for exponential backoff\",\n \"type\": \"integer\",\n \"minimum\": -9007199254740991,\n \"maximum\": 9007199254740991\n },\n \"seconds\": {\n \"description\": \"Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too\",\n \"type\": \"integer\",\n \"minimum\": 1,\n \"maximum\": 9007199254740991\n },\n \"random_factor\": {\n \"description\": \"Random jitter percentage (0-100) to avoid thundering herd\",\n \"type\": \"integer\",\n \"minimum\": 0,\n \"maximum\": 100\n }\n },\n \"additionalProperties\": false\n },\n \"retry_if\": {\n \"description\": \"Conditional retry based on error or result\",\n \"type\": \"object\",\n \"properties\": {\n \"expr\": {\n \"description\": \"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\",\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"expr\"\n ],\n \"additionalProperties\": false\n }\n },\n \"additionalProperties\": false\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"no_flow_overlap\": {\n \"description\": \"If true, skip this schedule's execution if the previous run is still in progress (prevents concurrent runs)\",\n \"type\": \"boolean\"\n },\n \"tag\": {\n \"anyOf\": [\n {\n \"description\": \"Worker tag to route jobs to specific worker groups\",\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"paused_until\": {\n \"anyOf\": [\n {\n \"description\": \"ISO 8601 datetime until which the schedule is paused. Schedule resumes automatically after this time\",\n \"type\": \"string\",\n \"format\": \"date-time\",\n \"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)))$\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"cron_version\": {\n \"anyOf\": [\n {\n \"description\": \"Cron parser version. Use 'v2' for extended syntax with additional features\",\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"dynamic_skip\": {\n \"anyOf\": [\n {\n \"description\": \"Path to a script that validates scheduled datetimes. Receives scheduled_for datetime and returns boolean to skip (true) or run (false)\",\n \"type\": \"string\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"permissioned_as\": {\n \"description\": \"The user or group this schedule runs as. Used during deployment to preserve the original schedule owner.\",\n \"type\": \"string\"\n },\n \"preserve_permissioned_as\": {\n \"description\": \"When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it.\",\n \"type\": \"boolean\"\n },\n \"labels\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n}" +} diff --git a/backend/windmill-api/src/mcp/session/gen/tools.json b/backend/windmill-api/src/mcp/session/gen/tools.json new file mode 100644 index 0000000000..4db0f3e4a4 --- /dev/null +++ b/backend/windmill-api/src/mcp/session/gen/tools.json @@ -0,0 +1,2543 @@ +[ + { + "name": "read_skill", + "description": "Load the full instructions for an AI skill by resource path. Skills are listed in the system prompt under \"Skills\"; call this before acting on a task a skill covers, then follow its instructions.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "The exact skill resource path as listed in the Skills section of the system prompt.", + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "open_page", + "description": "Open a Windmill page with filters applied — Runs, Schedules, Variables, Resources, Assets, Audit logs, Folders, Groups, Triggers (by kind), Workspace settings (on a specific tab), or the Compare & Deploy page. Inside an AI session it opens as a tab in the side-panel preview next to the chat; elsewhere it offers a clickable link. Use after surfacing something the user likely wants to inspect (e.g. \"show me the failed runs of X\", \"open the schedule for Y\", \"open the git sync settings\", \"open the kafka triggers\"), and ALWAYS when asking the user to perform a manual step themselves (fill in a resource's credentials, set a variable's value — pass open with the item path so its editor opens directly). Never offer page \"compare\" for draft review. Use it only when the user explicitly asks to deploy a forked workspace into its parent; it always opens the fork-vs-parent comparison. This is the only way to show one of these pages in the session preview — open_preview only handles editable items (scripts, flows, raw apps, pipelines). Only pages listed for this user are available; do not offer others.", + "parameters": { + "type": "object", + "properties": { + "page": { + "description": "Which page to open", + "type": "string", + "enum": [ + "runs", + "schedules", + "variables", + "resources", + "assets", + "audit_logs", + "folders", + "groups", + "triggers", + "compare" + ] + }, + "path": { + "description": "Runs/Schedules/Variables/Resources/Assets: the script, flow or item path to filter by. On Runs, comma-separate to match several values, or prefix each with ! to exclude them instead (never mix included and excluded values in one filter).", + "type": "string" + }, + "status": { + "description": "Runs: filter by job execution status", + "type": "string", + "enum": [ + "running", + "success", + "failure", + "canceled", + "waiting", + "suspended" + ] + }, + "schedule_path": { + "description": "Runs: only runs triggered by this schedule path. Schedules: filter by this exact schedule path.", + "type": "string" + }, + "job_kinds": { + "description": "Runs: filter by job category (defaults to top-level runs)", + "type": "string", + "enum": [ + "all", + "runs", + "dependencies", + "previews", + "deploymentcallbacks" + ] + }, + "user": { + "description": "Runs: filter by the user who created the job. comma-separate to match several values, or prefix each with ! to exclude them instead (never mix included and excluded values in one filter). (e.g. 'admin' or '!admin')", + "type": "string" + }, + "folder": { + "description": "Runs: filter by the folder containing the script or flow — one folder name, without the 'f/' prefix and without ! or commas (use path for anything finer)", + "type": "string" + }, + "job_trigger_kind": { + "description": "Runs: filter by how the job was triggered — one of webhook, default_email, http, websocket, kafka, email, nats, mqtt, amqp, sqs, postgres, schedule, gcp, azure, google, github, asset, freshness, app. comma-separate to match several values, or prefix each with ! to exclude them instead (never mix included and excluded values in one filter). (e.g. '!schedule' to hide scheduled runs)", + "type": "string" + }, + "label": { + "description": "Runs: filter by a custom label attached to the job. Comma-separate to match several values; * matches any substring.", + "type": "string" + }, + "tag": { + "description": "Runs: filter by worker tag. Comma-separate to match several values; * matches any substring.", + "type": "string" + }, + "worker": { + "description": "Runs: filter by the worker instance that ran the job. Comma-separate to match several values; * matches any substring.", + "type": "string" + }, + "concurrency_key": { + "description": "Runs: filter by concurrency limit key, e.g. custom-key or a full script path. Cannot be combined with worker, search, or a waiting/suspended status — that view has no way to apply them.", + "type": "string" + }, + "arg": { + "description": "Runs: only runs whose arguments contain these key/value pairs, as a JSON object string, e.g. {\"customer_id\":\"42\"}", + "type": "string" + }, + "result": { + "description": "Runs: only runs whose result contains these key/value pairs, as a JSON object string, e.g. {\"status\":\"ko\"}", + "type": "string" + }, + "search": { + "description": "Runs: free-text search matched case-insensitively across several run fields at once. Prefer a specific filter when you know which one applies.", + "type": "string" + }, + "timeframe": { + "description": "Runs: relative time window to look at, ending now. Ignored when min_ts or max_ts is set.", + "type": "string", + "enum": [ + "Latest runs", + "Within 30 seconds", + "Within last minute", + "Within last 5 minutes", + "Within last 30 minutes", + "Within last 24 hours", + "Within last 7 days", + "Within last month" + ] + }, + "min_ts": { + "description": "Runs: only runs after this instant, as an ISO 8601 timestamp (e.g. 2026-08-01T09:00:00Z); a bare 2026-08-01 means local midnight. Use it with max_ts for an absolute window; prefer timeframe for a relative one.", + "type": "string" + }, + "max_ts": { + "description": "Runs: only runs before this instant, as an ISO 8601 timestamp. A bare 2026-08-01 means local midnight, so it excludes that day — pass the next day, or an explicit time, to include it.", + "type": "string" + }, + "resolved": { + "description": "Runs: filter failures by whether they have been marked as handled — 'unresolved' hides the ones already resolved", + "type": "string", + "enum": [ + "all", + "unresolved", + "resolved" + ] + }, + "show_skipped": { + "description": "Runs: include skipped flow steps (excluded by default)", + "type": "boolean" + }, + "show_future_jobs": { + "description": "Runs: include jobs scheduled for later (included by default — pass false to hide)", + "type": "boolean" + }, + "open": { + "description": "Schedules/Triggers/Variables/Resources: exact item path to open in its editor, e.g. f/foo/my_schedule — in a session, a preview tab of its own instead of the list page. Use it whenever the user should act on one specific item (e.g. fill in credentials) so they land directly in its editor.", + "type": "string" + }, + "summary": { + "description": "Schedules: search text matched against schedule summaries", + "type": "string" + }, + "trigger_kind": { + "description": "Triggers: which trigger kind page to open", + "type": "string", + "enum": [ + "http", + "websocket", + "postgres", + "mqtt", + "amqp", + "email" + ] + }, + "resource_type": { + "description": "Resources: filter by resource type, e.g. postgres", + "type": "string" + }, + "owner": { + "description": "Variables/Resources: filter by owner, e.g. u/alice or f/team", + "type": "string" + }, + "username": { + "description": "Audit logs: filter by the acting username", + "type": "string" + }, + "operation": { + "description": "Audit logs: filter by operation, e.g. jobs.run", + "type": "string" + }, + "resource": { + "description": "Audit logs: filter by the affected resource path", + "type": "string" + }, + "items": { + "description": "Compare: preselect exactly these fork changes, each as '<kind>:<path>' where kind is script, flow, raw_app, app, resource, variable, or a trigger kind like trigger_schedule / trigger_http (e.g. 'script:f/foo/bar'). Omit to use the page's default selection; pass items only when the user asked to deploy specific ones.", + "minItems": 1, + "type": "array", + "items": { + "type": "string" + } + }, + "new_tab": { + "description": "Open in a NEW preview tab instead of updating the tab already showing this page. Only set true when the user explicitly asks for a new or separate tab; by default changing filters reuses the existing tab.", + "type": "boolean" + } + }, + "required": [ + "page" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "get_instructions", + "description": "Get authoring guidance for scripts, flows, data pipelines, resources, apps, or data tables: their migrations and permissions, and the SQL SDK (wmill.datatable()) used inside runnables.", + "parameters": { + "type": "object", + "properties": { + "subject": { + "description": "What to get authoring instructions for: a workspace item type (script, flow, resource, app), \"pipeline\" for building a data pipeline (a DAG of annotated scripts wired by storage assets — NOT a flow), or \"datatable\" for how data tables work (schema changes and migrations, roles and permissions) plus the wmill.datatable() SQL SDK used inside runnables, or \"workspace_settings\" for the keys and rules of the workspace settings (admins only). Schedules, triggers, and variables don't need instructions — their tool schemas describe everything.", + "type": "string", + "enum": [ + "script", + "flow", + "resource", + "app", + "datatable", + "pipeline", + "workspace_settings" + ] + }, + "language": { + "description": "The target language. Required when subject is script. For subjects \"datatable\" and \"app\" it selects which SDK reference to return (e.g. \"bun\" for TypeScript, \"python3\" for Python) and defaults to TypeScript if omitted. Use the existing language when modifying, or the requested target language when creating. Other subjects ignore it.", + "type": "string", + "enum": [ + "python3", + "deno", + "go", + "bash", + "powershell", + "postgresql", + "mysql", + "bigquery", + "snowflake", + "mssql", + "oracledb", + "graphql", + "nativets", + "bun", + "php", + "rust", + "ansible", + "csharp", + "nu", + "java", + "ruby", + "rlang", + "duckdb", + "bunnative", + "dbt" + ] + } + }, + "required": [ + "subject" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "search_hub_scripts", + "description": "Search the Windmill Hub for prebuilt integration scripts, or list one integration's scripts with `integration`.", + "parameters": { + "type": "object", + "properties": { + "query": { + "description": "What you want the script to do, e.g. \"send email\", \"list stripe invoices\". Semantic search, so describe the intent. Omit when browsing with `integration` alone.", + "type": "string" + }, + "integration": { + "description": "Integration slug (e.g. \"stripe\") from a previous result's `integration` or `suggested_integrations`, or the vendor the user named. Alone, lists that integration's scripts with descriptions, including ones that do not match your task but show how the integration is used. With `query`, narrows the search to it; pass it whenever the user names the vendor, since ranking alone can surface another integration's scripts that merely mention it.", + "type": "string" + } + }, + "additionalProperties": false, + "required": [] + }, + "read_only": true + }, + { + "name": "get_hub_integration", + "description": "Read how one integration works before writing code against it: the resource type it takes, its auth fields, and its most-used scripts as examples. For an integration a search marked `documented` it also returns provider knowledge checked against the live API: pagination, enums, error codes and gotchas.", + "parameters": { + "type": "object", + "properties": { + "integration": { + "description": "Integration slug, e.g. \"stripe\". Take it from a search_hub_scripts result's `integration`, or guess the vendor name: a wrong guess comes back with the closest real slugs.", + "type": "string" + } + }, + "required": [ + "integration" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "search_npm_packages", + "description": "Search for npm packages and their documentation", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The query to search for" + } + }, + "required": [ + "query" + ] + }, + "read_only": true + }, + { + "name": "search_docs", + "description": "Full-text search across the entire Windmill documentation. Provide one or more keywords; returns the most relevant docs pages, each with its Source URL and short matching snippets. Use this FIRST to find relevant pages by their content (a flag, function, error message, config key or concept). If the snippets answer the question, answer directly; otherwise call read_docs_page with a returned Source URL to read the full page or a section.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Keywords to search for in the documentation body, e.g. \"chromium worker tag\" or \"retry exponential backoff\". Fewer, more distinctive words match better." + } + }, + "required": [ + "query" + ] + }, + "read_only": true + }, + { + "name": "read_docs_page", + "description": "Fetch the raw markdown of a single Windmill documentation page. Provide the `url` of a page found via search_docs (its Source URL). If the page is large, this returns its list of section headings instead of the full content; call again with the `section` argument set to one of those headings to read that section.", + "parameters": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The docs page to read, as a Source URL returned by search_docs (e.g. https://www.windmill.dev/docs/core_concepts/jobs). A bare path (e.g. /docs/core_concepts/jobs) is also accepted." + }, + "section": { + "type": "string", + "description": "Optional. A heading title from the page outline to read just that section instead of the full page." + } + }, + "required": [ + "url" + ] + }, + "read_only": true + }, + { + "name": "askUserQuestion", + "description": "Ask the user a question with proposed answers and wait for their selected or custom answer before continuing.", + "parameters": { + "type": "object", + "properties": { + "question": { + "description": "The concise question to show to the user before continuing.", + "type": "string", + "minLength": 1 + }, + "choices": { + "description": "Two to ten proposed answer strings.", + "minItems": 2, + "maxItems": 10, + "type": "array", + "items": { + "description": "Proposed answer text shown to the user and returned as-is.", + "type": "string", + "minLength": 1 + } + }, + "multiSelect": { + "description": "When true, the user may select several answers; use only when the choices can genuinely co-apply (not mutually exclusive). Defaults to single-select.", + "type": "boolean" + } + }, + "required": [ + "question", + "choices" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "update_user_instructions", + "description": "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.", + "parameters": { + "type": "object", + "properties": { + "operation": { + "description": "What to do with your personal Global instructions. 'append' adds a new instruction to the end (use for \"remember this\" / \"always do X\"). 'replace' performs an exact find-and-replace to edit or remove existing text (use for \"change X\" / \"stop doing Y\"); set new_string to \"\" to remove.", + "type": "string", + "enum": [ + "append", + "replace" + ] + }, + "text": { + "description": "Required when operation is 'append': the instruction to add. Ignored for 'replace'.", + "type": "string", + "minLength": 1 + }, + "old_string": { + "description": "Required when operation is 'replace': exact text to find in your current personal instructions. Ignored for 'append'.", + "type": "string", + "minLength": 1 + }, + "new_string": { + "description": "Required when operation is 'replace': replacement text. Use an empty string to delete the matched text. Ignored for 'append'.", + "type": "string" + }, + "replace_all": { + "description": "For operation 'replace': when true, replace every exact match; when false, old_string must match exactly once.", + "default": false, + "type": "boolean" + } + }, + "required": [ + "operation", + "replace_all" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "list_workspace_items", + "description": "List workspace items and drafts. Returns metadata only, up to limit items per item type per page (default 50); pass page to continue past a full page.", + "parameters": { + "type": "object", + "properties": { + "types": { + "description": "Optional item types to list. Defaults to scripts and flows. Pass schedule or trigger explicitly when needed (listing triggers spans 9 kinds and is heavier). \"folder\" lists folders with their owners and extraPerms; \"group\" lists groups with their extraPerms (read one for its members). path_prefix does not apply to these two.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "script", + "flow", + "schedule", + "trigger", + "resource", + "variable", + "app", + "folder", + "group" + ] + } + }, + "query": { + "description": "Optional case-insensitive path or summary search string.", + "type": "string" + }, + "path_prefix": { + "description": "Optional path prefix filter, such as f/ or u/user/.", + "type": "string" + }, + "limit": { + "description": "Maximum items per item type per page (for triggers, per trigger kind). Defaults to 50 and is capped at 100.", + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "page": { + "description": "Page number, starting at 1. Each item type pages independently: request the next page while any type still returns a full page. Drafts appear on page 1 only, capped at limit per type.", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false, + "required": [] + }, + "read_only": true + }, + { + "name": "list_pipelines", + "description": "List the data pipelines of the workspace: each folder holding at least one pipeline script, with its number of scripts. A pipeline is named by its folder, not by an item path.", + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "required": [] + }, + "read_only": true + }, + { + "name": "read_workspace_item", + "description": "Read one workspace item or draft. Prefers your draft when one exists; pass version: \"deployed\" to read the deployed state instead. A deployed item carries extraPerms (a draft does not; pass version: \"deployed\" for it): who it is shared with beyond its owner, as u/<user> or g/<group> → true (write) or false (read-only). A folder returns its owners and extraPerms; a group returns its members and extraPerms (who may manage it). For a folder or group, path is its name. A g/<group> entry grants every member of that group (g/all is everyone), and workspace admins can read and write every folder and item whatever these lists say.", + "parameters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "script", + "flow", + "schedule", + "trigger", + "resource", + "variable", + "app", + "folder", + "group" + ] + }, + "path": { + "description": "Workspace path of the item to read, or a hub/<version>/<app>/<name> path from search_hub_scripts to read a hub script. For a folder or group, its name.", + "type": "string" + }, + "trigger_kind": { + "description": "Required when type is trigger. Identifies which trigger service to call.", + "type": "string", + "enum": [ + "http", + "websocket", + "kafka", + "nats", + "postgres", + "mqtt", + "amqp", + "sqs", + "gcp", + "azure", + "email" + ] + }, + "version": { + "description": "Pass \"deployed\" to read the deployed workspace state even when a draft exists (e.g. to learn the deployed input schema before running the deployed version). Default reads your draft when one exists.", + "type": "string", + "enum": [ + "deployed" + ] + } + }, + "required": [ + "type", + "path" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "create_folder", + "description": "Create a new shared folder (addressable as f/<name>/) in the workspace. The current user is added as an owner.", + "parameters": { + "type": "object", + "properties": { + "name": { + "description": "Folder name — letters, digits, underscores or hyphens only. The folder becomes addressable as `f/<name>/<item>`.", + "type": "string" + }, + "summary": { + "description": "Optional human-readable description of the folder.", + "type": "string" + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "write_script", + "description": "Create or overwrite a draft script.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the script, e.g. f/folder/name or u/user/name.", + "type": "string" + }, + "summary": { + "description": "Short human-readable summary.", + "type": "string" + }, + "language": { + "description": "Script language.", + "type": "string", + "enum": [ + "python3", + "deno", + "go", + "bash", + "powershell", + "postgresql", + "mysql", + "bigquery", + "snowflake", + "mssql", + "oracledb", + "graphql", + "nativets", + "bun", + "php", + "rust", + "ansible", + "csharp", + "nu", + "java", + "ruby", + "rlang", + "duckdb", + "bunnative", + "dbt" + ] + }, + "content": { + "description": "Full script source code.", + "type": "string" + }, + "override": { + "description": "Overwrite the server draft even if it changed externally since you last read it (resolve a save conflict, your version wins).", + "type": "boolean" + } + }, + "required": [ + "path", + "language", + "content" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "write_flow", + "description": "Create or overwrite a draft flow.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the flow, e.g. f/folder/name or u/user/name.", + "type": "string" + }, + "summary": { + "description": "Short human-readable summary.", + "type": "string" + }, + "description": { + "description": "Longer human-readable description of what the flow does. Top-level flow metadata, separate from the modules — not part of the compact value patched by patch_flow_json.", + "type": "string" + }, + "modules": { + "description": "JSON string containing the complete flow modules array.", + "type": "string" + }, + "schema": { + "description": "JSON string containing the flow input schema.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "preprocessor_module": { + "description": "JSON string containing the optional preprocessor module.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "failure_module": { + "description": "JSON string containing the optional failure module.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "groups": { + "description": "JSON string, array of semantic flow groups (call get_instructions subject:\"flow\" for the full field reference). color MUST be one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray — never hex codes. Pass null to clear groups.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "notes": { + "description": "JSON string, array of free-floating sticky notes (type must be \"free\"; call get_instructions subject:\"flow\" for the full field reference). color MUST be one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray — never hex codes. Pass null to clear notes.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "override": { + "description": "Overwrite the server draft even if it changed externally since you last read it (resolve a save conflict, your version wins).", + "type": "boolean" + } + }, + "required": [ + "path", + "modules" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "write_schedule", + "description": "Create or overwrite a draft schedule.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "The unique Windmill path for this schedule. Must be of the form `u/<user>/<path>` or `f/<folder>/<path>`.", + "type": "string" + }, + "schedule": { + "description": "Cron expression with 6 fields (seconds, minutes, hours, day of month, month, day of week). Example '0 0 12 * * *' for daily at noon", + "type": "string" + }, + "timezone": { + "description": "IANA timezone for the schedule (e.g., 'UTC', 'Europe/Paris', 'America/New_York')", + "type": "string" + }, + "script_path": { + "description": "Path to the script or flow to execute when triggered", + "type": "string" + }, + "is_flow": { + "description": "True if script_path points to a flow, false if it points to a script", + "type": "boolean" + }, + "args": { + "anyOf": [ + { + "description": "The arguments to pass to the script or flow", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + }, + "enabled": { + "description": "Whether the schedule is currently active and will trigger jobs", + "type": "boolean" + }, + "summary": { + "anyOf": [ + { + "description": "Short summary describing the purpose of this schedule", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "description": { + "anyOf": [ + { + "description": "Detailed description of what this schedule does", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "on_failure": { + "anyOf": [ + { + "description": "Path to a script or flow to run when the scheduled job fails", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "on_recovery": { + "anyOf": [ + { + "description": "Path to a script or flow to run when the schedule recovers after failures", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "on_success": { + "anyOf": [ + { + "description": "Path to a script or flow to run after each successful execution", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "advanced": { + "description": "Less common schedule options (on_failure_times, on_failure_exact, on_failure_extra_args, on_recovery_times, on_recovery_extra_args, on_success_extra_args, ws_error_handler_muted, retry, no_flow_overlap, tag, paused_until, cron_version, dynamic_skip, permissioned_as, preserve_permissioned_as, labels). Call get_schedule_schema for their exact shape.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "override": { + "description": "Overwrite the server draft even if it changed externally since you last read it (resolve a save conflict, your version wins).", + "type": "boolean" + } + }, + "required": [ + "path", + "schedule", + "timezone", + "script_path", + "is_flow", + "args" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "write_trigger", + "description": "Create or overwrite a draft trigger.", + "parameters": { + "type": "object", + "properties": { + "kind": { + "description": "Trigger kind. Determines which fields are valid in config.", + "type": "string", + "enum": [ + "http", + "websocket", + "kafka", + "nats", + "postgres", + "mqtt", + "amqp", + "sqs", + "gcp", + "azure", + "email" + ] + }, + "config": { + "description": "Full trigger configuration: path, script_path, is_flow plus the kind-specific fields. Call get_trigger_schema with the same kind first to get its exact fields.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "override": { + "description": "Overwrite the server draft even if it changed externally since you last read it (resolve a save conflict, your version wins).", + "type": "boolean" + } + }, + "required": [ + "kind", + "config" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "get_trigger_schema", + "description": "Get the configuration schema for one trigger kind — its config fields differ per kind.", + "parameters": { + "type": "object", + "properties": { + "kind": { + "description": "Trigger kind whose configuration schema to return.", + "type": "string", + "enum": [ + "http", + "websocket", + "kafka", + "nats", + "postgres", + "mqtt", + "amqp", + "sqs", + "gcp", + "azure", + "email" + ] + } + }, + "required": [ + "kind" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "get_schedule_schema", + "description": "Get the shape of a schedule's `advanced` object: retry, pausing, tags, and error-handler tuning.", + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "required": [] + }, + "read_only": true + }, + { + "name": "edit_script", + "description": "Find/replace exact text in a script and save a draft.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the script to edit.", + "type": "string" + }, + "old_string": { + "description": "Exact text to find in the script source.", + "type": "string", + "minLength": 1 + }, + "new_string": { + "description": "Replacement text.", + "type": "string" + }, + "replace_all": { + "description": "When true, replace every exact match. When false, old_string must match exactly once.", + "default": false, + "type": "boolean" + } + }, + "required": [ + "path", + "old_string", + "new_string", + "replace_all" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "patch_flow_json", + "description": "Find/replace exact text in compact flow JSON and save a draft.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the flow to edit.", + "type": "string" + }, + "old_string": { + "description": "Exact text to find in the flow value, serialized as compact JSON (no indent).", + "type": "string", + "minLength": 1 + }, + "new_string": { + "description": "Replacement JSON text.", + "type": "string" + }, + "replace_all": { + "description": "When true, replace every exact match. When false, old_string must match exactly once.", + "default": false, + "type": "boolean" + } + }, + "required": [ + "path", + "old_string", + "new_string", + "replace_all" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "test_run_script", + "description": "Execute a preview-style test run of a script by path, preferring draft content when it exists. It runs with exactly the `args` you send. For a secret argument prefer `$var:<path>` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the script to test.", + "type": "string" + }, + "args": { + "description": "Arguments to pass to the runnable. Omit or pass null when no arguments are needed. An argument typed as a resource (format \"resource-<type>\" in the input schema) takes the bare string \"$res:<path>\" as its whole value — never an object wrapper like {\"$res\": \"<path>\"}, and never a plain path, both of which reach the runnable unresolved. Same for a variable, with \"$var:<path>\". The prefixed string can also sit in a nested field, e.g. {\"gh_auth\": {\"token\": \"$var:g/all/gh_token\"}}. When the input schema carries a top-level \"prompt_for_ai\", it is the author's own instructions for choosing these arguments: follow it.", + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + }, + "background": { + "description": "Run in the background without waiting. Set true for jobs you expect to be long (deploys, backfills, big queries) — you will be notified when it finishes. Leave unset for normal runs, which wait briefly and only background automatically if slow.", + "type": "boolean" + }, + "wait_seconds": { + "description": "How many seconds to wait for the job in-turn before it detaches into the background jobs tray. Defaults to 15. Raise it (capped at 120) for a job you expect to finish in, say, 30–60s and want the result in this same turn. Ignored when background is true. Do not use this to poll — larger values just hold the turn longer.", + "type": "number" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "run_script", + "description": "Run a DEPLOYED script for real, under the user's own permissions. It runs with exactly the `args` you send. For a secret argument prefer `$var:<path>` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call. A required file is the user's to attach, so call this even when you cannot supply one rather than asking in chat. Use only when the user names the deployed version (\"the deployed X\", \"in production\", \"for real\").", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the deployed script to run.", + "type": "string" + }, + "args": { + "description": "Arguments to pass to the runnable. Omit or pass null when no arguments are needed. An argument typed as a resource (format \"resource-<type>\" in the input schema) takes the bare string \"$res:<path>\" as its whole value — never an object wrapper like {\"$res\": \"<path>\"}, and never a plain path, both of which reach the runnable unresolved. Same for a variable, with \"$var:<path>\". The prefixed string can also sit in a nested field, e.g. {\"gh_auth\": {\"token\": \"$var:g/all/gh_token\"}}. When the input schema carries a top-level \"prompt_for_ai\", it is the author's own instructions for choosing these arguments: follow it.", + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + }, + "background": { + "description": "Run in the background without waiting. Set true for jobs you expect to be long (deploys, backfills, big queries) — you will be notified when it finishes. Leave unset for normal runs, which wait briefly and only background automatically if slow.", + "type": "boolean" + }, + "wait_seconds": { + "description": "How many seconds to wait for the job in-turn before it detaches into the background jobs tray. Defaults to 15. Raise it (capped at 120) for a job you expect to finish in, say, 30–60s and want the result in this same turn. Ignored when background is true. Do not use this to poll — larger values just hold the turn longer.", + "type": "number" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "test_run_flow", + "description": "Execute a preview-style test run of a flow by path, preferring draft content when it exists. It runs with exactly the `args` you send. For a secret argument prefer `$var:<path>` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the flow to test.", + "type": "string" + }, + "args": { + "description": "Arguments to pass to the runnable. Omit or pass null when no arguments are needed. An argument typed as a resource (format \"resource-<type>\" in the input schema) takes the bare string \"$res:<path>\" as its whole value — never an object wrapper like {\"$res\": \"<path>\"}, and never a plain path, both of which reach the runnable unresolved. Same for a variable, with \"$var:<path>\". The prefixed string can also sit in a nested field, e.g. {\"gh_auth\": {\"token\": \"$var:g/all/gh_token\"}}. When the input schema carries a top-level \"prompt_for_ai\", it is the author's own instructions for choosing these arguments: follow it.", + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + }, + "memory_id": { + "description": "Chat-mode flows only. A UUID naming the conversation this turn belongs to, whose memory the agent steps read: reuse the same one across calls to test memory and follow-ups, and omit it for a one-off turn in a conversation of its own. Generate the UUID yourself so you can pass it again.", + "type": "string" + }, + "background": { + "description": "Run in the background without waiting. Set true for jobs you expect to be long (deploys, backfills, big queries) — you will be notified when it finishes. Leave unset for normal runs, which wait briefly and only background automatically if slow.", + "type": "boolean" + }, + "wait_seconds": { + "description": "How many seconds to wait for the job in-turn before it detaches into the background jobs tray. Defaults to 15. Raise it (capped at 120) for a job you expect to finish in, say, 30–60s and want the result in this same turn. Ignored when background is true. Do not use this to poll — larger values just hold the turn longer.", + "type": "number" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "run_flow", + "description": "Run a DEPLOYED flow for real, under the user's own permissions. It runs with exactly the `args` you send. For a secret argument prefer `$var:<path>` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call. A required file is the user's to attach, so call this even when you cannot supply one rather than asking in chat. Use only when the user names the deployed version (\"the deployed X\", \"in production\", \"for real\").", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the deployed flow to run.", + "type": "string" + }, + "args": { + "description": "Arguments to pass to the runnable. Omit or pass null when no arguments are needed. An argument typed as a resource (format \"resource-<type>\" in the input schema) takes the bare string \"$res:<path>\" as its whole value — never an object wrapper like {\"$res\": \"<path>\"}, and never a plain path, both of which reach the runnable unresolved. Same for a variable, with \"$var:<path>\". The prefixed string can also sit in a nested field, e.g. {\"gh_auth\": {\"token\": \"$var:g/all/gh_token\"}}. When the input schema carries a top-level \"prompt_for_ai\", it is the author's own instructions for choosing these arguments: follow it.", + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + }, + "background": { + "description": "Run in the background without waiting. Set true for jobs you expect to be long (deploys, backfills, big queries) — you will be notified when it finishes. Leave unset for normal runs, which wait briefly and only background automatically if slow.", + "type": "boolean" + }, + "wait_seconds": { + "description": "How many seconds to wait for the job in-turn before it detaches into the background jobs tray. Defaults to 15. Raise it (capped at 120) for a job you expect to finish in, say, 30–60s and want the result in this same turn. Ignored when background is true. Do not use this to poll — larger values just hold the turn longer.", + "type": "number" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "test_run_step", + "description": "Execute a test run of one step in a flow by path, preferring draft flow/script content when it exists. `args` are the step's OWN inputs, not the flow's: a step is normally fed by its input transforms, so send what that step's code takes, not what the flow takes. An AI agent step takes the inputs a run supplies rather than code arguments, `user_message` above all; its form opens on the step's own configuration, so send only what this run should change. An AI decision step takes `state`, what its questions are asked about; its provider and questions are the step's own. It runs with exactly the `args` you send. For a secret argument prefer `$var:<path>` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the flow containing the step to test.", + "type": "string" + }, + "stepId": { + "description": "The id of the step/module to test.", + "type": "string" + }, + "args": { + "description": "Arguments to pass to the runnable. Omit or pass null when no arguments are needed. An argument typed as a resource (format \"resource-<type>\" in the input schema) takes the bare string \"$res:<path>\" as its whole value — never an object wrapper like {\"$res\": \"<path>\"}, and never a plain path, both of which reach the runnable unresolved. Same for a variable, with \"$var:<path>\". The prefixed string can also sit in a nested field, e.g. {\"gh_auth\": {\"token\": \"$var:g/all/gh_token\"}}. When the input schema carries a top-level \"prompt_for_ai\", it is the author's own instructions for choosing these arguments: follow it.", + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + }, + "background": { + "description": "Run in the background without waiting. Set true for jobs you expect to be long (deploys, backfills, big queries) — you will be notified when it finishes. Leave unset for normal runs, which wait briefly and only background automatically if slow.", + "type": "boolean" + }, + "wait_seconds": { + "description": "How many seconds to wait for the job in-turn before it detaches into the background jobs tray. Defaults to 15. Raise it (capped at 120) for a job you expect to finish in, say, 30–60s and want the result in this same turn. Ignored when background is true. Do not use this to poll — larger values just hold the turn longer.", + "type": "number" + } + }, + "required": [ + "path", + "stepId" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "list_runs", + "description": "List recent runs (jobs), most recent first. Optionally filter by path, creator, label, or status. Returns compact metadata only — use get_run with a returned id to see what a run was called with, returned and logged.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Filter to runs of this exact script or flow path.", + "type": "string" + }, + "created_by": { + "description": "Filter by the username that started the run.", + "type": "string" + }, + "label": { + "description": "Filter by job label.", + "type": "string" + }, + "success": { + "description": "Only completed runs with this outcome (true = succeeded, false = failed).", + "type": "boolean" + }, + "running": { + "description": "Only currently running runs.", + "type": "boolean" + }, + "limit": { + "description": "Max number of runs to return, most recent first. Defaults to 30.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + "additionalProperties": false, + "required": [] + }, + "read_only": true + }, + { + "name": "list_workers", + "description": "List the workers connected to this Windmill instance (those that pinged in the last 5 minutes), with their worker group, custom tags, seconds since their last ping, and jobs executed. Pair with list_runs to diagnose a stuck queue: runs queued on a tag no listed worker picks up will never start. Three blind spots to report rather than reason past: an empty result states whether no worker is connected or whether workers may be hidden from you, so relay the one it gives instead of picking; a missing custom_tags can mean tags are hidden from you, not unset; and only the 100 most recently pinging workers are listed, so on a bigger instance a tag none of them carries may still be served.", + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "required": [] + }, + "read_only": true + }, + { + "name": "get_run", + "description": "Inspect one run: its status, arguments, result and logs, plus — for a flow — the per-step execution tree with each step's status and truncated result (subflow steps, loop iterations, branches and retries included). Works on running jobs too. Pass step to fetch one step's result in full (up to 12k chars).", + "parameters": { + "type": "object", + "properties": { + "id": { + "description": "The UUID of the run (job) to inspect.", + "type": "string" + }, + "step": { + "description": "Step to drill into for its result (returned in full up to 12k chars), addressed by the step ids shown in the tree: \"b\" for a top-level step, \"b/c\" for a step inside a subflow, \"b[12]\" for iteration 12 of a loop or attempt 12 of a retried step (1-based), composable as \"b[12]/c\". Omit to get the run itself.", + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "cancel_job", + "description": "Cancel a running or queued job by its id (e.g. a background test run you started that is no longer needed).", + "parameters": { + "type": "object", + "properties": { + "id": { + "description": "The UUID of the job to cancel.", + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "deploy_workspace_item", + "description": "Deploy a draft to the workspace. Mutates the workspace.", + "parameters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "script", + "flow", + "schedule", + "trigger", + "resource", + "variable", + "app" + ] + }, + "path": { + "description": "Workspace path of the draft to deploy.", + "type": "string" + }, + "trigger_kind": { + "description": "Required when type is trigger. Must match the draft trigger kind.", + "type": "string", + "enum": [ + "http", + "websocket", + "kafka", + "nats", + "postgres", + "mqtt", + "amqp", + "sqs", + "gcp", + "azure", + "email" + ] + }, + "deployment_message": { + "description": "Optional deployment message recorded with the change.", + "type": "string" + }, + "force": { + "description": "Deploy even if the draft was started from an older deployed version, overwriting the version deployed since. Defaults to false.", + "type": "boolean" + } + }, + "required": [ + "type", + "path" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "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.", + "parameters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "script", + "flow", + "schedule", + "trigger", + "resource", + "variable", + "app" + ] + }, + "path": { + "description": "Workspace path of the draft to rebase onto the latest deployed version.", + "type": "string" + } + }, + "required": [ + "type", + "path" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "diff", + "description": "Diff workspace changes. Read-only. Default: drafts vs deployed versions (index without type/path, one item's unified diff with them; file=<name> for one file inside an app). against='parent_workspace': deployed fork vs its parent workspace. search=<text> greps changed lines across all diffs.", + "parameters": { + "type": "object", + "properties": { + "against": { + "description": "What to compare against. 'deployed' (default): the current draft vs the deployed version. 'parent_workspace': the deployed fork vs its parent workspace (only in a fork; local drafts are flagged but not part of that comparison).", + "type": "string", + "enum": [ + "deployed", + "parent_workspace" + ] + }, + "type": { + "description": "With path: the item to diff. Omit both type and path for the workspace index.", + "type": "string", + "enum": [ + "script", + "flow", + "schedule", + "trigger", + "resource", + "variable", + "app" + ] + }, + "path": { + "description": "Workspace path of the item to diff (draft vs deployed). Omit for the index of every draft in the workspace.", + "type": "string" + }, + "trigger_kind": { + "description": "Required when type is trigger. Must match the draft trigger kind.", + "type": "string", + "enum": [ + "http", + "websocket", + "kafka", + "nats", + "postgres", + "mqtt", + "amqp", + "sqs", + "gcp", + "azure", + "email" + ] + }, + "file": { + "description": "Item mode, multi-file apps only: read one file's diff inside the app (e.g. \"src/App.tsx\"). Omit for the per-file summary plus config changes.", + "type": "string" + }, + "search": { + "description": "Search mode: literal substring (case-insensitive, not a regex) matched against added/removed diff lines across every diff in the comparison. Ignores type/path.", + "type": "string" + }, + "file_glob": { + "description": "Search mode: optional glob filter on item paths and app file paths (e.g. \"*.ts\" matches file names, \"f/dash/**\" matches full paths).", + "type": "string" + }, + "max_matches": { + "description": "Search mode: maximum matching diff lines returned (default 50, hard cap 200).", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "types": { + "description": "Index mode: only list drafts of these item types.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "script", + "flow", + "schedule", + "trigger", + "resource", + "variable", + "app" + ] + } + }, + "path_prefix": { + "description": "Index mode: only list drafts under this path prefix, such as f/billing/.", + "type": "string" + }, + "offset": { + "description": "Item mode: skip this many patch lines (paginate a large diff).", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "limit": { + "description": "Index mode: max items to list (default 50, capped at 100). Item mode: max patch lines to return (default 500).", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false, + "required": [] + }, + "read_only": true + }, + { + "name": "delete_workspace_item", + "description": "Delete an item that is already deployed in the workspace. Mutates the workspace. FAILS if the path has no deployed item, so never call it to undo something you created in this chat — that is a draft; use discard_local_draft instead.", + "parameters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "script", + "flow", + "schedule", + "trigger", + "resource", + "variable", + "app" + ] + }, + "path": { + "description": "Workspace path of the item to delete.", + "type": "string" + }, + "trigger_kind": { + "description": "Required when type is trigger. Identifies which trigger service to call.", + "type": "string", + "enum": [ + "http", + "websocket", + "kafka", + "nats", + "postgres", + "mqtt", + "amqp", + "sqs", + "gcp", + "azure", + "email" + ] + } + }, + "required": [ + "type", + "path" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "discard_local_draft", + "description": "Discard a draft only — the tool to undo an item you created or edited in this chat and have not deployed. Does not mutate deployed workspace items, but clears the matching open editor draft if one is mounted.", + "parameters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "script", + "flow", + "schedule", + "trigger", + "resource", + "variable", + "app" + ] + }, + "path": { + "description": "Workspace path of the draft to discard.", + "type": "string" + }, + "trigger_kind": { + "description": "Required when type is trigger. Must match the draft trigger kind.", + "type": "string", + "enum": [ + "http", + "websocket", + "kafka", + "nats", + "postgres", + "mqtt", + "amqp", + "sqs", + "gcp", + "azure", + "email" + ] + } + }, + "required": [ + "type", + "path" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "write_resource", + "description": "Create or overwrite a draft resource.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "The path to the resource", + "type": "string" + }, + "value": {}, + "description": { + "description": "The description of the resource", + "type": "string" + }, + "resource_type": { + "description": "The resource_type associated with the resource", + "type": "string" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "ws_specific": { + "type": "boolean" + }, + "preserve_on_behalf_of": { + "description": "ai_agent only. Keep the identity `value.on_behalf_of` names (`u/<username>`, `g/<group>`) rather than the caller's own. Honoured for admins and members of wm_deployers; otherwise every write of an agent sets it to the caller.", + "type": "boolean" + }, + "override": { + "description": "Overwrite the server draft even if it changed externally since you last read it (resolve a save conflict, your version wins).", + "type": "boolean" + } + }, + "required": [ + "path", + "value", + "resource_type" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "write_variable", + "description": "Create or edit a draft variable. Editing is a partial update: pass only the fields you are changing. You can never read the value of a variable, so to edit a secret variable you MUST omit value — any value you pass replaces the stored secret. FAILS if you pass a \"$var:\" self-reference as the value, or un-secret a variable without giving its new value.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "The path to the variable", + "type": "string" + }, + "value": { + "description": "The value of the variable. Omit it to leave the value alone — required only when creating a new variable, or when changing a secret variable into a non-secret one. Never invent or guess the value of an existing variable: you cannot read it, and a \"$var:...\" reference is NOT a valid value (a variable cannot reference itself). Omitting it keeps whatever the draft already holds, so a value you set earlier in this conversation stays set; discard_local_draft abandons it.", + "type": "string" + }, + "is_secret": { + "description": "Whether the variable is a secret. Omit it to keep the current secrecy — required only when creating a new variable. Never pass false for an existing secret variable unless the user explicitly asked to reveal it and you also pass its new value.", + "type": "boolean" + }, + "description": { + "description": "The description of the variable. Omit it to keep the current description.", + "type": "string" + }, + "account": { + "description": "The account identifier", + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "is_oauth": { + "description": "Whether the variable is an OAuth variable", + "type": "boolean" + }, + "expires_at": { + "description": "The expiration date of the variable", + "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)))$" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "ws_specific": { + "type": "boolean" + }, + "override": { + "description": "Overwrite the server draft even if it changed externally since you last read it (resolve a save conflict, your version wins).", + "type": "boolean" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "search_resource_types", + "description": "Search workspace resource types and schemas.", + "parameters": { + "type": "object", + "properties": { + "query": { + "description": "Natural-language description of the integration or capability you need, e.g. \"stripe\", \"postgres database\", or \"send emails\". Matched semantically against resource type names and descriptions, so describe the intent rather than guessing the exact name.", + "type": "string" + }, + "limit": { + "description": "Max number of resource types to return. Defaults to 5.", + "type": "integer", + "minimum": 1, + "maximum": 20 + } + }, + "required": [ + "query" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "create_resource_type", + "description": "Create a resource type in the workspace. Mutates the workspace.", + "parameters": { + "type": "object", + "properties": { + "name": { + "description": "Type name with the c_ prefix of custom types, e.g. c_acme_billing (added when missing): 1 to 50 letters, digits, \"_\" or \"-\". Resources and resource-typed parameters refer to it by this name.", + "type": "string" + }, + "description": { + "description": "What a resource of this type connects to.", + "type": "string" + }, + "schema": { + "description": "JSON Schema of a resource value of this type: { \"type\": \"object\", \"properties\": {...}, \"required\": [...] }, with every connection field, secrets included (a resource fills those with \"$var:\" references).", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "name", + "description", + "schema" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "get_db_schema", + "description": "Fetch the schema (tables and columns) of a database resource by its path. Supports postgresql, mysql, ms_sql_server, snowflake and bigquery resources.", + "parameters": { + "type": "object", + "properties": { + "resourcePath": { + "type": "string", + "description": "The path of the database resource" + } + }, + "required": [ + "resourcePath" + ] + }, + "read_only": true + }, + { + "name": "read_flow_module_code", + "description": "Read inline script code from one flow module.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the flow.", + "type": "string" + }, + "module_id": { + "description": "Module id whose inline rawscript content to read. Must reference a module whose value.type is \"rawscript\".", + "type": "string" + } + }, + "required": [ + "path", + "module_id" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "set_flow_module_code", + "description": "Overwrite inline script code in one flow module and save a draft.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the flow.", + "type": "string" + }, + "module_id": { + "description": "Module id whose inline rawscript content to overwrite. Must reference a module whose value.type is \"rawscript\". Use patch_flow_json for structural changes.", + "type": "string" + }, + "code": { + "description": "New script source. Replaces the module's value.content entirely.", + "type": "string" + } + }, + "required": [ + "path", + "module_id", + "code" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "init_app", + "description": "Initialize a draft raw app from a framework template.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path for the new app, e.g. f/folder/my_app or u/username/my_app. Errors if an app already exists at this path or a draft is already in flight.", + "type": "string" + }, + "summary": { + "description": "Short human-readable summary of the app.", + "type": "string" + }, + "framework": { + "description": "Frontend framework template: the one the user named or an app they point at uses, otherwise react19. Do not ask.", + "type": "string", + "enum": [ + "react19", + "react18", + "svelte5" + ] + }, + "data": { + "description": "Data setup of an app that stores data, as the user gave or chose it. Omit for an app that stores nothing.", + "type": "object", + "properties": { + "datatable": { + "description": "Data table the app stores its data in.", + "type": "string" + }, + "schema": { + "description": "Schema the app's new tables go in. Omit for public.", + "type": "string" + }, + "tables": { + "description": "Tables the app queries, reused or about to be created, each as `<datatable>/<table>` (public) or `<datatable>/<schema>:<table>`.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "datatable" + ], + "additionalProperties": false + } + }, + "required": [ + "path", + "framework" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "read_app_file", + "description": "Read one raw app frontend file or inline backend runnable. Large files are truncated to a head slice; pass offset/limit to page through the rest.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the app, e.g. f/folder/name.", + "type": "string" + }, + "file_path": { + "description": "Frontend file path like /index.tsx, or backend inline runnable path like backend/<key>/main.ts (or main.py).", + "type": "string" + }, + "offset": { + "description": "1-based line number to start reading from. Use to page through a large file.", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "limit": { + "description": "Maximum number of lines to return. Large files are truncated by default; pass offset/limit to read a specific line range.", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "path", + "file_path" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "search_app", + "description": "Grep across all of a raw app's frontend files and inline backend runnables in one call. Returns matching file:line rows (capped), not file bodies — use it to locate a symbol or string before read_app_file instead of reading whole files one by one.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the app, e.g. f/folder/name.", + "type": "string" + }, + "query": { + "description": "Literal substring to find across all app files (case-insensitive) — not a regex, so spaces and operators match verbatim. Returns matching file:line rows, not file bodies. To find call sites and skip similarly-named helpers, include the call paren (e.g. \"formatCurrency(\" matches calls but not \"formatCurrencyPrecise\"). Then read_app_file to inspect the ranges.", + "type": "string" + }, + "file_glob": { + "description": "Optional path filter. A pattern without \"/\" matches the file name anywhere (e.g. \"*.tsx\"); a pattern with \"/\" matches the full path (e.g. \"/components/*\", \"backend/**\"). Supports * (any chars except /), ** (any chars), and ?.", + "type": "string" + }, + "max_matches": { + "description": "Maximum number of matching lines to return (default 100, hard cap 200); each is shown with a few lines of surrounding context, so the output has more rows than this.", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "path", + "query" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "write_app_file", + "description": "Create or overwrite a frontend file in a local app draft.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the app.", + "type": "string" + }, + "file_path": { + "description": "Frontend file path (must start with /). Use write_app_runnable to set inline backend script content.", + "type": "string" + }, + "content": { + "description": "Full file content.", + "type": "string" + } + }, + "required": [ + "path", + "file_path", + "content" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "delete_app_file", + "description": "Remove a frontend file from a local app draft.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the app.", + "type": "string" + }, + "file_path": { + "description": "Frontend file path to remove from the draft. Use delete_app_runnable for backend runnables.", + "type": "string" + } + }, + "required": [ + "path", + "file_path" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "patch_app_file", + "description": "Find/replace exact text in a raw app file and save a draft.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the app.", + "type": "string" + }, + "file_path": { + "description": "Frontend file path like /index.tsx, or backend inline runnable path like backend/<key>/main.ts.", + "type": "string" + }, + "old_string": { + "description": "Exact text to find.", + "type": "string", + "minLength": 1 + }, + "new_string": { + "description": "Replacement text.", + "type": "string" + }, + "replace_all": { + "description": "When true, replace every exact match. When false, old_string must match exactly once.", + "default": false, + "type": "boolean" + } + }, + "required": [ + "path", + "file_path", + "old_string", + "new_string", + "replace_all" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "write_app_runnable", + "description": "Create or overwrite a backend runnable in a local app draft.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the app.", + "type": "string" + }, + "key": { + "description": "Unique key for the backend runnable (called from frontend as backend.<key>()). Becomes the file id at backend/<key>/main.{ts|py}.", + "type": "string" + }, + "runnable": { + "description": "Backend runnable shape: same as in app mode. Inline runnables carry their script content; path runnables reference an existing workspace/hub item.", + "type": "object", + "properties": { + "name": { + "description": "Short summary/description of what the runnable does.", + "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.", + "type": "string", + "enum": [ + "inline", + "script", + "flow", + "hubscript" + ] + }, + "staticInputs": { + "description": "Static inputs that are not overridable by the frontend caller. Useful for workspace/hub scripts to pre-fill arguments.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "inlineScript": { + "description": "Required when type is \"inline\".", + "type": "object", + "properties": { + "language": { + "description": "Inline script language.", + "type": "string", + "enum": [ + "bun", + "python3" + ] + }, + "content": { + "description": "Inline script source. Must export a main function.", + "type": "string" + } + }, + "required": [ + "language", + "content" + ], + "additionalProperties": false + }, + "path": { + "description": "Required when type is \"script\", \"flow\", or \"hubscript\". Workspace path of the runnable.", + "type": "string" + }, + "viewerDataAccess": { + "description": "Inline only. When true, the runnable reads data as the person viewing the app instead of as the app's identity: data table connections that name no role hold the viewer's roles, and object storage permission rules are checked against the viewer, so row-level security and per-user storage paths answer per viewer. Resources and variables stay the app's. Anonymous viewers are refused. Omit to keep the current setting.", + "type": "boolean" + } + }, + "required": [ + "name", + "type" + ], + "additionalProperties": false + } + }, + "required": [ + "path", + "key", + "runnable" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "delete_app_runnable", + "description": "Remove a backend runnable from a local app draft.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the app.", + "type": "string" + }, + "key": { + "description": "Key of the backend runnable to remove.", + "type": "string" + } + }, + "required": [ + "path", + "key" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "test_run_app_runnable", + "description": "Run one of an app's backend runnables with test arguments, the way the editor preview runs it. An inline runnable executes the draft code; a path runnable executes the deployed script/flow it points at. A deployed app runs under its stored policy instead, so this does not prove the runnable is reachable once deployed.", + "parameters": { + "type": "object", + "properties": { + "path": { + "description": "Workspace path of the app.", + "type": "string" + }, + "key": { + "description": "Key of the backend runnable to run.", + "type": "string" + }, + "args": { + "description": "Arguments to pass to the runnable. Omit or pass null when no arguments are needed. An argument typed as a resource (format \"resource-<type>\" in the input schema) takes the bare string \"$res:<path>\" as its whole value — never an object wrapper like {\"$res\": \"<path>\"}, and never a plain path, both of which reach the runnable unresolved. Same for a variable, with \"$var:<path>\". The prefixed string can also sit in a nested field, e.g. {\"gh_auth\": {\"token\": \"$var:g/all/gh_token\"}}. When the input schema carries a top-level \"prompt_for_ai\", it is the author's own instructions for choosing these arguments: follow it.", + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + }, + "background": { + "description": "Run in the background without waiting. Set true for jobs you expect to be long (deploys, backfills, big queries) — you will be notified when it finishes. Leave unset for normal runs, which wait briefly and only background automatically if slow.", + "type": "boolean" + }, + "wait_seconds": { + "description": "How many seconds to wait for the job in-turn before it detaches into the background jobs tray. Defaults to 15. Raise it (capped at 120) for a job you expect to finish in, say, 30–60s and want the result in this same turn. Ignored when background is true. Do not use this to poll — larger values just hold the turn longer.", + "type": "number" + } + }, + "required": [ + "path", + "key" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "list_datatables", + "description": "List datatables configured in the workspace with schema and table names only. Does not include column definitions. Use this directly for table-list or available-tables summaries. Only call get_datatable_table_schema when column names/types are required.", + "parameters": { + "type": "object", + "properties": { + "datatable_name": { + "description": "List only this datatable. Required with `role`.", + "type": "string" + }, + "role": { + "description": "The datatable role to connect as, when the code you are working on uses one (an app's `data.roles` entry, or the `role` it passes to wmill.datatable). Omit for the datatable's default role.", + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,63}$" + } + }, + "additionalProperties": false, + "required": [] + }, + "read_only": true + }, + { + "name": "get_datatable_table_schema", + "description": "Get column definitions for one datatable table. Do not call this for row counts or table-list summaries; list_datatables is enough for those.", + "parameters": { + "type": "object", + "properties": { + "datatable_name": { + "description": "The datatable name to inspect, e.g. \"main\".", + "type": "string" + }, + "schema_name": { + "description": "The schema name, e.g. \"public\".", + "type": "string" + }, + "table_name": { + "description": "The table name to inspect.", + "type": "string" + }, + "role": { + "description": "The datatable role to connect as, when the code you are working on uses one (an app's `data.roles` entry, or the `role` it passes to wmill.datatable). Omit for the datatable's default role.", + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,63}$" + } + }, + "required": [ + "datatable_name", + "schema_name", + "table_name" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "exec_datatable_sql", + "description": "Execute a SQL query on a workspace datatable. Use this to explore data, test queries, create/alter tables, or make changes. Creating a table is a normal CREATE TABLE statement — no registration step is needed.", + "parameters": { + "type": "object", + "properties": { + "datatable_name": { + "description": "The name of the datatable to query (e.g., \"main\"). Must be one of the datatables configured in the workspace.", + "type": "string" + }, + "sql": { + "description": "The SQL query to execute. Supports SELECT, INSERT, UPDATE, DELETE, CREATE TABLE, ALTER TABLE, DROP TABLE, etc. For SELECT queries, results are returned as an array of objects. A newly created table will appear in list_datatables automatically.", + "type": "string" + }, + "role": { + "description": "The datatable role to connect as, when the code you are working on uses one (an app's `data.roles` entry, or the `role` it passes to wmill.datatable). Omit for the datatable's default role.", + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,63}$" + }, + "background": { + "description": "Run in the background without waiting. Set true for queries you expect to be slow (large scans, heavy migrations) — you will be notified when it finishes. Leave unset for normal queries, which wait briefly and only background automatically if slow.", + "type": "boolean" + }, + "wait_seconds": { + "description": "How many seconds to wait for the query in-turn before it detaches into the background jobs tray. Defaults to 15. Raise it (capped at 120) for a query you expect to finish in ~30–60s and want the result in this same turn. Ignored when background is true.", + "type": "number" + } + }, + "required": [ + "datatable_name", + "sql" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "list_datatable_migrations", + "description": "List a datatable's migrations in order, each with its version, name and whether it is applied or pending. Pass `version` to read one migration's SQL.", + "parameters": { + "type": "object", + "properties": { + "datatable_name": { + "description": "The datatable the migrations belong to, e.g. \"main\".", + "type": "string" + }, + "version": { + "description": "Return the SQL of this one migration instead of the list.", + "type": "number" + } + }, + "required": [ + "datatable_name" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "create_datatable_migration", + "description": "Change the schema of a datatable that has migrations enabled: saves the change as a new migration and applies it. One that fails to apply stays saved as pending: call this again with its `version` to fix it.", + "parameters": { + "type": "object", + "properties": { + "datatable_name": { + "description": "The datatable the migrations belong to, e.g. \"main\".", + "type": "string" + }, + "name": { + "description": "Short name of the change, e.g. \"add_refunds_table\". Letters, digits, \"_\" and \"-\" only.", + "type": "string", + "pattern": "^[A-Za-z0-9_-]+$" + }, + "code_up": { + "description": "The SQL that makes the change. Wrap the statements in `BEGIN; ... END;` so they apply atomically, unless one of them cannot run in a transaction.", + "type": "string" + }, + "code_down": { + "description": "The SQL that undoes `code_up`. Without it the migration cannot be rolled back, so provide it whenever the change is reversible.", + "type": "string" + }, + "role": { + "description": "The datatable role the migration runs as, on a datatable that has roles. Omit to run as admin, which only the datatable admins may do.", + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,63}$" + }, + "schemas": { + "description": "Every schema the migration changes or depends on (including schemas of referenced tables, types and functions), with every name in `code_up` schema-qualified. A migration then waits only on earlier migrations touching those schemas. Omit only for a change that spans the whole database (an extension, a grant across schemas): every later migration then waits for it.", + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9_$-]{1,63}$" + } + }, + "version": { + "description": "Rewrite this migration instead of adding one: only for a pending migration, typically the one that just failed to apply.", + "type": "number" + }, + "apply": { + "description": "Set false to save the migration as pending without running it. Defaults to true.", + "type": "boolean" + } + }, + "required": [ + "datatable_name", + "name", + "code_up" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "run_datatable_migrations", + "description": "Apply a datatable's pending migrations, oldest first. It stops at the first one that fails.", + "parameters": { + "type": "object", + "properties": { + "datatable_name": { + "description": "The datatable the migrations belong to, e.g. \"main\".", + "type": "string" + } + }, + "required": [ + "datatable_name" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "rollback_datatable_migration", + "description": "Undo one applied migration of a datatable by running its down SQL. The migration stays in the list as pending.", + "parameters": { + "type": "object", + "properties": { + "datatable_name": { + "description": "The datatable the migrations belong to, e.g. \"main\".", + "type": "string" + }, + "version": { + "description": "The applied migration to undo, normally the most recent one, as listed.", + "type": "number" + } + }, + "required": [ + "datatable_name", + "version" + ], + "additionalProperties": false + }, + "read_only": false + }, + { + "name": "list_data_metrics", + "description": "List the measures and dimensions declared on DuckLake tables (from `// measure` / `// dimension` annotations in deployed scripts). Call this before writing any aggregate query over a DuckLake table: a declared measure is the canonical definition of that number, and reproducing it yourself silently disagrees with it (a `revenue` measure typically excludes refunds or test rows). Use each returned `expr` verbatim, and when a measure has a `filter` write it as `expr FILTER (WHERE filter)` so measures with different predicates share one GROUP BY. Only declarations whose producing script you can read are returned, so what comes back is never proof of what exists: if the number you need is not here you may still write your own aggregate, but say that you found no declared measure for it rather than implying none exists.", + "parameters": { + "type": "object", + "properties": { + "table": { + "description": "Only declarations on this DuckLake table, as `<lake>/<table>` or `<lake>/<schema>.<table>`, with or without the `ducklake://` scheme. A name with no lake matches nothing and comes back empty.", + "type": "string" + }, + "path_prefix": { + "description": "Only declarations made by scripts under this path, e.g. `f/analytics`.", + "type": "string" + }, + "limit": { + "description": "Max number of declarations to return. Defaults to 200.", + "type": "integer", + "minimum": 1, + "maximum": 1000 + } + }, + "additionalProperties": false, + "required": [] + }, + "read_only": true + }, + { + "name": "list_ducklakes", + "description": "List the DuckLake catalogs configured in this workspace, by name. Call this before building or deploying a data pipeline that materializes DuckLake tables or reads/writes S3 assets: if it returns none, the workspace has no object storage + DuckLake configured and the pipeline cannot run until a workspace admin sets it up. Returns names only.", + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "required": [] + }, + "read_only": true + }, + { + "name": "search_files", + "description": "Search the user-attached files with a regular expression and return matching lines with their line numbers. Use this to locate content before reading a specific window with read_file.", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "description": "JavaScript regular expression to search for.", + "type": "string" + }, + "file": { + "description": "Optional file to restrict the search to: its file id when one is listed, otherwise its exact filename. Omit to search across all attached files.", + "type": "string" + }, + "ignore_case": { + "description": "Case-insensitive matching. Defaults to false.", + "type": "boolean" + } + }, + "required": [ + "pattern" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "read_file", + "description": "Read a bounded window of lines from a user-attached file. Returns each line prefixed with its 1-based number (`<n>→<content>`) plus a pagination note. Files are not in context, so use this to inspect their contents.", + "parameters": { + "type": "object", + "properties": { + "file": { + "description": "File to read: its file id when one is listed, otherwise its exact filename.", + "type": "string" + }, + "start_line": { + "description": "1-based first line to read. Defaults to 1.", + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "end_line": { + "description": "1-based last line to read. The window is capped at 200 lines.", + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "file" + ], + "additionalProperties": false + }, + "read_only": true + }, + { + "name": "get_workspace_settings", + "description": "Read the workspace settings as YAML, in the layout of the settings.yaml file the CLI syncs. Secrets read as `__SENSITIVE_AND_UNCHANGED__`.", + "parameters": { + "type": "object", + "properties": { + "keys": { + "description": "Only these top-level keys. Omit for the whole document.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false, + "required": [] + }, + "read_only": true + }, + { + "name": "set_workspace_settings", + "description": "Change workspace settings. Keys the yaml omits are left as they are. Read get_instructions subject \"workspace_settings\" before your first change.", + "parameters": { + "type": "object", + "properties": { + "yaml": { + "description": "A YAML mapping of only the top-level keys to change, each given whole (a nested object replaces the stored one). A key set to null clears it.", + "type": "string" + } + }, + "required": [ + "yaml" + ], + "additionalProperties": false + }, + "read_only": false + } +] diff --git a/backend/windmill-api/src/mcp/session/guidance.rs b/backend/windmill-api/src/mcp/session/guidance.rs new file mode 100644 index 0000000000..385aaa5c2a --- /dev/null +++ b/backend/windmill-api/src/mcp/session/guidance.rs @@ -0,0 +1,142 @@ +//! `get_instructions` and `read_skill`: guidance the model pulls on demand. + +use std::collections::HashMap; +use std::sync::LazyLock; + +use serde::Deserialize; +use serde_json::Value; + +use super::{opt_str_arg, str_arg, Ctx, ToolResult}; + +pub(super) const TOOLS: &[&str] = &["get_instructions", "read_skill"]; + +pub(super) async fn call(ctx: &Ctx<'_>, name: &str, args: &Value) -> ToolResult { + match name { + "get_instructions" => get_instructions(ctx, args), + "read_skill" => read_skill(ctx, args).await, + _ => unreachable!("{name} is not a guidance tool"), + } +} + +/// Each subject's text per language, as the session renders it; `""` is the text for +/// a call that names no language. +#[derive(Deserialize)] +struct Instructions { + index: HashMap<String, HashMap<String, String>>, + texts: HashMap<String, String>, +} + +static INSTRUCTIONS: LazyLock<Instructions> = LazyLock::new(|| { + serde_json::from_str(include_str!("gen/instructions.json")) + .expect("gen/instructions.json is valid") +}); + +fn get_instructions(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let subject = str_arg(args, "subject")?; + if subject == "workspace_settings" && !ctx.auth.is_admin { + return Ok("Only a workspace admin can read or change the workspace settings. Tell the user plainly that you cannot, and suggest asking a workspace admin.".to_string()); + } + let by_language = INSTRUCTIONS + .index + .get(subject) + .ok_or_else(|| format!("Unknown subject `{subject}`"))?; + let language = opt_str_arg(args, "language").unwrap_or(""); + let id = by_language + .get(language) + .ok_or_else(|| format!("Unknown language `{language}`"))?; + Ok(INSTRUCTIONS.texts[id].clone()) +} + +const SKILL_RESOURCE_TYPE: &str = "ai_skill"; +const SKILLS_PAGE_SIZE: usize = 100; +/// Guard against a paging bug looping forever, not a product cap. +const MAX_SKILLS_PAGES: usize = 100; +const MAX_SKILL_DESCRIPTION_CHARS: usize = 1024; +const MAX_SKILL_INSTRUCTIONS_BYTES: usize = 64 * 1024; +const TRUNCATED: &str = "… [truncated]"; + +pub(super) struct Skill { + pub path: String, + pub description: String, +} + +/// Every skill the caller can read. The session lists only the ones its user left +/// switched on, a preference kept in the browser; here they are all in play. +pub(super) async fn list_skills(ctx: &Ctx<'_>) -> Result<Vec<Skill>, String> { + let mut skills = Vec::new(); + for page in 1..=MAX_SKILLS_PAGES { + let rows = ctx + .get(&ctx.ws(&format!( + "resources/list?resource_type={SKILL_RESOURCE_TYPE}&page={page}&per_page={SKILLS_PAGE_SIZE}" + ))) + .await?; + let rows = rows.as_array().map(Vec::as_slice).unwrap_or_default(); + for row in rows { + let Some(path) = row.get("path").and_then(Value::as_str) else { + continue; + }; + let description = row + .get("description") + .and_then(Value::as_str) + .unwrap_or_default(); + let description = if description.chars().count() > MAX_SKILL_DESCRIPTION_CHARS { + let cut: String = description + .chars() + .take(MAX_SKILL_DESCRIPTION_CHARS) + .collect(); + format!("{cut}{TRUNCATED}") + } else { + description.to_string() + }; + skills.push(Skill { path: path.to_string(), description }); + } + if rows.len() < SKILLS_PAGE_SIZE { + break; + } + } + Ok(skills) +} + +async fn read_skill(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + // Checked against the listing: the path is the model's, and without this the tool + // reads any resource holding a string `content`. + let listed = match list_skills(ctx).await { + Ok(skills) => skills.iter().any(|skill| skill.path == path), + Err(e) => return Ok(read_failed(path, &e)), + }; + if !listed { + return Ok(format!( + "\"{path}\" is not one of the skills available here. Only the paths listed under \"Skills\" in the instructions can be read." + )); + } + let value = match ctx + .get(&ctx.ws(&format!("resources/get_value/{path}"))) + .await + { + Ok(value) => value, + Err(e) => return Ok(read_failed(path, &e)), + }; + let Some(body) = value.get("content").and_then(Value::as_str) else { + return Ok(read_failed( + path, + &format!("resource {path} has no string \"content\" — is it an {SKILL_RESOURCE_TYPE}?"), + )); + }; + let body = if body.len() > MAX_SKILL_INSTRUCTIONS_BYTES { + let mut end = MAX_SKILL_INSTRUCTIONS_BYTES; + while !body.is_char_boundary(end) { + end -= 1; + } + format!("{}{TRUNCATED}", &body[..end]) + } else { + body.to_string() + }; + Ok(format!("Skill: {path}\n\nInstructions:\n{body}")) +} + +fn read_failed(path: &str, message: &str) -> String { + format!( + "Failed to read skill \"{path}\": {message}. Check the path against the Skills list in the instructions." + ) +} diff --git a/backend/windmill-api/src/mcp/session/instructions.rs b/backend/windmill-api/src/mcp/session/instructions.rs new file mode 100644 index 0000000000..f5c15c6e17 --- /dev/null +++ b/backend/windmill-api/src/mcp/session/instructions.rs @@ -0,0 +1,152 @@ +//! The instructions a client receives on connecting: the session's system prompt as +//! rendered for an external client (`gen/prompt.json`), filled in for the caller. What +//! differs per workspace is cut out of it, so a connection spanning several workspaces +//! gets the rest up front and each workspace's part from `get_workspace_context`. + +use std::sync::LazyLock; + +use serde::Deserialize; +use windmill_mcp::server::McpAuth; + +use super::{guidance::list_skills, Ctx}; + +/// The prompt with a `{{folder_guidance}}` slot, the guidance once per folder situation, +/// and the sections appended when the workspace has something to put there. +#[derive(Deserialize)] +struct PromptParts { + base: String, + folder_guidance: FolderGuidance, + skills_header: String, + skill_line: String, + workspace_instructions: String, +} + +#[derive(Deserialize)] +struct FolderGuidance { + admin: String, + admin_no_folders: String, + member: String, + member_no_folders: String, + member_readonly: String, + member_no_folders_readonly: String, +} + +static PARTS: LazyLock<PromptParts> = LazyLock::new(|| { + serde_json::from_str(include_str!("gen/prompt.json")).expect("gen/prompt.json is valid") +}); + +/// Folders named in the prompt before the rest are counted rather than listed, as the +/// session does, so a folder-heavy workspace cannot dominate the instructions. +const MAX_LISTED_FOLDERS: usize = 40; + +fn folder_list(names: &[&str]) -> String { + let shown = names + .iter() + .take(MAX_LISTED_FOLDERS) + .map(|n| format!("`f/{n}`")) + .collect::<Vec<_>>() + .join(", "); + if names.len() > MAX_LISTED_FOLDERS { + format!("{shown} (+{} more)", names.len() - MAX_LISTED_FOLDERS) + } else { + shown + } +} + +fn folder_guidance(auth: &crate::db::ApiAuthed) -> String { + let writable: Vec<&str> = auth + .folders() + .iter() + .filter(|(_, write, _)| *write) + .map(|(name, _, _)| name.as_str()) + .collect(); + let readonly: Vec<&str> = auth + .folders() + .iter() + .filter(|(_, write, _)| !*write) + .map(|(name, _, _)| name.as_str()) + .collect(); + + let guidance = &PARTS.folder_guidance; + let template = match (auth.is_admin(), writable.is_empty(), readonly.is_empty()) { + (true, false, _) => &guidance.admin, + (true, true, _) => &guidance.admin_no_folders, + (false, false, true) => &guidance.member, + (false, false, false) => &guidance.member_readonly, + (false, true, true) => &guidance.member_no_folders, + (false, true, false) => &guidance.member_no_folders_readonly, + }; + template + .replace("{{username}}", auth.username()) + .replace("{{writable_folders}}", &folder_list(&writable)) + .replace("{{readonly_folders}}", &folder_list(&readonly)) +} + +/// The workspace's skills and its admin-written instructions. +async fn appendix(ctx: &Ctx<'_>) -> String { + let mut text = String::new(); + // Either lookup failing leaves its section out: a client that cannot connect at all + // is worse than one that connects without the skills list. + if let Ok(skills) = list_skills(ctx).await { + if !skills.is_empty() { + text.push_str(&PARTS.skills_header); + let lines = skills + .iter() + .map(|skill| { + PARTS + .skill_line + .replace("{{skill_path}}", &skill.path) + .replace("{{skill_description}}", &skill.description) + }) + .collect::<Vec<_>>() + .join("\n"); + text.push_str(&lines); + } + } + if let Ok(info) = ctx.get(&ctx.ws("workspaces/get_copilot_info")).await { + let workspace = info + .pointer("/custom_prompts/global") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()); + if let Some(workspace) = workspace { + text.push_str( + &PARTS + .workspace_instructions + .replace("{{workspace_instructions}}", workspace), + ); + } + } + text +} + +/// The whole prompt, for a connection that reaches one workspace. +pub(in crate::mcp) async fn session_instructions(ctx: &Ctx<'_>) -> String { + let mut prompt = PARTS + .base + .replace("{{folder_guidance}}", &folder_guidance(ctx.auth)) + .replace("{{username}}", ctx.auth.username()); + prompt.push_str(&appendix(ctx).await); + prompt +} + +/// The prompt without anything specific to a workspace, for a connection that reaches +/// several: usernames are per workspace, so paths read `u/<username>/...`. +pub(in crate::mcp) fn generic_session_instructions() -> String { + PARTS + .base + .replace("{{folder_guidance}}", "") + .replace("{{username}}", "<username>") +} + +/// What `generic_session_instructions` leaves out, for one workspace. +pub(in crate::mcp) async fn workspace_context(ctx: &Ctx<'_>) -> String { + let username = ctx.auth.username(); + format!( + "Workspace `{}`. Your username here is \"{username}\", so your personal paths are \ + `u/{username}/<name>`.\n{}{}", + ctx.w_id, + folder_guidance(ctx.auth).trim_end(), + appendix(ctx).await + ) +} diff --git a/backend/windmill-api/src/mcp/session/items.rs b/backend/windmill-api/src/mcp/session/items.rs new file mode 100644 index 0000000000..4f221180d7 --- /dev/null +++ b/backend/windmill-api/src/mcp/session/items.rs @@ -0,0 +1,720 @@ +//! Draft writes for the item kinds that are plain records: schedules, triggers, resources, variables. + +use std::sync::LazyLock; + +use serde_json::{json, Map, Value}; + +use super::workspace::{finish_draft_write, persist_draft, write_base, Persist}; +use super::{non_null, put, tool_parameters, Ctx, ToolResult, SCHEMAS}; + +pub(super) const TOOLS: &[&str] = &[ + "write_schedule", + "write_trigger", + "write_resource", + "write_variable", +]; + +pub(super) async fn call(ctx: &Ctx<'_>, name: &str, args: &Value) -> ToolResult { + match name { + "write_schedule" => write_schedule(ctx, args).await, + "write_trigger" => write_trigger(ctx, args).await, + "write_resource" => write_resource(ctx, args).await, + "write_variable" => write_variable(ctx, args).await, + _ => unreachable!("{name} is not an items tool"), + } +} + +// ============================================================================ +// Argument parsing. The session parses arguments with zod schemas; the same schemas +// reach this server as JSON Schema (`gen/tools.json`, `gen/schemas.json`), and `parse` +// applies them with zod's semantics rather than JSON Schema's: an undeclared key is +// dropped instead of refused, a `default` fills a missing key, a union yields the +// first branch that accepts the value. +// ============================================================================ + +fn tool_schema(name: &str) -> Value { + tool_parameters(name) + .map(|parameters| Value::Object(parameters.clone())) + .unwrap_or_default() +} + +/// A schedule's whole schema: the fields `write_schedule` lists plus the ones it takes +/// through `advanced`, which the session spreads to the top level before parsing. +static SCHEDULE_SCHEMA: LazyLock<Value> = LazyLock::new(|| { + let mut schema = tool_schema("write_schedule"); + let advanced: Value = serde_json::from_str(&SCHEMAS.schedule).unwrap_or_default(); + if let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) { + properties.remove("advanced"); + if let Some(advanced) = advanced.get("properties").and_then(Value::as_object) { + properties.extend(advanced.clone()); + } + } + schema +}); + +fn received(value: Option<&Value>) -> &'static str { + match value { + None => "undefined", + Some(Value::Null) => "null", + Some(Value::Bool(_)) => "boolean", + Some(Value::Number(_)) => "number", + Some(Value::String(_)) => "string", + Some(Value::Array(_)) => "array", + Some(Value::Object(_)) => "object", + } +} + +fn join(path: &str, key: &str) -> String { + if path.is_empty() { + key.to_string() + } else { + format!("{path}.{key}") + } +} + +/// `value` as `schema` accepts it, `None` standing for a missing one. Each violation is +/// pushed to `issues` as `<dotted path>: <message>`. +fn parse( + schema: &Value, + value: Option<&Value>, + path: &str, + issues: &mut Vec<String>, +) -> Option<Value> { + let mut issue = |message: String| { + let at = if path.is_empty() { "<root>" } else { path }; + issues.push(format!("{at}: {message}")); + None + }; + if value.is_none() { + if let Some(default) = schema.get("default") { + return Some(default.clone()); + } + } + if let Some(branches) = schema.get("anyOf").and_then(Value::as_array) { + let is_null = |branch: &&Value| branch.get("type").and_then(Value::as_str) == Some("null"); + if value.is_some_and(Value::is_null) && branches.iter().any(|b| is_null(&b)) { + return Some(Value::Null); + } + let branches: Vec<&Value> = branches.iter().filter(|b| !is_null(b)).collect(); + // A nullable field reports what its one real type refuses. + if let [only] = branches[..] { + return parse(only, value, path, issues); + } + for branch in branches { + let mut refused = Vec::new(); + let parsed = parse(branch, value, path, &mut refused); + if refused.is_empty() { + return parsed; + } + } + return issue("Invalid input".to_string()); + } + if let Some(options) = schema.get("enum").and_then(Value::as_array) { + if value.is_some_and(|v| options.contains(v)) { + return value.cloned(); + } + let options: Vec<String> = options.iter().map(Value::to_string).collect(); + return issue(format!( + "Invalid option: expected one of {}", + options.join("|") + )); + } + let Some(kind) = schema.get("type").and_then(Value::as_str) else { + return value.cloned(); + }; + match (kind, value) { + ("boolean", Some(Value::Bool(_))) | ("null", Some(Value::Null)) => value.cloned(), + ("string", Some(Value::String(text))) => { + let matches = schema + .get("pattern") + .and_then(Value::as_str) + .and_then(|pattern| regex::Regex::new(pattern).ok()) + .is_none_or(|pattern| pattern.is_match(text)); + if matches { + value.cloned() + } else if schema.get("format").and_then(Value::as_str) == Some("date-time") { + issue("Invalid ISO datetime".to_string()) + } else { + issue("Invalid string".to_string()) + } + } + ("number" | "integer", Some(Value::Number(number))) => { + let n = number.as_f64().unwrap_or_default(); + let bound = |key: &str| schema.get(key).and_then(Value::as_f64); + if kind == "integer" && n.fract() != 0.0 { + issue("Invalid input: expected int, received number".to_string()) + } else if let Some(min) = bound("minimum").filter(|min| n < *min) { + issue(format!("Too small: expected number to be >={min}")) + } else if let Some(max) = bound("maximum").filter(|max| n > *max) { + issue(format!("Too big: expected number to be <={max}")) + } else { + value.cloned() + } + } + ("array", Some(Value::Array(items))) => { + let any = json!({}); + let item_schema = schema.get("items").unwrap_or(&any); + Some(Value::Array( + items + .iter() + .enumerate() + .filter_map(|(i, item)| { + parse(item_schema, Some(item), &join(path, &i.to_string()), issues) + }) + .collect(), + )) + } + ("object", Some(Value::Object(fields))) => { + let Some(properties) = schema.get("properties").and_then(Value::as_object) else { + return value.cloned(); + }; + let required = schema.get("required").and_then(Value::as_array); + let mut out = Map::new(); + for (key, property) in properties { + let field = fields.get(key); + let optional = !required.is_some_and(|r| r.iter().any(|k| k == key)); + if field.is_none() && optional && property.get("default").is_none() { + continue; + } + if let Some(parsed) = parse(property, field, &join(path, key), issues) { + out.insert(key.clone(), parsed); + } + } + if schema.get("additionalProperties") != Some(&Value::Bool(false)) { + for (key, field) in fields { + if !properties.contains_key(key) { + out.insert(key.clone(), field.clone()); + } + } + } + Some(Value::Object(out)) + } + _ => { + let expected = match kind { + "integer" => "number", + "object" if schema.get("properties").is_none() => "record", + other => other, + }; + issue(format!( + "Invalid input: expected {expected}, received {}", + received(value) + )) + } + } +} + +fn parse_args(tool: &str, args: &Value) -> Result<Value, String> { + parse_with(&tool_schema(tool), args) +} + +fn parse_with(schema: &Value, args: &Value) -> Result<Value, String> { + let mut issues = Vec::new(); + let parsed = parse(schema, Some(args), "", &mut issues); + match parsed { + Some(parsed) if issues.is_empty() => Ok(parsed), + _ => Err(format!("Invalid arguments. Issues: {}", issues.join("; "))), + } +} + +/// Key paths `supplied` carries that parsing dropped. Every sub-field of a schedule's +/// `retry` is optional, so a guessed shape parses clean, loses its misspelled keys and +/// would save a policy that does nothing. +fn dropped_keys(supplied: &Value, parsed: Option<&Value>, prefix: &str, out: &mut Vec<String>) { + let Value::Object(supplied) = supplied else { + if parsed.is_none() && !prefix.is_empty() { + out.push(prefix.to_string()); + } + return; + }; + match parsed { + Some(parsed @ (Value::Object(_) | Value::Array(_))) => { + for (key, value) in supplied { + dropped_keys(value, parsed.get(key), &join(prefix, key), out); + } + } + _ => { + if !supplied.is_empty() && !prefix.is_empty() { + out.push(prefix.to_string()); + } + } + } +} + +fn describe_dropped_schedule_options(dropped: &[String]) -> String { + let is_field = |path: &&String| { + let field = path.split('.').next().unwrap_or_default(); + field != "override" && SCHEDULE_SCHEMA["properties"].get(field).is_some() + }; + let (misshaped, unknown): (Vec<&String>, Vec<&String>) = dropped.iter().partition(is_field); + let list = |paths: &[&String]| { + paths + .iter() + .map(|p| p.as_str()) + .collect::<Vec<_>>() + .join(", ") + }; + let mut parts = Vec::new(); + if !misshaped.is_empty() { + parts.push(format!("These schedule options do not match their schema and would have been dropped: {}. Call get_schedule_schema for their exact shape.", list(&misshaped))); + } + if !unknown.is_empty() { + parts.push(format!( + "These are not schedule fields: {}.", + list(&unknown) + )); + } + parts.join(" ") +} + +// ============================================================================ +// Draft values +// ============================================================================ + +fn into_object(value: Option<Value>) -> Map<String, Value> { + match value { + Some(Value::Object(map)) => map, + _ => Map::new(), + } +} + +/// A schedule's or trigger's draft: the base without what the server stamps on a +/// deployed row, then the fields of this write. +fn merge_config(base: Option<Value>, config: Value, path: &str) -> Map<String, Value> { + let mut draft = into_object(base); + for key in ["workspace_id", "edited_by", "edited_at", "email", "error"] { + draft.remove(key); + } + draft.extend(into_object(Some(config))); + draft.insert("path".into(), json!(path)); + draft +} + +fn resource_to_draft_state(resource: &Value) -> Value { + let mut state = Map::new(); + state.insert("path".into(), resource["path"].clone()); + state.insert( + "description".into(), + non_null(resource.get("description")) + .cloned() + .unwrap_or_else(|| json!("")), + ); + state.insert( + "args".into(), + non_null(resource.get("value")) + .cloned() + .unwrap_or_else(|| json!({})), + ); + put( + &mut state, + "labels", + non_null(resource.get("labels")).cloned(), + ); + state.insert( + "wsSpecific".into(), + non_null(resource.get("ws_specific")) + .cloned() + .unwrap_or(json!(false)), + ); + state.insert("resource_type".into(), resource["resource_type"].clone()); + Value::Object(state) +} + +fn resource_draft(args: &Value, base: Option<Value>) -> Value { + let mut draft = into_object(base); + let merged = |arg: &str, key: &str| { + non_null(args.get(arg)) + .or(non_null(draft.get(key))) + .cloned() + }; + let description = merged("description", "description").unwrap_or_else(|| json!("")); + let value = merged("value", "args").unwrap_or_else(|| json!({})); + let labels = merged("labels", "labels"); + let ws_specific = merged("ws_specific", "wsSpecific").unwrap_or(json!(false)); + let resource_type = merged("resource_type", "resource_type"); + draft.insert("path".into(), args["path"].clone()); + draft.insert("description".into(), description); + draft.insert("args".into(), value); + put(&mut draft, "labels", labels); + draft.insert("wsSpecific".into(), ws_specific); + put(&mut draft, "resource_type", resource_type); + Value::Object(draft) +} + +fn variable_to_draft_state(variable: &Value) -> Value { + let field = |key: &str| non_null(variable.get(key)).cloned(); + // An OAuth variable's value belongs to the refresh flow, and the API hands back a + // freshly refreshed live token for an expired one even undecrypted: carried into a + // draft, it would pin a rotating value to a static one at the next deploy. + let oauth_managed = variable.get("is_oauth").and_then(Value::as_bool) == Some(true) + || field("account").is_some(); + let value = if oauth_managed { None } else { field("value") }; + let mut state = Map::new(); + state.insert("path".into(), variable["path"].clone()); + state.insert( + "variable".into(), + json!({ + "value": value.unwrap_or_else(|| json!("")), + "is_secret": variable["is_secret"], + "description": field("description").unwrap_or_else(|| json!("")), + }), + ); + put(&mut state, "labels", field("labels")); + state.insert( + "wsSpecific".into(), + field("ws_specific").unwrap_or(json!(false)), + ); + for key in ["account", "is_oauth", "expires_at"] { + put(&mut state, key, field(key)); + } + Value::Object(state) +} + +/// The draft a `write_variable` call leaves. A variable's value is never read back, so +/// an omitted `value` keeps whatever the base holds: `""` for "nothing staged", or the +/// `$encrypted:` marker the draft store turned a staged secret into. +fn variable_draft(args: &Value, base: Option<Value>) -> Result<Value, String> { + let path = args["path"].as_str().unwrap_or_default(); + let arg = |key: &str| non_null(args.get(key)).cloned(); + let stored = |key: &str| { + base.as_ref() + .and_then(|b| b.get("variable")) + .and_then(|v| non_null(v.get(key)).cloned()) + }; + let value = arg("value"); + if base.is_none() && (value.is_none() || arg("is_secret").is_none()) { + return Err(format!("Variable \"{path}\" does not exist yet, so creating it requires both value and is_secret.")); + } + let was_secret = stored("is_secret").and_then(|s| s.as_bool()); + let is_secret = arg("is_secret") + .and_then(|s| s.as_bool()) + .or(was_secret) + .unwrap_or(false); + let empty = json!(""); + // "" is how a secret draft says "nothing staged", so it cannot also mean "set the + // secret to empty". + if is_secret && value.as_ref() == Some(&empty) { + return Err(format!("An empty string is not a valid value for secret variable \"{path}\". Omit value to keep the stored secret, or pass the real new one.")); + } + let next_value = value.clone().or_else(|| stored("value")); + if is_secret && was_secret == Some(false) && next_value.as_ref() == Some(&empty) { + return Err(format!("Cannot make variable \"{path}\" secret without a value: it currently holds an empty one, so there would be nothing to encrypt. Pass the value it should hold.")); + } + // The stored value of a secret cannot be read, and an `$encrypted:` marker is only + // decrypted at deploy while the target stays secret. + if !is_secret && was_secret == Some(true) && value.is_none() { + return Err(format!("Cannot turn secret variable \"{path}\" into a non-secret one without a value: its stored value cannot be read, so it would be replaced by an empty one. Pass the new plaintext value, or leave is_secret unset to keep it secret.")); + } + let description = arg("description").or_else(|| stored("description")); + + let mut draft = into_object(base); + let merged = |arg_key: &str, key: &str| { + non_null(args.get(arg_key)) + .or(non_null(draft.get(key))) + .cloned() + }; + let labels = merged("labels", "labels"); + let ws_specific = merged("ws_specific", "wsSpecific").unwrap_or(json!(false)); + let extras = ["account", "is_oauth", "expires_at"].map(|key| (key, merged(key, key))); + draft.insert("path".into(), json!(path)); + draft.insert( + "variable".into(), + json!({ + "value": next_value.unwrap_or(empty), + "is_secret": is_secret, + "description": description.unwrap_or_else(|| json!("")), + }), + ); + put(&mut draft, "labels", labels); + draft.insert("wsSpecific".into(), ws_specific); + for (key, value) in extras { + put(&mut draft, key, value); + } + Ok(Value::Object(draft)) +} + +// ============================================================================ +// Tools +// ============================================================================ + +/// The shared write: build on the caller's draft, else on the deployed item (shaped +/// into a draft value by `from_deployed`), else from nothing. +async fn write_draft( + ctx: &Ctx<'_>, + item_type: &str, + trigger_kind: Option<&str>, + path: &str, + force: bool, + from_deployed: impl FnOnce(Value) -> Value, + build: impl FnOnce(Option<Value>) -> Result<Value, String>, +) -> ToolResult { + let base = write_base(ctx, item_type, path, trigger_kind).await?; + let existed = base.base.is_some(); + let from_draft = base.from_draft; + let draft = build( + base.base + .map(|b| if from_draft { b } else { from_deployed(b) }), + )?; + let persisted = persist_draft( + ctx, + item_type, + path, + trigger_kind, + &draft, + base.last_sync.as_deref(), + force, + ) + .await?; + Ok(match persisted { + Persist::Saved(item) => finish_draft_write(ctx, &item, existed), + Persist::Failed(text) => text, + }) +} + +fn take_override(parsed: &mut Value) -> bool { + parsed + .as_object_mut() + .and_then(|o| o.remove("override")) + .and_then(|v| v.as_bool()) + .unwrap_or(false) +} + +async fn write_schedule(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + // A named argument outranks the same key inside `advanced`. + let mut merged = into_object(args.get("advanced").cloned()); + for (key, value) in args.as_object().into_iter().flatten() { + if key != "advanced" { + merged.insert(key.clone(), value.clone()); + } + } + let merged = Value::Object(merged); + let mut parsed = parse_with(&SCHEDULE_SCHEMA, &merged)?; + let mut dropped = Vec::new(); + dropped_keys(&merged, Some(&parsed), "", &mut dropped); + if !dropped.is_empty() { + return Err(describe_dropped_schedule_options(&dropped)); + } + let force = take_override(&mut parsed); + let path = parsed["path"].as_str().unwrap_or_default().to_string(); + write_draft( + ctx, + "schedule", + None, + &path, + force, + |deployed| deployed, + |base| Ok(Value::Object(merge_config(base, parsed, &path))), + ) + .await +} + +async fn write_trigger(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let mut parsed = parse_args("write_trigger", args)?; + let force = take_override(&mut parsed); + let kind = parsed["kind"].as_str().unwrap_or_default(); + let schema: Value = SCHEMAS + .trigger + .get(kind) + .and_then(|schema| serde_json::from_str(schema).ok()) + .ok_or_else(|| format!("Unknown trigger kind `{kind}`"))?; + // The draft is filed under `kind`, so a config of another kind is refused here + // rather than stored as a draft no editor or deploy can read. + let mut issues = Vec::new(); + let config = parse(&schema, parsed.get("config"), "", &mut issues); + let config = match config { + Some(config) if issues.is_empty() => config, + _ => { + return Err(format!( + "Invalid config for a \"{kind}\" trigger. Call get_trigger_schema with kind \"{kind}\" for its exact fields. Issues: {}", + issues.join("; ") + )) + } + }; + let path = config["path"].as_str().unwrap_or_default().to_string(); + write_draft( + ctx, + "trigger", + Some(kind), + &path, + force, + |deployed| deployed, + |base| { + let mut draft = merge_config(base, config, &path); + // Optional in the schema, but a NOT NULL column. Defaulted on the merged + // draft so an omitted field keeps the existing trigger's value. + if kind == "email" && non_null(draft.get("workspaced_local_part")).is_none() { + draft.insert("workspaced_local_part".into(), json!(false)); + } + Ok(Value::Object(draft)) + }, + ) + .await +} + +async fn write_resource(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let mut parsed = parse_args("write_resource", args)?; + let force = take_override(&mut parsed); + let path = parsed["path"].as_str().unwrap_or_default().to_string(); + write_draft( + ctx, + "resource", + None, + &path, + force, + |deployed| resource_to_draft_state(&deployed), + |base| Ok(resource_draft(&parsed, base)), + ) + .await +} + +async fn write_variable(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let mut parsed = parse_args("write_variable", args)?; + let force = take_override(&mut parsed); + let path = parsed["path"].as_str().unwrap_or_default().to_string(); + // A variable's value is never interpolated, so its own `$var:` reference is the + // reference syntax echoed back, not a value. + if let Some(value) = parsed.get("value").and_then(Value::as_str) { + if value == format!("$var:{path}") { + return Err(format!("\"{value}\" is not a valid value for variable \"{path}\" — it is a self-reference. Omit value to keep the current one.")); + } + } + write_draft( + ctx, + "variable", + None, + &path, + force, + |deployed| variable_to_draft_state(&deployed), + |base| variable_draft(&parsed, base), + ) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + + fn issues_of(schema: &Value, value: Value) -> (Option<Value>, Vec<String>) { + let mut issues = Vec::new(); + let parsed = parse(schema, Some(&value), "", &mut issues); + (parsed, issues) + } + + #[test] + fn trigger_config_parses_like_the_session() { + let kafka: Value = serde_json::from_str(&SCHEMAS.trigger["kafka"]).unwrap(); + let (parsed, issues) = issues_of( + &kafka, + json!({ + "path": "p", "script_path": "s", "is_flow": false, "kafka_resource_path": "r", + "group_id": "g", "topics": ["a"], "unknown_field": 1, + "filters": [{"key": "k", "value": 1, "zzz": 2}, {"any_of": [{"a": 1}]}], + }), + ); + assert_eq!(issues, Vec::<String>::new()); + let parsed = parsed.unwrap(); + assert_eq!(parsed.get("unknown_field"), None); + assert_eq!( + parsed["filters"], + json!([{"key": "k", "value": 1}, {"any_of": [{"a": 1}]}]) + ); + assert_eq!(parsed["filter_logic"], "and"); + assert_eq!(parsed["auto_commit"], true); + + let (_, issues) = issues_of( + &kafka, + json!({ + "path": 5, "is_flow": "x", "topics": "a", "filters": [3], "group_id": null, + "auto_offset_reset": "x", "kafka_resource_path": "r", + "retry": {"exponential": {"seconds": 0, "random_factor": 1.5}, "retry_if": {}}, + }), + ); + assert_eq!( + issues, + [ + "path: Invalid input: expected string, received number", + "script_path: Invalid input: expected string, received undefined", + "is_flow: Invalid input: expected boolean, received string", + "group_id: Invalid input: expected string, received null", + "topics: Invalid input: expected array, received string", + "filters.0: Invalid input", + "auto_offset_reset: Invalid option: expected one of \"latest\"|\"earliest\"", + "retry.exponential.seconds: Too small: expected number to be >=1", + "retry.exponential.random_factor: Invalid input: expected int, received number", + "retry.retry_if.expr: Invalid input: expected string, received undefined", + ] + ); + } + + #[test] + fn schedule_reports_what_parsing_would_drop() { + let merged = json!({ + "path": "p", "schedule": "s", "timezone": "UTC", "script_path": "x", + "is_flow": false, "args": null, "bogus": 1, "override": true, + "retry": {"constant": {"attempts": 2, "secs": 3}}, + }); + let parsed = parse_with(&SCHEDULE_SCHEMA, &merged).unwrap(); + let mut dropped = Vec::new(); + dropped_keys(&merged, Some(&parsed), "", &mut dropped); + assert_eq!( + describe_dropped_schedule_options(&dropped), + "These schedule options do not match their schema and would have been dropped: retry.constant.secs. Call get_schedule_schema for their exact shape. These are not schedule fields: bogus." + ); + assert!(parse_with( + &SCHEDULE_SCHEMA, + &json!({"path": "p", "paused_until": "tomorrow"}) + ) + .unwrap_err() + .contains("paused_until: Invalid ISO datetime")); + } + + #[test] + fn variable_value_rules() { + let secret = json!({ + "path": "p", + "variable": {"value": "$encrypted:abc", "is_secret": true, "description": "d"}, + "wsSpecific": false, + }); + let plain_empty = + json!({"path": "p", "variable": {"value": "", "is_secret": false, "description": ""}}); + + assert!(variable_draft(&json!({"path": "p", "value": "v"}), None).is_err()); + let kept = variable_draft( + &json!({"path": "p", "description": "new"}), + Some(secret.clone()), + ) + .unwrap(); + assert_eq!( + kept["variable"], + json!({"value": "$encrypted:abc", "is_secret": true, "description": "new"}) + ); + assert!(variable_draft(&json!({"path": "p", "value": ""}), Some(secret.clone())).is_err()); + assert!(variable_draft( + &json!({"path": "p", "is_secret": false}), + Some(secret.clone()) + ) + .is_err()); + let revealed = variable_draft( + &json!({"path": "p", "is_secret": false, "value": "v"}), + Some(secret), + ) + .unwrap(); + assert_eq!(revealed["variable"]["value"], "v"); + assert!( + variable_draft(&json!({"path": "p", "is_secret": true}), Some(plain_empty)).is_err() + ); + + let oauth = variable_to_draft_state(&json!({ + "path": "p", "value": "live-token", "is_secret": true, "account": 3, + "description": null, "expires_at": null, + })); + assert_eq!( + oauth, + json!({ + "path": "p", + "variable": {"value": "", "is_secret": true, "description": ""}, + "wsSpecific": false, + "account": 3, + }) + ); + } +} diff --git a/backend/windmill-api/src/mcp/session/misc.rs b/backend/windmill-api/src/mcp/session/misc.rs new file mode 100644 index 0000000000..d613b89f92 --- /dev/null +++ b/backend/windmill-api/src/mcp/session/misc.rs @@ -0,0 +1,728 @@ +//! Lookups that touch no draft: hub, docs, npm, resource types, workers, folders. + +use std::collections::HashSet; + +use futures::future::join_all; +use serde_json::{json, Map, Value}; +use windmill_common::utils::HTTP_CLIENT; + +use super::{ + array, enc, is_not_found, opt_str_arg, pretty, str_arg, truthy, Ctx, ToolResult, SCHEMAS, +}; + +pub(super) const TOOLS: &[&str] = &[ + "search_hub_scripts", + "get_hub_integration", + "search_npm_packages", + "search_docs", + "read_docs_page", + "search_resource_types", + "create_resource_type", + "list_workers", + "create_folder", + "get_trigger_schema", + "get_schedule_schema", +]; + +pub(super) async fn call(ctx: &Ctx<'_>, name: &str, args: &Value) -> ToolResult { + match name { + "search_hub_scripts" => search_hub_scripts(ctx, args).await, + "get_hub_integration" => get_hub_integration(ctx, args).await, + "search_npm_packages" => search_npm_packages(args).await, + "search_docs" => search_docs(ctx, args).await, + "read_docs_page" => read_docs_page(ctx, args).await, + "search_resource_types" => search_resource_types(ctx, args).await, + "create_resource_type" => create_resource_type(ctx, args).await, + "list_workers" => list_workers(ctx).await, + "create_folder" => create_folder(ctx, args).await, + "get_trigger_schema" => get_trigger_schema(args), + "get_schedule_schema" => Ok(SCHEMAS.schedule.clone()), + _ => unreachable!("{name} is not a misc tool"), + } +} + +fn get_trigger_schema(args: &Value) -> ToolResult { + let kind = str_arg(args, "kind")?; + SCHEMAS + .trigger + .get(kind) + .cloned() + .ok_or_else(|| format!("Unknown trigger kind `{kind}`")) +} + +/// Copies `keys` from `from` into `to`, each only when truthy. +fn copy_truthy(to: &mut Map<String, Value>, from: &Value, keys: &[&str]) { + for key in keys { + if truthy(from.get(key)) { + to.insert(key.to_string(), from[key].clone()); + } + } +} + +/// Copies `keys` from `from` into `to`, each only when present (`null` included). +fn copy_present(to: &mut Map<String, Value>, from: &Value, keys: &[&str]) { + for key in keys { + if let Some(value) = from.get(key) { + to.insert(key.to_string(), value.clone()); + } + } +} + +fn words(text: &str) -> impl Iterator<Item = String> + '_ { + text.split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|w| !w.is_empty()) + .map(str::to_ascii_lowercase) +} + +// --------------------------------------------------------------------------- +// Hub +// --------------------------------------------------------------------------- + +const MAX_BROWSED_HUB_SCRIPTS: usize = 20; +const MAX_SUGGESTED_INTEGRATIONS: usize = 5; +const MAX_INTEGRATION_EXAMPLES: usize = 5; + +/// An unreachable hub degrades to "no suggestions" rather than a tool error. +async fn hub_integrations(ctx: &Ctx<'_>) -> Vec<Value> { + match ctx.get("/integrations/hub/list?kind=script").await { + Ok(Value::Array(integrations)) => integrations, + _ => Vec::new(), + } +} + +/// A token matches a whole word of the slug or display name, or extends one from four +/// characters on: a bare substring test makes `for` hit sales*for*ce. +fn token_matches_integration(token: &str, integration: &Value) -> bool { + let name = integration.get("name").and_then(Value::as_str); + let display_name = integration.get("display_name").and_then(Value::as_str); + let words: Vec<String> = [name, display_name] + .into_iter() + .flatten() + .flat_map(words) + .collect(); + if words.iter().any(|w| w == token) { + return true; + } + token.len() >= 4 + && words + .iter() + .any(|w| w.len() >= 4 && (w.starts_with(token) || token.starts_with(w.as_str()))) +} + +/// Ranked by how many query words each integration accounts for, so "google drive" +/// puts `gdrive` ahead of every other `g*` integration matching on "google" alone. +fn suggest_hub_integrations(integrations: &[Value], query: &str) -> Vec<String> { + let tokens: Vec<String> = words(query).filter(|t| t.len() >= 2).collect(); + let mut ranked: Vec<(usize, &str)> = integrations + .iter() + .filter_map(|integration| { + let matched = tokens + .iter() + .filter(|t| token_matches_integration(t, integration)) + .count(); + let name = integration.get("name")?.as_str()?; + (matched > 0).then_some((matched, name)) + }) + .collect(); + ranked.sort_by(|a, b| b.0.cmp(&a.0)); + ranked + .into_iter() + .take(MAX_SUGGESTED_INTEGRATIONS) + .map(|(_, name)| name.to_string()) + .collect() +} + +/// The hub resolves a script by its version id alone; the other segments are descriptive. +fn hub_script_path(script: &Value) -> String { + let summary = script + .get("summary") + .and_then(Value::as_str) + .unwrap_or_default() + .to_lowercase(); + format!( + "hub/{}/{}/{}", + script.get("version_id").unwrap_or(&Value::Null), + script + .get("app") + .and_then(Value::as_str) + .unwrap_or_default(), + summary.split_whitespace().collect::<Vec<_>>().join("_") + ) +} + +async fn search_hub_scripts(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let query = opt_str_arg(args, "query").filter(|s| !s.is_empty()); + let app = opt_str_arg(args, "integration").filter(|s| !s.is_empty()); + if query.is_none() && app.is_none() { + return Ok( + "Pass query (what the script should do), integration (a slug to list), or both." + .to_string(), + ); + } + let app_param = app + .map(|app| format!("&app={}", enc(app))) + .unwrap_or_default(); + // Listing an integration goes through the top-scripts endpoint: it applies no + // similarity floor, so near-misses worth reading as examples survive. + let scripts = match query { + Some(query) => { + ctx.get(&format!( + "/embeddings/query_hub_scripts?text={}&kind=script{app_param}", + enc(query) + )) + .await? + } + None => ctx + .get(&format!( + "/scripts/hub/top?limit={MAX_BROWSED_HUB_SCRIPTS}&kind=script{app_param}" + )) + .await? + .get("asks") + .cloned() + .unwrap_or_default(), + }; + let scripts = array(Some(&scripts)); + let integrations = hub_integrations(ctx).await; + + if scripts.is_empty() { + let subject = [app, query] + .into_iter() + .flatten() + .collect::<Vec<_>>() + .join(" "); + return Ok(json!({ + "results": [], + "suggested_integrations": suggest_hub_integrations(&integrations, &subject) + }) + .to_string()); + } + + // Lowercased because a hub slug is case-sensitive and both casings can exist. + let documented: HashSet<String> = integrations + .iter() + .filter(|i| i.get("documented") == Some(&Value::Bool(true))) + .filter_map(|i| Some(i.get("name")?.as_str()?.to_lowercase())) + .collect(); + let results: Vec<Value> = scripts + .iter() + .map(|script| { + let mut result = Map::new(); + result.insert("path".to_string(), hub_script_path(script).into()); + copy_present(&mut result, script, &["summary"]); + let app = script.get("app").cloned().unwrap_or_default(); + let is_documented = + documented.contains(&app.as_str().unwrap_or_default().to_lowercase()); + result.insert("integration".to_string(), app); + if is_documented { + result.insert("documented".to_string(), true.into()); + } + copy_truthy(&mut result, script, &["description"]); + Value::Object(result) + }) + .collect(); + Ok(json!({ "results": results }).to_string()) +} + +/// How the authored notes were checked is provenance for a human reviewing the hub; +/// the verdict and the fields it flags as shaky are what a caller can act on. +fn without_provenance(meta: &Value) -> Value { + let Value::Object(meta) = meta else { + return meta.clone(); + }; + let mut rest = meta.clone(); + let validation = rest.shift_remove("validation"); + let kept: Map<String, Value> = validation + .as_ref() + .and_then(Value::as_object) + .into_iter() + .flatten() + .filter(|(key, _)| *key == "status" || *key == "low_confidence_fields") + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + if !kept.is_empty() { + rest.insert("validation".to_string(), Value::Object(kept)); + } + Value::Object(rest) +} + +async fn get_hub_integration(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let integration = str_arg(args, "integration")?; + let doc = match ctx + .get(&format!("/integrations/hub/{}/meta", enc(integration))) + .await + { + Ok(doc) => doc, + Err(e) => { + // Only a 404 means the integration is absent. Reporting a timeout the same + // way would teach the model that a real integration does not exist. + let error = if is_not_found(&e) { + format!("No hub metadata for \"{integration}\".") + } else { + format!("Could not reach the hub for \"{integration}\"; it may still exist. Read its scripts instead, or try again.") + }; + let integrations = hub_integrations(ctx).await; + return Ok(json!({ + "error": error, + "suggested_integrations": suggest_hub_integrations(&integrations, integration) + }) + .to_string()); + } + }; + + // The hub owns this response's shape and can be older than this server, so every + // section is optional: a hub that sends less returns less. + let mut out = Map::new(); + if let Some(app) = doc.get("app") { + out.insert("integration".to_string(), app.clone()); + } + copy_present(&mut out, &doc, &["display_name"]); + copy_truthy(&mut out, &doc, &["description", "docs_url"]); + if truthy(doc.get("meta")) { + out.insert( + "verified_provider_notes".to_string(), + without_provenance(&doc["meta"]), + ); + } + let derived = doc.get("derived").filter(|d| truthy(Some(d))); + if let Some(derived) = derived { + let mut observed = Map::new(); + let hosts: Vec<Value> = array(derived.get("api_hosts")) + .iter() + .filter_map(|h| h.get("host").cloned()) + .collect(); + observed.insert("api_hosts".to_string(), hosts.into()); + copy_present(&mut observed, derived, &["style"]); + let languages: Vec<&String> = derived + .get("languages") + .and_then(Value::as_object) + .map(|l| l.keys().collect()) + .unwrap_or_default(); + observed.insert("languages".to_string(), json!(languages)); + copy_present(&mut observed, derived, &["script_counts"]); + out.insert("observed_from_scripts".to_string(), observed.into()); + } + if doc.get("curated") == Some(&Value::Bool(true)) { + out.insert( + "scripts_note".to_string(), + "These scripts were pruned to idiomatic actions rather than generated one per API endpoint, so they are worth following as style examples.".into(), + ); + } + let resource_types: Vec<Value> = array(doc.get("resource_types")) + .iter() + .map(|rt| { + let mut entry = Map::new(); + copy_present(&mut entry, rt, &["name"]); + copy_truthy(&mut entry, rt, &["description"]); + // The hub keeps a schema in a text column and hands it back as a JSON string. + match rt.get("schema") { + Some(Value::String(schema)) => { + let parsed = serde_json::from_str(schema) + .unwrap_or_else(|_| Value::String(schema.clone())); + entry.insert("schema".to_string(), parsed); + } + Some(schema) => { + entry.insert("schema".to_string(), schema.clone()); + } + None => {} + } + Value::Object(entry) + }) + .collect(); + out.insert("resource_types".to_string(), resource_types.into()); + let examples: Vec<Value> = array(derived.and_then(|d| d.get("top_scripts"))) + .iter() + .take(MAX_INTEGRATION_EXAMPLES) + .map(|script| { + let mut entry = Map::new(); + copy_present(&mut entry, script, &["path", "summary"]); + copy_truthy(&mut entry, script, &["description", "language"]); + Value::Object(entry) + }) + .collect(); + out.insert("example_scripts".to_string(), examples.into()); + Ok(Value::Object(out).to_string()) +} + +// --------------------------------------------------------------------------- +// npm +// --------------------------------------------------------------------------- + +const NPM_SCORE_THRESHOLD: f64 = 1000.0; +/// The session caps a package's readme and types at 1% of its model's context window +/// each. The caller's model is unknown here, so this is 1% of the window the session +/// assumes for an unknown model (128000). +const NPM_TEXT_LIMIT: usize = 1280; + +fn take_chars(text: &str, limit: usize) -> String { + text.chars().take(limit).collect() +} + +async fn fetch(url: &str) -> Result<reqwest::Response, reqwest::Error> { + HTTP_CLIENT.get(url).send().await +} + +/// The package's declared entry declaration file. The session walks every `.d.ts` of the +/// package in the browser and keeps the same number of characters of the result. +async fn npm_package_types(name: &str, version: &str) -> Option<String> { + let base = format!("https://unpkg.com/{name}@{version}"); + let manifest: Value = fetch(&format!("{base}/package.json")) + .await + .ok()? + .error_for_status() + .ok()? + .json() + .await + .ok()?; + let entry = ["types", "typings"] + .iter() + .find_map(|key| manifest.get(key)?.as_str()) + .unwrap_or("index.d.ts") + .trim_start_matches("./"); + let types = fetch(&format!("{base}/{entry}")) + .await + .ok()? + .error_for_status() + .ok()? + .text() + .await + .ok()?; + Some(format!("// /node_modules/{name}/{entry}\n{types}")) +} + +async fn npm_search(query: &str) -> Result<Vec<Value>, String> { + let data: Value = fetch(&format!( + "https://registry.npmjs.org/-/v1/search?text={}&size=2", + enc(query) + )) + .await + .map_err(|e| e.to_string())? + .json() + .await + .map_err(|e| e.to_string())?; + let packages = data + .get("objects") + .and_then(Value::as_array) + .ok_or("no `objects` in the registry answer")? + .iter() + .filter(|o| { + o.get("searchScore").and_then(Value::as_f64).unwrap_or(0.0) >= NPM_SCORE_THRESHOLD + }) + .filter_map(|o| { + let package = o.get("package")?; + Some(( + package.get("name")?.as_str()?, + package.get("version").and_then(Value::as_str), + )) + }); + Ok(join_all(packages.map(|(name, version)| async move { + let documentation = match fetch(&format!("https://unpkg.com/{name}/readme.md")).await { + Ok(response) => response.text().await.unwrap_or_default(), + Err(_) => String::new(), + }; + let types = npm_package_types(name, version.unwrap_or("latest")) + .await + .unwrap_or_default(); + json!({ + "package": name, + "documentation": take_chars(&documentation, NPM_TEXT_LIMIT), + "types": take_chars(&types, NPM_TEXT_LIMIT), + }) + })) + .await) +} + +async fn search_npm_packages(args: &Value) -> ToolResult { + let query = str_arg(args, "query")?; + Ok(match npm_search(query).await { + Ok(results) => Value::Array(results).to_string(), + Err(e) => { + tracing::warn!("npm package search failed: {e}"); + "Error searching external integration resources".to_string() + } + }) +} + +// --------------------------------------------------------------------------- +// Docs +// --------------------------------------------------------------------------- + +/// `text` of a `/docs/*` answer, or the session's wording for a failed lookup. +fn docs_text(result: Result<Value, String>, action: &str) -> String { + match result { + Ok(response) => response + .get("text") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + Err(e) => format!("Failed to {action}: {e}, pursuing with the user request..."), + } +} + +async fn search_docs(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let query = opt_str_arg(args, "query").unwrap_or_default().trim(); + if query.is_empty() { + return Ok( + "No search query was provided. Provide a `query` of one or more keywords.".to_string(), + ); + } + let result = ctx.get(&format!("/docs/search?query={}", enc(query))).await; + Ok(docs_text(result, "search documentation")) +} + +async fn read_docs_page(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let url = opt_str_arg(args, "url").unwrap_or_default(); + if url.trim().is_empty() { + return Ok("No documentation page URL was provided. Provide a `url` — e.g. a `Source` URL returned by search_docs.".to_string()); + } + let section = opt_str_arg(args, "section") + .filter(|s| !s.trim().is_empty()) + .map(|s| format!("§ion={}", enc(s))) + .unwrap_or_default(); + let result = ctx + .get(&format!("/docs/page?url={}{section}", enc(url))) + .await; + Ok(docs_text(result, "read documentation page")) +} + +// --------------------------------------------------------------------------- +// Resource types +// --------------------------------------------------------------------------- + +/// The hub publishes hundreds of resource types and an instance seeds only a few of its +/// own, so fewer than this means the instance never synced with the hub. +const UNSYNCED_RESOURCE_TYPE_COUNT: usize = 20; + +const HUB_SYNC_INSTRUCTIONS: &str = "Tell the user: no resource of a missing type can be created until a superadmin syncs resource types from the hub, with \"Sync resource types with Hub\" in the Add resource dialog of the Resources page."; + +async fn search_resource_types(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let query = str_arg(args, "query")?; + let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(5); + let search_route = ctx.ws(&format!( + "embeddings/query_resource_types?text={}&limit={limit}", + enc(query) + )); + let names_route = ctx.ws("resources/type/listnames"); + let (results, names) = + futures::future::join(ctx.get(&search_route), ctx.get(&names_route)).await; + let results = results?; + let results = array(Some(&results)); + let count = names + .map(|names| { + array(Some(&names)) + .iter() + .filter_map(Value::as_str) + .collect::<HashSet<_>>() + .len() + }) + .unwrap_or(0); + let listing: Vec<Value> = results + .iter() + .map(|rt| { + let mut entry = Map::new(); + copy_present(&mut entry, rt, &["name", "schema"]); + Value::Object(entry) + }) + .collect(); + let listing = pretty(&Value::Array(listing)); + // The similarity search still returns the nearest of the few types there are, so the + // note cannot wait for an empty result; it replaces the "no dedicated type" one, which + // would have the model create a duplicate of a type a sync brings. + if count > 0 && count < UNSYNCED_RESOURCE_TYPE_COUNT { + return Ok(format!("{listing}\n\nOnly {count} resource types exist on this instance, so it has most likely never synced with the Windmill Hub, which has types for most services. {HUB_SYNC_INSTRUCTIONS}")); + } + // The search is semantic and always returns its closest types, so a miss reads + // like a hit unless the absence is spelled out. + let words: Vec<String> = words(query).filter(|w| w.len() >= 3).collect(); + let named = results.iter().any(|rt| { + let name = rt + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_lowercase(); + words.iter().any(|w| name.contains(w.as_str())) + }); + Ok(if named || words.is_empty() { + listing + } else { + format!("{listing}\n\nNo resource type name contains \"{query}\": these are only the nearest matches, so no dedicated type exists. Searching again with synonyms will not find one — use a fitting generic type above, or create a custom c_<name> type.") + }) +} + +/// The prefix the Add resource type form adds: a hub type of the same name, synced later, +/// would otherwise clash with this one. +fn custom_resource_type_name(name: &str) -> String { + if name.starts_with("c_") { + name.to_string() + } else { + format!("c_{name}") + } +} + +async fn create_resource_type(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let name = custom_resource_type_name(str_arg(args, "name")?); + let description = str_arg(args, "description")?; + let schema = args + .get("schema") + .filter(|s| s.is_object()) + .ok_or("Missing required object argument `schema`")?; + ctx.post( + &ctx.ws("resources/type/create"), + json!({ "name": name, "description": description, "schema": schema }), + ) + .await?; + Ok(json!({ + "success": true, + "message": format!("Created resource type {name}. Refer to it by this name.") + }) + .to_string()) +} + +// --------------------------------------------------------------------------- +// Workers +// --------------------------------------------------------------------------- + +const WORKER_PAGE_SIZE: usize = 100; +// `GET /workers/list` hides workers from a caller without the devops role by returning +// an empty list, not an error, when HIDE_WORKERS_FOR_NON_ADMINS is set. The flag is not +// visible here, so absence is only provable for a devops or superadmin caller. +const NO_WORKERS_VISIBLE_MESSAGE: &str = "No workers came back. This does NOT establish that no workers are running: an instance can hide workers from callers without the devops role, and it does so by returning an empty list rather than an error. Tell the user you cannot see any workers and that worker visibility may be restricted for your account, and suggest they check the Workers page themselves if they can open it, or ask a workspace admin otherwise. Never state that no workers are online or that the instance has none."; +const NO_WORKERS_CONNECTED_MESSAGE: &str = "No workers are connected to this instance (none pinged in the last 5 minutes). Queued runs will stay queued until a worker starts."; + +async fn list_workers(ctx: &Ctx<'_>) -> ToolResult { + let pings = ctx + .get(&format!("/workers/list?per_page={WORKER_PAGE_SIZE}")) + .await?; + let pings = array(Some(&pings)); + if pings.is_empty() { + let me = ctx.get("/users/whoami").await.unwrap_or_default(); + let sees_all = truthy(me.get("super_admin")) || truthy(me.get("devops")); + return Ok(if sees_all { + NO_WORKERS_CONNECTED_MESSAGE + } else { + NO_WORKERS_VISIBLE_MESSAGE + } + .to_string()); + } + let workers: Vec<Value> = pings + .iter() + .map(|ping| { + let mut worker = Map::new(); + copy_present( + &mut worker, + ping, + &[ + "worker", + "worker_group", + "custom_tags", + "last_ping", + "jobs_executed", + ], + ); + Value::Object(worker) + }) + .collect(); + let mut out = Map::new(); + let listed = workers.len(); + out.insert("workers".to_string(), workers.into()); + if listed == WORKER_PAGE_SIZE { + out.insert( + "note".to_string(), + format!("Only the first {WORKER_PAGE_SIZE} workers are listed; more may be connected.") + .into(), + ); + } + Ok(pretty(&Value::Object(out))) +} + +// --------------------------------------------------------------------------- +// Folders +// --------------------------------------------------------------------------- + +async fn create_folder(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let name = str_arg(args, "name")?; + // Same rule as the API's own check, so an invalid name gets this wording. + if name.is_empty() + || !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Ok(json!({ + "success": false, + "error": "Folder name can only contain alphanumeric characters, underscores, and hyphens." + }) + .to_string()); + } + let mut body = Map::new(); + body.insert("name".to_string(), name.into()); + copy_present(&mut body, args, &["summary"]); + Ok( + match ctx.post(&ctx.ws("folders/create"), body.into()).await { + Ok(_) => json!({ + "success": true, + "message": format!("Created folder `f/{name}`. You can now write items to `f/{name}/<name>`.") + }), + Err(error) => json!({ "success": false, "error": error }), + } + .to_string(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn suggestions_rank_by_matched_words() { + let integrations = vec![ + json!({ "name": "gcal", "display_name": "Google Calendar" }), + json!({ "name": "gdrive", "display_name": "Google Drive" }), + json!({ "name": "salesforce", "display_name": null }), + json!({ "name": "s3", "display_name": "S3" }), + ]; + assert_eq!( + suggest_hub_integrations(&integrations, "google drive"), + ["gdrive", "gcal"] + ); + // A short token must equal a word; a long one may extend it. + assert!(suggest_hub_integrations(&integrations, "for").is_empty()); + assert_eq!( + suggest_hub_integrations(&integrations, "sales"), + ["salesforce"] + ); + assert_eq!(suggest_hub_integrations(&integrations, "s3 bucket"), ["s3"]); + } + + #[test] + fn provenance_keeps_only_the_verdict() { + let meta = json!({ + "pagination": "cursor", + "validation": { "status": "verified", "method": "live", "low_confidence_fields": ["a"] } + }); + assert_eq!( + without_provenance(&meta), + json!({ + "pagination": "cursor", + "validation": { "status": "verified", "low_confidence_fields": ["a"] } + }) + ); + assert_eq!( + without_provenance(&json!({ "a": 1, "validation": { "method": "live" } })), + json!({ "a": 1 }) + ); + } + + #[test] + fn custom_resource_types_carry_the_c_prefix_once() { + assert_eq!(custom_resource_type_name("acme"), "c_acme"); + assert_eq!(custom_resource_type_name("c_acme"), "c_acme"); + } + + #[test] + fn hub_path_from_summary() { + let script = json!({ "version_id": 28479, "app": "stripe", "summary": "Cancel Or Reverse a Payout" }); + assert_eq!( + hub_script_path(&script), + "hub/28479/stripe/cancel_or_reverse_a_payout" + ); + } +} diff --git a/backend/windmill-api/src/mcp/session/mod.rs b/backend/windmill-api/src/mcp/session/mod.rs new file mode 100644 index 0000000000..607c6be5f8 --- /dev/null +++ b/backend/windmill-api/src/mcp/session/mod.rs @@ -0,0 +1,609 @@ +//! The Windmill MCP server's toolset (`/api/mcp`, tokens with `mcp:all`): an AI session's toolset, for a client +//! outside the Windmill UI. +//! +//! Everything a model reads here — tool names, descriptions, input schemas, the +//! instructions, the `get_instructions` texts — is generated from the session's own +//! definitions into `gen/` (`npm run gen:mcp-session` in `frontend/`). What a tool +//! *does* is written here, mirroring its session counterpart in +//! `frontend/src/lib/components/copilot/chat/global/core.ts`: a change to one side's +//! behaviour belongs on the other. +//! +//! A tool acts through the API as the caller, the way the session's does, so every +//! permission check is the API's own. It calls the server's router in-process rather +//! than over a socket. + +use std::collections::HashMap; +use std::sync::LazyLock; + +use serde::Deserialize; +use serde_json::{Map, Value}; +use windmill_mcp::server::{ + BackendResult, CallToolResult, ContentBlock, ErrorData, MetaObject, SessionAccess, Tool, + ToolAnnotations, +}; + +use crate::db::ApiAuthed; + +use super::utils::{mint_caller_jwt, send_as_caller}; + +/// Each running server's fully layered router, by its `base_internal_url`. Keyed rather +/// than global because the backend tests run one server per test in a single process, +/// each on its own database: a tool must reach the server it was called on. +static IN_PROCESS_ROUTERS: LazyLock<std::sync::RwLock<HashMap<String, axum::Router>>> = + LazyLock::new(Default::default); + +/// Let session tools of the server at `base_internal_url` call its API in-process. +/// Without a registration they fall back to HTTP. +pub(crate) fn register_router(base_internal_url: &str, router: Option<axum::Router>) { + let mut routers = IN_PROCESS_ROUTERS.write().unwrap(); + match router { + Some(router) => routers.insert(base_internal_url.to_string(), router), + None => routers.remove(base_internal_url), + }; +} + +mod apps; +mod data; +mod deploy; +mod flows; +mod guidance; +mod instructions; +mod items; +mod misc; +mod runs; +mod settings; +mod workspace; + +pub(super) use instructions::{ + generic_session_instructions, session_instructions, workspace_context, +}; + +#[derive(Deserialize)] +struct ToolDef { + name: String, + description: String, + parameters: Map<String, Value>, + read_only: bool, +} + +static TOOL_DEFS: LazyLock<Vec<ToolDef>> = LazyLock::new(|| { + serde_json::from_str(include_str!("gen/tools.json")).expect("gen/tools.json is valid") +}); + +/// A tool's input schema, as generated from the session's zod schema. +pub(super) fn tool_parameters(name: &str) -> Option<&'static Map<String, Value>> { + TOOL_DEFS + .iter() + .find(|def| def.name == name) + .map(|def| &def.parameters) +} + +/// The JSON Schema text of each trigger kind's config, and of a schedule's `advanced` bag, +/// as the session renders them from its request schemas. +#[derive(Deserialize)] +pub(super) struct Schemas { + pub trigger: HashMap<String, String>, + pub schedule: String, +} + +pub(super) static SCHEMAS: LazyLock<Schemas> = LazyLock::new(|| { + serde_json::from_str(include_str!("gen/schemas.json")).expect("gen/schemas.json is valid") +}); + +/// Session tools that need the Windmill UI and have no meaning for an external client. +/// Every generated tool is either here or implemented by a group below. +#[cfg(test)] +const EDITOR_ONLY: &[&str] = &[ + // The side panel, the question card and the browser-held user instructions. + "open_page", + "askUserQuestion", + "update_user_instructions", + // Files attached to the chat. + "search_files", + "read_file", +]; + +/// Tools whose effect reaches the deployed workspace and cannot be undone by discarding +/// a draft. Stands in for the session's confirmation card. +const DESTRUCTIVE: &[&str] = &[ + "deploy_workspace_item", + "delete_workspace_item", + "create_resource_type", + "set_workspace_settings", +]; + +/// Tools the session counts as reads that still start a job. A read-only token runs +/// nothing on the tools server, so it runs nothing here either. +const RUNS_A_JOB: &[&str] = &["get_db_schema"]; + +/// Tools that run a deployed script or flow, the one write a read-and-run token keeps. +const RUNS_DEPLOYED: &[&str] = &["run_script", "run_flow"]; + +fn allowed(def: &ToolDef, access: SessionAccess) -> bool { + let read = def.read_only && !RUNS_A_JOB.contains(&def.name.as_str()); + match access { + SessionAccess::Full => true, + SessionAccess::ReadAndRun => read || RUNS_DEPLOYED.contains(&def.name.as_str()), + SessionAccess::ReadOnly => read, + } +} + +/// Each group's tools and its dispatcher, tried in order. +macro_rules! groups { + ($($group:ident),*) => { + fn implemented() -> impl Iterator<Item = &'static str> { + std::iter::empty()$(.chain($group::TOOLS.iter().copied()))* + } + + async fn dispatch(ctx: &Ctx<'_>, name: &str, args: &Value) -> Option<ToolResult> { + $(if $group::TOOLS.contains(&name) { + return Some($group::call(ctx, name, args).await); + })* + None + } + }; +} +groups!(guidance, workspace, items, misc, flows, apps, runs, deploy, data, settings); + +/// Claude clients keep an MCP result in context only up to a token budget well under the +/// authoring references `get_instructions` returns, unless the tool raises it. Above the +/// runner's own cap on a result, so that cap is the one that applies. +fn result_size_meta() -> MetaObject { + let mut meta = Map::new(); + meta.insert("anthropic/maxResultSizeChars".to_string(), 100_000.into()); + MetaObject(meta) +} + +pub(super) fn session_tools(access: SessionAccess) -> Vec<Tool> { + TOOL_DEFS + .iter() + .filter(|def| implemented().any(|name| name == def.name)) + .filter(|def| allowed(def, access)) + .map(|def| { + Tool::new( + def.name.clone(), + def.description.clone(), + def.parameters.clone(), + ) + .with_annotations( + ToolAnnotations::new() + .read_only(def.read_only) + .destructive(DESTRUCTIVE.contains(&def.name.as_str())), + ) + .with_meta(result_size_meta()) + }) + .collect() +} + +/// What a tool hands back: the text the model reads, or the message of a failed call. +pub(super) type ToolResult = Result<String, String>; + +/// The caller, and the API reached as them. +pub(super) struct Ctx<'a> { + pub auth: &'a ApiAuthed, + pub w_id: &'a str, + pub base_internal_url: &'a str, + pub access: SessionAccess, + /// The scheme and host the MCP client reached, when known. + pub origin: Option<&'a str>, + /// Minted once per tool call: every JWT the API sees is cached by the auth layer, and a + /// tool can make dozens of calls, which would evict other users' cached sessions. + pub jwt: tokio::sync::OnceCell<String>, +} + +impl Ctx<'_> { + /// Call the API as the caller. `path` follows `/api`; a non-2xx answer is the error. + pub async fn request(&self, method: &str, path: &str, body: Option<Value>) -> ToolResult { + let url = guarded_url(self.base_internal_url, self.w_id, method, path, self.access)?; + // No route scope: this server only admits `mcp:all`, several routes (hub, embeddings) + // map to none, and `guarded_url` is what keeps a call on its route. The tag fence + // is not a route scope and stays: it limits which jobs the caller runs and reads. + let tag_scopes: Vec<String> = self + .auth + .scopes + .iter() + .flatten() + .filter(|s| s.starts_with("if_jobs:filter_tags:")) + .cloned() + .collect(); + let scopes = (!tag_scopes.is_empty()).then_some(tag_scopes); + let router = IN_PROCESS_ROUTERS + .read() + .unwrap() + .get(self.base_internal_url) + .cloned(); + let (status, text) = match router { + Some(router) => self.call_router(router, method, &url, body, scopes).await?, + None => { + let response = send_as_caller(method, &url, self.w_id, self.auth, body, scopes) + .await + .map_err(|e| e.message.to_string())?; + let status = response.status(); + (status, response.text().await.map_err(|e| e.to_string())?) + } + }; + if status.is_success() { + Ok(text) + } else { + Err(format!("{}: {}", status.as_u16(), text)) + } + } + + /// `request` through the server's own router: the same layers and handlers as over + /// HTTP, without a socket. + async fn call_router( + &self, + router: axum::Router, + method: &str, + url: &str, + body: Option<Value>, + scopes: Option<Vec<String>>, + ) -> Result<(http::StatusCode, String), String> { + use tower::ServiceExt; + let parsed = reqwest::Url::parse(url).map_err(|e| e.to_string())?; + // The router does not see the base URL's own path prefix. + let at = reqwest::Url::parse(self.base_internal_url) + .map_or(0, |base| base.path().trim_end_matches('/').len()); + let uri = match parsed.query() { + Some(q) => format!("{}?{q}", &parsed.path()[at..]), + None => parsed.path()[at..].to_string(), + }; + let token = self + .jwt + .get_or_try_init(|| mint_caller_jwt(self.w_id, self.auth, scopes)) + .await + .map_err(|e| e.message.to_string())?; + let mut request = http::Request::builder() + .method(method) + .uri(uri) + .header(http::header::AUTHORIZATION, format!("Bearer {token}")); + let body = match body { + Some(body) => { + request = request.header(http::header::CONTENT_TYPE, "application/json"); + axum::body::Body::from(serde_json::to_vec(&body).map_err(|e| e.to_string())?) + } + None => axum::body::Body::empty(), + }; + let request = request.body(body).map_err(|e| e.to_string())?; + // The total timeout of the HTTP client this replaces. + tokio::time::timeout(std::time::Duration::from_secs(30), async { + let response = router.oneshot(request).await.map_err(|e| e.to_string())?; + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .map_err(|e| e.to_string())?; + Ok((status, String::from_utf8_lossy(&bytes).into_owned())) + }) + .await + .map_err(|_| format!("Failed to execute request: {method} {url} timed out"))? + } + + pub async fn get(&self, path: &str) -> Result<Value, String> { + let text = self.request("GET", path, None).await?; + serde_json::from_str(&text).map_err(|e| format!("unexpected response from {path}: {e}")) + } + + /// `POST` a JSON body. Several endpoints answer with plain text (a path, a job id): + /// that comes back as a JSON string. + pub async fn post(&self, path: &str, body: Value) -> Result<Value, String> { + let text = self.request("POST", path, Some(body)).await?; + Ok(serde_json::from_str(&text).unwrap_or(Value::String(text))) + } + + pub async fn delete(&self, path: &str) -> Result<Value, String> { + let text = self.request("DELETE", path, None).await?; + Ok(serde_json::from_str(&text).unwrap_or(Value::String(text))) + } + + /// A workspace-scoped path: `ws("scripts/list")` is `/w/<workspace>/scripts/list`. + pub fn ws(&self, rest: &str) -> String { + format!("/w/{}/{}", self.w_id, rest) + } + + /// A Windmill page in this workspace, as a link the user can open: an external client + /// has no preview panel, so a link is how it shows what a tool made or ran. + pub fn page_url(&self, page: &str) -> String { + let (page, hash) = page + .split_once('#') + .map_or((page, None), |(p, h)| (p, Some(h))); + let sep = if page.contains('?') { '&' } else { '?' }; + let hash = hash.map(|h| format!("#{h}")).unwrap_or_default(); + // An instance that never set its base URL holds this placeholder, which no client + // can open; the host the client reached is the better guess then. + let base = windmill_common::BASE_URL.load(); + let base = match self.origin { + Some(origin) if base.as_str() == "http://localhost" => origin, + _ => base.as_str(), + }; + format!("{base}{page}{sep}workspace={}{hash}", enc(self.w_id)) + } +} + +/// Routes outside the workspace that a tool reads. +const INSTANCE_ROUTES: &[&str] = &[ + "/docs/", + "/embeddings/", + "/integrations/hub/", + "/scripts/hub/", + "/users/whoami", + "/workers/list", + "/workspaces/users", +]; + +/// The URL of an API call, refused unless it lands on the route its handler wrote. +/// +/// The JWT behind a call carries the caller's full rights, and paths embed arguments a +/// model chose: a `..` segment, or an id carrying `#`, would otherwise turn a read of +/// one job into a run of any script. A read-only or read-and-run token is held to its rule +/// here too, since that JWT carries neither. +fn guarded_url( + base: &str, + w_id: &str, + method: &str, + path: &str, + access: SessionAccess, +) -> Result<String, String> { + let refused = || format!("Refusing to call `{path}`: not a route of this tool"); + let route = path.split('?').next().unwrap_or_default(); + let in_workspace = route + .strip_prefix("/w/") + .and_then(|rest| rest.strip_prefix(w_id)) + .is_some_and(|rest| rest.starts_with('/')); + let workspace_is_plain = w_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); + if path.contains('#') + || !workspace_is_plain + || !(in_workspace || INSTANCE_ROUTES.iter().any(|r| route.starts_with(r))) + { + return Err(refused()); + } + let url = format!("{base}/api{path}"); + let parsed = reqwest::Url::parse(&url).map_err(|_| refused())?; + // Parsing resolves dot segments, encoded or not: a path that changed went elsewhere. + if !parsed.path().ends_with(&format!("/api{route}")) { + return Err(refused()); + } + // Runs of deployed items by path only: the preview routes take code, so they are writes. + // A run also stores its secret arguments as expiring variables; the write tools that + // could create other variables are not offered to a read-and-run token. + let run_writes = method == "POST" + && ["jobs/run/p/", "jobs/run/f/"] + .iter() + .any(|run| route.starts_with(&format!("/w/{w_id}/{run}"))) + || method == "POST" && route == format!("/w/{w_id}/variables/create"); + let held_to_reads = match access { + SessionAccess::Full => false, + SessionAccess::ReadAndRun => !run_writes, + SessionAccess::ReadOnly => true, + }; + if held_to_reads { + windmill_api_auth::scopes::check_read_only_for_route(parsed.path(), method) + .map_err(|e| e.to_string())?; + } + Ok(url) +} + +/// A string argument, or the error the model gets for leaving it out. +pub(super) fn str_arg<'a>(args: &'a Value, name: &str) -> Result<&'a str, String> { + args.get(name) + .and_then(Value::as_str) + .ok_or_else(|| format!("Missing required string argument `{name}`")) +} + +pub(super) fn opt_str_arg<'a>(args: &'a Value, name: &str) -> Option<&'a str> { + args.get(name).and_then(Value::as_str) +} + +/// `JSON.stringify(value, null, 2)`, the form the session's tools return objects in. +pub(super) fn pretty(value: &Value) -> String { + serde_json::to_string_pretty(value).unwrap_or_default() +} + +/// Percent-encode one query-string value. +pub(super) fn enc(value: &str) -> String { + urlencoding::encode(value).into_owned() +} + +pub(super) fn bool_arg(args: &Value, name: &str) -> bool { + args.get(name).and_then(Value::as_bool).unwrap_or(false) +} + +/// Whether a `Ctx` error is the API answering 404. +pub(super) fn is_not_found(error: &str) -> bool { + error.starts_with("404: ") +} + +/// JavaScript truthiness, for the fields the session keeps only `if (value)`. +pub(super) fn truthy(value: Option<&Value>) -> bool { + match value { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(Value::Number(n)) => n.as_f64().is_some_and(|n| n != 0.0), + Some(Value::String(s)) => !s.is_empty(), + Some(_) => true, + } +} + +/// `value ?? undefined`: the value unless it is missing or null. +pub(super) fn non_null(value: Option<&Value>) -> Option<&Value> { + value.filter(|v| !v.is_null()) +} + +/// Set `key` unless the value is JS `undefined`, which `JSON.stringify` drops. +pub(super) fn put(map: &mut Map<String, Value>, key: &str, value: Option<Value>) { + if let Some(value) = value { + map.insert(key.to_string(), value); + } +} + +/// The elements of an array; none for anything else. +pub(super) fn array(value: Option<&Value>) -> &[Value] { + value + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() +} + +/// A value as a JS template literal prints it. +pub(super) fn js_str(value: &Value) -> String { + match value { + Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +/// `String.length`: UTF-16 code units. +pub(super) fn js_len(text: &str) -> usize { + text.encode_utf16().count() +} + +/// `text.slice(0, max)` in UTF-16 units, never splitting a character. +pub(super) fn js_head(text: &str, max: usize) -> &str { + if text.len() <= max { + return text; + } + let mut units = 0; + for (i, c) in text.char_indices() { + units += c.len_utf16(); + if units > max { + return &text[..i]; + } + } + text +} + +/// `text.slice(-max)` in UTF-16 units, never splitting a character. +pub(super) fn js_tail(text: &str, max: usize) -> &str { + if text.len() <= max { + return text; + } + let mut units = 0; + for (i, c) in text.char_indices().rev() { + units += c.len_utf16(); + if units > max { + return &text[i + c.len_utf8()..]; + } + } + text +} + +/// `*` any characters but `/`, `**` any characters, `?` one character but `/`. A pattern +/// without `/` is matched against the file name alone, so `*.tsx` finds nested files. +pub(super) fn matches_glob(subject: &str, glob: &str) -> bool { + let subject = if glob.contains('/') { + subject + } else { + subject.rsplit('/').next().unwrap_or(subject) + }; + let mut pattern = String::from("^"); + let mut chars = glob.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '*' if chars.peek() == Some(&'*') => { + chars.next(); + pattern.push_str(".*"); + } + '*' => pattern.push_str("[^/]*"), + '?' => pattern.push_str("[^/]"), + c => pattern.push_str(®ex::escape(c.encode_utf8(&mut [0; 4]))), + } + } + pattern.push('$'); + regex::Regex::new(&pattern).is_ok_and(|re| re.is_match(subject)) +} + +pub(super) async fn call_session_tool( + ctx: &Ctx<'_>, + name: &str, + args: Value, +) -> BackendResult<CallToolResult> { + let Some(def) = TOOL_DEFS.iter().find(|def| def.name == name) else { + return Err(ErrorData::invalid_params( + format!("Unknown tool '{name}'"), + None, + )); + }; + if !allowed(def, ctx.access) { + let token = match ctx.access { + SessionAccess::ReadAndRun => "can only read and run deployed scripts and flows", + _ => "is read-only", + }; + return Err(ErrorData::invalid_request( + format!("Tool '{name}' changes or runs something, and this token {token}"), + None, + )); + } + let Some(result) = dispatch(ctx, name, &args).await else { + return Err(ErrorData::invalid_params( + format!("Tool '{name}' is not available on this server"), + None, + )); + }; + windmill_common::feature_usage::log_feature_usage("mcp_workspace", "tool_call", name); + Ok(match result { + Ok(text) => CallToolResult::success(vec![ContentBlock::text(text)]), + // Without the status code `Ctx` prefixes an API failure with, as the session reads it. + Err(message) => { + let body = workspace::api_message(&message); + let text = if body.is_empty() { &message } else { body }; + CallToolResult::error(vec![ContentBlock::text(text.to_string())]) + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_call_stays_on_the_route_its_handler_wrote() { + use SessionAccess::*; + let url = |method, path, access| guarded_url("http://h", "ws", method, path, access); + assert!(url("GET", "/w/ws/jobs_u/get/0191?no_logs=true", Full).is_ok()); + assert!(url("GET", "/docs/search?query=a%2Fb", ReadOnly).is_ok()); + for path in [ + "/w/ws/jobs_u/get/../../jobs/run_wait_result/p/u/a/b", + "/w/ws/jobs_u/get/%2e%2e/%2E%2E/variables/get_value/u/a/b", + "/w/ws/jobs_u/get/x#", + "/w/other/scripts/list", + "/w/ws", + "/settings/global/x", + "/docs/../users/tokens/list", + ] { + assert!(url("GET", path, Full).is_err(), "{path} should be refused"); + } + assert!(guarded_url("http://h", "a/../b", "GET", "/w/a/../b/scripts/list", Full).is_err()); + // A read-only token neither writes nor runs, whatever the tool. + assert!(url("POST", "/w/ws/drafts/update", ReadOnly).is_err()); + assert!(url("GET", "/w/ws/jobs/run_wait_result/p/u/a/b", ReadOnly).is_err()); + assert!(url("POST", "/w/ws/jobs/run/p/u/a/b", ReadOnly).is_err()); + // A read-and-run token runs deployed items by path, and writes nothing else: a + // preview takes code. + assert!(url("POST", "/w/ws/jobs/run/p/u/a/b", ReadAndRun).is_ok()); + assert!(url("POST", "/w/ws/jobs/run/f/u/a/b", ReadAndRun).is_ok()); + assert!(url("POST", "/w/ws/jobs/run/preview", ReadAndRun).is_err()); + assert!(url("POST", "/w/ws/drafts/update", ReadAndRun).is_err()); + assert!(url("POST", "/w/ws/variables/create", ReadAndRun).is_ok()); + assert!(url("POST", "/w/ws/variables/update/u/a/b", ReadAndRun).is_err()); + assert!(url("POST", "/w/ws/variables/create", ReadOnly).is_err()); + } + + #[test] + fn every_session_tool_is_implemented_or_editor_only() { + let implemented: Vec<&str> = implemented().collect(); + for def in TOOL_DEFS.iter() { + let name = def.name.as_str(); + assert!( + implemented.contains(&name) != EDITOR_ONLY.contains(&name), + "session tool `{name}` must be implemented here or listed in EDITOR_ONLY, not both" + ); + } + for name in implemented.iter().chain(EDITOR_ONLY) { + assert!( + TOOL_DEFS.iter().any(|def| def.name == *name), + "`{name}` is not a session tool any more" + ); + } + } +} diff --git a/backend/windmill-api/src/mcp/session/runs.rs b/backend/windmill-api/src/mcp/session/runs.rs new file mode 100644 index 0000000000..10f1c7e224 --- /dev/null +++ b/backend/windmill-api/src/mcp/session/runs.rs @@ -0,0 +1,2009 @@ +//! Running scripts and flows, test runs on drafts, and reading runs back. + +use std::collections::HashMap; +use std::time::Duration; + +use serde::Serialize; +use serde_json::{json, Map, Value}; + +use super::workspace::{ + api_message, get_draft, infer_script_schema, load_flow, load_script, path_enc, +}; +use super::{enc, js_head, js_len, js_tail, opt_str_arg, pretty, str_arg, truthy, Ctx, ToolResult}; + +pub(super) const TOOLS: &[&str] = &[ + "test_run_script", + "run_script", + "test_run_flow", + "run_flow", + "test_run_step", + "list_runs", + "get_run", + "cancel_job", +]; + +pub(super) async fn call(ctx: &Ctx<'_>, name: &str, args: &Value) -> ToolResult { + match name { + "test_run_script" => test_run_script(ctx, args).await, + "run_script" => run_deployed(ctx, args, false).await, + "test_run_flow" => test_run_flow(ctx, args).await, + "run_flow" => run_deployed(ctx, args, true).await, + "test_run_step" => test_run_step(ctx, args).await, + "list_runs" => list_runs(ctx, args).await, + "get_run" => get_run(ctx, args).await, + "cancel_job" => cancel_job(ctx, args).await, + _ => unreachable!("{name} is not a runs tool"), + } +} + +const MAX_RESULT_LENGTH: usize = 12000; +const MAX_LOG_LENGTH: usize = 4000; +const DEFAULT_WAIT_MS: f64 = 15000.0; +const MAX_WAIT_MS: f64 = 120000.0; +const MAX_SUBMITTED_ARGS_LENGTH: usize = 4000; + +/// `JSON.stringify(value, null, 1)`, the indent `get_run` renders with. +fn pretty1(value: &Value) -> String { + let mut out = Vec::new(); + let formatter = serde_json::ser::PrettyFormatter::with_indent(b" "); + let mut serializer = serde_json::Serializer::with_formatter(&mut out, formatter); + value + .serialize(&mut serializer) + .expect("a JSON value serializes"); + String::from_utf8(out).unwrap_or_default() +} + +// ============================================================================ +// Job helpers shared with the other groups. +// ============================================================================ + +fn job_id(response: Value) -> String { + match response { + Value::String(id) => id.trim().to_string(), + other => other.to_string(), + } +} + +/// Queue a script preview and return its job id. `path` files the run under an item. +pub(super) async fn run_script_preview( + ctx: &Ctx<'_>, + path: Option<&str>, + language: &str, + content: &str, + args: Value, +) -> Result<String, String> { + let mut body = Map::new(); + if let Some(path) = path { + body.insert("path".into(), path.into()); + } + body.insert("content".into(), content.into()); + body.insert("language".into(), language.into()); + body.insert("args".into(), args); + ctx.post(&ctx.ws("jobs/run/preview"), Value::Object(body)) + .await + .map(job_id) +} + +/// Queue a flow preview of `value` and return its job id. `memory_id` names the +/// conversation a chat-enabled flow's run belongs to. +pub(super) async fn run_flow_preview( + ctx: &Ctx<'_>, + path: Option<&str>, + value: &Value, + args: Value, + memory_id: Option<&str>, +) -> Result<String, String> { + let mut body = Map::new(); + if let Some(path) = path { + body.insert("path".into(), path.into()); + } + body.insert("value".into(), value.clone()); + body.insert("args".into(), args); + let query = memory_id + .map(|id| format!("?memory_id={}", enc(id))) + .unwrap_or_default(); + ctx.post( + &ctx.ws(&format!("jobs/run/preview_flow{query}")), + Value::Object(body), + ) + .await + .map(job_id) +} + +/// How many seconds a run tool waits before leaving the job in the background, from +/// its `background` and `wait_seconds` arguments: 15 by default, 120 at most. +pub(super) fn wait_budget(tool_args: &Value) -> u64 { + if tool_args.get("background").and_then(Value::as_bool) == Some(true) { + return 0; + } + let ms = tool_args + .get("wait_seconds") + .and_then(Value::as_f64) + .map_or(DEFAULT_WAIT_MS, |seconds| seconds.max(0.0) * 1000.0) + .min(MAX_WAIT_MS); + (ms / 1000.0).ceil() as u64 +} + +/// Poll a job once a second for up to `seconds`. The completed job, fetched once more +/// with its logs when `logs` is set, or `None` when it is still running by then. A +/// failed poll is retried on the next tick. +pub(super) async fn wait_job(ctx: &Ctx<'_>, id: &str, seconds: u64, logs: bool) -> Option<Value> { + let path = |no_logs: bool| { + ctx.ws(&format!( + "jobs_u/get/{}?no_logs={no_logs}&no_code=true", + enc(id) + )) + }; + for _ in 0..seconds { + tokio::time::sleep(Duration::from_secs(1)).await; + if let Ok(job) = ctx.get(&path(true)).await { + if job["type"] == "CompletedJob" { + return Some(match logs { + true => ctx.get(&path(false)).await.unwrap_or(job), + false => job, + }); + } + } + } + None +} + +/// What the model reads for a job that outlived its wait. +pub(super) fn backgrounded_summary(id: &str, label: &str) -> String { + format!( + "Job {id} for \"{label}\" is taking a while and is now running in the background. \ + To inspect it, call get_run with id=\"{id}\" (or list_runs); \ + to stop it, call cancel_job with id=\"{id}\"." + ) +} + +/// A job result as text: a string as it is, anything else as indented JSON. +pub(super) fn format_result(result: Option<&Value>) -> String { + match result { + Some(Value::String(text)) => text.clone(), + other => pretty(other.unwrap_or(&Value::Null)), + } +} + +/// The last 4000 chars of a job's logs, or `None` when it logged nothing. +pub(super) fn format_logs(logs: Option<&str>) -> Option<&str> { + logs.filter(|logs| !logs.trim().is_empty()) + .map(|logs| js_tail(logs, MAX_LOG_LENGTH)) +} + +/// A completed job as the run tools report it: outcome, result head (12k chars) and +/// logs tail. +pub(super) fn format_result_summary(job: &Value) -> String { + let success = truthy(job.get("success")); + let result = format_result(job.get("result")); + format!( + "Result ({})\n\n{}\n\nLogs:\n\n{}", + if success { "SUCCESS" } else { "FAILED" }, + js_head(&result, MAX_RESULT_LENGTH), + format_logs(job.get("logs").and_then(Value::as_str)).unwrap_or("No logs available"), + ) +} + +/// Settle a just-queued job (`started` is the queueing call's answer) into a run +/// tool's result: wait within the tool's budget, then the result summary or the +/// background notice. `action` is `test` or `run`, `label` names the job in the notice. +pub(super) async fn finish_run( + ctx: &Ctx<'_>, + started: Result<String, String>, + label: &str, + is_flow: bool, + action: &str, + tool_args: &Value, +) -> ToolResult { + let id = started.map_err(|e| format!("Failed to execute {action}: {}", api_message(&e)))?; + let run_page = format!("\n\nRun page: {}", ctx.page_url(&format!("/run/{id}"))); + let Some(job) = wait_job(ctx, &id, wait_budget(tool_args), true).await else { + return Ok(format!("{}{run_page}", backgrounded_summary(&id, label))); + }; + let mut summary = format_result_summary(&job); + if is_flow && !truthy(job.get("success")) { + summary.push_str(&format!( + "\n\nFor per-step statuses and results (subflow steps included), call get_run with id=\"{id}\"." + )); + } + summary.push_str(&run_page); + Ok(summary) +} + +// ============================================================================ +// Arguments: conformed to the schema of what is about to run, secrets minted. +// ============================================================================ + +const REFERENCE_PREFIXES: [&str; 3] = ["$var:", "$res:", "$jsonvar:"]; + +fn is_reference(value: &Value) -> bool { + value.as_str().is_some_and(|s| { + REFERENCE_PREFIXES + .iter() + .any(|prefix| s.starts_with(prefix)) + }) +} + +fn is_secret_prop(prop: &Value) -> bool { + truthy(prop.get("password")) +} + +fn is_file_prop(prop: &Value) -> bool { + prop["contentEncoding"] == "base64" || prop["items"]["contentEncoding"] == "base64" +} + +/// Apply `visit` to every value whose declaration matches `is_leaf`, at any depth; +/// `None` removes it. Descends on the value's shape, through every `oneOf` branch. +fn map_leaves( + value: &Value, + prop: Option<&Value>, + is_leaf: fn(&Value) -> bool, + visit: &mut dyn FnMut(&Value, &Value, &[Value]) -> Option<Value>, + path: &mut Vec<Value>, +) -> Value { + let items = prop.and_then(|p| p.get("items")).filter(|v| !v.is_null()); + match value { + Value::Array(list) => Value::Array( + list.iter() + .enumerate() + .map(|(i, item)| { + path.push(json!(i)); + let mapped = map_leaves(item, items.or(prop), is_leaf, visit, path); + path.pop(); + mapped + }) + .collect(), + ), + Value::Object(map) => { + let mut bags: Vec<&Map<String, Value>> = Vec::new(); + if let Some(properties) = prop.and_then(|p| p["properties"].as_object()) { + bags.push(properties); + } + for branch in prop + .and_then(|p| p["oneOf"].as_array()) + .into_iter() + .flatten() + { + if let Some(properties) = branch["properties"].as_object() { + bags.push(properties); + } + } + if bags.is_empty() { + return match items { + Some(items) => map_leaves(value, Some(items), is_leaf, visit, path), + None => value.clone(), + }; + } + let mut result = Map::new(); + for (key, current) in map { + let declared: Vec<&Value> = bags.iter().filter_map(|bag| bag.get(key)).collect(); + path.push(json!(key)); + if let Some(leaf) = declared.iter().find(|d| is_leaf(d)) { + if let Some(mapped) = visit(current, leaf, path) { + result.insert(key.clone(), mapped); + } + } else { + let mut mapped = current.clone(); + for declaration in declared { + mapped = map_leaves(&mapped, Some(declaration), is_leaf, visit, path); + } + result.insert(key.clone(), mapped); + } + path.pop(); + } + Value::Object(result) + } + other => other.clone(), + } +} + +fn map_arg_leaves( + args: &Map<String, Value>, + schema: &Value, + is_leaf: fn(&Value) -> bool, + visit: &mut dyn FnMut(&Value, &Value, &[Value]) -> Option<Value>, +) -> Map<String, Value> { + let root = json!({ "properties": schema.get("properties").filter(|p| p.is_object()).cloned().unwrap_or_else(|| json!({})) }); + match map_leaves( + &Value::Object(args.clone()), + Some(&root), + is_leaf, + visit, + &mut Vec::new(), + ) { + Value::Object(map) => map, + _ => Map::new(), + } +} + +/// `$var:` hands the job the variable's text and `$jsonvar:` the parsed value, which a +/// field that cannot hold a string needs. +fn reference_prefix(prop: &Value, value: &Value) -> &'static str { + if value.is_string() && prop["type"] != "object" && prop["type"] != "array" { + "$var:" + } else { + "$jsonvar:" + } +} + +/// Turn every sensitive argument into a reference: a literal is minted into a secret +/// variable that expires on its own, so the job's stored arguments name a secret +/// instead of holding one. +async fn process_secret_args( + ctx: &Ctx<'_>, + args: Map<String, Value>, + schema: &Value, +) -> Result<Map<String, Value>, String> { + if !schema.get("properties").is_some_and(Value::is_object) { + return Ok(args); + } + let username = ctx.auth.username.split('@').next().unwrap_or_default(); + if username.is_empty() { + return Ok(args); + } + + let mut pending: Vec<(String, &'static str, Value)> = Vec::new(); + map_arg_leaves(&args, schema, is_secret_prop, &mut |value, prop, path| { + let holds_secret = !value.is_null() && value != "" && !is_reference(value); + if holds_secret { + pending.push(( + Value::from(path).to_string(), + reference_prefix(prop, value), + value.clone(), + )); + } + Some(value.clone()) + }); + + let mut minted: HashMap<String, String> = HashMap::new(); + for (key, prefix, value) in pending { + let variable = format!( + "u/{username}/secret_arg/{}", + windmill_common::utils::rd_string(12) + ); + let secret = match &value { + Value::String(text) if prefix == "$var:" => text.clone(), + other => other.to_string(), + }; + let expires_at = (chrono::Utc::now() + chrono::Duration::days(7)) + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + ctx.post( + &ctx.ws("variables/create"), + json!({ + "value": secret, + "is_secret": true, + "path": variable, + "description": "Ephemeral secret variable", + "expires_at": expires_at, + }), + ) + .await?; + minted.insert(key, format!("{prefix}{variable}")); + } + + Ok(map_arg_leaves( + &args, + schema, + is_secret_prop, + &mut |value, prop, path| { + if let Some(reference) = minted.get(&Value::from(path).to_string()) { + return Some(reference.as_str().into()); + } + // A plain variable named for a field that cannot hold a string: its contents + // are meant, read the way the field needs them. + match value.as_str().and_then(|s| s.strip_prefix("$var:")) { + Some(variable) if reference_prefix(prop, value) == "$jsonvar:" => { + Some(format!("$jsonvar:{variable}").into()) + } + _ => Some(value.clone()), + } + }, + )) +} + +fn file_marker(base64: &str) -> String { + let bytes = js_len(base64) * 3 / 4; + if bytes < 1024 * 1024 { + format!( + "<file: {} KB>", + ((bytes as f64 / 1024.0).round() as usize).max(1) + ) + } else { + format!("<file: {:.1} MB>", bytes as f64 / 1024.0 / 1024.0) + } +} + +/// The arguments as the model may read them back: secrets hidden, file bytes replaced +/// by their size. +fn redact_args(args: &Map<String, Value>, schema: &Value) -> Map<String, Value> { + let secrets = map_arg_leaves( + args, + schema, + is_secret_prop, + &mut |value, _, _| match value { + Value::Null => None, + value if value == "" || is_reference(value) => Some(value.clone()), + _ => Some("<hidden>".into()), + }, + ); + let mark = |value: &Value| match value { + Value::String(base64) => file_marker(base64).into(), + other => other.clone(), + }; + map_arg_leaves( + &secrets, + schema, + is_file_prop, + &mut |value, _, _| match value { + Value::Array(files) => Some(files.iter().map(mark).collect()), + other => Some(mark(other)), + }, + ) +} + +/// Keep the arguments the schema declares, naming the rest. A schema without +/// `properties` declares nothing. +fn drop_undeclared_args( + args: Map<String, Value>, + schema: &Value, +) -> (Map<String, Value>, Vec<String>) { + let properties = schema.get("properties").and_then(Value::as_object); + let mut undeclared = Vec::new(); + let kept = args + .into_iter() + .filter(|(key, _)| { + let declared = properties.is_some_and(|p| p.contains_key(key)); + if !declared { + undeclared.push(key.clone()); + } + declared + }) + .collect(); + (kept, undeclared) +} + +/// A field the schema disables is not the caller's to set: the run sends its default. +/// Returns the keys whose proposed value was overwritten. +fn enforce_disabled_defaults(args: &mut Map<String, Value>, schema: &Value) -> Vec<String> { + let mut reset = Vec::new(); + for (key, prop) in schema + .get("properties") + .and_then(Value::as_object) + .into_iter() + .flatten() + { + let (true, Some(default)) = (truthy(prop.get("disabled")), prop.get("default")) else { + continue; + }; + if args.get(key).is_some_and(|proposed| proposed != default) { + reset.push(key.clone()); + } + args.insert(key.clone(), default.clone()); + } + reset +} + +struct Prepared { + args: Map<String, Value>, + /// Lines telling the model how what ran differs from what it proposed. + notes: Vec<String>, +} + +fn plural<'a>(keys: &[String], one: &'a str, many: &'a str) -> &'a str { + if keys.len() > 1 { + many + } else { + one + } +} + +/// The arguments a run carries, from the ones proposed. `schema` is the input schema +/// of the version about to run; `None` when it is unknown, which conforms nothing. +/// `Err` is the message the tool returns when a secret could not be stored. +async fn prepare_args( + ctx: &Ctx<'_>, + path: &str, + proposed: Map<String, Value>, + schema: Option<&Value>, + schema_noun: &str, +) -> Result<Prepared, String> { + let Some(schema) = schema else { + return Ok(Prepared { args: proposed, notes: Vec::new() }); + }; + let (mut args, undeclared) = drop_undeclared_args(proposed.clone(), schema); + let reset = enforce_disabled_defaults(&mut args, schema); + let args = process_secret_args(ctx, args, schema).await.map_err(|e| { + format!( + "Failed to store the sensitive arguments of \"{path}\": {}", + api_message(&e) + ) + })?; + + let mut notes = Vec::new(); + let redacted = redact_args(&args, schema); + if redacted != proposed { + let json = Value::Object(redacted).to_string(); + notes.push(if js_len(&json) > MAX_SUBMITTED_ARGS_LENGTH { + format!( + "Ran with arguments: {}... (truncated)", + js_head(&json, MAX_SUBMITTED_ARGS_LENGTH) + ) + } else { + format!("Ran with arguments: {json}") + }); + } + if !reset.is_empty() { + notes.push(format!( + "The {schema_noun} schema disables {}, so the run used {} rather than the proposed {}. Do not propose {} again.", + reset.join(", "), + plural(&reset, "its default", "their defaults"), + plural(&reset, "value", "values"), + plural(&reset, "it", "them"), + )); + } + if !undeclared.is_empty() { + notes.push(format!( + "The {schema_noun} schema does not declare {}, so {} not sent. Re-read the input schema and use the arguments it declares.", + undeclared.join(", "), + plural(&undeclared, "it was", "they were"), + )); + } + Ok(Prepared { args, notes }) +} + +fn proposed_args(tool_args: &Value) -> Map<String, Value> { + tool_args + .get("args") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default() +} + +fn has_properties(schema: Option<&Value>) -> bool { + schema + .and_then(|s| s.get("properties")) + .and_then(Value::as_object) + .is_some_and(|p| !p.is_empty()) +} + +/// The input schema a test run conforms its arguments to: the stored one where it +/// declares fields and speaks for the entrypoint about to run, else inferred from the +/// code. `None` when this build has no parser for the language. +fn schema_for_test_run( + language: &str, + content: &str, + stored: Option<Value>, + entrypoint: Option<&str>, +) -> Option<Value> { + if entrypoint.is_none() && has_properties(stored.as_ref()) { + return stored; + } + infer_script_schema(language, content, None, entrypoint) +} + +async fn conclude( + ctx: &Ctx<'_>, + tool_args: &Value, + mut notes: Vec<String>, + started: Result<String, String>, + label: &str, + is_flow: bool, + action: &str, +) -> ToolResult { + notes.push(finish_run(ctx, started, label, is_flow, action, tool_args).await?); + Ok(notes.join("\n")) +} + +// ============================================================================ +// Scripts and flows +// ============================================================================ + +async fn test_run_script(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let script = load_script(ctx, path).await?; + let schema = schema_for_test_run(&script.language, &script.content, script.schema, None); + let prepared = + match prepare_args(ctx, path, proposed_args(args), schema.as_ref(), "script").await { + Ok(prepared) => prepared, + Err(message) => return Ok(message), + }; + let started = run_script_preview( + ctx, + Some(path), + &script.language, + &script.content, + Value::Object(prepared.args), + ) + .await; + conclude(ctx, args, prepared.notes, started, path, false, "test").await +} + +/// `run_script` and `run_flow`: the deployed item, whatever draft sits on top of it. +async fn run_deployed(ctx: &Ctx<'_>, args: &Value, is_flow: bool) -> ToolResult { + let path = str_arg(args, "path")?; + let (get, run) = if is_flow { + ("flows/get", "jobs/run/f") + } else { + ("scripts/get/p", "jobs/run/p") + }; + let item = ctx + .get(&ctx.ws(&format!("{get}/{}", path_enc(path)))) + .await?; + let schema = item + .get("schema") + .filter(|s| !s.is_null()) + .cloned() + .unwrap_or_else(|| json!({})); + let prepared = + match prepare_args(ctx, path, proposed_args(args), Some(&schema), "deployed").await { + Ok(prepared) => prepared, + Err(message) => return Ok(message), + }; + // A chat-enabled flow is refused without a conversation, and this run is one of its own. + let memory_id = if is_flow && truthy(item["value"].get("chat_input_enabled")) { + format!("&memory_id={}", uuid::Uuid::new_v4()) + } else { + String::new() + }; + // The arguments fill the main input schema; a preprocessor would take them for a + // trigger event. + let started = ctx + .post( + &ctx.ws(&format!( + "{run}/{}?skip_preprocessor=true{memory_id}", + path_enc(path) + )), + Value::Object(prepared.args), + ) + .await + .map(job_id); + conclude(ctx, args, prepared.notes, started, path, is_flow, "run").await +} + +/// The flow value a preview runs, from a loaded `{value, schema, groups}`. +fn flow_preview_value(flow: &Value) -> Value { + let mut value = flow.get("value").cloned().unwrap_or_else(|| json!({})); + if let (Some(groups), Some(map)) = (flow.get("groups"), value.as_object_mut()) { + if groups.is_null() { + map.remove("groups"); + } else { + map.insert("groups".into(), groups.clone()); + } + } + value +} + +async fn test_run_flow(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let memory_id = opt_str_arg(args, "memory_id"); + if memory_id.is_some_and(|id| id.len() != 36 || uuid::Uuid::parse_str(id).is_err()) { + return Err("memory_id must be a UUID".to_string()); + } + let flow = load_flow(ctx, path).await?; + let schema = flow + .flow + .get("schema") + .filter(|s| !s.is_null()) + .cloned() + .unwrap_or_else(|| json!({})); + let prepared = match prepare_args(ctx, path, proposed_args(args), Some(&schema), "flow").await { + Ok(prepared) => prepared, + Err(message) => return Ok(message), + }; + let value = flow_preview_value(&flow.flow); + // The server refuses a chat-enabled flow's run without a conversation; a test run + // is one of its own unless the caller names another. + let memory_id = memory_id.map(String::from).or_else(|| { + truthy(value.get("chat_input_enabled")).then(|| uuid::Uuid::new_v4().to_string()) + }); + let started = run_flow_preview( + ctx, + Some(path), + &value, + Value::Object(prepared.args), + memory_id.as_deref(), + ) + .await; + conclude(ctx, args, prepared.notes, started, path, true, "test").await +} + +// ============================================================================ +// One step of a flow +// ============================================================================ + +fn modules_of<'a>(value: &'a Value, key: &str) -> impl Iterator<Item = &'a Value> { + value[key].as_array().into_iter().flatten() +} + +/// The modules nested directly under one: loop bodies, branches, and an agent's +/// flow-module tools. +fn child_modules(module: &Value, with_tools: bool) -> Vec<&Value> { + let value = &module["value"]; + let branches = || modules_of(value, "branches").flat_map(|b| modules_of(b, "modules")); + match value["type"].as_str() { + Some("forloopflow" | "whileloopflow") => modules_of(value, "modules").collect(), + Some("branchall") => branches().collect(), + Some("branchone") => branches().chain(modules_of(value, "default")).collect(), + Some("aiagent") if with_tools => modules_of(value, "tools") + .filter(|tool| { + matches!(tool["value"].get("tool_type"), None | Some(Value::Null)) + || tool["value"]["tool_type"] == "flowmodule" + }) + .collect(), + _ => Vec::new(), + } +} + +fn find_in_modules<'a>(modules: Vec<&'a Value>, id: &str) -> Option<&'a Value> { + modules.into_iter().find_map(|module| { + if module["id"] == id { + Some(module) + } else { + find_in_modules(child_modules(module, true), id) + } + }) +} + +fn find_module<'a>(flow: &'a Value, id: &str) -> Option<&'a Value> { + ["failure_module", "preprocessor_module"] + .iter() + .map(|key| &flow[*key]) + .find(|module| module["id"] == id) + .or_else(|| find_in_modules(modules_of(flow, "modules").collect(), id)) +} + +fn available_step_ids(flow: &Value) -> String { + fn collect<'a>(modules: Vec<&'a Value>, ids: &mut Vec<&'a str>) { + for module in modules { + ids.extend(module["id"].as_str()); + collect(child_modules(module, false), ids); + } + } + let mut ids = Vec::new(); + collect(modules_of(flow, "modules").collect(), &mut ids); + ids.extend(flow["preprocessor_module"]["id"].as_str()); + ids.extend(flow["failure_module"]["id"].as_str()); + let mut seen = Vec::new(); + for id in ids { + if !seen.contains(&id) { + seen.push(id); + } + } + seen.join(", ") +} + +/// The agent form's rows in its own order: key, whether an agent always has it, and +/// the value a run cannot tell apart from an absent key. +fn agent_fields() -> [(&'static str, bool, Option<Value>); 16] { + [ + ("provider", true, None), + ("temperature", false, None), + ("max_completion_tokens", false, None), + ("user_message", true, None), + ("system_prompt", true, None), + ("memory", false, Some(json!({ "kind": "off" }))), + ("memory_id", false, Some(json!(""))), + ("previous_messages", false, Some(json!([]))), + ("user_attachments", false, Some(json!([]))), + ("enabled_tools", false, None), + ("max_iterations", false, Some(json!(10))), + ("output_type", false, Some(json!("text"))), + ("output_schema", false, None), + ("streaming", false, Some(json!(true))), + ("model", false, Some(json!(""))), + ("reasoning_effort", false, Some(json!(""))), + ] +} + +/// The inputs a step linked to a saved agent supplies itself; the rest is the agent's. +const AGENT_FLOW_LOCAL_KEYS: [&str; 5] = [ + "user_message", + "user_attachments", + "enabled_tools", + "memory_id", + "previous_messages", +]; +const AGENT_HISTORY_KEYS: [&str; 2] = ["memory_id", "previous_messages"]; +/// A linked step's overrides of what its agent saved; an unlinked step has no such inputs. +const AGENT_OVERRIDE_KEYS: [&str; 2] = ["model", "reasoning_effort"]; + +/// Older spellings of two agent inputs, read as the current ones. +fn migrate_agent_transforms(transforms: &mut Map<String, Value>) { + if !transforms.contains_key("user_attachments") { + if let Some(images) = transforms.remove("user_images") { + transforms.insert("user_attachments".into(), images); + } + } + if transforms.contains_key("memory") { + return; + } + let Some(legacy) = transforms.get("messages_context_length").cloned() else { + return; + }; + if legacy.is_null() { + return; + } + match legacy["type"].as_str() { + Some("static") => { + let length = legacy.get("value").filter(|v| !v.is_null()).cloned(); + transforms.insert( + "memory".into(), + json!({ "type": "static", "value": { "kind": "auto", "context_length": length.unwrap_or(json!(0)) } }), + ); + } + Some("javascript") => { + let expr = legacy["expr"].as_str().unwrap_or("undefined"); + transforms.insert( + "memory".into(), + json!({ "type": "javascript", "expr": format!("{{ kind: 'auto', context_length: {expr} }}") }), + ); + } + _ => {} + } + transforms.remove("messages_context_length"); +} + +/// The inputs a test run of an agent step takes, in the agent form's order, and the +/// values the step itself configures for them. A row is one the step sets, an agent +/// always has, or the caller proposed. +fn agent_step_form( + module_value: &Value, + proposed: &Map<String, Value>, +) -> (Vec<String>, Map<String, Value>) { + let transforms = module_value.get("input_transforms"); + let linked = truthy(module_value.get("agent")); + let mut keys = Vec::new(); + let mut args = Map::new(); + for (key, core, implicit) in agent_fields() { + let override_key = AGENT_OVERRIDE_KEYS.contains(&key); + let applies = if linked { + override_key || AGENT_FLOW_LOCAL_KEYS.contains(&key) + } else { + !override_key + }; + if !applies { + continue; + } + let transform = transforms.and_then(|t| t.get(key)); + let is_set = core + || match transform.and_then(|t| t["type"].as_str()) { + Some("javascript") => truthy(transform.and_then(|t| t.get("expr"))), + Some("ai") => true, + _ => transform + .filter(|t| t.is_object()) + .and_then(|t| t.get("value")) + .is_some_and(|value| !value.is_null() && Some(value) != implicit.as_ref()), + }; + if !is_set && !proposed.contains_key(key) { + continue; + } + keys.push(key.to_string()); + // ponytail: only a static input opens on its value. An expression is evaluated by + // the session with no flow input or earlier results, so all but a literal one + // reads as unset there too; evaluate it here if literal expressions matter. + if let Some(value) = transform + .filter(|t| t["type"] == "static") + .and_then(|t| t.get("value")) + { + args.insert(key.to_string(), value.clone()); + } + } + for (key, value) in proposed { + args.insert(key.clone(), value.clone()); + } + // An absent `enabled_tools` runs every tool and `[]` none, and the row is fed from + // the run's arguments either way. + if keys.iter().any(|key| key == "enabled_tools") + && args.get("enabled_tools").is_none_or(Value::is_null) + { + args.insert("enabled_tools".into(), json!([])); + } + (keys, args) +} + +/// The input transforms a one-off test of an agent step runs with: each form key fed +/// from `flow_input`, over the step's own. A blank history input is unset. +fn agent_test_transforms( + step: Option<&Value>, + args: &Map<String, Value>, + form_keys: &[String], +) -> Map<String, Value> { + let kept = |key: &str| { + let blank = match args.get(key) { + None | Some(Value::Null) => true, + Some(Value::String(s)) => s.is_empty(), + Some(Value::Array(a)) => a.is_empty(), + Some(_) => false, + }; + !AGENT_HISTORY_KEYS.contains(&key) || !blank + }; + let mut out: Map<String, Value> = step + .and_then(Value::as_object) + .into_iter() + .flatten() + .filter(|(key, _)| kept(key)) + .map(|(key, transform)| (key.clone(), transform.clone())) + .collect(); + for key in form_keys.iter().filter(|key| kept(key)) { + out.insert( + key.clone(), + json!({ "type": "javascript", "expr": format!("flow_input.{key}") }), + ); + } + out +} + +fn normalize_agent_ref(agent: &str) -> &str { + let agent = agent.strip_prefix("$res:").unwrap_or(agent); + agent.strip_prefix("res://").unwrap_or(agent) +} + +fn linked_agent_paths(module: &Value, paths: &mut Vec<String>) { + let value = &module["value"]; + if value["type"] != "aiagent" { + return; + } + if let Some(agent) = value["agent"].as_str().filter(|a| !a.is_empty()) { + let path = normalize_agent_ref(agent).to_string(); + if !paths.contains(&path) { + paths.push(path); + } + } + for tool in modules_of(value, "tools") { + linked_agent_paths(tool, paths); + } +} + +fn inline_agent_draft(module: &mut Value, drafts: &HashMap<String, Value>) { + let value = &mut module["value"]; + if value["type"] != "aiagent" { + return; + } + let draft = value["agent"] + .as_str() + .and_then(|agent| drafts.get(normalize_agent_ref(agent))); + if let (Some(draft), Some(map)) = (draft, value.as_object_mut()) { + map.remove("agent"); + let mut transforms = Map::new(); + for (key, arg) in draft.as_object().into_iter().flatten() { + if key != "tools" && !AGENT_HISTORY_KEYS.contains(&key.as_str()) { + transforms.insert(key.clone(), json!({ "type": "static", "value": arg })); + } + } + for key in AGENT_FLOW_LOCAL_KEYS.into_iter().chain(AGENT_OVERRIDE_KEYS) { + if let Some(own) = map + .get("input_transforms") + .and_then(|t| t.get(key)) + .filter(|t| truthy(Some(t))) + { + transforms.insert(key.to_string(), own.clone()); + } + } + map.insert( + "tools".into(), + draft + .get("tools") + .filter(|t| !t.is_null()) + .cloned() + .unwrap_or_else(|| json!([])), + ); + map.insert("input_transforms".into(), Value::Object(transforms)); + } + if let Some(tools) = value.get_mut("tools").and_then(Value::as_array_mut) { + for tool in tools { + inline_agent_draft(tool, drafts); + } + } +} + +/// Replace every linked agent that has an unsaved draft with the draft's own +/// configuration, so the test runs what the agent editor shows. +async fn with_agent_drafts(ctx: &Ctx<'_>, module: &mut Value) -> Result<(), String> { + let mut paths = Vec::new(); + linked_agent_paths(module, &mut paths); + let mut drafts = HashMap::new(); + for path in paths { + let route = ctx.ws(&format!("resources/get/{}?get_draft=true", path_enc(&path))); + match ctx.get(&route).await { + Ok(resource) => { + if let Some(draft) = resource.get("draft").filter(|d| !d.is_null()) { + drafts.insert( + path, + draft.get("args").cloned().unwrap_or_else(|| json!({})), + ); + } + } + // A deleted or unreadable agent is an ordinary state of a link: the step + // then runs against whatever is deployed. + Err(e) if ["401", "403", "404"].iter().any(|s| e.starts_with(s)) => {} + Err(e) => return Err(format!("Could not load the agent {path}: {e}")), + } + } + inline_agent_draft(module, &drafts); + Ok(()) +} + +async fn test_run_step(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let step_id = str_arg(args, "stepId")?; + let flow_value = flow_preview_value(&load_flow(ctx, path).await?.flow); + let Some(module) = find_module(&flow_value, step_id) else { + return Err(format!( + "Step with id \"{step_id}\" not found in flow. Available steps: {}", + available_step_ids(&flow_value) + )); + }; + let value = &module["value"]; + let module_type = value["type"].as_str().unwrap_or_default(); + let text = |key: &str| value[key].as_str().unwrap_or_default().to_string(); + // A preprocessor takes the arguments of its own entrypoint, which no schema declares. + let entrypoint = (module["id"] == "preprocessor").then_some("preprocessor"); + let label = format!("step {step_id}"); + let proposed = proposed_args(args); + + let (schema, proposed) = match module_type { + "rawscript" => ( + schema_for_test_run(&text("language"), &text("content"), None, entrypoint), + proposed, + ), + "script" | "flow" | "aidecision" => (None, proposed), + "aiagent" => { + let (keys, args) = agent_step_form(&migrated_agent_value(value), &proposed); + let properties: Map<String, Value> = + keys.into_iter().map(|key| (key, json!({}))).collect(); + (Some(json!({ "properties": properties })), args) + } + other => { + return Err(format!( + "Cannot test step of type \"{other}\". Supported types: rawscript, script, flow, aiagent, aidecision" + )); + } + }; + + // The targets read from the workspace, each with the schema of what will run. + let mut script = None; + let mut subflow_preview = None; + let schema = match module_type { + "script" => { + let target = text("path"); + let hash = value["hash"].as_str().filter(|h| !h.is_empty()); + // A draft of the script runs even where the step pins a hash. + let loaded = match hash { + Some(hash) if get_draft(ctx, "script", &target).await?.is_none() => { + let deployed = ctx + .get(&ctx.ws(&format!("scripts/get/h/{}", enc(hash)))) + .await?; + ( + deployed["content"].as_str().unwrap_or_default().to_string(), + deployed["language"] + .as_str() + .unwrap_or_default() + .to_string(), + deployed.get("schema").filter(|s| !s.is_null()).cloned(), + ) + } + _ => { + let loaded = load_script(ctx, &target).await?; + (loaded.content, loaded.language, loaded.schema) + } + }; + let schema = schema_for_test_run(&loaded.1, &loaded.0, loaded.2, entrypoint); + script = Some((target, loaded.0, loaded.1)); + schema + } + "flow" => { + // Only a draft is previewed; a deployed subflow runs by path, as its parent + // flow would run it. + let subflow = load_flow(ctx, &text("path")).await?; + if subflow.is_draft { + subflow_preview = Some(flow_preview_value(&subflow.flow)); + } + Some( + subflow + .flow + .get("schema") + .filter(|s| !s.is_null()) + .cloned() + .unwrap_or_else(|| json!({})), + ) + } + // What a run supplies is the state; the provider and questions are the step's own. + "aidecision" => Some(json!({ "properties": { "state": {} } })), + _ => schema, + }; + + let prepared = match prepare_args(ctx, path, proposed, schema.as_ref(), "step").await { + Ok(prepared) => prepared, + Err(message) => return Ok(message), + }; + let mut run_args = prepared.args; + let with_entrypoint = |args: Map<String, Value>| { + let mut out = Map::new(); + if entrypoint.is_some() { + out.insert("_ENTRYPOINT_OVERRIDE".into(), "preprocessor".into()); + } + out.extend(args); + Value::Object(out) + }; + // Run alone, stripped of the settings around it: a mock would answer for the step + // and a suspend would hold the run for an approval. + let single_step = + |step_value: Value| json!({ "modules": [{ "id": module["id"], "value": step_value }] }); + + let (started, is_flow) = match module_type { + "rawscript" => ( + run_script_preview( + ctx, + None, + &text("language"), + &text("content"), + with_entrypoint(run_args), + ) + .await, + false, + ), + "script" => { + let (target, content, language) = script.unwrap_or_default(); + ( + run_script_preview( + ctx, + Some(&target), + &language, + &content, + with_entrypoint(run_args), + ) + .await, + false, + ) + } + "flow" => { + let target = text("path"); + let started = match subflow_preview { + Some(preview) => { + run_flow_preview(ctx, Some(&target), &preview, Value::Object(run_args), None) + .await + } + None => ctx + .post( + &ctx.ws(&format!( + "jobs/run/f/{}?skip_preprocessor=true", + path_enc(&target) + )), + Value::Object(run_args), + ) + .await + .map(job_id), + }; + (started, true) + } + "aiagent" => { + let mut step_value = migrated_agent_value(value); + let form_keys: Vec<String> = schema + .as_ref() + .and_then(|s| s["properties"].as_object()) + .map(|p| p.keys().cloned().collect()) + .unwrap_or_default(); + step_value["input_transforms"] = Value::Object(agent_test_transforms( + step_value.get("input_transforms"), + &run_args, + &form_keys, + )); + let mut step = json!({ "id": module["id"], "value": step_value }); + with_agent_drafts(ctx, &mut step).await?; + let preview = json!({ "modules": [step] }); + ( + run_flow_preview(ctx, Some(path), &preview, Value::Object(run_args), None).await, + true, + ) + } + _ => { + let mut step_value = value.clone(); + let mut transforms = value["input_transforms"] + .as_object() + .cloned() + .unwrap_or_default(); + for key in run_args.keys() { + transforms.insert( + key.clone(), + json!({ "type": "javascript", "expr": format!("flow_input.{key}") }), + ); + } + step_value["input_transforms"] = Value::Object(transforms); + let preview = single_step(step_value); + ( + run_flow_preview( + ctx, + Some(path), + &preview, + Value::Object(std::mem::take(&mut run_args)), + None, + ) + .await, + true, + ) + } + }; + conclude(ctx, args, prepared.notes, started, &label, is_flow, "test").await +} + +fn migrated_agent_value(value: &Value) -> Value { + let mut value = value.clone(); + if let Some(transforms) = value + .get_mut("input_transforms") + .and_then(Value::as_object_mut) + { + migrate_agent_transforms(transforms); + } + value +} + +// ============================================================================ +// Reading runs back +// ============================================================================ + +/// Compact metadata for one run: what identifies it and how it ended. +fn summarize_run(job: &Value) -> Map<String, Value> { + let mut out = Map::new(); + for (key, source) in [ + ("id", "id"), + ("job_kind", "job_kind"), + ("path", "script_path"), + ("created_by", "created_by"), + ("created_at", "created_at"), + ("started_at", "started_at"), + ("schedule_path", "schedule_path"), + ("is_flow_step", "is_flow_step"), + ("tag", "tag"), + ("worker", "worker"), + ] { + if let Some(value) = job.get(source) { + out.insert(key.into(), value.clone()); + } + } + let flag = |key: &str| truthy(job.get(key)); + let status = if job.get("success").is_some() { + // `success` is true for a skipped job too, so it is read last. + if flag("canceled") { + "canceled" + } else if flag("is_skipped") { + "skipped" + } else if flag("success") { + "success" + } else { + "failure" + } + } else if !flag("running") { + "queued" + } else if flag("suspend") { + // Parked on an approval or a parallelism slot, not working. + "suspended" + } else { + "running" + }; + out.insert("status".into(), status.into()); + if job.get("success").is_some() { + if let Some(duration) = job.get("duration_ms") { + out.insert("duration_ms".into(), duration.clone()); + } + } + out +} + +async fn list_runs(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let limit = args + .get("limit") + .and_then(Value::as_u64) + .unwrap_or(30) + .clamp(1, 100); + let mut query = format!("per_page={limit}"); + for (param, arg) in [ + ("script_path_exact", "path"), + ("created_by", "created_by"), + ("label", "label"), + ] { + if let Some(value) = opt_str_arg(args, arg) { + query.push_str(&format!("&{param}={}", enc(value))); + } + } + for flag in ["success", "running"] { + if let Some(value) = args.get(flag).and_then(Value::as_bool) { + query.push_str(&format!("&{flag}={value}")); + } + } + let jobs = ctx.get(&ctx.ws(&format!("jobs/list?{query}"))).await?; + let runs: Vec<Value> = jobs + .as_array() + .into_iter() + .flatten() + .map(|job| Value::Object(summarize_run(job))) + .collect(); + Ok(pretty(&Value::Array(runs))) +} + +async fn cancel_job(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let id = str_arg(args, "id")?; + Ok( + match ctx + .post( + &ctx.ws(&format!("jobs_u/queue/cancel/{}", enc(id))), + json!({}), + ) + .await + { + Ok(_) => format!("Job {id} was canceled."), + Err(e) => format!( + "Failed to cancel job {id}: {}. It may have already finished.", + api_message(&e) + ), + }, + ) +} + +/// Per-entry result budget asked of the server for the tree view. +const TREE_RESULT_HEAD_CHARS: usize = 700; +/// Cap on a full payload handed to the model: a drilled step result, and the run's own +/// args, result and logs. +const STEP_RESULT_MAX_CHARS: usize = 12000; +/// Cap on the whole rendered tree; heads shrink progressively to fit. +const TREE_TOTAL_BUDGET_CHARS: usize = 20000; +const MAX_FAILED_ITERATIONS_SHOWN: usize = 3; +/// `(head, success_head)` tried in order until the tree fits: succeeded steps lose +/// their results first so failure detail survives longest. +const SHRINK_LADDER: [(usize, usize); 4] = [ + (TREE_RESULT_HEAD_CHARS, TREE_RESULT_HEAD_CHARS), + (250, 250), + (250, 0), + (100, 0), +]; +/// Module types whose sibling jobs are iterations or branches; sibling jobs of any +/// other step are retry attempts of it. +const FAN_OUT_MODULE_TYPES: [&str; 5] = [ + "forloopflow", + "whileloopflow", + "branchall", + "branchone", + "aiagent", +]; +const LOGS_UNREADABLE: &str = "Logs could not be read for this run."; + +/// A run's jobs as the server lists them, depth-first, with each one's children. +struct RunTree<'a> { + entries: &'a [Value], + children: Vec<Vec<usize>>, +} + +impl<'a> RunTree<'a> { + fn new(entries: &'a [Value]) -> Self { + let depth = |i: usize| entries[i]["depth"].as_i64().unwrap_or(0); + let mut children = vec![Vec::new(); entries.len()]; + let mut stack: Vec<usize> = Vec::new(); + for i in 0..entries.len() { + while stack.last().is_some_and(|&top| depth(top) >= depth(i)) { + stack.pop(); + } + if let Some(&parent) = stack.last() { + children[parent].push(i); + } + stack.push(i); + } + RunTree { entries, children } + } + + fn status(&self, i: usize) -> &str { + self.entries[i]["status"].as_str().unwrap_or_default() + } + + fn shape_step( + &self, + i: usize, + heads: (usize, usize), + iteration: Option<&Value>, + ) -> Map<String, Value> { + let entry = &self.entries[i]; + let mut out = Map::new(); + if let Some(step) = entry["step_path"].as_str().filter(|s| !s.is_empty()) { + out.insert("step".into(), step.into()); + } + if let Some(iteration) = iteration { + out.insert("iteration".into(), iteration.clone()); + } + for key in ["label", "job_id", "status"] { + if let Some(value) = entry.get(key) { + out.insert(key.into(), value.clone()); + } + } + if let Some(duration) = entry.get("duration_ms").filter(|d| !d.is_null()) { + out.insert("duration_ms".into(), duration.clone()); + } + if let Some(prefix) = entry["result_prefix"].as_str() { + let budget = if truthy(entry.get("success")) { + heads.1 + } else { + heads.0 + }; + let length = entry["result_length"].as_u64(); + if budget == 0 { + if let Some(length) = length { + out.insert("result_total_chars".into(), length.into()); + } + } else { + // The server counts code points, so sizes are compared in them. + let head = js_head(prefix, budget); + let total = length.unwrap_or(prefix.chars().count() as u64); + out.insert("result".into(), head.into()); + if total > head.chars().count() as u64 { + out.insert("result_total_chars".into(), total.into()); + } + } + } + let steps = self.shape_children(i, heads); + if !steps.is_empty() { + out.insert("steps".into(), steps.into()); + } + out + } + + /// Sibling jobs grouped by flow step, so the iterations, branches or attempts of + /// one step read as one entry. + fn shape_children(&self, i: usize, heads: (usize, usize)) -> Vec<Value> { + let mut groups: Vec<(String, Vec<usize>)> = Vec::new(); + for &child in &self.children[i] { + let entry = &self.entries[child]; + let step_id = match entry["flow_step_id"].as_str() { + Some(id) => id.to_string(), + None => format!("job:{}", entry["job_id"].as_str().unwrap_or_default()), + }; + match groups.iter_mut().find(|(id, _)| *id == step_id) { + Some((_, nodes)) => nodes.push(child), + None => groups.push((step_id, vec![child])), + } + } + groups + .iter() + .map(|(step_id, nodes)| Value::Object(self.shape_group(step_id, nodes, heads))) + .collect() + } + + fn shape_group( + &self, + step_id: &str, + nodes: &[usize], + heads: (usize, usize), + ) -> Map<String, Value> { + let first = &self.entries[nodes[0]]; + if nodes.len() == 1 && first["sibling_count"].as_i64().is_some_and(|n| n <= 1) { + return self.shape_step(nodes[0], heads, None); + } + let step = first["step_path"].as_str().unwrap_or(step_id); + let parent_type = first["parent_module_type"].as_str().unwrap_or_default(); + let mut out = Map::new(); + out.insert("step".into(), step.into()); + if parent_type == "branchall" { + out.insert("type".into(), "branchall".into()); + let branches: Vec<Value> = nodes + .iter() + .map(|&n| Value::Object(self.shape_step(n, heads, None))) + .collect(); + out.insert("branches".into(), branches.into()); + return out; + } + + let index = |n: usize| &self.entries[n]["sibling_index"]; + let mut by_index = nodes.to_vec(); + by_index.sort_by_key(|&n| index(n).as_i64().unwrap_or(0)); + let last = by_index[by_index.len() - 1]; + + if !parent_type.is_empty() && !FAN_OUT_MODULE_TYPES.contains(&parent_type) { + // A retried step: the last attempt is its outcome, earlier ones are + // status-only references. + let mut shaped = self.shape_step(last, heads, None); + shaped.insert("attempts".into(), by_index.len().into()); + let previous: Vec<Value> = by_index[..by_index.len() - 1] + .iter() + .map(|&n| { + json!({ + "attempt": index(n), + "status": self.status(n), + "job_id": self.entries[n]["job_id"], + }) + }) + .collect(); + shaped.insert("previous_attempts".into(), previous.into()); + return shaped; + } + + let count = |status: &str| { + by_index + .iter() + .filter(|&&n| self.status(n) == status) + .count() + }; + let ok = count("success"); + let failed: Vec<usize> = by_index + .iter() + .copied() + .filter(|&n| matches!(self.status(n), "failure" | "canceled")) + .collect(); + let skipped = count("skipped"); + // Skipped is terminal: only running, queued and suspended are unfinished. + let unfinished = by_index.len() - ok - failed.len() - skipped; + let mut shown: Vec<usize> = failed + .iter() + .copied() + .take(MAX_FAILED_ITERATIONS_SHOWN) + .collect(); + if !shown.contains(&last) { + shown.push(last); + } + + out.insert( + "type".into(), + if parent_type.is_empty() { + "loop" + } else { + parent_type + } + .into(), + ); + out.insert("iterations".into(), by_index.len().into()); + out.insert("ok".into(), ok.into()); + if !failed.is_empty() { + let indices: Vec<Value> = failed.iter().map(|&n| index(n).clone()).collect(); + out.insert("failed_iterations".into(), indices.into()); + } + if skipped > 0 { + out.insert("skipped".into(), skipped.into()); + } + if unfinished > 0 { + out.insert("unfinished".into(), unfinished.into()); + } + let iterations: Vec<Value> = shown + .iter() + .map(|&n| Value::Object(self.shape_step(n, heads, Some(index(n))))) + .collect(); + out.insert("iterations_shown".into(), iterations.into()); + if by_index.len() > shown.len() { + out.insert( + "iterations_elided".into(), + (by_index.len() - shown.len()).into(), + ); + } + out + } + + fn render( + &self, + note: Option<&str>, + heads: (usize, usize), + run_overrides: &Map<String, Value>, + ) -> Value { + let mut run = self.shape_step(0, heads, None); + let steps = run.remove("steps"); + let kind = self.entries[0]["kind"].as_str().unwrap_or_default(); + // The server labels every depth-0 job "Flow", a plain script run included. + if steps.is_none() && kind != "flow" && kind != "flowpreview" { + run.insert("label".into(), format!("Job ({kind})").into()); + } + // A replacement result supersedes the tree entry's head. Without one the head + // stays: it is a real prefix of a payload too big to be read whole. + if run_overrides.contains_key("result") { + run.remove("result"); + run.remove("result_total_chars"); + } + for (key, value) in run_overrides { + run.insert(key.clone(), value.clone()); + } + let mut out = Map::new(); + if let Some(note) = note { + out.insert("note".into(), note.into()); + } + out.insert("run".into(), Value::Object(run)); + if let Some(steps) = steps { + out.insert("steps".into(), steps); + out.insert( + "hint".into(), + format!("Step results are truncated. Call get_run again with step=\"<step>\" (e.g. \"b/c\", or \"b[12]\" for one loop iteration) for a step's result in full (up to {STEP_RESULT_MAX_CHARS} chars), or with id set to a step's job_id for that step's own args, result and logs.").into(), + ); + } + Value::Object(out) + } +} + +/// Render a run's execution tree around the run's own summary and payloads +/// (`run_overrides`), shrinking result heads until the tree fits its budget. +fn shape_flow_run_tree(response: &Value, run_overrides: &Map<String, Value>) -> String { + let entries = response["entries"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default(); + if entries.is_empty() { + return pretty1(&json!({ "run": run_overrides })); + } + let mut notes = Vec::new(); + if let Some(enclosing) = response["enclosing_job"].as_str().filter(|j| !j.is_empty()) { + notes.push(format!("This job is a step of a larger flow run — its enclosing run is {enclosing}; pass that id to see more of the tree.")); + } + if truthy(response.get("truncated")) { + notes.push(format!("The run has more jobs than the server returns — this tree only covers the first {} (depth-first), so tallies may undercount.", entries.len())); + } + if truthy(response.get("scope_filtered")) { + notes.push( + "Your token is tag-scoped: steps running on other tags are omitted from this tree." + .to_string(), + ); + } + let note = (!notes.is_empty()).then(|| notes.join(" ")); + + // The budget caps the tree, not the run's own args, result and logs: those are + // capped on their own. + let override_chars = js_len(&pretty1(&Value::Object(run_overrides.clone()))); + let tree = RunTree::new(entries); + let mut rendered = String::new(); + for heads in SHRINK_LADDER { + rendered = pretty1(&tree.render(note.as_deref(), heads, run_overrides)); + if js_len(&rendered).saturating_sub(override_chars) <= TREE_TOTAL_BUDGET_CHARS { + return rendered; + } + } + format!( + "{}\n… (tree truncated at {TREE_TOTAL_BUDGET_CHARS} chars — drill into specific steps with the step parameter)", + js_head(&rendered, TREE_TOTAL_BUDGET_CHARS + override_chars) + ) +} + +/// Cap a payload to `STEP_RESULT_MAX_CHARS`. `tail` keeps the end, for logs, where the +/// failure is at the bottom. +fn cap(text: &str, tail: bool) -> String { + if js_len(text) <= STEP_RESULT_MAX_CHARS { + return text.to_string(); + } + let total = text.chars().count(); + if total <= STEP_RESULT_MAX_CHARS { + return text.to_string(); + } + let note = format!("… (truncated: {total} chars total)"); + if tail { + format!("{note}\n{}", js_tail(text, STEP_RESULT_MAX_CHARS)) + } else { + format!("{}\n{note}", js_head(text, STEP_RESULT_MAX_CHARS)) + } +} + +fn stringify(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + other => pretty1(other), + } +} + +async fn get_run(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let id = str_arg(args, "id")?; + let results_route = |max: usize| { + ctx.ws(&format!( + "jobs_u/get_flow_all_results/{}?max_result_len={max}", + enc(id) + )) + }; + if let Some(step) = opt_str_arg(args, "step").filter(|s| !s.is_empty()) { + let route = format!( + "{}&step={}", + results_route(STEP_RESULT_MAX_CHARS), + enc(step) + ); + return Ok(step_result(&ctx.get(&route).await?, step)); + } + + // Only the job read is load-bearing: logs and the step tree each answer part of + // the question, so neither failing costs the rest. + let job_route = ctx.ws(&format!("jobs_u/get/{}?no_logs=true&no_code=true", enc(id))); + let logs_route = ctx.ws(&format!( + "jobs_u/get_logs/{}?remove_ansi_warnings=true", + enc(id) + )); + let tree_route = results_route(TREE_RESULT_HEAD_CHARS); + let (job, logs, results) = tokio::join!( + ctx.get(&job_route), + ctx.request("GET", &logs_route, None), + ctx.get(&tree_route), + ); + let results = results.unwrap_or_else(|_| json!({ "entries": [] })); + Ok(format!( + "{}\n\nRun page: {}", + shape_run(id, &job?, logs, &results), + ctx.page_url(&format!("/run/{}", enc(id))) + )) +} + +/// A run as `get_run` reports it: its summary, args, result and logs around the step +/// tree. `logs` is the log read's own outcome, so a failed read is told apart from an +/// empty log. +fn shape_run(id: &str, job: &Value, logs: Result<String, String>, results: &Value) -> String { + let mut run = summarize_run(job); + run.remove("id"); + // Why a run ended badly. + for (key, source) in [ + ("canceled_by", "canceled_by"), + ("canceled_reason", "canceled_reason"), + ("mem_peak_kb", "mem_peak"), + ] { + if let Some(value) = job.get(source).filter(|v| truthy(Some(v))) { + run.insert(key.into(), value.clone()); + } + } + // A successful read always carries the job itself as the root entry, so no entries + // means the read failed, and nothing else would name the run. + if results["entries"].as_array().is_none_or(Vec::is_empty) { + run.insert("job_id".into(), id.into()); + run.insert("steps_unavailable".into(), true.into()); + } + // The server swaps a payload too big to send for a marker, each field in its own + // form. That is reported, never fetched around: the result keeps the head the step + // tree carries. + if let Some(job_args) = job.get("args").filter(|a| truthy(Some(a))) { + let too_big = job_args + .as_object() + .is_some_and(|o| o.len() == 1 && o.get("reason") == Some(&json!("WINDMILL_TOO_BIG"))); + if too_big { + run.insert("args_truncated".into(), true.into()); + } else { + run.insert("args".into(), cap(&stringify(job_args), false).into()); + } + } + if let Some(result) = job.get("result") { + if result == "WINDMILL_TOO_BIG" { + run.insert("result_truncated".into(), true.into()); + } else { + run.insert("result".into(), cap(&stringify(result), false).into()); + } + } + let logs = match logs { + Err(_) => LOGS_UNREADABLE.to_string(), + Ok(logs) if logs.trim().is_empty() => "No logs for this run.".to_string(), + Ok(logs) => cap(&logs, true), + }; + run.insert("logs".into(), logs.into()); + shape_flow_run_tree(results, &run) +} + +/// One step's result in full, from the single entry the server resolved its address to. +fn step_result(response: &Value, step: &str) -> String { + if let Some(error) = response["step_error"].as_str().filter(|e| !e.is_empty()) { + let hidden = if truthy(response.get("scope_filtered")) { + " (Steps running on tags outside your token’s scope are hidden.)" + } else { + "" + }; + return format!("{error}{hidden}"); + } + let Some(entry) = response["entries"].get(0) else { + return "No jobs found for this run.".to_string(); + }; + let job_id = entry["job_id"].as_str().unwrap_or_default(); + let status = entry["status"].as_str().unwrap_or_default(); + if matches!(status, "running" | "queued" | "suspended") { + return format!("Step \"{step}\" (job {job_id}) has not completed yet — status: {status}."); + } + let Some(prefix) = entry["result_prefix"].as_str() else { + return format!("Step \"{step}\" (job {job_id}, {status}) has no recorded result."); + }; + let received = prefix.chars().count() as u64; + let total = entry["result_length"].as_u64().unwrap_or(received); + let truncated = if total > received { + format!("\n… (result truncated: {total} chars total)") + } else { + String::new() + }; + format!("Step \"{step}\" (job {job_id}, {status}) result:\n{prefix}{truncated}") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(overrides: Value) -> Value { + let mut entry = json!({ + "job_id": "j", + "label": "Flow", + "kind": "script", + "depth": 1, + "sibling_index": 1, + "sibling_count": 1, + "status": "success", + "success": true, + "result_prefix": "\"ok\"", + "result_length": 4, + }); + for (key, value) in overrides.as_object().unwrap() { + entry[key] = value.clone(); + } + entry + } + + fn root() -> Value { + entry(json!({ "kind": "flow", "depth": 0, "step_path": "" })) + } + + fn shape(entries: Vec<Value>) -> Value { + serde_json::from_str(&shape_flow_run_tree( + &json!({ "entries": entries }), + &Map::new(), + )) + .unwrap() + } + + fn loop_entries(n: i64, failed: &[i64]) -> Vec<Value> { + let mut entries = vec![root()]; + for i in 1..=n { + entries.push(entry(json!({ + "step_path": "l", "flow_step_id": "l", "kind": "flow", + "parent_module_type": "forloopflow", "sibling_index": i, "sibling_count": n, + "status": if failed.contains(&i) { "failure" } else { "success" }, + "success": !failed.contains(&i), + }))); + } + entries + } + + #[test] + fn subflow_steps_nest_under_their_parent_step() { + let rendered = shape(vec![ + root(), + entry(json!({ "step_path": "a", "flow_step_id": "a" })), + entry(json!({ "step_path": "b", "flow_step_id": "b", "kind": "flow" })), + entry(json!({ + "step_path": "b/c", "flow_step_id": "c", "depth": 2, "status": "failure", + "success": false, "result_prefix": "{\"error\":\"boom\"}", "result_length": 16, + })), + ]); + assert_eq!(rendered["run"]["status"], "success"); + assert!(rendered["run"].get("step").is_none()); + let step_b = &rendered["steps"][1]; + assert_eq!(step_b["step"], "b"); + assert_eq!(step_b["steps"][0]["step"], "b/c"); + assert_eq!(step_b["steps"][0]["result"], "{\"error\":\"boom\"}"); + assert!(rendered["hint"].is_string()); + } + + #[test] + fn loop_iterations_collapse_to_a_tally() { + let looped = &shape(loop_entries(50, &[3, 7, 21, 30]))["steps"][0]; + assert_eq!(looped["type"], "forloopflow"); + assert_eq!(looped["iterations"], 50); + assert_eq!(looped["ok"], 46); + assert_eq!(looped["failed_iterations"], json!([3, 7, 21, 30])); + let shown: Vec<&Value> = looped["iterations_shown"] + .as_array() + .unwrap() + .iter() + .map(|i| &i["iteration"]) + .collect(); + assert_eq!(shown, [&json!(3), &json!(7), &json!(21), &json!(50)]); + assert_eq!(looped["iterations_elided"], 46); + + let mut entries = loop_entries(4, &[]); + entries[2] = entry(json!({ + "step_path": "l", "flow_step_id": "l", "parent_module_type": "forloopflow", + "sibling_index": 2, "sibling_count": 4, "status": "running", + "success": null, "result_prefix": null, "result_length": null, + })); + entries[3]["status"] = json!("skipped"); + let looped = &shape(entries)["steps"][0]; + assert_eq!(looped["ok"], 2); + assert_eq!(looped["unfinished"], 1); + assert_eq!(looped["skipped"], 1); + assert!(looped.get("failed_iterations").is_none()); + } + + #[test] + fn retried_steps_are_attempts_of_one_step() { + let mut entries = vec![root()]; + for i in 1..=3 { + entries.push(entry(json!({ + "step_path": "a", "flow_step_id": "a", "label": format!("Step a (attempt {i}/3)"), + "parent_module_type": "rawscript", "sibling_index": i, "sibling_count": 3, + "status": if i < 3 { "failure" } else { "success" }, "success": i == 3, + }))); + } + let step = &shape(entries)["steps"][0]; + assert!(step.get("iterations").is_none()); + assert_eq!(step["attempts"], 3); + assert_eq!(step["label"], "Step a (attempt 3/3)"); + assert_eq!(step["previous_attempts"][1]["status"], "failure"); + } + + #[test] + fn result_heads_shrink_to_fit_and_run_payloads_do_not_count() { + let mut entries = vec![root()]; + for i in 0..60 { + entries.push(entry(json!({ + "step_path": format!("s{i}"), "flow_step_id": format!("s{i}"), + "result_prefix": "x".repeat(700), "result_length": 5000, + }))); + } + let mut overrides = Map::new(); + overrides.insert("logs".into(), "y".repeat(12000).into()); + let rendered = shape_flow_run_tree(&json!({ "entries": entries }), &overrides); + assert!(rendered.len() - 12000 <= TREE_TOTAL_BUDGET_CHARS + 100); + let parsed: Value = serde_json::from_str(&rendered).unwrap(); + assert_eq!(parsed["run"]["logs"].as_str().unwrap().len(), 12000); + let step = &parsed["steps"][0]; + assert!(step["result"].as_str().is_none_or(|r| r.len() <= 250)); + assert_eq!(step["result_total_chars"], 5000); + } + + #[test] + fn truncation_counts_utf16_units_without_splitting_a_character() { + let text = format!("{}😀b", "a".repeat(3)); + assert_eq!(js_head(&text, 4), "aaa"); + assert_eq!(js_head(&text, 5), "aaa😀"); + assert_eq!(js_tail(&text, 2), "b"); + let long = "é".repeat(STEP_RESULT_MAX_CHARS + 5); + assert!(cap(&long, true).starts_with("… (truncated: 12005 chars total)\né")); + assert_eq!(cap("short", false), "short"); + } + + #[test] + fn args_conform_to_the_schema_and_read_back_redacted() { + let schema = json!({ "properties": { + "name": { "type": "string" }, + "locked": { "type": "number", "disabled": true, "default": 1 }, + "creds": { "type": "object", "properties": { "token": { "type": "string", "password": true } } }, + "file": { "type": "string", "contentEncoding": "base64" }, + }}); + let proposed = json!({ + "name": "x", "locked": 2, "extra": true, + "creds": { "token": "s3cret", "user": "u" }, "file": "A".repeat(4096), + }); + let (mut args, undeclared) = + drop_undeclared_args(proposed.as_object().unwrap().clone(), &schema); + assert_eq!(undeclared, ["extra"]); + assert_eq!(enforce_disabled_defaults(&mut args, &schema), ["locked"]); + assert_eq!(args["locked"], 1); + let redacted = redact_args(&args, &schema); + assert_eq!( + redacted["creds"], + json!({ "token": "<hidden>", "user": "u" }) + ); + assert_eq!(redacted["file"], "<file: 3 KB>"); + args["creds"]["token"] = json!("$var:u/admin/secret_arg/abc"); + assert_eq!( + redact_args(&args, &schema)["creds"]["token"], + "$var:u/admin/secret_arg/abc" + ); + } + + #[test] + fn agent_step_form_opens_on_what_the_step_sets() { + let value = json!({ "type": "aiagent", "input_transforms": { + "provider": { "type": "static", "value": { "kind": "openai" } }, + "user_message": { "type": "javascript", "expr": "flow_input.q" }, + "max_iterations": { "type": "static", "value": 10 }, + "temperature": { "type": "static", "value": 0.2 }, + "memory_id": { "type": "static", "value": "stale" }, + }}); + let proposed = json!({ "user_message": "hi", "enabled_tools": null }); + let (keys, args) = agent_step_form(&value, proposed.as_object().unwrap()); + assert_eq!( + keys, + [ + "provider", + "temperature", + "user_message", + "system_prompt", + "memory_id", + "enabled_tools" + ] + ); + assert_eq!(args["user_message"], "hi"); + assert_eq!(args["enabled_tools"], json!([])); + let mut run_args = args.clone(); + run_args.insert("memory_id".into(), json!("")); + let transforms = agent_test_transforms(value.get("input_transforms"), &run_args, &keys); + assert_eq!( + transforms["user_message"]["expr"], + "flow_input.user_message" + ); + assert_eq!(transforms["max_iterations"]["value"], 10); + assert!(!transforms.contains_key("memory_id")); + } +} diff --git a/backend/windmill-api/src/mcp/session/settings.rs b/backend/windmill-api/src/mcp/session/settings.rs new file mode 100644 index 0000000000..3548dada4b --- /dev/null +++ b/backend/windmill-api/src/mcp/session/settings.rs @@ -0,0 +1,140 @@ +//! The workspace settings document, in the layout of the `settings.yaml` the CLI syncs. + +use serde_json::{json, Map, Value}; + +use super::workspace::api_message; +use super::{array, js_head, js_len, js_str, str_arg, Ctx, ToolResult}; + +pub(super) const TOOLS: &[&str] = &["get_workspace_settings", "set_workspace_settings"]; + +pub(super) async fn call(ctx: &Ctx<'_>, name: &str, args: &Value) -> ToolResult { + match name { + "get_workspace_settings" => get_workspace_settings(ctx, args).await, + "set_workspace_settings" => set_workspace_settings(ctx, args).await, + _ => unreachable!("{name} is not a settings tool"), + } +} + +const MAX_READ_CHARS: usize = 20_000; + +async fn get_workspace_settings(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let doc = ctx + .get(&ctx.ws("workspaces/settings_document?secrets=redacted")) + .await?; + let keys: Vec<&str> = array(args.get("keys")) + .iter() + .filter_map(Value::as_str) + .collect(); + let mut picked = doc.clone(); + let mut note = String::new(); + if !keys.is_empty() { + picked = Value::Object( + keys.iter() + .filter_map(|k| Some((k.to_string(), doc.get(k)?.clone()))) + .collect::<Map<_, _>>(), + ); + let unset: Vec<&str> = keys + .iter() + .copied() + .filter(|k| doc.get(k).is_none()) + .collect(); + if !unset.is_empty() { + note = format!( + "\n# Not in the document (unset, or not a setting): {}", + unset.join(", ") + ); + } + } + let mut text = serde_yml::to_string(&picked).map_err(|e| e.to_string())?; + text.push_str(¬e); + Ok(if js_len(&text) > MAX_READ_CHARS { + format!( + "{}\n… truncated at {MAX_READ_CHARS} chars; read the keys you need with `keys`.", + js_head(&text, MAX_READ_CHARS) + ) + } else { + text + }) +} + +fn parse_settings_yaml(text: &str) -> Result<Value, String> { + match serde_yml::from_str::<Value>(text) { + Ok(doc @ Value::Object(_)) => Ok(doc), + Ok(_) => Err("The yaml must be a mapping of setting keys to values.".to_string()), + Err(e) => Err(e.to_string()), + } +} + +/// The dry run stands in for the session's confirmation card: it reports a no-op or an +/// invalid document before anything is written. +async fn set_workspace_settings(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let route = ctx.ws("workspaces/settings_document"); + let document = match parse_settings_yaml(str_arg(args, "yaml")?) { + Ok(document) => document, + Err(e) => return Ok(format!("Nothing was changed: {e}")), + }; + let dry_run = json!({ "document": document, "replace": false, "dry_run": true }); + let dry_run = match ctx.post(&route, dry_run).await { + Ok(result) => result, + Err(e) => return Ok(format!("Nothing was changed: {}", api_message(&e))), + }; + if array(dry_run.get("changes")).is_empty() { + let warnings: Vec<String> = array(dry_run.get("warnings")).iter().map(js_str).collect(); + let warnings = if warnings.is_empty() { + String::new() + } else { + format!(" Warnings: {}", warnings.join(" ")) + }; + return Ok(format!( + "The settings already hold these values: nothing to change.{warnings}" + )); + } + let applied = match ctx + .post(&route, json!({ "document": document, "replace": false })) + .await + { + Ok(result) => result, + Err(e) => return Ok(json!({ "success": false, "error": api_message(&e) }).to_string()), + }; + let keys: Vec<String> = array(applied.get("changes")) + .iter() + .map(|c| js_str(c.get("key").unwrap_or(&Value::Null))) + .collect(); + let mut out = Map::new(); + out.insert("success".into(), json!(true)); + out.insert( + "message".into(), + json!(format!( + "These settings are now applied: {}.", + keys.join(", ") + )), + ); + if let Some(warnings) = applied + .get("warnings") + .filter(|w| !array(Some(w)).is_empty()) + { + out.insert("warnings".into(), warnings.clone()); + } + Ok(Value::Object(out).to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn settings_yaml_must_be_a_mapping() { + assert_eq!( + parse_settings_yaml("webhook: null\ncolor: red").unwrap(), + json!({ "webhook": null, "color": "red" }) + ); + for text in ["- a\n- b", "just text", "42"] { + assert_eq!( + parse_settings_yaml(text).unwrap_err(), + "The yaml must be a mapping of setting keys to values.", + "{text}" + ); + } + assert!(parse_settings_yaml("a: [").is_err()); + } +} diff --git a/backend/windmill-api/src/mcp/session/workspace.rs b/backend/windmill-api/src/mcp/session/workspace.rs new file mode 100644 index 0000000000..3e3cf45459 --- /dev/null +++ b/backend/windmill-api/src/mcp/session/workspace.rs @@ -0,0 +1,1975 @@ +//! Listing and reading workspace items, and the draft store the write tools share. + +use std::collections::HashMap; + +use serde_json::{json, Map, Value}; + +use super::{ + array, bool_arg, enc, is_not_found, js_len, js_str, non_null, opt_str_arg, pretty, put, + str_arg, truthy, Ctx, ToolResult, +}; + +pub(super) const TOOLS: &[&str] = &[ + "list_workspace_items", + "read_workspace_item", + "write_script", + "edit_script", + "discard_local_draft", + "rebase_draft", + "list_pipelines", +]; + +pub(super) async fn call(ctx: &Ctx<'_>, name: &str, args: &Value) -> ToolResult { + match name { + "list_workspace_items" => list_workspace_items(ctx, args).await, + "read_workspace_item" => read_workspace_item(ctx, args).await, + "write_script" => { + write_script_draft( + ctx, + str_arg(args, "path")?, + opt_str_arg(args, "summary"), + str_arg(args, "language")?, + str_arg(args, "content")?, + bool_arg(args, "override"), + ) + .await + } + "edit_script" => edit_script(ctx, args).await, + "discard_local_draft" => discard_local_draft(ctx, args).await, + "rebase_draft" => rebase_draft(ctx, args).await, + "list_pipelines" => { + let pipelines = ctx.get(&ctx.ws("assets/pipelines")).await?; + Ok(pipelines.to_string()) + } + _ => unreachable!("{name} is not a workspace tool"), + } +} + +// ============================================================================ +// Shared helpers: the per-user draft store and the item shapes built on it. +// +// Vocabulary. An item *type* is what a tool takes (`script`, `flow`, `schedule`, +// `trigger`, `resource`, `variable`, `app`); a draft *kind* is what the drafts API +// stores under (`script`, `flow`, `raw_app`, `trigger_schedule`, `trigger_<kind>`, +// `resource`, `variable`). An *item* is the JSON object the tools show the model +// (`{type, path, summary, value, isDraft, ...}`). A draft's *value* is stored exactly +// as the Windmill editors store it, so either side opens the other's drafts: +// script the `NewScript` body (`path, summary, description, content, schema, +// is_template, language, kind`, + `parent_hash` once forked from a deploy) +// flow the `Flow` object (`path, summary, description, value, schema`, + +// `version_id` when forked from a deploy) +// raw_app `{summary, files, runnables, data, policy?, custom_path?, +// parent_version?, draft_path?}` (`normalize_app_draft`) +// trigger_schedule the schedule request body; `trigger_<kind>` that trigger's body +// resource `{path, description, args, labels, wsSpecific, resource_type}` +// variable `{path, variable: {value, is_secret, description}, labels, +// wsSpecific, account?, is_oauth?, expires_at?}` +// ============================================================================ + +pub(super) const TRIGGER_KINDS: &[&str] = &[ + "http", + "websocket", + "kafka", + "nats", + "postgres", + "mqtt", + "amqp", + "sqs", + "gcp", + "azure", + "email", +]; + +/// The item types in the order a listing reports them. +pub(super) const ITEM_TYPES: &[&str] = &[ + "script", "flow", "schedule", "trigger", "resource", "variable", "app", +]; + +/// Folders and groups only carry permissions, so they are listed and read but kept out of +/// `ITEM_TYPES`, which the draft, deploy and delete tools share. +const ACCESS_TYPES: &[&str] = &["folder", "group"]; + +pub(super) const FLOW_VALUE_SETTINGS_KEYS: &[&str] = &[ + "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", +]; + +const MAX_LIST_LIMIT: u64 = 100; + +pub(super) fn item_type_arg(args: &Value) -> Result<&str, String> { + let item_type = str_arg(args, "type")?; + if ITEM_TYPES.contains(&item_type) { + Ok(item_type) + } else { + Err(format!("Invalid type \"{item_type}\"")) + } +} + +fn object_or_empty(value: Option<&Value>) -> Value { + match value { + Some(Value::Object(o)) => Value::Object(o.clone()), + _ => json!({}), + } +} + +pub(super) fn api_message(error: &str) -> &str { + match error.split_once(": ") { + Some((status, rest)) if status.len() == 3 && status.bytes().all(|b| b.is_ascii_digit()) => { + rest + } + _ => error, + } +} + +pub(super) fn path_enc(path: &str) -> String { + path.split('/').map(enc).collect::<Vec<_>>().join("/") +} + +pub(super) fn draft_kind(item_type: &str, trigger_kind: Option<&str>) -> Option<String> { + match item_type { + "script" | "flow" | "resource" | "variable" => Some(item_type.to_string()), + "app" => Some("raw_app".to_string()), + "schedule" => Some("trigger_schedule".to_string()), + "trigger" => trigger_kind + .filter(|kind| TRIGGER_KINDS.contains(kind)) + .map(|kind| format!("trigger_{kind}")), + _ => None, + } +} + +/// The item type (and trigger kind) a draft kind surfaces as. None for the kinds no +/// tool addresses: low-code apps, webhook and poll triggers, pipelines. +fn item_type_of_kind(kind: &str) -> Option<(&'static str, Option<&str>)> { + Some(match kind { + "script" => ("script", None), + "flow" => ("flow", None), + "resource" => ("resource", None), + "variable" => ("variable", None), + "raw_app" => ("app", None), + "trigger_schedule" => ("schedule", None), + _ => { + let trigger_kind = kind.strip_prefix("trigger_")?; + if !TRIGGER_KINDS.contains(&trigger_kind) { + return None; + } + ("trigger", Some(trigger_kind)) + } + }) +} + +fn item_key(item_type: &str, path: &str, trigger_kind: Option<&str>) -> String { + if item_type == "trigger" { + format!("trigger:{}:{path}", trigger_kind.unwrap_or_default()) + } else { + format!("{item_type}:{path}") + } +} + +pub(super) fn service(item_type: &str, trigger_kind: Option<&str>) -> Result<String, String> { + Ok(match item_type { + "trigger" => { + let kind = trigger_kind + .filter(|kind| TRIGGER_KINDS.contains(kind)) + .ok_or("trigger_kind is required when type is trigger.")?; + format!("{kind}_triggers") + } + "script" | "flow" | "schedule" | "resource" | "variable" | "app" => { + format!("{item_type}s") + } + _ => return Err(format!("Invalid type \"{item_type}\"")), + }) +} + +pub(super) fn exists_route( + ctx: &Ctx<'_>, + item_type: &str, + trigger_kind: Option<&str>, + path: &str, +) -> Result<String, String> { + let by_path = if item_type == "script" { "p/" } else { "" }; + Ok(ctx.ws(&format!( + "{}/exists/{by_path}{}", + service(item_type, trigger_kind)?, + path_enc(path) + ))) +} + +pub(super) fn get_route( + ctx: &Ctx<'_>, + item_type: &str, + trigger_kind: Option<&str>, + path: &str, +) -> Result<String, String> { + let by_path = if matches!(item_type, "script" | "app") { + "p/" + } else { + "" + }; + // A variable's value never reaches a tool, so it is not decrypted to begin with. + let query = if item_type == "variable" { + "?decrypt_secret=false" + } else { + "" + }; + Ok(ctx.ws(&format!( + "{}/get/{by_path}{}{query}", + service(item_type, trigger_kind)?, + path_enc(path) + ))) +} + +pub(super) async fn item_exists( + ctx: &Ctx<'_>, + item_type: &str, + trigger_kind: Option<&str>, + path: &str, +) -> Result<bool, String> { + let exists = ctx + .get(&exists_route(ctx, item_type, trigger_kind, path)?) + .await?; + Ok(exists.as_bool().unwrap_or(false)) +} + +/// `exists` hides an archived script, which its path still resolves to and a delete by +/// path still removes, so an archived script counts as deployed. +pub(super) async fn deployed_item_exists( + ctx: &Ctx<'_>, + item_type: &str, + trigger_kind: Option<&str>, + path: &str, +) -> Result<bool, String> { + if item_exists(ctx, item_type, trigger_kind, path).await? { + return Ok(true); + } + if item_type != "script" { + return Ok(false); + } + match get_deployed(ctx, "script", None, path).await { + Ok(_) => Ok(true), + Err(e) if is_not_found(&e) => Ok(false), + Err(e) => Err(e), + } +} + +pub(super) async fn get_deployed( + ctx: &Ctx<'_>, + item_type: &str, + trigger_kind: Option<&str>, + path: &str, +) -> Result<Value, String> { + ctx.get(&get_route(ctx, item_type, trigger_kind, path)?) + .await +} + +pub(super) struct Draft { + pub value: Value, + pub created_at: Option<String>, +} + +pub(super) async fn get_draft( + ctx: &Ctx<'_>, + kind: &str, + path: &str, +) -> Result<Option<Draft>, String> { + let row = ctx + .get(&ctx.ws(&format!("drafts/get_own/{kind}/{}", path_enc(path)))) + .await?; + let Some(value) = non_null(row.get("value")).cloned() else { + return Ok(None); + }; + let created_at = row + .get("created_at") + .and_then(Value::as_str) + .map(String::from); + Ok(Some(Draft { value, created_at })) +} + +pub(super) async fn save_draft( + ctx: &Ctx<'_>, + kind: &str, + path: &str, + value: Option<&Value>, + last_sync: Option<&str>, + force: bool, +) -> Result<bool, String> { + let mut body = Map::new(); + body.insert("value".into(), value.cloned().unwrap_or(Value::Null)); + if !force { + put(&mut body, "last_sync", last_sync.map(Value::from)); + } + body.insert("force".into(), json!(force)); + let response = ctx + .post( + &ctx.ws(&format!("drafts/update/{kind}/{}", path_enc(path))), + Value::Object(body), + ) + .await?; + Ok(response.get("status").and_then(Value::as_str) != Some("conflict")) +} + +pub(super) async fn delete_draft( + ctx: &Ctx<'_>, + kind: &str, + path: &str, + last_sync: Option<&str>, +) -> Result<(), String> { + match save_draft(ctx, kind, path, None, last_sync, false).await { + Ok(true) => Ok(()), + Ok(false) => Err(format!( + "Draft \"{path}\" changed externally since you last read it; it was not removed. Re-read and retry." + )), + Err(e) => Err(api_message(&e).to_string()), + } +} + +pub(super) async fn list_drafts(ctx: &Ctx<'_>) -> Result<Vec<Value>, String> { + let rows = ctx.get(&ctx.ws("drafts/list")).await?; + let rows = array(Some(&rows)); + let mut drafts = Map::new(); + for row in rows { + let (Some(kind), Some(path)) = ( + row.get("kind").and_then(Value::as_str), + row.get("path").and_then(Value::as_str), + ) else { + continue; + }; + let Some((item_type, trigger_kind)) = item_type_of_kind(kind) else { + continue; + }; + let mut item = Map::new(); + item.insert("type".into(), json!(item_type)); + item.insert("path".into(), json!(path)); + put( + &mut item, + "draftPath", + row.get("draft_path") + .filter(|p| p.as_str() != Some(path)) + .cloned(), + ); + put(&mut item, "summary", row.get("summary").cloned()); + item.insert("isDraft".into(), json!(true)); + put(&mut item, "triggerKind", trigger_kind.map(Value::from)); + if kind == "raw_app" { + item.insert("rawApp".into(), json!(true)); + } + drafts.insert(item_key(item_type, path, trigger_kind), Value::Object(item)); + } + Ok(drafts.into_iter().map(|(_, item)| item).collect()) +} + +pub(super) fn normalize_app_draft(value: &Value) -> Value { + let mut out = Map::new(); + put(&mut out, "summary", value.get("summary").cloned()); + out.insert("files".into(), object_or_empty(value.get("files"))); + out.insert("runnables".into(), object_or_empty(value.get("runnables"))); + out.insert( + "data".into(), + non_null(value.get("data")) + .cloned() + .unwrap_or_else(|| json!({ "tables": [] })), + ); + for key in ["policy", "custom_path", "parent_version", "draft_path"] { + put(&mut out, key, value.get(key).cloned()); + } + Value::Object(out) +} + +pub(super) fn draft_item(kind: &str, path: &str, draft: &Value) -> Option<Value> { + let (item_type, trigger_kind) = item_type_of_kind(kind)?; + let field = |key: &str| draft.get(key).cloned(); + let mut item = Map::new(); + item.insert("type".into(), json!(item_type)); + put(&mut item, "triggerKind", trigger_kind.map(Value::from)); + item.insert("path".into(), json!(path)); + // A staged name that repeats the storage path tells the reader nothing. + let draft_path = |value: &Value| { + value + .get("draft_path") + .filter(|p| p.as_str() != Some(path)) + .cloned() + }; + match item_type { + "script" => { + put(&mut item, "draftPath", draft_path(draft)); + put(&mut item, "summary", field("summary")); + put(&mut item, "language", field("language")); + put(&mut item, "value", field("content")); + put(&mut item, "schema", field("schema")); + put(&mut item, "parentHash", field("parent_hash")); + } + "flow" => { + put(&mut item, "draftPath", draft_path(draft)); + put(&mut item, "summary", field("summary")); + put(&mut item, "parentVersionId", field("version_id")); + let or_null = |v: Option<&Value>| v.cloned().unwrap_or(Value::Null); + item.insert( + "value".into(), + json!({ + "value": or_null(draft.get("value")), + "schema": or_null(draft.get("schema")), + "groups": or_null(draft.get("value").and_then(|v| v.get("groups"))), + "description": or_null(draft.get("description")), + }), + ); + } + "app" => { + let value = normalize_app_draft(draft); + put(&mut item, "draftPath", draft_path(&value)); + put(&mut item, "summary", value.get("summary").cloned()); + put( + &mut item, + "parentVersionId", + value.get("parent_version").cloned(), + ); + item.insert("value".into(), value); + item.insert("rawApp".into(), json!(true)); + } + "schedule" => { + put( + &mut item, + "summary", + non_null(draft.get("summary")).cloned(), + ); + item.insert("value".into(), draft.clone()); + } + "trigger" => { + put( + &mut item, + "summary", + field("summary").filter(|s| truthy(Some(s))), + ); + item.insert("value".into(), draft.clone()); + } + "resource" => { + put( + &mut item, + "summary", + field("description").filter(|d| truthy(Some(d))), + ); + let mut value = Map::new(); + value.insert("path".into(), json!(path)); + put(&mut value, "value", field("args")); + put(&mut value, "description", field("description")); + value.insert( + "resource_type".into(), + non_null(draft.get("resource_type")) + .cloned() + .unwrap_or_else(|| json!("")), + ); + put(&mut value, "labels", field("labels")); + put(&mut value, "ws_specific", field("wsSpecific")); + item.insert("value".into(), Value::Object(value)); + } + "variable" => { + let variable = draft.get("variable").cloned().unwrap_or(Value::Null); + put( + &mut item, + "summary", + variable + .get("description") + .filter(|d| truthy(Some(d))) + .cloned(), + ); + put(&mut item, "isSecret", variable.get("is_secret").cloned()); + let mut value = Map::new(); + value.insert("path".into(), json!(path)); + put(&mut value, "value", variable.get("value").cloned()); + put(&mut value, "is_secret", variable.get("is_secret").cloned()); + put( + &mut value, + "description", + variable.get("description").cloned(), + ); + 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, "labels", field("labels")); + put(&mut value, "ws_specific", field("wsSpecific")); + item.insert("value".into(), Value::Object(value)); + } + _ => return None, + } + item.insert("isDraft".into(), json!(true)); + Some(Value::Object(item)) +} + +pub(super) async fn get_draft_item( + ctx: &Ctx<'_>, + item_type: &str, + path: &str, + trigger_kind: Option<&str>, +) -> Result<Option<(Value, Draft)>, String> { + let Some(kind) = draft_kind(item_type, trigger_kind) else { + return Ok(None); + }; + let Some(draft) = get_draft(ctx, &kind, path).await? else { + return Ok(None); + }; + Ok(draft_item(&kind, path, &draft.value).map(|item| (item, draft))) +} + +fn stash_inline_script( + value: &mut Map<String, Value>, + id: &str, + scripts: &mut HashMap<String, String>, +) { + if let Some(Value::String(content)) = value.get("content") { + scripts.insert(id.to_string(), content.clone()); + value.insert("content".into(), json!(format!("inline_script.{id}"))); + } +} + +fn compact_modules(modules: Option<&Value>, scripts: &mut HashMap<String, String>) -> Value { + let Some(modules) = modules.and_then(Value::as_array) else { + return json!([]); + }; + Value::Array( + modules + .iter() + .map(|module| compact_module(module, scripts)) + .collect(), + ) +} + +fn compact_branches(value: &mut Map<String, Value>, scripts: &mut HashMap<String, String>) { + let Some(Value::Array(branches)) = value.get_mut("branches") else { + return; + }; + for branch in branches { + if let Value::Object(branch) = branch { + let modules = compact_modules(branch.get("modules"), scripts); + branch.insert("modules".into(), modules); + } + } +} + +fn compact_module(module: &Value, scripts: &mut HashMap<String, String>) -> Value { + let mut module = module.clone(); + let id = module + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let Some(value) = module.get_mut("value").and_then(Value::as_object_mut) else { + return module; + }; + match value.get("type").and_then(Value::as_str) { + Some("rawscript") => stash_inline_script(value, &id, scripts), + Some("forloopflow" | "whileloopflow") if truthy(value.get("modules")) => { + let modules = compact_modules(value.get("modules"), scripts); + value.insert("modules".into(), modules); + } + Some("branchone") => { + compact_branches(value, scripts); + if truthy(value.get("default")) { + let default = compact_modules(value.get("default"), scripts); + value.insert("default".into(), default); + } + } + Some("branchall") => compact_branches(value, scripts), + Some("aiagent") => { + if let Some(Value::Array(tools)) = value.get_mut("tools") { + for tool in tools { + let tool_id = tool + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let Some(tool_value) = tool.get_mut("value").and_then(Value::as_object_mut) + else { + continue; + }; + let is = |key: &str, expected: &str| { + tool_value.get(key).and_then(Value::as_str) == Some(expected) + }; + if is("tool_type", "flowmodule") && is("type", "rawscript") { + stash_inline_script(tool_value, &tool_id, scripts); + } + } + } + } + _ => {} + } + module +} + +pub(super) fn compact_flow( + value: &Value, + schema: Option<&Value>, +) -> (Value, HashMap<String, String>) { + let mut scripts = HashMap::new(); + let mut out = Map::new(); + out.insert( + "modules".into(), + compact_modules(value.get("modules"), &mut scripts), + ); + out.insert("schema".into(), schema.cloned().unwrap_or(Value::Null)); + for key in ["preprocessor_module", "failure_module"] { + let mut module = value.get(key).cloned().unwrap_or(Value::Null); + let id = module + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + if let Some(module_value) = module.get_mut("value").and_then(Value::as_object_mut) { + if module_value.get("type").and_then(Value::as_str) == Some("rawscript") { + stash_inline_script(module_value, &id, &mut scripts); + } + } + out.insert(key.into(), module); + } + for key in ["groups", "notes"] { + out.insert(key.into(), value.get(key).cloned().unwrap_or(Value::Null)); + } + for key in FLOW_VALUE_SETTINGS_KEYS { + put(&mut out, key, value.get(*key).cloned()); + } + (Value::Object(out), scripts) +} + +pub(super) fn app_source_to_draft_value(app: &Value, fallback: Option<&Value>) -> Value { + // A deployed app nests its source under `value`; a draft carries it at the top. + let source = app.get("value").filter(|v| !v.is_null()).unwrap_or(app); + let data = source.get("data"); + let creation = data.and_then(|d| d.get("creation")); + let tables_only = |tables: &Value| json!({ "tables": tables }); + let data = if truthy(creation) { + let mut out = Map::new(); + out.insert( + "tables".into(), + data.and_then(|d| non_null(d.get("tables"))) + .cloned() + .unwrap_or_else(|| json!([])), + ); + put( + &mut out, + "datatable", + creation.and_then(|c| c.get("datatable")).cloned(), + ); + put( + &mut out, + "schema", + creation.and_then(|c| c.get("schema")).cloned(), + ); + Value::Object(out) + } else if truthy(data) { + data.cloned().unwrap_or_default() + } else if truthy(source.get("datatables")) { + tables_only(&source["datatables"]) + } else if truthy(source.get("dataTableRefs")) { + tables_only(&source["dataTableRefs"]) + } else { + tables_only(&json!([])) + }; + let or_fallback = |key: &str| { + non_null(app.get(key)) + .cloned() + .or_else(|| fallback?.get(key).cloned()) + }; + let mut out = Map::new(); + out.insert( + "summary".into(), + non_null(app.get("summary")) + .cloned() + .unwrap_or_else(|| json!("")), + ); + out.insert("files".into(), object_or_empty(source.get("files"))); + out.insert("runnables".into(), object_or_empty(source.get("runnables"))); + out.insert("data".into(), data); + put(&mut out, "policy", or_fallback("policy")); + put(&mut out, "custom_path", or_fallback("custom_path")); + // The fork base: the head of a deployed app's versions, or what a draft already pinned. + let head = app + .get("versions") + .and_then(Value::as_array) + .and_then(|versions| versions.last()) + .filter(|v| !v.is_null()) + .cloned(); + put( + &mut out, + "parent_version", + non_null(app.get("parent_version")) + .cloned() + .or(head) + .or_else(|| fallback?.get("parent_version").cloned()), + ); + Value::Object(out) +} + +pub(super) fn summarize_app_value(value: &Value) -> Value { + let entries = |key: &str| { + value + .get(key) + .and_then(Value::as_object) + .cloned() + .unwrap_or_default() + }; + let frontend: Vec<Value> = entries("files") + .iter() + .map(|(path, content)| json!({ "path": path, "size": content.as_str().map(js_len).unwrap_or(0) })) + .collect(); + let backend: Vec<Value> = entries("runnables") + .iter() + .map(|(key, runnable)| { + let mut out = Map::new(); + out.insert("key".into(), json!(key)); + if !truthy(Some(runnable)) { + out.insert("name".into(), json!(key)); + out.insert("type".into(), json!("inline")); + return Value::Object(out); + } + out.insert( + "name".into(), + non_null(runnable.get("name")) + .cloned() + .unwrap_or_else(|| json!(key)), + ); + let kind = runnable.get("type").and_then(Value::as_str); + let by_path = matches!(kind, Some("path" | "runnableByPath")); + let run_type = match runnable.get("runType").and_then(Value::as_str) { + _ if !by_path => "inline", + Some("flow") => "flow", + Some("hubscript") => "hubscript", + _ => "script", + }; + out.insert("type".into(), json!(run_type)); + if by_path && truthy(runnable.get("path")) { + out.insert("path".into(), runnable["path"].clone()); + } + let inline = runnable.get("inlineScript").filter(|s| truthy(Some(s))); + if let Some(inline) = + inline.filter(|_| matches!(kind, Some("inline" | "runnableByName"))) + { + let language = match inline.get("language") { + Some(Value::String(l)) if l == "nativets" || l == "deno" => Some(json!("bun")), + other => other.cloned(), + }; + put(&mut out, "language", language); + let content = inline + .get("content") + .and_then(Value::as_str) + .unwrap_or_default(); + out.insert("contentSize".into(), json!(js_len(content))); + } + let static_keys: Vec<&String> = runnable + .get("fields") + .and_then(Value::as_object) + .map(|fields| { + fields + .iter() + .filter(|(_, v)| v.get("type").and_then(Value::as_str) == Some("static")) + .map(|(k, _)| k) + .collect() + }) + .unwrap_or_default(); + if !static_keys.is_empty() { + out.insert("staticInputKeys".into(), json!(static_keys)); + } + Value::Object(out) + }) + .collect(); + let mut out = Map::new(); + out.insert("frontend".into(), Value::Array(frontend)); + out.insert("backend".into(), Value::Array(backend)); + if truthy(value.get("data")) { + out.insert("data".into(), value["data"].clone()); + } + Value::Object(out) +} + +/// `guest` is stored on an app whether or not anyone is admitted by it, so the mode +/// alone would say strangers can open an app that admits members only. +pub(super) async fn describe_app_exposure(ctx: &Ctx<'_>, mode: Option<&Value>) -> Option<Value> { + if mode.and_then(Value::as_str) != Some("guest") { + return mode.cloned(); + } + let usage = ctx.get(&ctx.ws("workspaces/guest_usage")).await; + let settings = ctx.get(&ctx.ws("workspaces/get_public_settings")).await; + // A switch that could not be read proves nothing either way: report the mode bare. + let (Ok(usage), Ok(settings)) = (usage, settings) else { + return mode.cloned(); + }; + let live = truthy(usage.get("available")) + && truthy(usage.get("instance_enabled")) + && truthy(settings.get("guest_access_enabled")); + if live { + mode.cloned() + } else { + Some(json!( + "guest, but inert: the instance or workspace admits no guest, so this app admits members only" + )) + } +} + +pub(super) fn serialize_for_read(item: &Value, execution_mode: Option<Value>) -> Value { + let item_type = item.get("type").and_then(Value::as_str); + let mut out = Map::new(); + let copy = |out: &mut Map<String, Value>, keys: &[&str]| { + for key in keys { + put(out, key, item.get(*key).cloned()); + } + }; + match item_type { + Some("variable") => copy( + &mut out, + &[ + "type", + "path", + "summary", + "isSecret", + "extraPerms", + "isDraft", + ], + ), + Some("app") if item.get("value").is_some_and(|v| v.get("files").is_some()) => { + copy(&mut out, &["type", "path", "summary"]); + out.insert("value".into(), summarize_app_value(&item["value"])); + copy(&mut out, &["rawApp"]); + put( + &mut out, + "executionMode", + execution_mode.or_else(|| item.get("executionMode").cloned()), + ); + copy(&mut out, &["extraPerms", "isDraft"]); + } + Some("flow") if truthy(item.get("value")) => { + let flow = &item["value"]; + // A draft carries `groups` beside the flow value; a set one outranks the value's. + let mut value = flow.get("value").cloned().unwrap_or_else(|| json!({})); + if let (Some(groups), Some(value)) = (flow.get("groups"), value.as_object_mut()) { + if groups.is_null() { + value.remove("groups"); + } else { + value.insert("groups".into(), groups.clone()); + } + } + copy(&mut out, &["type", "path", "summary"]); + out.insert( + "value".into(), + compact_flow(&value, non_null(flow.get("schema"))).0, + ); + copy(&mut out, &["extraPerms", "isDraft"]); + } + _ => { + let mut item = item.clone(); + if let (Some(mode), Some(item)) = (execution_mode, item.as_object_mut()) { + item.insert("executionMode".into(), mode); + } + return item; + } + } + Value::Object(out) +} + +pub(super) struct LoadedScript { + pub content: String, + pub language: String, + pub summary: Option<String>, + pub schema: Option<Value>, +} + +pub(super) async fn load_script(ctx: &Ctx<'_>, path: &str) -> Result<LoadedScript, String> { + let text = |value: &Value, key: &str| value.get(key).and_then(Value::as_str).map(String::from); + if let Some((item, _)) = get_draft_item(ctx, "script", path, None).await? { + let (Some(content), Some(language)) = ( + text(&item, "value"), + text(&item, "language").filter(|l| !l.is_empty()), + ) else { + return Err(format!( + "Draft script \"{path}\" is missing content or language." + )); + }; + return Ok(LoadedScript { + content, + language, + summary: text(&item, "summary"), + schema: non_null(item.get("schema")).cloned(), + }); + } + let script = get_deployed(ctx, "script", None, path).await?; + Ok(LoadedScript { + content: text(&script, "content").unwrap_or_default(), + language: text(&script, "language").unwrap_or_default(), + summary: text(&script, "summary"), + schema: non_null(script.get("schema")).cloned(), + }) +} + +pub(super) struct LoadedFlow { + /// `{value, schema, groups}`, plus `description` when read from a draft. + pub flow: Value, + pub summary: Option<String>, + pub is_draft: bool, +} + +pub(super) async fn load_flow(ctx: &Ctx<'_>, path: &str) -> Result<LoadedFlow, String> { + let summary = |value: &Value| { + value + .get("summary") + .and_then(Value::as_str) + .map(String::from) + }; + if let Some((item, _)) = get_draft_item(ctx, "flow", path, None).await? { + let Some(flow) = item.get("value").filter(|v| v.is_object()) else { + return Err(format!("Draft flow \"{path}\" has no value.")); + }; + return Ok(LoadedFlow { flow: flow.clone(), summary: summary(&item), is_draft: true }); + } + let flow = get_deployed(ctx, "flow", None, path).await?; + Ok(LoadedFlow { flow: deployed_flow_value(&flow), summary: summary(&flow), is_draft: false }) +} + +fn deployed_flow_value(flow: &Value) -> Value { + let mut out = Map::new(); + put(&mut out, "value", flow.get("value").cloned()); + put(&mut out, "schema", flow.get("schema").cloned()); + out.insert( + "groups".into(), + flow.get("value") + .and_then(|v| non_null(v.get("groups"))) + .cloned() + .unwrap_or(Value::Null), + ); + Value::Object(out) +} + +pub(super) struct WriteBase { + pub base: Option<Value>, + pub from_draft: bool, + pub last_sync: Option<String>, +} + +pub(super) async fn write_base( + ctx: &Ctx<'_>, + item_type: &str, + path: &str, + trigger_kind: Option<&str>, +) -> Result<WriteBase, String> { + // A failed draft read must not fall through to the deployed item: the write would + // then be built on it and replace the draft's own changes. + let draft = match draft_kind(item_type, trigger_kind) { + Some(kind) => get_draft(ctx, &kind, path).await?, + None => None, + }; + if let Some(draft) = draft { + return Ok(WriteBase { + base: Some(draft.value), + from_draft: true, + last_sync: draft.created_at, + }); + } + let base = if item_exists(ctx, item_type, trigger_kind, path).await? { + Some(get_deployed(ctx, item_type, trigger_kind, path).await?) + } else { + None + }; + Ok(WriteBase { base, from_draft: false, last_sync: None }) +} + +pub(super) enum Persist { + Saved(Value), + Failed(String), +} + +pub(super) async fn persist_draft( + ctx: &Ctx<'_>, + item_type: &str, + path: &str, + trigger_kind: Option<&str>, + value: &Value, + last_sync: Option<&str>, + force: bool, +) -> Result<Persist, String> { + let kind = draft_kind(item_type, trigger_kind) + .ok_or_else(|| format!("Unsupported draft type \"{item_type}\"."))?; + let item = draft_item(&kind, path, value) + .ok_or_else(|| format!("Could not synthesize {item_type} draft \"{path}\"."))?; + let failure = match save_draft(ctx, &kind, path, Some(value), last_sync, force).await { + Ok(true) => return Ok(Persist::Saved(item)), + Ok(false) => json!({ + "success": false, + "conflict": true, + "message": format!("The {item_type} draft \"{path}\" changed externally since you last read it. Re-run this tool to merge onto the latest version, or pass override:true to overwrite. If an editor for it is open, a conflict dialog is also shown there."), + }), + Err(e) => json!({ + "success": false, + "error": true, + "message": format!("The {item_type} draft \"{path}\" could NOT be saved ({}). The change was not persisted — retry; do not assume it succeeded.", api_message(&e)), + }), + }; + Ok(Persist::Failed(pretty(&failure))) +} + +/// Whether a resource's draft or deployed value is an AI agent, which has its own page. +pub(super) fn is_agent(value: &Value) -> bool { + value.get("resource_type").and_then(Value::as_str) == Some("ai_agent") +} + +/// The editor that shows the user's draft: a detail page shows only the deployed version. +fn draft_page(item_type: &str, path: &str, value: &Value) -> Option<String> { + Some(match item_type { + "script" => format!("/scripts/edit/{path}?nomenubar=true"), + "flow" => format!("/flows/edit/{path}?nomenubar=true"), + "resource" if is_agent(value) => format!("/agents/edit/{path}?nomenubar=true"), + _ => return None, + }) +} + +pub(super) fn finish_draft_write(ctx: &Ctx<'_>, item: &Value, existed: bool) -> String { + let text = |key: &str| item.get(key).and_then(Value::as_str).unwrap_or_default(); + let (item_type, path) = (text("type"), text("path")); + let verb = if existed { "Updated" } else { "Created" }; + let mut out = Map::new(); + out.insert("success".into(), json!(true)); + out.insert( + "message".into(), + json!(format!("{verb} {item_type} \"{path}\" as a per-user draft (saved server-side, visible only to this user — not a deployed workspace item). It was not deployed.")), + ); + let value = item.get("value").unwrap_or(&Value::Null); + if let Some(page) = draft_page(item_type, path, value) { + out.insert("url".into(), json!(ctx.page_url(&page))); + } + // A flow is not echoed back: the model just sent it. A variable comes back redacted. + match item_type { + "flow" => {} + "variable" => { + out.insert("item".into(), serialize_for_read(item, None)); + } + _ => { + out.insert("item".into(), item.clone()); + } + } + pretty(&Value::Object(out)) +} + +pub(super) fn find_and_replace( + content: &str, + old: &str, + new: &str, + replace_all: bool, + label: &str, +) -> Result<String, String> { + let matches = if old.is_empty() { + 0 + } else { + content.matches(old).count() + }; + if matches == 0 { + return Err(format!("old_string was not found in the {label}.")); + } + if !replace_all && matches != 1 { + return Err(format!( + "old_string matched {matches} locations. Make it more specific or set replace_all to true." + )); + } + Ok(content.replace(old, new)) +} + +pub(super) fn empty_schema() -> Value { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": {}, + "required": [], + "type": "object", + }) +} + +pub(super) fn infer_script_schema( + language: &str, + content: &str, + previous: Option<&Value>, + entrypoint: Option<&str>, +) -> Option<Value> { + use windmill_common::scripts::{ScriptKind, ScriptLang}; + let language: ScriptLang = serde_json::from_value(Value::String(language.to_string())).ok()?; + let kind = (entrypoint == Some("preprocessor")).then_some(ScriptKind::Preprocessor); + let schema = windmill_api_scripts::scripts::infer_main_schema( + &language, + kind.as_ref(), + content, + previous, + )?; + serde_json::to_value(schema).ok() +} + +/// Lines with their terminator kept, so a missing final newline is a difference. +fn patch_lines(text: &str) -> Vec<&str> { + text.split_inclusive('\n').collect() +} + +/// `(tag, line)` for every line of both sides, `' '` for a shared one, deletions ahead +/// of insertions within a change. +fn diff_lines<'a>(old: &[&'a str], new: &[&'a str]) -> Vec<(char, &'a str)> { + let prefix = old.iter().zip(new).take_while(|(a, b)| a == b).count(); + let suffix = old[prefix..] + .iter() + .rev() + .zip(new[prefix..].iter().rev()) + .take_while(|(a, b)| a == b) + .count(); + let (a, b) = ( + &old[prefix..old.len() - suffix], + &new[prefix..new.len() - suffix], + ); + let mut ops: Vec<(char, &str)> = old[..prefix].iter().map(|l| (' ', *l)).collect(); + let (n, m) = (a.len(), b.len()); + // ponytail: quadratic LCS table. Past the cap the changed region is reported as + // replaced wholesale, still a valid patch; switch to Myers if that ever shows. + const MAX_CELLS: usize = 4_000_000; + if (n + 1).saturating_mul(m + 1) > MAX_CELLS { + ops.extend(a.iter().map(|l| ('-', *l))); + ops.extend(b.iter().map(|l| ('+', *l))); + } else { + let width = m + 1; + let mut lcs = vec![0u32; (n + 1) * width]; + for i in (0..n).rev() { + for j in (0..m).rev() { + lcs[i * width + j] = if a[i] == b[j] { + lcs[(i + 1) * width + j + 1] + 1 + } else { + lcs[(i + 1) * width + j].max(lcs[i * width + j + 1]) + }; + } + } + let (mut i, mut j) = (0, 0); + let mut inserted = Vec::new(); + while i < n || j < m { + if i < n && j < m && a[i] == b[j] { + ops.extend(inserted.drain(..)); + ops.push((' ', a[i])); + i += 1; + j += 1; + } else if j == m || (i < n && lcs[(i + 1) * width + j] >= lcs[i * width + j + 1]) { + ops.push(('-', a[i])); + i += 1; + } else { + inserted.push(('+', b[j])); + j += 1; + } + } + ops.extend(inserted); + } + ops.extend(old[old.len() - suffix..].iter().map(|l| (' ', *l))); + ops +} + +pub(super) fn unified_patch( + old_name: &str, + new_name: &str, + old: &str, + new: &str, + context: usize, +) -> String { + let ops = diff_lines(&patch_lines(old), &patch_lines(new)); + let mut out = String::new(); + if old_name == new_name { + out.push_str(&format!("Index: {old_name}\n")); + } + out.push_str(&format!( + "===================================================================\n--- {old_name}\t\n+++ {new_name}\t\n" + )); + let changes: Vec<usize> = (0..ops.len()).filter(|i| ops[*i].0 != ' ').collect(); + let mut next = 0; + while next < changes.len() { + let first = changes[next]; + let mut last = first; + // Two changes share a hunk when their contexts would touch. + while next + 1 < changes.len() && changes[next + 1] - last - 1 <= 2 * context { + next += 1; + last = changes[next]; + } + next += 1; + let start = first.saturating_sub(context); + let end = (last + context + 1).min(ops.len()); + let count = + |range: &[(char, &str)], skip: char| range.iter().filter(|op| op.0 != skip).count(); + let (old_before, new_before) = (count(&ops[..start], '+'), count(&ops[..start], '-')); + let (old_count, new_count) = (count(&ops[start..end], '+'), count(&ops[start..end], '-')); + // An empty side is addressed by the line before it. + let old_start = old_before + usize::from(old_count > 0); + let new_start = new_before + usize::from(new_count > 0); + out.push_str(&format!( + "@@ -{old_start},{old_count} +{new_start},{new_count} @@\n" + )); + for (tag, line) in &ops[start..end] { + out.push(*tag); + match line.strip_suffix('\n') { + Some(line) => { + out.push_str(line); + out.push('\n'); + } + None => { + out.push_str(line); + out.push_str("\n\\ No newline at end of file\n"); + } + } + } + } + out +} + +// ============================================================================ +// Tools +// ============================================================================ + +fn deployed_item(item_type: &str, trigger_kind: Option<&str>, row: &Value) -> Value { + let mut item = Map::new(); + item.insert("type".into(), json!(item_type)); + put(&mut item, "triggerKind", trigger_kind.map(Value::from)); + put(&mut item, "path", row.get("path").cloned()); + let mut is_draft = false; + match item_type { + "script" | "flow" => { + put(&mut item, "summary", row.get("summary").cloned()); + if item_type == "script" { + put(&mut item, "language", row.get("language").cloned()); + } + // A listing synthesizes rows for drafts with no deployed counterpart. + is_draft = truthy(row.get("draft_only")); + } + "schedule" | "trigger" => put(&mut item, "summary", non_null(row.get("summary")).cloned()), + "resource" => put(&mut item, "summary", row.get("description").cloned()), + "variable" => { + put(&mut item, "summary", row.get("description").cloned()); + put(&mut item, "isSecret", row.get("is_secret").cloned()); + } + _ => { + put(&mut item, "summary", row.get("summary").cloned()); + // The server omits the flag rather than sending false. + item.insert( + "rawApp".into(), + non_null(row.get("raw_app")) + .cloned() + .unwrap_or(json!(false)), + ); + } + } + item.insert("isDraft".into(), json!(is_draft)); + Value::Object(item) +} + +/// Put `value` (and a script's `schema`) where the session's item shape has them. +fn with_value(item: Value, value: Option<Value>, schema: Option<Value>) -> Value { + let Value::Object(item) = item else { + return item; + }; + let mut out = Map::new(); + for (key, field) in item { + if key == "isDraft" { + put(&mut out, "value", value.clone()); + put(&mut out, "schema", schema.clone()); + } + out.insert(key, field); + } + Value::Object(out) +} + +async fn list_workspace_items(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let requested: Vec<&str> = args + .get("types") + .and_then(Value::as_array) + .map(|types| types.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + let (access_types, item_types): (Vec<&str>, Vec<&str>) = requested + .into_iter() + .partition(|t| ACCESS_TYPES.contains(t)); + // Asking only for folders or groups must not fall back to the default scripts and flows. + let types = if item_types.is_empty() && access_types.is_empty() { + vec!["script", "flow"] + } else { + item_types + }; + let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(50); + let page = args.get("page").and_then(Value::as_u64); + let prefix = opt_str_arg(args, "path_prefix"); + + let mut query = format!("per_page={}", limit.min(MAX_LIST_LIMIT)); + if let Some(page) = page { + query.push_str(&format!("&page={page}")); + } + + // Group rows carry no members: the list endpoint does not return them. + let mut access_rows = Vec::new(); + for access_type in ACCESS_TYPES.iter().filter(|t| access_types.contains(t)) { + let rows = ctx + .get(&ctx.ws(&format!("{access_type}s/list?{query}"))) + .await?; + for row in array(Some(&rows)) { + let mut item = Map::new(); + item.insert("type".into(), json!(access_type)); + put(&mut item, "path", row.get("name").cloned()); + put(&mut item, "summary", row.get("summary").cloned()); + if *access_type == "folder" { + put(&mut item, "owners", row.get("owners").cloned()); + } + put(&mut item, "extraPerms", row.get("extra_perms").cloned()); + access_rows.push(Value::Object(item)); + } + } + + if let Some(prefix) = prefix { + query.push_str(&format!("&path_start={}", enc(prefix))); + } + + let mut by_key = Map::new(); + for item_type in ITEM_TYPES.iter().filter(|t| types.contains(t)) { + let kinds: Vec<Option<&str>> = if *item_type == "trigger" { + TRIGGER_KINDS.iter().map(|kind| Some(*kind)).collect() + } else { + vec![None] + }; + let extra = if matches!(*item_type, "script" | "flow") { + "&include_draft_only=true&without_description=true" + } else { + "" + }; + for trigger_kind in kinds { + let route = ctx.ws(&format!( + "{}/list?{query}{extra}", + service(item_type, trigger_kind)? + )); + let rows = match ctx.get(&route).await { + Ok(rows) => rows, + // A trigger kind this build does not serve answers 404: skip that kind + // alone. Any other failure is real. + Err(e) if trigger_kind.is_some() && is_not_found(&e) => continue, + Err(e) => return Err(e), + }; + for row in array(Some(&rows)) { + let path = row.get("path").and_then(Value::as_str).unwrap_or_default(); + by_key.insert( + item_key(item_type, path, trigger_kind), + deployed_item(item_type, trigger_kind, row), + ); + } + } + } + + // Drafts are not paginated: they overlay page 1 only, capped per type, so later + // pages never repeat a page-1 item as its draft twin. + if page.unwrap_or(1) == 1 { + let mut count_by_type: HashMap<String, u64> = HashMap::new(); + for draft in list_drafts(ctx).await? { + let text = |key: &str| draft.get(key).and_then(Value::as_str); + let (item_type, path) = ( + text("type").unwrap_or_default(), + text("path").unwrap_or_default(), + ); + if !types.contains(&item_type) { + continue; + } + // A draft's staged name is often not where it is stored, and this pass is + // the only source of draft-only rows under a prefix: either name may match. + if let Some(prefix) = prefix { + let staged = text("draftPath").is_some_and(|p| p.starts_with(prefix)); + if !path.starts_with(prefix) && !staged { + continue; + } + } + let count = count_by_type.entry(item_type.to_string()).or_default(); + if *count >= limit { + continue; + } + *count += 1; + by_key.insert( + item_key(item_type, path, text("triggerKind")), + draft.clone(), + ); + } + } + + let query = opt_str_arg(args, "query") + .map(|q| q.trim().to_lowercase()) + .filter(|q| !q.is_empty()); + let results: Vec<Value> = by_key + .into_iter() + .map(|(_, item)| item) + .chain(access_rows) + .filter(|item| { + let Some(query) = &query else { return true }; + ["path", "summary"].iter().any(|key| { + item.get(*key) + .and_then(Value::as_str) + .is_some_and(|text| text.to_lowercase().contains(query)) + }) + }) + .collect(); + Ok(pretty(&Value::Array(results))) +} + +async fn read_workspace_item(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + if let Some(access_type) = opt_str_arg(args, "type").filter(|t| ACCESS_TYPES.contains(t)) { + return read_access_entity(ctx, access_type, str_arg(args, "path")?).await; + } + let item_type = item_type_arg(args)?; + let path = str_arg(args, "path")?; + let trigger_kind = opt_str_arg(args, "trigger_kind"); + if item_type == "trigger" && trigger_kind.is_none() { + return Ok( + json!({ "success": false, "error": "trigger_kind is required when type is trigger." }) + .to_string(), + ); + } + let deployed_only = opt_str_arg(args, "version") == Some("deployed"); + // Hub scripts live outside the workspace: no draft, no workspace lookup. + let hub = path.starts_with("hub/"); + + if !deployed_only && !hub { + if let Some((item, _)) = get_draft_item(ctx, item_type, path, trigger_kind).await? { + // The mode reported is the draft's, which is what deploying it will write. + let mode = if item_type == "app" { + let mode = item + .get("value") + .and_then(|v| v.get("policy")) + .and_then(|p| p.get("execution_mode")); + describe_app_exposure(ctx, mode).await + } else { + None + }; + return Ok(pretty(&serialize_for_read(&item, mode))); + } + } + + let item = match item_type { + "script" if hub => { + let script = ctx + .get(&format!("/scripts/hub/get_full/{}", path_enc(path))) + .await?; + let mut row = script.clone(); + row["path"] = json!(path); + let mut item = with_value( + deployed_item("script", None, &row), + script.get("content").cloned(), + script.get("schema").cloned(), + ); + item["isDraft"] = json!(false); + item + } + "script" | "flow" => { + let route = get_route(ctx, item_type, None, path)?; + let deployed = ctx + .get(&format!("{route}?get_draft={}", !deployed_only)) + .await?; + let extra_perms = deployed.get("extra_perms").cloned(); + // The draft stored on the item itself outranks its deployed version. + let row = non_null(deployed.get("draft")) + .cloned() + .filter(|_| !deployed_only) + .unwrap_or(deployed); + if item_type == "script" { + with_extra_perms( + with_value( + deployed_item("script", None, &row), + row.get("content").cloned(), + row.get("schema").cloned(), + ), + extra_perms, + ) + } else { + let item = with_value( + deployed_item("flow", None, &row), + Some(deployed_flow_value(&row)), + None, + ); + serialize_for_read(&with_extra_perms(item, extra_perms), None) + } + } + "variable" => { + let row = get_deployed(ctx, "variable", None, path).await?; + let item = with_extra_perms( + deployed_item("variable", None, &row), + row.get("extra_perms").cloned(), + ); + serialize_for_read(&item, None) + } + "app" => { + // Metadata only: file and runnable contents come through `read_app_file`. + let app = get_deployed(ctx, "app", None, path).await?; + let mode = + describe_app_exposure(ctx, app.get("policy").and_then(|p| p.get("execution_mode"))) + .await; + let mut item = Map::new(); + item.insert("type".into(), json!("app")); + put(&mut item, "path", app.get("path").cloned()); + // A grid is not files and runnables: summarizing one would report an empty app. + if app.get("raw_app") == Some(&json!(false)) { + put(&mut item, "summary", app.get("summary").cloned()); + } else { + let value = app_source_to_draft_value(&app, None); + put(&mut item, "summary", value.get("summary").cloned()); + item.insert("value".into(), summarize_app_value(&value)); + } + put(&mut item, "rawApp", app.get("raw_app").cloned()); + put(&mut item, "executionMode", mode); + put(&mut item, "extraPerms", app.get("extra_perms").cloned()); + item.insert("isDraft".into(), json!(false)); + Value::Object(item) + } + _ => { + let row = get_deployed(ctx, item_type, trigger_kind, path).await?; + let item = with_value( + deployed_item(item_type, trigger_kind, &row), + Some(row.clone()), + None, + ); + with_extra_perms(item, row.get("extra_perms").cloned()) + } + }; + Ok(pretty(&item)) +} + +/// Who a deployed item is shared with. A draft has no permissions of its own, so a draft +/// read leaves it out rather than reporting the item unshared. +fn with_extra_perms(mut item: Value, extra_perms: Option<Value>) -> Value { + if let Some(item) = item.as_object_mut() { + put(item, "extraPerms", extra_perms); + } + item +} + +/// Accepts the `f/<name>` / `g/<name>` form the model sees in extra_perms keys and paths. +fn access_entity_name<'a>(access_type: &str, path: &'a str) -> &'a str { + let prefix = if access_type == "folder" { "f/" } else { "g/" }; + path.strip_prefix(prefix) + .unwrap_or(path) + .trim_end_matches('/') +} + +async fn read_access_entity(ctx: &Ctx<'_>, access_type: &str, path: &str) -> ToolResult { + let name = access_entity_name(access_type, path); + let row = ctx + .get(&ctx.ws(&format!("{access_type}s/get/{}", enc(name)))) + .await?; + let mut entity = Map::new(); + entity.insert("type".into(), json!(access_type)); + put(&mut entity, "name", row.get("name").cloned()); + put(&mut entity, "summary", row.get("summary").cloned()); + if access_type == "folder" { + put(&mut entity, "owners", row.get("owners").cloned()); + put(&mut entity, "extraPerms", row.get("extra_perms").cloned()); + put( + &mut entity, + "defaultPermissionedAs", + row.get("default_permissioned_as").cloned(), + ); + } else { + put(&mut entity, "members", row.get("members").cloned()); + put(&mut entity, "extraPerms", row.get("extra_perms").cloned()); + } + Ok(pretty(&Value::Object(entity))) +} + +async fn write_script_draft( + ctx: &Ctx<'_>, + path: &str, + summary: Option<&str>, + language: &str, + content: &str, + force: bool, +) -> ToolResult { + let base = write_base(ctx, "script", path, None).await?; + let existed = base.base.is_some(); + let mut draft = match base.base { + Some(Value::Object(mut script)) => { + if !base.from_draft { + let hash = script.get("hash").cloned().unwrap_or(Value::Null); + script.insert("parent_hash".into(), hash); + // The deployed lock describes the deployed content, not the draft's; the + // deploy recomputes it. + script.shift_remove("lock"); + script.shift_remove("lock_error_logs"); + } + script.insert("path".into(), json!(path)); + put(&mut script, "summary", summary.map(Value::from)); + script.insert("content".into(), json!(content)); + script.insert("language".into(), json!(language)); + script + } + _ => json!({ + "path": path, + "summary": summary.unwrap_or_default(), + "description": "", + "content": content, + "schema": empty_schema(), + "is_template": false, + "language": language, + "kind": "script", + }) + .as_object() + .cloned() + .unwrap_or_default(), + }; + // Inferred into the schema the base carries: its properties are the only copy of + // what no parser emits (`password`, enums, formats, titles). Code that does not + // parse keeps the schema as it was. + let previous = draft + .get("schema") + .filter(|schema| truthy(schema.get("properties"))) + .cloned() + .unwrap_or_else(empty_schema); + let code = if content.is_empty() { " " } else { content }; + // A language this build cannot parse, or code that does not parse yet, keeps the + // schema the draft had. + let schema = infer_script_schema(language, code, Some(&previous), None).unwrap_or(previous); + draft.insert("schema".into(), schema); + let persisted = persist_draft( + ctx, + "script", + path, + None, + &Value::Object(draft), + base.last_sync.as_deref(), + force, + ) + .await?; + Ok(match persisted { + Persist::Saved(item) => finish_draft_write(ctx, &item, existed), + Persist::Failed(text) => text, + }) +} + +async fn edit_script(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let path = str_arg(args, "path")?; + let script = load_script(ctx, path).await?; + let updated = find_and_replace( + &script.content, + str_arg(args, "old_string")?, + str_arg(args, "new_string")?, + bool_arg(args, "replace_all"), + "script source", + )?; + write_script_draft( + ctx, + path, + script.summary.as_deref(), + &script.language, + &updated, + false, + ) + .await +} + +async fn discard_local_draft(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let item_type = item_type_arg(args)?; + let path = str_arg(args, "path")?; + let trigger_kind = opt_str_arg(args, "trigger_kind"); + if item_type == "trigger" && trigger_kind.is_none() { + return Err("trigger_kind is required when discarding a trigger draft.".to_string()); + } + let not_found = || format!("No draft found for {item_type} \"{path}\"."); + let kind = draft_kind(item_type, trigger_kind).ok_or_else(not_found)?; + let draft = get_draft(ctx, &kind, path).await?.ok_or_else(not_found)?; + delete_draft(ctx, &kind, path, draft.created_at.as_deref()).await?; + let mut out = Map::new(); + out.insert("success".into(), json!(true)); + out.insert( + "message".into(), + json!(format!( + "Discarded the draft for {item_type} \"{path}\". The deployed workspace item was not changed." + )), + ); + out.insert("type".into(), json!(item_type)); + out.insert("path".into(), json!(path)); + put(&mut out, "triggerKind", trigger_kind.map(Value::from)); + Ok(pretty(&Value::Object(out))) +} + +/// Discards a stale draft and hands back its own changes against the version it was +/// forked from. Discarding rather than resetting keeps the fork base honest: the next +/// write starts from the current head, and a premature deploy finds no draft instead +/// of shipping the latest version unchanged. +async fn rebase_draft(ctx: &Ctx<'_>, args: &Value) -> ToolResult { + let item_type = item_type_arg(args)?; + let path = str_arg(args, "path")?; + let (label, noun) = match item_type { + "script" => ("script", "Script"), + "flow" => ("flow", "Flow"), + "app" => ("app", "App"), + _ => return Err("rebase_draft currently supports scripts, flows, and apps.".to_string()), + }; + let no_draft = || format!("No {label} draft found for \"{path}\"."); + let (item, draft) = get_draft_item(ctx, item_type, path, None) + .await? + .ok_or_else(no_draft)?; + let value = item.get("value").cloned().unwrap_or(Value::Null); + let usable = match item_type { + "script" => value.is_string() && truthy(item.get("language")), + _ => value.is_object(), + }; + if !usable { + return Err(no_draft()); + } + if !item_exists(ctx, item_type, None, path).await? { + return Err(format!( + "{noun} \"{path}\" is not deployed; there is no newer version to rebase onto." + )); + } + let latest = get_deployed(ctx, item_type, None, path).await?; + let (base, head, head_key) = match item_type { + "script" => ( + item.get("parentHash").filter(|h| truthy(Some(h))).cloned(), + latest.get("hash").cloned(), + "latest_hash", + ), + "flow" => ( + non_null(item.get("parentVersionId")).cloned(), + latest.get("version_id").cloned(), + "latest_version", + ), + _ => ( + non_null(item.get("parentVersionId")).cloned(), + latest + .get("versions") + .and_then(Value::as_array) + .and_then(|versions| versions.last()) + .cloned(), + "latest_version", + ), + }; + let head_text = head + .as_ref() + .map_or_else(|| "undefined".to_string(), js_str); + if base.is_some() && base == head { + let mut out = Map::new(); + out.insert("success".into(), json!(true)); + out.insert("alreadyLatest".into(), json!(true)); + put(&mut out, head_key, head); + out.insert( + "message".into(), + json!(format!( + "Draft \"{path}\" is already based on the latest deployed version ({head_text})." + )), + ); + return Ok(Value::Object(out).to_string()); + } + + // Captured before the discard: this diff is the only record of what to replay. A + // base version that can no longer be fetched diffs against nothing, so the whole + // draft surfaces. + let base_id = base.as_ref().map(js_str); + let (theirs, ours) = match item_type { + "script" => { + let base_content = match &base_id { + Some(hash) => ctx + .get(&ctx.ws(&format!("scripts/get/h/{}", enc(hash)))) + .await + .ok() + .and_then(|s| s.get("content").and_then(Value::as_str).map(String::from)), + None => None, + }; + ( + base_content.unwrap_or_default(), + value.as_str().unwrap_or_default().to_string(), + ) + } + "flow" => { + let base_value = match &base_id { + Some(version) => ctx + .get(&ctx.ws(&format!("flows/get/v/{}", enc(version)))) + .await + .ok() + .and_then(|flow| flow.get("value").cloned()), + None => None, + }; + ( + pretty(&base_value.unwrap_or_else(|| json!({}))), + pretty(value.get("value").unwrap_or(&Value::Null)), + ) + } + _ => { + let source = |app: &Value| { + let mut out = Map::new(); + for key in ["files", "runnables", "data"] { + put(&mut out, key, app.get(key).cloned()); + } + Value::Object(out) + }; + let base_app = match &base_id { + Some(version) => ctx + .get(&ctx.ws(&format!("apps/get/v/{}", enc(version)))) + .await + .ok() + .map(|app| app_source_to_draft_value(&app, Some(&app))), + None => None, + }; + ( + pretty(&source( + &base_app.unwrap_or_else(|| json!({ "files": {}, "runnables": {} })), + )), + pretty(&source(&value)), + ) + } + }; + let your_changes = unified_patch("fork-base", "your-draft", &theirs, &ours, 4); + + let (diff, tools, version) = match item_type { + "script" => ( + "a diff", + "edit_script / write_script", + format!("hash {head_text}"), + ), + "flow" => ( + "a JSON diff", + "the flow edit tools", + format!("version {head_text}"), + ), + _ => ( + "a JSON diff", + "the app edit tools", + format!("version {head_text}"), + ), + }; + let mut out = Map::new(); + out.insert("success".into(), json!(true)); + out.insert( + "message".into(), + json!(format!( + "Discarded the stale draft for \"{path}\". Your changes are in \"your_changes\" ({diff} against the version you forked from). Re-apply them with {tools} — the new draft will be based on the latest deployed version ({version}) — then deploy." + )), + ); + put(&mut out, head_key, head); + out.insert("your_changes".into(), json!(your_changes)); + let result = pretty(&Value::Object(out)); + // The diff is the only copy of the draft once it is discarded: a result the server + // would truncate must keep the draft instead. + if result.len() > windmill_mcp::server::MAX_TOOL_RESULT_BYTES { + let editor = match item_type { + "script" => "/scripts/edit", + "flow" => "/flows/edit", + _ => "/apps_raw/edit", + }; + return Err(format!( + "The draft of \"{path}\" differs from the version it was forked from by more than a tool result can hold, so it was kept as is. Ask the user to reconcile it with the latest deployed version ({version}) in the editor: {}", + ctx.page_url(&format!("{editor}/{path}")) + )); + } + let kind = draft_kind(item_type, None).ok_or_else(no_draft)?; + delete_draft(ctx, &kind, path, draft.created_at.as_deref()).await?; + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn access_entities_are_named_with_or_without_their_prefix() { + assert_eq!(access_entity_name("folder", "f/ops/"), "ops"); + assert_eq!(access_entity_name("folder", "ops"), "ops"); + assert_eq!(access_entity_name("group", "g/devs"), "devs"); + // Only the type's own prefix is stripped. + assert_eq!(access_entity_name("group", "f/devs"), "f/devs"); + } + + #[test] + fn find_and_replace_requires_one_match_unless_replace_all() { + let replace = |all| find_and_replace("a b a", "a", "x", all, "script source"); + assert_eq!(replace(true).unwrap(), "x b x"); + assert_eq!( + replace(false).unwrap_err(), + "old_string matched 2 locations. Make it more specific or set replace_all to true." + ); + assert_eq!( + find_and_replace("a", "z", "x", false, "script source").unwrap_err(), + "old_string was not found in the script source." + ); + } + + // Expected texts are jsdiff's `createTwoFilesPatch` output for the same inputs. + #[test] + fn unified_patch_matches_jsdiff() { + let header = + "===================================================================\n--- fork-base\t\n+++ your-draft\t\n"; + let patch = |old, new, context| unified_patch("fork-base", "your-draft", old, new, context); + assert_eq!( + patch( + "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\no\np\n", + "a\nb\nC\nd\ne\nf\ng\nh\ni\nj\nk\nl\nm\nn\nO\np\nq", + 4 + ), + format!("{header}@@ -1,7 +1,7 @@\n a\n b\n-c\n+C\n d\n e\n f\n g\n@@ -11,6 +11,7 @@\n k\n l\n m\n n\n-o\n+O\n p\n+q\n\\ No newline at end of file\n") + ); + assert_eq!( + patch("", "x\ny\n", 4), + format!("{header}@@ -0,0 +1,2 @@\n+x\n+y\n") + ); + assert_eq!( + patch("x\ny", "", 4), + format!("{header}@@ -1,2 +0,0 @@\n-x\n-y\n\\ No newline at end of file\n") + ); + assert_eq!(patch("same\n", "same\n", 4), header); + assert_eq!( + patch("a\nb\nc\n", "a\nX\nY\nc\n", 1), + format!("{header}@@ -1,3 +1,4 @@\n a\n-b\n+X\n+Y\n c\n") + ); + assert_eq!( + patch("a\nb", "a\nb\n", 4), + format!("{header}@@ -1,2 +1,2 @@\n a\n-b\n\\ No newline at end of file\n+b\n") + ); + } + + #[test] + fn compact_flow_replaces_nested_inline_scripts() { + let raw = |id: &str| json!({ "id": id, "value": { "type": "rawscript", "content": id.to_uppercase(), "language": "bun" } }); + let value = json!({ + "modules": [ + raw("a"), + { "id": "loop", "value": { "type": "forloopflow", "modules": [raw("b")] } }, + { "id": "br", "value": { "type": "branchone", "branches": [{ "modules": [raw("c")] }], "default": [raw("d")] } }, + ], + "failure_module": raw("failure"), + "same_worker": true, + }); + let (compact, scripts) = compact_flow(&value, None); + assert_eq!(scripts.len(), 5); + assert_eq!(scripts["c"], "C"); + assert_eq!( + compact["modules"][2]["value"]["default"][0]["value"]["content"], + "inline_script.d" + ); + assert_eq!( + compact["failure_module"]["value"]["content"], + "inline_script.failure" + ); + let keys: Vec<&String> = compact.as_object().unwrap().keys().collect(); + assert_eq!( + keys, + [ + "modules", + "schema", + "preprocessor_module", + "failure_module", + "groups", + "notes", + "same_worker" + ] + ); + } + + #[test] + fn draft_item_drops_a_staged_path_equal_to_the_storage_path() { + let draft = json!({ "path": "u/a/s", "draft_path": "u/a/s", "summary": "s", "language": "bun", "content": "x", "parent_hash": "h" }); + let item = draft_item("script", "u/a/s", &draft).unwrap(); + assert_eq!( + item, + json!({ "type": "script", "path": "u/a/s", "summary": "s", "language": "bun", "value": "x", "parentHash": "h", "isDraft": true }) + ); + assert!(draft_item("app", "u/a/s", &draft).is_none()); + } +} diff --git a/backend/windmill-api/src/mcp/utils.rs b/backend/windmill-api/src/mcp/utils.rs index ce5cb478cf..dcf84e1851 100644 --- a/backend/windmill-api/src/mcp/utils.rs +++ b/backend/windmill-api/src/mcp/utils.rs @@ -171,18 +171,16 @@ pub async fn get_resources( Ok(rows) } -/// Generic function to get items (scripts or flows) from the database -pub async fn get_items<T: for<'a> sqlx::FromRow<'a, sqlx::postgres::PgRow> + Send + Unpin>( - user_db: &UserDB, +/// The scripts or flows a listing covers: the caller's workspace, unarchived, +/// callable, and narrowed by favorites and `path_filter`. +fn items_query( authed: &ApiAuthed, workspace_id: &str, scope_type: &str, item_type: &str, path_filter: Option<PathFilter<'_>>, -) -> Result<Vec<T>, ErrorData> { +) -> SqlBuilder { let mut sqlb = SqlBuilder::select_from(&format!("{} as o", item_type)); - let fields = vec!["o.path", "o.summary", "o.description", "o.schema"]; - sqlb.fields(&fields); 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) @@ -209,17 +207,29 @@ pub async fn get_items<T: for<'a> sqlx::FromRow<'a, sqlx::postgres::PgRow> + Sen sqlb.and_where(cond); } } + Some(PathFilter::LastSegment(segments)) => { + if segments.is_empty() { + sqlb.and_where("false"); + } else { + let conds: Vec<String> = segments + .iter() + .map(|s| { + "o.path LIKE ? ESCAPE '\\'".bind(&format!("%/{}", escape_like(s))) + }) + .collect(); + sqlb.and_where(format!("({})", conds.join(" OR "))); + } + } } + sqlb +} - sqlb.order_by( - if item_type == "flow" { - "o.edited_at" - } else { - "o.created_at" - }, - false, - ) - .limit(ITEMS_FETCH_MAX_LIMIT); +async fn fetch_items<T: for<'a> sqlx::FromRow<'a, sqlx::postgres::PgRow> + Send + Unpin>( + user_db: &UserDB, + authed: &ApiAuthed, + sqlb: &SqlBuilder, + item_type: &str, +) -> Result<Vec<T>, ErrorData> { let sql = sqlb.sql().map_err(|e| { tracing::error!("failed to build sql: {}", e); ErrorData::internal_error(format!("failed to build sql: {}", e), None) @@ -242,6 +252,44 @@ pub async fn get_items<T: for<'a> sqlx::FromRow<'a, sqlx::postgres::PgRow> + Sen Ok(rows) } +/// Generic function to get items (scripts or flows) from the database +pub async fn get_items<T: for<'a> sqlx::FromRow<'a, sqlx::postgres::PgRow> + Send + Unpin>( + user_db: &UserDB, + authed: &ApiAuthed, + workspace_id: &str, + scope_type: &str, + item_type: &str, + path_filter: Option<PathFilter<'_>>, +) -> Result<Vec<T>, ErrorData> { + let mut sqlb = items_query(authed, workspace_id, scope_type, item_type, path_filter); + sqlb.fields(&["o.path", "o.summary", "o.description", "o.schema"]); + sqlb.order_by( + if item_type == "flow" { + "o.edited_at" + } else { + "o.created_at" + }, + false, + ) + .limit(ITEMS_FETCH_MAX_LIMIT); + fetch_items(user_db, authed, &sqlb, item_type).await +} + +/// Every path `get_items` would return with no fetch cap. +pub async fn get_item_paths( + user_db: &UserDB, + authed: &ApiAuthed, + workspace_id: &str, + scope_type: &str, + item_type: &str, + path_filter: Option<PathFilter<'_>>, +) -> Result<Vec<String>, ErrorData> { + let mut sqlb = items_query(authed, workspace_id, scope_type, item_type, path_filter); + sqlb.distinct().field("o.path"); + let rows: Vec<(String,)> = fetch_items(user_db, authed, &sqlb, item_type).await?; + Ok(rows.into_iter().map(|(path,)| path).collect()) +} + /// Get scripts from the Hub pub async fn get_scripts_from_hub( db: &DB, @@ -652,6 +700,47 @@ pub async fn create_http_request( workspace_id: &str, api_authed: &ApiAuthed, body_json: Option<Value>, +) -> BackendResult<reqwest::Response> { + // Scope the minted JWT to the proxied route so a scope-restricted MCP token + // can't be widened into a full-privilege blank check. See + // `jwt_scopes_for_proxied_route`. + let parsed = reqwest::Url::parse(url) + .map_err(|e| ErrorData::internal_error(format!("Invalid proxied URL: {}", e), None))?; + let scopes = jwt_scopes_for_proxied_route(api_authed.scopes.as_deref(), method, parsed.path())?; + send_as_caller(method, url, workspace_id, api_authed, body_json, scopes).await +} + +/// A JWT that acts as `api_authed`, carrying `scopes`; `None` is the caller's full rights. +pub(crate) async fn mint_caller_jwt( + workspace_id: &str, + api_authed: &ApiAuthed, + scopes: Option<Vec<String>>, +) -> BackendResult<String> { + // Carry the caller's job provenance into the JWT: a job's WM_TOKEN is capped at + // workspace admin (GHSA-hfh4-cx4h-3fcr), and dropping `job_id` here would re-mint an + // uncapped token that satisfies require_super_admin / require_devops_role. + create_jwt_token( + Authed::from(api_authed.clone()), + workspace_id, + 3600, + api_authed.job_id, + None, + None, + scopes, + ) + .await + .map_err(|e| ErrorData::internal_error(e.to_string(), None)) +} + +/// Call the API as `api_authed` with a JWT carrying `scopes`; `None` is the caller's +/// full rights, for a server whose own code picks every route it calls. +pub async fn send_as_caller( + method: &str, + url: &str, + workspace_id: &str, + api_authed: &ApiAuthed, + body_json: Option<Value>, + scopes: Option<Vec<String>>, ) -> BackendResult<reqwest::Response> { let client = &HTTP_CLIENT; let mut request_builder = match method { @@ -668,29 +757,7 @@ pub async fn create_http_request( } }; - // Scope the minted JWT to the proxied route so a scope-restricted MCP token - // can't be widened into a full-privilege blank check. See - // `jwt_scopes_for_proxied_route`. - let parsed = reqwest::Url::parse(url) - .map_err(|e| ErrorData::internal_error(format!("Invalid proxied URL: {}", e), None))?; - let scopes = jwt_scopes_for_proxied_route(api_authed.scopes.as_deref(), method, parsed.path())?; - - // Add authorization header. Carry the caller's job provenance into the proxy - // JWT: a job's WM_TOKEN is capped at workspace admin (GHSA-hfh4-cx4h-3fcr), and - // dropping `job_id` here would re-mint an uncapped token that satisfies - // require_super_admin / require_devops_role on the proxied route. - let authed = Authed::from(api_authed.clone()); - let token = create_jwt_token( - authed, - workspace_id, - 3600, - api_authed.job_id, - None, - None, - scopes, - ) - .await - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + let token = mint_caller_jwt(workspace_id, api_authed, scopes).await?; request_builder = request_builder.header("Authorization", format!("Bearer {}", token)); // Add body if present diff --git a/backend/windmill-api/src/mcp_tools.rs b/backend/windmill-api/src/mcp_tools.rs index a9049951a3..a7d32f6e9e 100644 --- a/backend/windmill-api/src/mcp_tools.rs +++ b/backend/windmill-api/src/mcp_tools.rs @@ -10,7 +10,10 @@ use windmill_common::{ error::{Error, JsonResult, Result}, utils::{not_found_if_none, StripPath}, }; -use windmill_store::{resources::explain_resource_perm_error, variables::get_value_internal}; +use windmill_store::{ + resources::{check_resource_var_ref_scope, explain_resource_perm_error}, + variables::get_value_internal, +}; /// A connected MCP server is a third party the user chose, reached over a /// connection this request holds open: without a deadline one that never answers @@ -67,12 +70,22 @@ async fn connect_mcp_client( serde_json::from_str::<windmill_mcp::McpResource>(resource_value.0.get()) .map_err(|e| Error::BadRequest(format!("Failed to parse MCP resource: {}", e)))?; + // The token, URL and header secrets are read on the caller's behalf and sent to a URL + // the resource chose, so each needs the same scope as a resource reference. The token + // is checked before the OAuth refresh touches its variable. + let token_var_path = mcp_resource + .token + .as_deref() + .map(|t| t.trim_start_matches("$var:")) + .filter(|p| !p.trim().is_empty()); + if let Some(token_var_path) = token_var_path { + check_resource_var_ref_scope(authed, path, token_var_path)?; + } + #[cfg(feature = "oauth2")] { tracing::info!("Checking if token needs refresh before creating MCP client"); - if let Some(ref token_path) = mcp_resource.token { - let token_var_path = token_path.trim_start_matches("$var:"); - + if let Some(token_var_path) = token_var_path { let token_info = sqlx::query!( r#" SELECT @@ -90,13 +103,12 @@ async fn connect_mcp_client( if let Some(info) = token_info { if let (Some(account_id), Some(true)) = (info.account_id, info.is_expired) { - let refresh_tx = user_db.clone().begin(authed).await?; if let Err(e) = crate::oauth2_oss::_refresh_token( - refresh_tx, token_var_path, w_id, account_id, db, + false, ) .await { @@ -113,14 +125,9 @@ async fn connect_mcp_client( // Resolve the token through the caller's permissioned (RLS + audit) path so // a developer cannot exfiltrate a secret they are not allowed to read by // pointing an MCP resource's token at it. - let token = if let Some(token_path) = &mcp_resource.token { - let token_var_path = token_path.trim_start_matches("$var:"); - if token_var_path.trim().is_empty() { - None - } else { - let db_authed = DbWithOptAuthed::from_authed(authed, db.clone(), Some(user_db.clone())); - Some(get_value_internal(&db_authed, w_id, token_var_path, false).await?) - } + let token = if let Some(token_var_path) = token_var_path { + let db_authed = DbWithOptAuthed::from_authed(authed, db.clone(), Some(user_db.clone())); + Some(get_value_internal(&db_authed, w_id, token_var_path, false).await?) } else { None }; @@ -130,7 +137,7 @@ async fn connect_mcp_client( .chain(mcp_resource.headers.iter_mut().flatten().map(|(_, v)| v)) { if let Some(var_path) = value.strip_prefix("$var:") { - check_scopes(authed, || format!("variables:read:{}", var_path))?; + check_resource_var_ref_scope(authed, path, var_path)?; let db_authed = DbWithOptAuthed::from_authed(authed, db.clone(), Some(user_db.clone())); *value = get_value_internal(&db_authed, w_id, var_path, false).await?; } diff --git a/backend/windmill-api/src/oauth2_oss.rs b/backend/windmill-api/src/oauth2_oss.rs index 6a9caee28f..0513cd5c68 100644 --- a/backend/windmill-api/src/oauth2_oss.rs +++ b/backend/windmill-api/src/oauth2_oss.rs @@ -22,8 +22,6 @@ use hmac::Mac; use itertools::Itertools; #[cfg(not(feature = "private"))] use serde::{Deserialize, Serialize}; -#[cfg(not(feature = "private"))] -use sqlx::{Postgres, Transaction}; #[cfg(all(feature = "oauth2", not(feature = "private")))] use windmill_common::more_serde::maybe_number_opt; #[cfg(all(feature = "oauth2", not(feature = "private")))] @@ -84,6 +82,7 @@ pub async fn build_oauth_clients( logins: HashMap::default(), connects: HashMap::default(), slack: None, + slack_signing_secret: None, }); } @@ -133,12 +132,12 @@ async fn list_connects() -> windmill_common::error::JsonResult<Vec<String>> { } #[cfg(not(feature = "private"))] -pub async fn _refresh_token<'c>( - _tx: Transaction<'c, Postgres>, +pub async fn _refresh_token( _path: &str, _w_id: &str, _id: i32, _db: &DB, + _force: bool, ) -> error::Result<String> { // Implementation is not open source Err(error::Error::BadRequest( diff --git a/backend/windmill-api/src/offboarding.rs b/backend/windmill-api/src/offboarding.rs index b7f83dfd96..defaa05234 100644 --- a/backend/windmill-api/src/offboarding.rs +++ b/backend/windmill-api/src/offboarding.rs @@ -266,6 +266,17 @@ async fn get_offboard_preview( departing.as_deref(), &user_prefix, w_id ).fetch_all(db).await?; + // An agent's identity sits in its value, under any path, the user's own included: those + // move with the path but keep running as the departing user until re-pointed. + let obo_agents: Vec<String> = sqlx::query_scalar( + "SELECT path FROM resource WHERE resource_type = 'ai_agent' + AND value->>'on_behalf_of' = $1 AND workspace_id = $2", + ) + .bind(departing.as_deref()) + .bind(w_id) + .fetch_all(db) + .await?; + let obo_schedules = sqlx::query_scalar!( "SELECT path FROM schedule WHERE permissioned_as = $1 AND NOT path LIKE $2 AND workspace_id = $3", &user_owner, &user_prefix, w_id @@ -354,6 +365,7 @@ async fn get_offboard_preview( scripts: obo_scripts, flows: obo_flows, apps: obo_apps, + resources: obo_agents, schedules: obo_schedules, triggers: obo_triggers, ..Default::default() @@ -1007,6 +1019,16 @@ async fn offboard_user_from_workspace<'c>( .await? .unwrap_or(0); + sqlx::query( + "UPDATE resource SET value = jsonb_set(value, '{on_behalf_of}', to_jsonb($1::text)) + WHERE resource_type = 'ai_agent' AND value->>'on_behalf_of' = $2 AND workspace_id = $3", + ) + .bind(new_permissioned_as) + .bind(departing.as_deref()) + .bind(w_id) + .execute(&mut **tx) + .await?; + // ---- eval datasets ---- // The foreign keys cascade the rename onto cases and experiments; the paths held inside JSONB // (an experiment's subject, a dataset's scorers) are rewritten separately since the cascade @@ -1187,6 +1209,11 @@ async fn offboard_user_from_workspace<'c>( // They are NOT done here for reassign-only (delete_user=false) since the user stays. // ---- Related path tables ---- + sqlx::query!( + r#"UPDATE native_trigger SET connection_path = REGEXP_REPLACE(connection_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE connection_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + &new_prefix, username, w_id + ).execute(&mut **tx).await?; + sqlx::query!( r#"UPDATE workspace_integrations SET resource_path = REGEXP_REPLACE(resource_path, 'u/' || $2 || '/(.*)', $1 || '/\1') WHERE resource_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, &new_prefix, username, w_id diff --git a/backend/windmill-api/src/runnables.rs b/backend/windmill-api/src/runnables.rs index 7e64b48a1a..0037a37042 100644 --- a/backend/windmill-api/src/runnables.rs +++ b/backend/windmill-api/src/runnables.rs @@ -7,15 +7,17 @@ */ //! Unified, keyset-paginated listing of a workspace's runnables (scripts, -//! flows, apps) merged into one globally-ordered stream. The homepage uses it -//! so a chosen order (recently updated / oldest / name) is correct and complete -//! across all three kinds at any workspace size, instead of client-sorting a +//! flows, apps, and the `ai_agent` resources listed as agents) merged into one +//! globally-ordered stream. The homepage uses it so a chosen order (recently +//! updated / oldest / name) is correct and complete across every kind at any +//! workspace size, instead of client-sorting a //! per-kind capped window. //! //! Efficiency: each kind is a UNION ALL branch ordered by an index on //! `(workspace_id, archived, <sort key>)` (created_at / edited_at, or the lowered //! summary-or-path expression for name orders); Postgres merges the ordered -//! branches and stops at the page limit. Pagination is keyset — a +//! branches and stops at the page limit. Agents, which are few, are instead +//! reached through the partial `idx_resource_agent_owner_prefix` and sorted whole. Pagination is keyset — a //! `(sort_key, path, kind, tiebreak)` cursor, where `tiebreak` (a script's hash, //! 0 for flow/app) is a stable final key that keeps the order total even when rows //! tie on (sort_key, path, kind) — so deep pages don't re-scan. Visibility is @@ -34,7 +36,7 @@ use std::collections::HashMap; use windmill_common::{ db::UserDB, error::{Error, JsonResult}, - workspaces::{operator_builder_rights, OperatorBuilderRights}, + roles::{caller_capabilities, AuthorLevel, RoleCapabilities}, }; use windmill_types::scripts::ScriptHash; use windmill_types::user_drafts::DraftUserRef; @@ -51,7 +53,7 @@ struct ListRunnablesQuery { order_by: Option<String>, /// Descending when true (default true). order_desc: Option<bool>, - /// Comma-separated subset of `script,flow,app`; omitted means all. + /// Comma-separated subset of `script,flow,app,agent`; omitted means all. kinds: Option<String>, show_archived: Option<bool>, /// Include library scripts (no runnable main). Ignored for flows/apps. @@ -80,7 +82,7 @@ struct ListRunnablesQuery { #[derive(Serialize, sqlx::FromRow)] struct RunnableItem { #[serde(rename = "type")] - kind: String, // 'script' | 'flow' | 'app' + kind: String, // 'script' | 'flow' | 'app' | 'agent' path: String, #[serde(skip_serializing_if = "Option::is_none")] summary: Option<String>, @@ -130,6 +132,11 @@ struct RunnableItem { id: Option<i64>, #[serde(skip_serializing_if = "Option::is_none")] version: Option<i64>, + // agent-only: the `memory` setting, which decides whether the agent opens as a chat. + #[serde(skip_serializing_if = "Option::is_none")] + agent_memory: Option<serde_json::Value>, + #[serde(skip_serializing_if = "Option::is_none")] + ws_specific: Option<bool>, // sort keys, echoed into the cursor (not serialized to the client) #[serde(skip)] sort_time: chrono::DateTime<chrono::Utc>, @@ -247,7 +254,7 @@ fn scope_path_predicate( } } -/// The three UNION-ALL branch SELECTs, each projecting the shared `RunnableItem` +/// The UNION-ALL branch SELECTs, each projecting the shared `RunnableItem` /// column set (NULL for columns that don't apply to that kind). `$1`=workspace, /// `$2`=username (favorites), `$3`=email (drafts). Kind-specific and per-request /// WHERE fragments are appended by the caller. @@ -255,6 +262,7 @@ struct Branches { script: String, flow: String, app: String, + agent: String, } fn branch_sqls() -> Branches { @@ -281,6 +289,7 @@ fn branch_sqls() -> Branches { o.codebase IS NOT NULL as use_codebase, \ (o.lock_error_logs IS NOT NULL) as has_deploy_errors, NULL::bool as chat_input_enabled, \ NULL::bool as raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \ + NULL::jsonb as agent_memory, NULL::bool as ws_specific, \ o.created_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, o.hash as tiebreak \ FROM script o \ LEFT JOIN favorite ON favorite.favorite_kind = 'script' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = $2 \ @@ -298,6 +307,7 @@ fn branch_sqls() -> Branches { NULL::bool as use_codebase, NULL::bool as has_deploy_errors, \ (o.value->>'chat_input_enabled')::bool as chat_input_enabled, \ NULL::bool as raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \ + NULL::jsonb as agent_memory, NULL::bool as ws_specific, \ o.edited_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, 0::bigint as tiebreak \ FROM flow o \ LEFT JOIN favorite ON favorite.favorite_kind = 'flow' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = $2 \ @@ -315,6 +325,7 @@ fn branch_sqls() -> Branches { NULL::bool as use_codebase, NULL::bool as has_deploy_errors, NULL::bool as chat_input_enabled, \ av.raw_app, o.policy->>'execution_mode' as execution_mode, o.id, \ o.versions[array_upper(o.versions, 1)] as version, \ + NULL::jsonb as agent_memory, NULL::bool as ws_specific, \ COALESCE(av.created_at, 'epoch'::timestamptz) as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, 0::bigint as tiebreak \ FROM app o \ LEFT JOIN favorite ON favorite.favorite_kind = 'app' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = $2 \ @@ -323,25 +334,68 @@ fn branch_sqls() -> Branches { draft_users = draft_users("d.typ IN ('app', 'raw_app')") ); - Branches { script, flow, app } + // An agent is an `ai_agent` resource; the subquery renames its description to the + // `summary` the shared search and name sort read. + let agent = format!( + "SELECT 'agent' as kind, o.path, o.summary, o.workspace_id, o.extra_perms, \ + favorite.path IS NOT NULL as starred, false as archived, \ + draft.email IS NOT NULL as is_draft, NULL::bool as draft_only, NULL::text as draft_path, \ + {draft_users}, o.labels, folder_labels(o.workspace_id, o.path) as inherited_labels, \ + NULL::bool as ws_error_handler_muted, o.edited_at, \ + NULL::bigint as hash, NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \ + NULL::bool as use_codebase, NULL::bool as has_deploy_errors, NULL::bool as chat_input_enabled, \ + NULL::bool as raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \ + o.agent_memory, ws_specific.path IS NOT NULL as ws_specific, \ + COALESCE(o.edited_at, 'epoch'::timestamptz) as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, 0::bigint as tiebreak \ + FROM (SELECT workspace_id, path, description as summary, extra_perms, labels, edited_at, \ + value->'memory' as agent_memory \ + FROM resource WHERE resource_type = 'ai_agent') o \ + LEFT JOIN favorite ON favorite.favorite_kind = 'agent' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = $2 \ + LEFT JOIN draft ON draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'resource' AND draft.email = $3 \ + LEFT JOIN ws_specific ON ws_specific.path = o.path AND ws_specific.workspace_id = o.workspace_id AND ws_specific.item_kind = 'resource'", + draft_users = draft_users("d.typ = 'resource'") + ); + + Branches { script, flow, app, agent } +} + +/// The draft rows a kind lists and the table whose rows make a draft not draft-only. +fn draft_kind_source(kind: &str) -> (&'static str, &'static str) { + match kind { + "script" => ("d.typ = 'script'", "script"), + "flow" => ("d.typ = 'flow'", "flow"), + // Typed by the draft itself, as the resource listing filters its draft-only rows. + "agent" => ( + "d.typ = 'resource' AND d.value->>'resource_type' = 'ai_agent'", + "resource", + ), + _ => ("d.typ IN ('app', 'raw_app')", "app"), + } +} + +/// Where a draft records the path its editor has staged. Scripts and resources bind the +/// Path widget to the item's own `path`, so it round-trips through the draft JSON's +/// `path`; flows and apps write a separate `draft_path` only when it differs from the +/// deployed one. See scripts.rs. +fn draft_typed_path(kind: &str) -> &'static str { + if kind == "script" || kind == "agent" { + "path" + } else { + "draft_path" + } } /// The draft-only branch for a kind: the caller's drafts at paths carrying no /// deployed row, projected into the same column set as `branch_sqls` so they /// sort, search and paginate as ordinary rows. Same `$1`/`$2`/`$3` contract. fn draft_branch_sql(kind: &str) -> String { - let (typ_pred, deployed) = match kind { - "script" => ("d.typ = 'script'", "script"), - "flow" => ("d.typ = 'flow'", "flow"), - _ => ("d.typ IN ('app', 'raw_app')", "app"), - }; - // Scripts bind the Path widget to `script.path`, so the typed path round-trips - // through the draft JSON's own `path`; flows and apps write a separate - // `draft_path` only when it differs from the deployed one. See scripts.rs. - let typed_path = if kind == "script" { - "path" + let (typ_pred, deployed) = draft_kind_source(kind); + let typed_path = draft_typed_path(kind); + // A resource draft carries the description where the other kinds carry a summary. + let summary_field = if kind == "agent" { + "description" } else { - "draft_path" + "summary" }; // `auto_kind` is only what the editor stamped into the draft — a `// pipeline` // annotation is not re-derived from the content here, unlike the per-kind @@ -351,7 +405,8 @@ fn draft_branch_sql(kind: &str) -> String { "script" => { "d.value->>'language' as language, d.value->>'kind' as script_kind, \ d.value->>'auto_kind' as auto_kind, false as raw_app, \ - NULL::bool as chat_input_enabled" + NULL::bool as chat_input_enabled, \ + NULL::jsonb as agent_memory, NULL::bool as ws_specific" } // Type-guarded rather than a bare `::bool` cast: draft JSON is stored // unvalidated, so a malformed value must yield NULL, not abort the list, @@ -360,11 +415,20 @@ fn draft_branch_sql(kind: &str) -> String { "NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \ false as raw_app, \ CASE WHEN json_typeof(d.value->'value'->'chat_input_enabled') = 'boolean' \ - THEN (d.value->'value'->>'chat_input_enabled')::bool END as chat_input_enabled" + THEN (d.value->'value'->>'chat_input_enabled')::bool END as chat_input_enabled, \ + NULL::jsonb as agent_memory, NULL::bool as ws_specific" + } + "agent" => { + "NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \ + false as raw_app, NULL::bool as chat_input_enabled, \ + (d.value->'args'->'memory')::jsonb as agent_memory, \ + CASE WHEN json_typeof(d.value->'wsSpecific') = 'boolean' \ + THEN (d.value->>'wsSpecific')::bool END as ws_specific" } _ => { "NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \ - (d.typ = 'raw_app') as raw_app, NULL::bool as chat_input_enabled" + (d.typ = 'raw_app') as raw_app, NULL::bool as chat_input_enabled, \ + NULL::jsonb as agent_memory, NULL::bool as ws_specific" } }; format!( @@ -378,11 +442,12 @@ fn draft_branch_sql(kind: &str) -> String { NULL::bigint as hash, o.language, o.script_kind, o.auto_kind, \ NULL::bool as use_codebase, NULL::bool as has_deploy_errors, o.chat_input_enabled, \ o.raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \ + o.agent_memory, o.ws_specific, \ o.created_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.draft_path, o.path)) as sort_name, 0::bigint as tiebreak \ FROM ( \ SELECT DISTINCT ON (d.path) d.workspace_id, d.path, d.created_at, \ d.email IS NULL as legacy, \ - COALESCE(d.value->>'summary', '') as summary, \ + COALESCE(d.value->>'{summary_field}', '') as summary, \ NULLIF(NULLIF(d.value->>'{typed_path}', ''), d.path) as draft_path, \ {kind_cols} \ FROM draft d \ @@ -397,6 +462,8 @@ fn draft_branch_sql(kind: &str) -> String { ) } +const ALL_KINDS: [&str; 4] = ["script", "flow", "app", "agent"]; + async fn list_runnables( authed: ApiAuthed, Extension(user_db): Extension<UserDB>, @@ -404,6 +471,7 @@ async fn list_runnables( Path(w_id): Path<String>, Query(q): Query<ListRunnablesQuery>, ) -> JsonResult<ListRunnablesResponse> { + let caps = caller_capabilities(&db, &w_id, &authed).await?; let order_by_name = q.order_by.as_deref() == Some("name"); let desc = q.order_desc.unwrap_or(true); let per_page = q.per_page.unwrap_or(50).clamp(1, 1000); @@ -416,16 +484,16 @@ async fn list_runnables( }; let mut kinds: Vec<&str> = match q.kinds.as_deref() { - None | Some("") => vec!["script", "flow", "app"], + None | Some("") => ALL_KINDS.to_vec(), Some(csv) => csv .split(',') .map(|s| s.trim()) - .filter(|s| ["script", "flow", "app"].contains(s)) + .filter(|s| ALL_KINDS.contains(s)) .collect(), }; - // Apps carry no `archived` column and are never listed as archived. + // Apps and agents carry no `archived` column and are never listed as archived. if show_archived { - kinds.retain(|k| *k != "app"); + kinds.retain(|k| *k != "app" && *k != "agent"); } let branches = branch_sqls(); @@ -533,6 +601,11 @@ async fn list_runnables( } else { None }; + let agent_scope = if kinds.contains(&"agent") { + scope_path_predicate(&authed, "resources", "o", 3, &mut binds) + } else { + None + }; // Per-kind archived predicate (scripts/flows have the column; apps don't and // are excluded from the archived view). @@ -542,7 +615,7 @@ async fn list_runnables( "o.archived = false" }; let mut script_extras: Vec<String> = vec![]; - if !q.include_without_main.unwrap_or(false) || authed.is_operator { + if !q.include_without_main.unwrap_or(false) || !caps.code { script_extras.push("(o.auto_kind IS NULL OR o.auto_kind <> 'lib')".to_string()); } script_extras.push(archived_pred.to_string()); @@ -570,18 +643,17 @@ async fn list_runnables( if let Some(s) = &app_scope { app_extras.push(s.clone()); } + let mut agent_extras: Vec<String> = vec![]; + if let Some(s) = &agent_scope { + agent_extras.push(s.clone()); + } // Draft-only rows are the caller's own work in progress: never archived, so they // have no place in the archived view, and carrying no labels of their own they are // out of scope of a label filter (as in the per-kind endpoints). Operators don't // see other people's drafts and have none of their own, except of what they may build. - let builder = if authed.is_operator { - operator_builder_rights(&db, &w_id).await? - } else { - OperatorBuilderRights::default() - }; let include_drafts = q.include_draft_only.unwrap_or(false) - && (!authed.is_operator || builder.any()) + && ALL_KINDS.iter().any(|k| role_drafts_kind(&caps, k)) && !show_archived && q.label.as_ref().filter(|s| !s.is_empty()).is_none(); let draft_extras_for = |kind: &str| -> Vec<String> { @@ -589,11 +661,12 @@ async fn list_runnables( let scope = match kind { "script" => &script_scope, "flow" => &flow_scope, + "agent" => &agent_scope, _ => &app_scope, }; // The lib filter reads the same projected `auto_kind`, so a draft-only library // script hides with the deployed ones. - if kind == "script" && (!q.include_without_main.unwrap_or(false) || authed.is_operator) { + if kind == "script" && (!q.include_without_main.unwrap_or(false) || !caps.code) { extras.push("(o.auto_kind IS NULL OR o.auto_kind <> 'lib')".to_string()); } if let Some(s) = scope { @@ -649,6 +722,7 @@ async fn list_runnables( "script" => (&branches.script, &script_extras), "flow" => (&branches.flow, &flow_extras), "app" => (&branches.app, &app_extras), + "agent" => (&branches.agent, &agent_extras), _ => return None, }; Some(build_branch( @@ -667,10 +741,7 @@ async fn list_runnables( keyset: Option<&str>, limit: Option<usize>| -> Option<String> { - if !include_drafts - || !kinds.contains(&kind) - || (authed.is_operator && !operator_drafts_kind(builder, kind)) - { + if !include_drafts || !kinds.contains(&kind) || !role_drafts_kind(&caps, kind) { return None; } // `fav` is ignored: with no favorite join there is nothing to filter on, and the @@ -708,7 +779,7 @@ async fn list_runnables( // No draft branch here: a draft-only path has no favorite row (the UI won't let // you star one), so it would scan the caller's whole draft slice per kind to // return nothing. The main stream below takes them unfiltered instead. - let starred_branches: Vec<String> = ["script", "flow", "app"] + let starred_branches: Vec<String> = ALL_KINDS .iter() .filter_map(|k| branch_for(k, Some(true), None, None)) .collect(); @@ -728,10 +799,10 @@ async fn list_runnables( // Main paged stream: non-starred rows (starred were pinned on the first page above). let main_fav = Some(false); let ns_branches: Vec<String> = - ["script", "flow", "app"] + ALL_KINDS .iter() .filter_map(|k| branch_for(k, main_fav, keyset_sql.as_deref(), Some(per_page))) - .chain(["script", "flow", "app"].iter().filter_map(|k| { + .chain(ALL_KINDS.iter().filter_map(|k| { draft_branch_for(k, main_fav, keyset_sql.as_deref(), Some(per_page)) })) .collect(); @@ -762,7 +833,7 @@ async fn list_runnables( #[derive(Deserialize)] struct CountRunnablesQuery { - /// Comma-separated subset of `script,flow,app`; omitted means all. + /// Comma-separated subset of `script,flow,app,agent`; omitted means all. kinds: Option<String>, /// Include library scripts (no runnable main). Ignored for flows/apps. include_without_main: Option<bool>, @@ -789,7 +860,7 @@ struct OwnerCount { /// `owner/<rest>` is >= `owner || '/'` and, since '/' (0x2F) is immediately /// followed by '0' (0x30), < `owner || '0'`. `~>=~` / `~<~` are the /// text_pattern_ops operators, which is what lets `idx_<kind>_owner_prefix` -/// answer the count with an index-only scan — the default opclass sorts by the +/// (`idx_resource_agent_owner_prefix` for agents) answer the count with an index-only scan — the default opclass sorts by the /// database collation and cannot serve a byte-prefix range. fn owner_prefix_range(alias: &str) -> String { format!("{alias}.path ~>=~ (o.owner || '/') AND {alias}.path ~<~ (o.owner || '0')") @@ -817,14 +888,15 @@ async fn count_runnables_by_owner( Path(w_id): Path<String>, Query(q): Query<CountRunnablesQuery>, ) -> JsonResult<RunnableCountsResponse> { + let caps = caller_capabilities(&db, &w_id, &authed).await?; let kinds: Vec<&str> = match q.kinds.as_deref() { - None | Some("") => vec!["script", "flow", "app"], + None | Some("") => ALL_KINDS.to_vec(), // Deduplicated: every kind becomes its own count subquery, so a repeated // entry would both double that kind's count and multiply the scans. Some(csv) => { let mut ks: Vec<&str> = vec![]; for k in csv.split(',').map(|s| s.trim()) { - if ["script", "flow", "app"].contains(&k) && !ks.contains(&k) { + if ALL_KINDS.contains(&k) && !ks.contains(&k) { ks.push(k); } } @@ -834,7 +906,7 @@ async fn count_runnables_by_owner( if kinds.is_empty() { return Ok(Json(RunnableCountsResponse { counts: HashMap::new() })); } - let with_libs = q.include_without_main.unwrap_or(false) && !authed.is_operator; + let with_libs = q.include_without_main.unwrap_or(false) && caps.code; // Per-kind predicates shared by both passes. `alias` is the branch's table // alias; `binds` collects the scope values, numbered from `base + 1`. @@ -866,6 +938,13 @@ async fn count_runnables_by_owner( w.push(s); } } + "agent" => { + w.push(format!("{alias}.resource_type = 'ai_agent'")); + if let Some(s) = scope_path_predicate(&authed, "resources", alias, base, binds) + { + w.push(s); + } + } _ => { if let Some(s) = scope_path_predicate(&authed, "apps", alias, base, binds) { w.push(s); @@ -877,6 +956,7 @@ async fn count_runnables_by_owner( let table_of = |kind: &str| match kind { "script" => "script", "flow" => "flow", + "agent" => "resource", _ => "app", }; @@ -920,7 +1000,17 @@ async fn count_runnables_by_owner( for r in query.fetch_all(&db).await? { counts.insert(r.owner, r.count); } - add_draft_counts(&authed, &db, &w_id, &kinds, with_libs, &q, &mut counts).await?; + add_draft_counts( + &caps, + &db, + &w_id, + &authed, + &kinds, + with_libs, + &q, + &mut counts, + ) + .await?; counts.retain(|_, c| *c > 0); return Ok(Json(RunnableCountsResponse { counts })); } @@ -993,17 +1083,27 @@ async fn count_runnables_by_owner( counts.entry(r.owner).or_insert(r.count); } - add_draft_counts(&authed, &db, &w_id, &kinds, with_libs, &q, &mut counts).await?; + add_draft_counts( + &caps, + &db, + &w_id, + &authed, + &kinds, + with_libs, + &q, + &mut counts, + ) + .await?; counts.retain(|_, c| *c > 0); Ok(Json(RunnableCountsResponse { counts })) } -/// Whether an operator with `builder` rights can have drafts of a `/list` kind. -fn operator_drafts_kind(builder: OperatorBuilderRights, kind: &str) -> bool { +/// Whether a role can have drafts of a `/list` kind: one it may write. +fn role_drafts_kind(caps: &RoleCapabilities, kind: &str) -> bool { match kind { - "flow" => builder.flows, - "app" => builder.apps, - _ => false, + "flow" => caps.flows != AuthorLevel::None, + "app" => caps.apps != AuthorLevel::None, + _ => caps.code, } } @@ -1016,9 +1116,10 @@ fn operator_drafts_kind(builder: OperatorBuilderRights, kind: &str) -> bool { /// Deployed and draft rows can't overlap (the anti-join is what makes a draft /// "draft-only"), so the two counts add. async fn add_draft_counts( - authed: &ApiAuthed, + caps: &RoleCapabilities, db: &DB, w_id: &str, + authed: &ApiAuthed, kinds: &[&str], with_libs: bool, q: &CountRunnablesQuery, @@ -1027,37 +1128,32 @@ async fn add_draft_counts( if !q.include_draft_only.unwrap_or(false) { return Ok(()); } - // An operator has no drafts of their own, except of what they may build. - let builder_kinds: Vec<&str>; - let kinds: &[&str] = if !authed.is_operator { - kinds - } else { - let builder = operator_builder_rights(db, w_id).await?; - builder_kinds = kinds - .iter() - .copied() - .filter(|k| operator_drafts_kind(builder, k)) - .collect(); - if builder_kinds.is_empty() { - return Ok(()); - } - &builder_kinds - }; + // A role has no drafts of its own except of what it may write. + let kinds: Vec<&str> = kinds + .iter() + .copied() + .filter(|k| role_drafts_kind(caps, k)) + .collect(); + if kinds.is_empty() { + return Ok(()); + } + let kinds = kinds.as_slice(); // $1 = workspace, $2 = the caller's email. let mut binds: Vec<String> = vec![]; let branches: Vec<String> = kinds .iter() .map(|kind| { - let (typ_pred, deployed, domain) = match *kind { - "script" => ("d.typ = 'script'", "script", "scripts"), - "flow" => ("d.typ = 'flow'", "flow", "flows"), - _ => ("d.typ IN ('app', 'raw_app')", "app", "apps"), + let (typ_pred, deployed) = draft_kind_source(kind); + let domain = match *kind { + "script" => "scripts", + "flow" => "flows", + "agent" => "resources", + _ => "apps", }; // The owner a draft counts under is where it says it will live, not the // `u/<caller>/draft_<uuid>` it is parked at — same path `/list` groups and - // filters on. Scripts round-trip the typed path through the draft JSON's - // own `path`; flows and apps write `draft_path`. See scripts.rs. - let typed_path = if *kind == "script" { "path" } else { "draft_path" }; + // filters on. + let typed_path = draft_typed_path(kind); let effective_path = format!("COALESCE(NULLIF(d.value->>'{typed_path}', ''), d.path) as path"); let mut w = vec![ diff --git a/backend/windmill-api/src/slack_agents.rs b/backend/windmill-api/src/slack_agents.rs new file mode 100644 index 0000000000..dae32285df --- /dev/null +++ b/backend/windmill-api/src/slack_agents.rs @@ -0,0 +1,1291 @@ +//! AI agents answering in Slack. +//! +//! An agent answers only in the Slack channels it is shared in, to the workspace members whose +//! email matches the asker's Slack email, as Windmill would run it for them: through their own +//! permissions, so only if they can read it. With `slack_anyone`, set in its settings and kept +//! only while it runs on behalf of an identity, it answers anyone in those channels instead: the +//! channel is the trust boundary, as Slack decides who is in it, and everyone runs it as that +//! identity, who agreed to it by deploying the agent so. Who may share it is then only who may +//! edit it. A message reaches an agent when it names it (`+name` or `~name`), when its thread was +//! started with it, or, for a mention, when it is its channel's default. Anything else goes to the workspace's +//! command script as before, or, where there is none, is answered with the agents shared there. +//! One Slack thread is one conversation per person: a run's tool results are its asker's to see, +//! and a shared conversation would hand them to whoever replies next. + +use std::collections::HashMap; + +use axum::{ + extract::{Path, Query}, + routing::get, + Extension, Json, Router, +}; +use serde::{Deserialize, Serialize}; +use serde_json::value::RawValue; +use uuid::Uuid; +use windmill_api_auth::{ + fetch_api_authed, fetch_api_authed_from_permissioned_as, require_is_writer, +}; +use windmill_common::{ + error::{Error, JsonResult, Result}, + oauth2::WORKSPACE_SLACK_BOT_TOKEN_PATH, + utils::{calculate_hash, StripPath}, + variables::{get_secret_value_as_admin, get_workspace_key}, + BASE_URL, +}; + +use crate::{ + agent_runs::run_agent_as, + db::{ApiAuthed, DB}, + jobs::RunJobQuery, + HTTP_CLIENT, +}; +use windmill_common::db::UserDB; + +/// How long the reply waits for the agent before pointing at the run instead. +const MAX_WAIT_SECS: u64 = 3600; +/// Under the 12,000 characters Slack takes in a markdown block. +const MAX_REPLY_CHARS: usize = 11_000; + +pub fn workspaced_service() -> Router { + Router::new() + .route("/channels", get(list_workspace_channels)) + .route("/available_channels", get(list_available_channels)) + .route( + "/agent_channels/{*path}", + get(get_agent_slack).post(share_agent).delete(unshare_agent), + ) + .route("/status", get(status)) +} + +#[derive(Serialize)] +struct SlackAgentsStatus { + signing_secret_set: bool, +} + +async fn status( + _authed: ApiAuthed, + Extension(db): Extension<DB>, + Path(w_id): Path<String>, +) -> JsonResult<SlackAgentsStatus> { + Ok(Json(SlackAgentsStatus { + signing_secret_set: signing_secret_set(&db, &w_id).await?, + })) +} + +/// Which of the workspaces connected to a Slack team a request may reach. +#[derive(Clone, Debug, Default)] +pub struct SlackRequest { + /// Those whose app's signing secret verified it or, when none did, those whose app has none + /// set, which take requests unauthenticated. + pub reachable: Vec<String>, + /// Those whose app's signing secret verified it, the only ones agents answer for. + pub verified: Vec<String>, +} + +fn env_signing_secret() -> Option<String> { + std::env::var("SLACK_SIGNING_SECRET") + .ok() + .filter(|s| !s.is_empty()) +} + +fn instance_signing_secrets() -> Vec<String> { + #[cfg(feature = "oauth2")] + { + env_signing_secret() + .into_iter() + .chain(crate::OAUTH_CLIENTS.load().slack_signing_secret.clone()) + .filter(|s| !s.is_empty()) + .collect() + } + #[cfg(not(feature = "oauth2"))] + { + vec![] + } +} + +/// Agents answer only when the server verifies Slack's request signatures, with the secret of the +/// app the workspace is connected through: its own app's, or else the instance's. +async fn signing_secret_set(db: &DB, w_id: &str) -> Result<bool> { + let own_app = sqlx::query!( + "SELECT slack_oauth_client_id IS NOT NULL AS \"own_app!\", + slack_signing_secret IS NOT NULL AS \"own_secret!\" + FROM workspace_settings WHERE workspace_id = $1", + w_id + ) + .fetch_optional(db) + .await?; + Ok(match own_app { + Some(r) if r.own_app => r.own_secret || env_signing_secret().is_some(), + _ => !instance_signing_secrets().is_empty(), + }) +} + +/// Whether a Slack request was signed by the app a workspace is connected through: `None` when that +/// app has no secret set, which leaves the request unauthenticated. A workspace's own app is checked +/// against its own secret, never the instance setting's: the instance's app signs nothing for it, so +/// setting an instance secret must not start rejecting its requests. Without its own secret it falls +/// back to `SLACK_SIGNING_SECRET`, which may hold that app's secret from before workspaces had one. +#[cfg(feature = "oauth2")] +fn workspace_verified( + own_app: bool, + own_secret: Option<String>, + instance_verified: Option<bool>, + matches: impl Fn(&str) -> bool, +) -> Option<bool> { + if own_app { + own_secret.or_else(env_signing_secret).map(|s| matches(&s)) + } else { + instance_verified + } +} + +#[cfg(feature = "oauth2")] +fn slack_signature_matcher<'a>( + sig: &'a str, + ts: &'a str, + body: &'a str, +) -> impl Fn(&str) -> bool + 'a { + // Slack's recommended replay window: a captured request stops verifying after it. + let fresh = ts + .parse::<i64>() + .is_ok_and(|t| chrono::Utc::now().timestamp().abs_diff(t) <= 5 * 60); + move |secret: &str| { + fresh + && windmill_oauth::SlackVerifier::new(secret) + .is_ok_and(|v| v.verify(ts, body, sig).is_ok()) + } +} + +#[cfg(feature = "oauth2")] +fn instance_verified(matches: &impl Fn(&str) -> bool) -> Option<bool> { + let instance = instance_signing_secrets(); + (!instance.is_empty()).then(|| instance.iter().any(|s| matches(s))) +} + +#[cfg(feature = "oauth2")] +fn signature_rejected<T>() -> Result<T> { + Err(Error::BadRequest( + "Slack signature verification failed".to_string(), + )) +} + +/// Checks a Slack request, for each workspace connected to `team_id`, against the signing secret +/// of the app it is connected through (see [`workspace_verified`]). For a team no workspace is +/// connected to, only the instance's secret applies. An error when the request can reach no +/// workspace. +#[cfg(feature = "oauth2")] +pub async fn verify_slack_request( + db: &DB, + team_id: &str, + sig: &str, + ts: &str, + body: &str, +) -> Result<SlackRequest> { + let matches = slack_signature_matcher(sig, ts, body); + let instance_verified = instance_verified(&matches); + + let workspaces = sqlx::query!( + "SELECT workspace_id, slack_oauth_client_id IS NOT NULL AS \"own_app!\", + slack_signing_secret + FROM workspace_settings WHERE slack_team_id = $1", + team_id + ) + .fetch_all(db) + .await?; + if workspaces.is_empty() { + return match instance_verified { + Some(false) => signature_rejected(), + _ => Ok(SlackRequest::default()), + }; + } + let mut req = SlackRequest::default(); + for w in workspaces { + let verified = workspace_verified( + w.own_app, + w.slack_signing_secret, + instance_verified, + &matches, + ); + if verified != Some(false) { + req.reachable.push(w.workspace_id.clone()); + } + if verified == Some(true) { + req.verified.push(w.workspace_id); + } + } + if req.reachable.is_empty() { + return signature_rejected(); + } + // A matching secret proves which app sent the request, so a workspace that merely has no secret + // to rule it out must not take it. + if !req.verified.is_empty() { + req.reachable = req.verified.clone(); + } + Ok(req) +} + +/// Checks a Slack approval callback sent through `w_id`'s Slack connection against the secret of +/// the app it is connected through (see [`workspace_verified`]). +#[cfg(feature = "oauth2")] +pub async fn verify_approval_slack_request( + db: &DB, + w_id: &str, + sig: &str, + ts: &str, + body: &str, +) -> Result<()> { + let matches = slack_signature_matcher(sig, ts, body); + let (own_app, own_secret) = sqlx::query!( + "SELECT slack_oauth_client_id IS NOT NULL AS \"own_app!\", slack_signing_secret + FROM workspace_settings WHERE workspace_id = $1", + w_id + ) + .fetch_optional(db) + .await? + .map_or((false, None), |s| (s.own_app, s.slack_signing_secret)); + match workspace_verified(own_app, own_secret, instance_verified(&matches), &matches) { + Some(false) => signature_rejected(), + _ => Ok(()), + } +} + +#[derive(Serialize)] +struct SharedChannel { + channel_id: String, + channel_name: String, + agent_path: String, + is_default: bool, +} + +/// Listed through the caller's permissions: an agent they cannot read stays unnamed. +async fn list_workspace_channels( + authed: ApiAuthed, + Extension(user_db): Extension<UserDB>, + Path(w_id): Path<String>, +) -> JsonResult<Vec<SharedChannel>> { + let mut tx = user_db.begin(&authed).await?; + let rows = sqlx::query_as!( + SharedChannel, + "SELECT c.channel_id, c.channel_name, c.agent_path, c.is_default + FROM slack_agent_channel c + JOIN resource r ON r.workspace_id = c.workspace_id AND r.path = c.agent_path + WHERE c.workspace_id = $1 ORDER BY c.channel_name, c.agent_path", + w_id + ) + .fetch_all(&mut *tx) + .await?; + tx.commit().await?; + Ok(Json(rows)) +} + +#[derive(Serialize)] +struct AgentSlack { + /// The connected Slack workspace's name; `None` when Slack is not connected. + slack_team_name: Option<String>, + /// Agents answer only when the instance verifies Slack's request signatures. + signing_secret_set: bool, + /// The bot's Slack handle, what people mention; `None` when Slack can't be reached. + bot_name: Option<String>, + /// Agent scopes the workspace's connection lacks: it was made without AI agents enabled. + missing_scopes: Vec<String>, + /// Who the agent runs as, in Slack and elsewhere; `None` unless it runs on behalf of someone. + runs_as: Option<String>, + /// Whether anyone in its channels can ask it, rather than workspace members only. + anyone: bool, + /// Whether the caller can change its channels: anyone who can edit it. + can_share: bool, + channels: Vec<SharedChannel>, +} + +/// The agent's identity when it runs on behalf of one, and whether anyone in its channels can +/// ask it; the agent is read with the caller's permissions, so one they cannot read is not found. +async fn agent_identity( + authed: &ApiAuthed, + user_db: &UserDB, + w_id: &str, + path: &str, +) -> Result<(Option<String>, bool)> { + let mut tx = user_db.clone().begin(authed).await?; + let row = sqlx::query!( + "SELECT value->'run_on_behalf_of' = 'true'::jsonb AS run_on_behalf_of, + value->>'on_behalf_of' AS on_behalf_of, + value->'slack_anyone' = 'true'::jsonb AS slack_anyone + FROM resource WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent'", + w_id, + path + ) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + let row = row.ok_or_else(|| Error::NotFound(format!("Agent {path} not found")))?; + let runs_as = row + .on_behalf_of + .filter(|_| row.run_on_behalf_of == Some(true)); + let anyone = runs_as.is_some() && row.slack_anyone == Some(true); + Ok((runs_as, anyone)) +} + +async fn is_writer(authed: &ApiAuthed, db: &DB, w_id: &str, path: &str) -> Result<bool> { + Ok(require_is_writer( + authed, + path, + w_id, + db.clone(), + "SELECT extra_perms FROM resource WHERE path = $1 AND workspace_id = $2", + "resource", + ) + .await + .is_ok()) +} + +async fn get_agent_slack( + authed: ApiAuthed, + Extension(db): Extension<DB>, + Extension(user_db): Extension<UserDB>, + Path((w_id, path)): Path<(String, StripPath)>, +) -> JsonResult<AgentSlack> { + let path = path.to_path(); + let (runs_as, anyone) = agent_identity(&authed, &user_db, &w_id, path).await?; + let can_share = is_writer(&authed, &db, &w_id, path).await?; + let slack_team_name = sqlx::query_scalar!( + "SELECT COALESCE(slack_name, slack_team_id) FROM workspace_settings + WHERE workspace_id = $1 AND slack_team_id IS NOT NULL", + w_id + ) + .fetch_optional(&db) + .await? + .flatten(); + let channels = sqlx::query_as!( + SharedChannel, + "SELECT channel_id, channel_name, agent_path, is_default + FROM slack_agent_channel WHERE workspace_id = $1 AND agent_path = $2 ORDER BY channel_name", + w_id, + path + ) + .fetch_all(&db) + .await?; + let (bot_name, missing_scopes) = match slack_team_name { + Some(_) => bot_identity(&db, &w_id).await.unzip(), + None => (None, None), + }; + Ok(Json(AgentSlack { + slack_team_name, + signing_secret_set: signing_secret_set(&db, &w_id).await?, + bot_name, + missing_scopes: missing_scopes.unwrap_or_default(), + runs_as, + anyone, + can_share, + channels, + })) +} + +/// The bot's handle and the agent scopes its token lacks; `None` when Slack can't be reached. +async fn bot_identity(db: &DB, w_id: &str) -> Option<(String, Vec<String>)> { + let token = bot_token(db, w_id).await.ok()?; + let call = async { + let res = HTTP_CLIENT + .post("https://slack.com/api/auth.test") + .bearer_auth(&token) + .send() + .await + .ok()?; + // Slack lists the token's scopes in this header on every Web API response. + let granted = res + .headers() + .get("x-oauth-scopes") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .split(',') + .map(|s| s.trim().to_string()) + .collect::<Vec<_>>(); + let body: serde_json::Value = res.json().await.ok()?; + let name = body["user"].as_str()?.to_string(); + let missing = crate::SLACK_AGENT_SCOPES + .iter() + .filter(|s| !granted.iter().any(|g| g == *s)) + .map(|s| s.to_string()) + .collect(); + Some((name, missing)) + }; + // On every agent page load: a slow Slack must not hold the page for the client's 20s timeout. + tokio::time::timeout(std::time::Duration::from_secs(3), call) + .await + .ok()? +} + +#[derive(Deserialize)] +struct ShareAgent { + channel_id: String, + channel_name: String, + #[serde(default)] + is_default: bool, +} + +/// Shares the agent in a channel, or changes how it answers there. +async fn share_agent( + authed: ApiAuthed, + Extension(db): Extension<DB>, + Extension(user_db): Extension<UserDB>, + Path((w_id, path)): Path<(String, StripPath)>, + Json(body): Json<ShareAgent>, +) -> Result<String> { + let path = path.to_path(); + agent_identity(&authed, &user_db, &w_id, path).await?; + if !is_writer(&authed, &db, &w_id, path).await? { + return Err(Error::PermissionDenied(format!( + "Only those who can edit {path} can share it." + ))); + } + let team_id = connected_team(&db, &w_id).await?; + + if body.is_default { + // A channel belongs to the Slack team, which several workspaces may share: one agent + // must not take over the default another one holds. + let current = sqlx::query!( + "SELECT workspace_id, agent_path FROM slack_agent_channel + WHERE slack_team_id = $1 AND channel_id = $2 AND is_default", + team_id, + body.channel_id + ) + .fetch_optional(&db) + .await?; + match current { + Some(c) if c.workspace_id == w_id && c.agent_path == path => {} + Some(c) => { + return Err(Error::BadRequest(format!( + "{} already answers in #{} without being named. Make it stop there first.", + c.agent_path, body.channel_name + ))); + } + // It takes the channel's unnamed messages from the command script an admin set up. + None if !authed.is_admin => { + return Err(Error::PermissionDenied( + "Only workspace admins can make an agent answer a channel without being named." + .to_string(), + )); + } + None => {} + } + } + sqlx::query!( + "INSERT INTO slack_agent_channel + (slack_team_id, channel_id, channel_name, workspace_id, agent_path, is_default) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (slack_team_id, channel_id, workspace_id, agent_path) DO UPDATE + SET channel_name = EXCLUDED.channel_name, is_default = EXCLUDED.is_default", + team_id, + body.channel_id, + body.channel_name, + w_id, + path, + body.is_default + ) + .execute(&db) + .await?; + Ok(format!("{path} is shared in #{}", body.channel_name)) +} + +#[derive(Deserialize)] +struct UnshareQuery { + channel_id: String, +} + +/// Taking an agent out of a channel lends nothing, so anyone who can edit it can. +async fn unshare_agent( + authed: ApiAuthed, + Extension(db): Extension<DB>, + Path((w_id, path)): Path<(String, StripPath)>, + Query(q): Query<UnshareQuery>, +) -> Result<String> { + let path = path.to_path(); + if !is_writer(&authed, &db, &w_id, path).await? { + return Err(Error::PermissionDenied(format!( + "Only those who can edit {path} can stop sharing it." + ))); + } + sqlx::query!( + "DELETE FROM slack_agent_channel + WHERE workspace_id = $1 AND agent_path = $2 AND channel_id = $3", + w_id, + path, + q.channel_id + ) + .execute(&db) + .await?; + Ok(format!("{path} is no longer shared there")) +} + +#[derive(Serialize)] +struct SlackChannel { + id: String, + name: String, +} + +#[derive(Deserialize)] +struct ChannelsQuery { + cursor: Option<String>, +} + +#[derive(Serialize)] +struct SlackChannels { + channels: Vec<SlackChannel>, + next_cursor: Option<String>, +} + +/// The team's public channels, a page at a time, for picking one. Private channels stay out: +/// their names are for their members only. +async fn list_available_channels( + authed: ApiAuthed, + Extension(db): Extension<DB>, + Path(w_id): Path<String>, + Query(q): Query<ChannelsQuery>, +) -> JsonResult<SlackChannels> { + if authed.is_operator { + return Err(Error::PermissionDenied( + "Operators can't share agents in Slack".to_string(), + )); + } + let token = bot_token(&db, &w_id).await?; + let mut form = vec![ + ("types", "public_channel".to_string()), + ("exclude_archived", "true".to_string()), + ("limit", "200".to_string()), + ]; + if let Some(cursor) = q.cursor.filter(|c| !c.is_empty()) { + form.push(("cursor", cursor)); + } + let res = slack_call(&token, "conversations.list", &form) + .await + .map_err(|e| Error::BadRequest(e.to_string()))?; + let channels = res["channels"] + .as_array() + .map(|cs| { + cs.iter() + .filter_map(|c| { + Some(SlackChannel { + id: c["id"].as_str()?.to_string(), + name: c["name"].as_str()?.to_string(), + }) + }) + .collect() + }) + .unwrap_or_default(); + let next_cursor = res["response_metadata"]["next_cursor"] + .as_str() + .filter(|c| !c.is_empty()) + .map(str::to_string); + Ok(Json(SlackChannels { channels, next_cursor })) +} + +async fn connected_team(db: &DB, w_id: &str) -> Result<String> { + sqlx::query_scalar!( + "SELECT slack_team_id FROM workspace_settings WHERE workspace_id = $1", + w_id + ) + .fetch_optional(db) + .await? + .flatten() + .ok_or_else(|| Error::BadRequest("Slack is not connected to this workspace".to_string())) +} + +async fn bot_token(db: &DB, w_id: &str) -> Result<String> { + get_secret_value_as_admin(db, w_id, WORKSPACE_SLACK_BOT_TOKEN_PATH).await +} + +/// A Slack Web API call, form-encoded since read methods such as `users.info` take no JSON. +/// Slack answers 200 with `ok: false` on failure; that is returned as the error. +async fn slack_call( + token: &str, + method: &str, + form: &[(&str, String)], +) -> anyhow::Result<serde_json::Value> { + let res: serde_json::Value = HTTP_CLIENT + .post(format!("https://slack.com/api/{method}")) + .bearer_auth(token) + .form(form) + .send() + .await? + .json() + .await?; + if res["ok"].as_bool() != Some(true) { + let error = res["error"].as_str().unwrap_or("unknown_error"); + let needed = res["needed"].as_str().unwrap_or_default(); + anyhow::bail!("{method}: {error} {needed}"); + } + Ok(res) +} + +/// A message addressed to Windmill: a bot mention or a direct message. +pub struct SlackMessage { + pub team_id: String, + pub channel_id: String, + pub user_id: String, + /// With the bot mention stripped, still escaped the way Slack sends it. + pub text: String, + pub ts: String, + pub thread_ts: Option<String>, + /// The workspaces whose Slack app's signing secret verified this request. Carried from the + /// check rather than re-read from the settings, which can change between the two. + pub verified: Vec<String>, +} + +enum Route { + Agent(String), + /// No agent to run: say which ones are shared here, after `note` when there is one. + Help { + note: Option<String>, + }, +} + +struct SharedHere { + workspace_id: String, + agent_path: String, + is_default: bool, +} + +/// Whether an agent answers this message; if so, the answer is under way when this returns. +/// `false` leaves the message to the workspace's command script. +pub async fn try_answer(db: &DB, msg: SlackMessage) -> Result<bool> { + // Without it the Slack endpoints take unsigned requests, and anyone could post as a channel. + if msg.verified.is_empty() { + return Ok(false); + } + let text = decode_entities(&msg.text); + let (name, question) = split_agent_name(&text); + let thread_root = msg.thread_ts.clone().unwrap_or_else(|| msg.ts.clone()); + + // Only a workspace still connected to the team answers: disconnecting keeps its bot token. + let shared = sqlx::query_as!( + SharedHere, + "SELECT c.workspace_id, c.agent_path, c.is_default FROM slack_agent_channel c + JOIN workspace_settings ws ON ws.workspace_id = c.workspace_id + AND ws.slack_team_id = c.slack_team_id + WHERE c.slack_team_id = $1 AND c.channel_id = $2 + AND ws.workspace_id = ANY($3) + ORDER BY c.agent_path", + msg.team_id, + msg.channel_id, + &msg.verified + ) + .fetch_all(db) + .await?; + let thread = sqlx::query!( + "SELECT workspace_id, agent_path FROM slack_thread_agent + WHERE slack_team_id = $1 AND channel_id = $2 AND thread_ts = $3", + msg.team_id, + msg.channel_id, + thread_root + ) + .fetch_optional(db) + .await? + // A thread's agent answers only while it is still shared here. + .and_then(|t| { + shared + .iter() + .find(|s| s.workspace_id == t.workspace_id && s.agent_path == t.agent_path) + }); + + let named = name.filter(|_| !shared.is_empty()).map(|name| { + let matches = shared + .iter() + .filter(|s| s.agent_path == name || s.agent_path.ends_with(&format!("/{name}"))) + .collect::<Vec<_>>(); + (name, matches) + }); + let (here, route, source) = match named { + Some((_, matches)) if matches.len() == 1 => ( + matches[0], + Route::Agent(matches[0].agent_path.clone()), + "mention", + ), + Some((name, matches)) => { + let note = if matches.is_empty() { + format!("No agent named *{name}* is shared in this channel.") + } else { + format!("Several agents shared here are named *{name}*, use the whole path.") + }; + (&shared[0], Route::Help { note: Some(note) }, "help") + } + None => match thread.or_else(|| shared.iter().find(|s| s.is_default)) { + Some(s) => ( + s, + Route::Agent(s.agent_path.clone()), + if thread.is_some() { + "thread" + } else { + "channel_default" + }, + ), + None => { + match workspace_without_command_script(db, &msg.team_id, &msg.verified).await? { + Some(w_id) => { + return start_answer( + db, + w_id, + Route::Help { note: None }, + "help", + question, + msg, + ) + .await; + } + None => return Ok(false), + } + } + }, + }; + let w_id = here.workspace_id.clone(); + + // Recorded before answering, so a follow-up sent while the first answer is on its way + // already finds its agent. + if let Route::Agent(path) = &route { + if let Err(e) = + record_thread_agent(db, &msg.team_id, &msg.channel_id, &thread_root, &w_id, path).await + { + tracing::warn!("Could not record the Slack thread's agent: {e:#}"); + } + } + start_answer(db, w_id, route, source, question, msg).await +} + +async fn start_answer( + db: &DB, + w_id: String, + route: Route, + source: &str, + question: &str, + msg: SlackMessage, +) -> Result<bool> { + let first = sqlx::query_scalar!( + "INSERT INTO slack_answered_message (slack_team_id, channel_id, ts) + VALUES ($1, $2, $3) ON CONFLICT DO NOTHING RETURNING true", + msg.team_id, + msg.channel_id, + msg.ts + ) + .fetch_optional(db) + .await? + .is_some(); + if !first { + return Ok(true); + } + if matches!(route, Route::Agent(_)) { + windmill_common::feature_usage::log_feature_usage("slack_agent", "invoked", source); + } + let db = db.clone(); + let question = question.to_string(); + // ponytail: the reply waits in this API process, so a restart mid-run loses the reply (not + // the run, which still shows in Windmill); a job-completion hook would make it durable. + tokio::spawn(async move { + prune(&db).await; + if let Err(e) = answer(&db, &w_id, route, &question, msg).await { + tracing::error!("Slack agent answer failed: {e:#}"); + } + }); + Ok(true) +} + +async fn record_thread_agent( + db: &DB, + team_id: &str, + channel_id: &str, + thread_ts: &str, + w_id: &str, + agent_path: &str, +) -> Result<()> { + sqlx::query!( + "INSERT INTO slack_thread_agent (slack_team_id, channel_id, thread_ts, workspace_id, agent_path) + VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING", + team_id, + channel_id, + thread_ts, + w_id, + agent_path + ) + .execute(db) + .await?; + Ok(()) +} + +/// Best effort: a message already marked answered must still be answered if this fails. +async fn prune(db: &DB) { + let res = sqlx::query!( + "WITH a AS (DELETE FROM slack_answered_message WHERE created_at < now() - interval '1 day') + DELETE FROM slack_thread_agent WHERE created_at < now() - interval '90 days'" + ) + .execute(db) + .await; + if let Err(e) = res { + tracing::warn!("Could not prune Slack agent routing: {e:#}"); + } +} + +/// `+name rest` or `~name rest` → (name, rest). +fn split_agent_name(text: &str) -> (Option<&str>, &str) { + let text = text.trim(); + let Some(after) = text.strip_prefix('+').or_else(|| text.strip_prefix('~')) else { + return (None, text); + }; + let (name, rest) = after.split_once(char::is_whitespace).unwrap_or((after, "")); + if name.is_empty() + || !name + .chars() + .all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '/' | '.')) + { + return (None, text); + } + (Some(name), rest.trim()) +} + +/// Slack escapes these three in message text. +fn decode_entities(text: &str) -> String { + text.replace("<", "<") + .replace(">", ">") + .replace("&", "&") +} + +/// The team's workspace when no workspace of the team has a command script to take the message: +/// the only one connected to it. +async fn workspace_without_command_script( + db: &DB, + team_id: &str, + verified: &[String], +) -> Result<Option<String>> { + let rows = sqlx::query!( + "SELECT workspace_id, slack_command_script IS NOT NULL AS \"has_script!\" + FROM workspace_settings ws WHERE slack_team_id = $1 + AND ws.workspace_id = ANY($2) + ORDER BY slack_command_script IS NOT NULL DESC LIMIT 2", + team_id, + verified + ) + .fetch_all(db) + .await?; + Ok(match rows.as_slice() { + [only] if !only.has_script => Some(only.workspace_id.clone()), + _ => None, + }) +} + +/// The agents shared in the message's channel, and how to ask one. +async fn help_text(db: &DB, msg: &SlackMessage, note: Option<String>) -> Result<String> { + let agents = sqlx::query_scalar!( + "SELECT c.agent_path FROM slack_agent_channel c + JOIN workspace_settings ws ON ws.workspace_id = c.workspace_id + AND ws.slack_team_id = c.slack_team_id + WHERE c.slack_team_id = $1 AND c.channel_id = $2 + AND ws.workspace_id = ANY($3) + ORDER BY c.agent_path LIMIT 11", + msg.team_id, + msg.channel_id, + &msg.verified + ) + .fetch_all(db) + .await?; + let note = note.map(|n| format!("{n}\n\n")).unwrap_or_default(); + let Some(first) = agents.first() else { + let text = if msg.channel_id.starts_with('D') { + "Agents answer in the Slack channels they're shared in, not in direct messages." + } else { + "No agent is shared in this channel. Anyone who can edit an agent in Windmill can share it here from its settings." + }; + return Ok(format!("{note}{text}")); + }; + let handle = |p: &str| p.rsplit('/').next().unwrap_or(p).to_string(); + let mut list = agents + .iter() + .take(10) + .map(|p| format!("• `+{}` ({p})", handle(p))) + .collect::<Vec<_>>() + .join("\n"); + if agents.len() > 10 { + list.push_str("\n• …"); + } + Ok(format!( + "{note}Start your message with an agent's name, for example `+{} what can you do?`\n\nAgents shared in this channel:\n{list}", + handle(first) + )) +} + +/// Who sent a message, as far as agents are concerned. +enum Asker { + /// With their Slack email, `None` when Slack hides it or the connection lacks the scope. + Person { email: Option<String> }, + /// A bot or a workflow: never answered, so two bots cannot keep each other talking, and an + /// agent's identity is never lent to something no person typed. + Bot, + /// Someone from another Slack organization, in a channel shared with it. + Outsider, +} + +async fn asker(token: &str, msg: &SlackMessage) -> anyhow::Result<Asker> { + if msg.user_id.is_empty() || msg.user_id == "USLACKBOT" { + return Ok(Asker::Bot); + } + let info = match slack_call(token, "users.info", &[("user", msg.user_id.clone())]).await { + Ok(info) => info, + // Slack hides some external users from the team's token. + Err(e) if e.to_string().contains("user_not_found") => return Ok(Asker::Outsider), + Err(e) => return Err(e), + }; + let user = &info["user"]; + if user["is_bot"].as_bool() == Some(true) { + return Ok(Asker::Bot); + } + if user["is_stranger"].as_bool() == Some(true) + || user["team_id"].as_str() != Some(msg.team_id.as_str()) + { + return Ok(Asker::Outsider); + } + Ok(Asker::Person { email: user["profile"]["email"].as_str().map(str::to_string) }) +} + +async fn answer( + db: &DB, + w_id: &str, + route: Route, + question: &str, + msg: SlackMessage, +) -> anyhow::Result<()> { + let token = bot_token(db, w_id).await?; + let channel = msg.channel_id.clone(); + + let asker = match asker(&token, &msg).await { + Ok(asker) => asker, + Err(e) if e.to_string().contains("missing_scope") => { + tracing::warn!("Slack agents in {w_id} need the connection's users:read scope: {e:#}"); + return Ok(()); + } + Err(e) => return Err(e), + }; + if matches!(asker, Asker::Bot) { + return Ok(()); + } + + let thread_root = msg.thread_ts.clone().unwrap_or_else(|| msg.ts.clone()); + let reply = Reply::start(&token, &channel, &thread_root).await?; + + let email = match asker { + Asker::Person { email } => email, + _ => { + return reply + .send( + "Agents answer only people from this Slack workspace.".to_string(), + None, + ) + .await; + } + }; + let agent_path = match route { + Route::Help { note } => return reply.send(help_text(db, &msg, note).await?, None).await, + Route::Agent(path) => path, + }; + + if question.is_empty() { + return reply + .send(format!("What would you like to ask `{agent_path}`?"), None) + .await; + } + + // Read at each message: who can ask follows the agent's last deploy. + let share = sqlx::query!( + "SELECT COALESCE(r.value->'run_on_behalf_of' = 'true'::jsonb + AND r.value->'slack_anyone' = 'true'::jsonb, false) AS \"anyone!\", + r.value->>'on_behalf_of' AS on_behalf_of + FROM slack_agent_channel c + JOIN resource r ON r.workspace_id = c.workspace_id AND r.path = c.agent_path + WHERE c.slack_team_id = $1 AND c.channel_id = $2 AND c.workspace_id = $3 + AND c.agent_path = $4 AND r.resource_type = 'ai_agent'", + msg.team_id, + channel, + w_id, + agent_path + ) + .fetch_optional(db) + .await?; + let members_only = share.as_ref().is_some_and(|s| !s.anyone); + let authed = match share { + None => { + return reply + .send( + format!("`{agent_path}` is no longer shared in this channel."), + None, + ) + .await; + } + Some(share) if !share.anyone => { + let Some(email) = email else { + return reply.send( + "Windmill can't read your Slack email. A workspace admin needs to reconnect Slack in the workspace settings to grant it.".to_string(), + None, + ) + .await; + }; + let member = sqlx::query!( + "SELECT username, email FROM usr + WHERE workspace_id = $1 AND lower(email) = lower($2) AND NOT disabled", + w_id, + email + ) + .fetch_optional(db) + .await?; + let Some(member) = member else { + return reply.send( + format!( + "Your Slack email isn't a member of the Windmill workspace *{w_id}*. Ask an admin to invite you." + ), + None, + ) + .await; + }; + fetch_api_authed(member.username, member.email, w_id, db, None).await? + } + Some(share) => { + let on_behalf_of = windmill_common::on_behalf_of_from_permissioned_as( + share.on_behalf_of.as_deref(), + w_id, + db, + ) + .await?; + let Some(on_behalf_of) = on_behalf_of else { + return reply + .send( + format!( + "`{agent_path}` doesn't run on behalf of anyone, so it can't answer in Slack." + ), + None, + ) + .await; + }; + fetch_api_authed_from_permissioned_as( + on_behalf_of.permissioned_as, + on_behalf_of.email, + w_id, + db, + None, + ) + .await? + } + }; + let user_db = UserDB::new(db.clone()); + + // Keyed with the workspace's secret: anyone who can read the agent's runs could rebuild the + // plain thread and user ids and load this person's conversation, tool results included. + let workspace_key = get_workspace_key(w_id, db).await?; + let run_query = RunJobQuery { + memory_id: Some(calculate_hash(&format!( + "{workspace_key}:slack:{}:{}:{}:{}", + msg.team_id, channel, thread_root, msg.user_id + ))), + ..Default::default() + }; + let args: HashMap<String, Box<RawValue>> = [( + "user_message".to_string(), + windmill_common::worker::to_raw_value(&question), + )] + .into(); + let job_id = + match run_agent_as(&authed, db, &user_db, w_id, &agent_path, &run_query, &args).await { + Ok(id) => id, + Err(Error::NotFound(_)) if members_only => { + return reply + .send( + format!("You don't have access to `{agent_path}` in Windmill."), + None, + ) + .await; + } + Err(e) => { + return reply + .send(format!("`{agent_path}` couldn't start: {e}"), None) + .await; + } + }; + + let run_link = format!( + "<{}/run/{job_id}?workspace={w_id}|View run> · `{agent_path}`", + BASE_URL.load() + ); + let thinking = reply.keep_thinking(); + let result = wait_for_result(db, w_id, job_id).await; + if let Some(thinking) = thinking { + thinking.abort(); + } + let text = match result? { + Some((result, true)) => answer_text(&result), + Some((result, false)) => format!("The agent failed: {}", error_text(&result)), + None => "Still working, follow it in the run.".to_string(), + }; + reply.send(text, Some(run_link)).await +} + +async fn wait_for_result( + db: &DB, + w_id: &str, + job_id: Uuid, +) -> Result<Option<(serde_json::Value, bool)>> { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(MAX_WAIT_SECS); + while std::time::Instant::now() < deadline { + let row = sqlx::query!( + "SELECT result AS \"result: sqlx::types::Json<serde_json::Value>\", + status = 'success' AS \"success!\" + FROM v2_job_completed WHERE id = $1 AND workspace_id = $2", + job_id, + w_id + ) + .fetch_optional(db) + .await?; + if let Some(row) = row { + return Ok(Some(( + row.result.map(|r| r.0).unwrap_or_default(), + row.success, + ))); + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + Ok(None) +} + +/// The agent step's `output`: its text, or a structured output as a JSON block. +fn answer_text(result: &serde_json::Value) -> String { + match &result["output"] { + serde_json::Value::String(s) if !s.trim().is_empty() => s.clone(), + serde_json::Value::Null => "The agent returned no answer.".to_string(), + other => format!( + "```\n{}\n```", + serde_json::to_string_pretty(other).unwrap_or_default() + ), + } +} + +fn error_text(result: &serde_json::Value) -> String { + result["error"]["message"] + .as_str() + .map(str::to_string) + .unwrap_or_else(|| result.to_string()) +} + +/// The bot's reply in a thread. While it is being worked out the thread shows the bot as thinking, +/// or, where Slack refuses that status, a placeholder message the reply then replaces. +#[derive(Clone)] +struct Reply { + token: String, + channel: String, + thread_ts: String, + placeholder: Option<String>, +} + +impl Reply { + async fn start(token: &str, channel: &str, thread_ts: &str) -> anyhow::Result<Self> { + let mut reply = Reply { + token: token.to_string(), + channel: channel.to_string(), + thread_ts: thread_ts.to_string(), + placeholder: None, + }; + if let Err(e) = reply.set_status("is thinking…").await { + tracing::debug!("Slack refused the thinking status, posting a placeholder: {e:#}"); + let posted = slack_call( + token, + "chat.postMessage", + &[ + ("channel", channel.to_string()), + ("thread_ts", thread_ts.to_string()), + ("text", "Thinking…".to_string()), + ], + ) + .await?; + reply.placeholder = posted["ts"].as_str().map(str::to_string); + } + Ok(reply) + } + + async fn set_status(&self, status: &str) -> anyhow::Result<()> { + slack_call( + &self.token, + "assistant.threads.setStatus", + &[ + ("channel_id", self.channel.clone()), + ("thread_ts", self.thread_ts.clone()), + ("status", status.to_string()), + ], + ) + .await + .map(|_| ()) + } + + /// Slack drops the status after two minutes without a reply, so a long run renews it. + fn keep_thinking(&self) -> Option<tokio::task::JoinHandle<()>> { + if self.placeholder.is_some() { + return None; + } + let reply = self.clone(); + Some(tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_secs(90)).await; + let _ = reply.set_status("is thinking…").await; + } + })) + } + + async fn send(&self, text: String, footer: Option<String>) -> anyhow::Result<()> { + let (text, blocks) = reply_blocks(&text, footer)?; + let mut form = vec![ + ("channel", self.channel.clone()), + ("text", text), + ("blocks", blocks), + ]; + match &self.placeholder { + Some(ts) => { + form.push(("ts", ts.clone())); + slack_call(&self.token, "chat.update", &form).await?; + } + None => { + form.push(("thread_ts", self.thread_ts.clone())); + slack_call(&self.token, "chat.postMessage", &form).await?; + let _ = self.set_status("").await; + } + } + Ok(()) + } +} + +/// The reply's notification text and its blocks: the answer as markdown, and an optional footer. +fn reply_blocks(text: &str, footer: Option<String>) -> anyhow::Result<(String, String)> { + let text = if text.chars().count() > MAX_REPLY_CHARS { + let cut: String = text.chars().take(MAX_REPLY_CHARS).collect(); + format!("{cut}\n\n_The answer is cut short here; the run has all of it._") + } else { + text.to_string() + }; + let mut blocks = vec![serde_json::json!({ "type": "markdown", "text": text })]; + if let Some(footer) = footer { + blocks.push(serde_json::json!({ + "type": "context", + "elements": [{ "type": "mrkdwn", "text": footer }] + })); + } + let fallback: String = text.chars().take(300).collect(); + Ok((fallback, serde_json::to_string(&blocks)?)) +} + +#[cfg(test)] +mod tests { + use super::split_agent_name; + + #[test] + fn agent_name_prefix() { + assert_eq!( + split_agent_name("+sql_analyst how many?"), + (Some("sql_analyst"), "how many?") + ); + assert_eq!( + split_agent_name("~f/data/sql_analyst hi"), + (Some("f/data/sql_analyst"), "hi") + ); + assert_eq!(split_agent_name("+helper"), (Some("helper"), "")); + assert_eq!(split_agent_name("what is +x"), (None, "what is +x")); + assert_eq!(split_agent_name("+ spaced"), (None, "+ spaced")); + } +} diff --git a/backend/windmill-api/src/slack_approvals.rs b/backend/windmill-api/src/slack_approvals.rs index 204b30010d..8635cc3e1e 100644 --- a/backend/windmill-api/src/slack_approvals.rs +++ b/backend/windmill-api/src/slack_approvals.rs @@ -138,37 +138,70 @@ struct PrivateMetadata { skin: ApprovalSkin, } -// Opportunistic transport-level check: when `SLACK_SIGNING_SECRET` is configured we verify -// the Slack request signature (which also defeats replay). It is NOT the primary defense: -// the secret is unset in the default deployment, so authorization of the sensitive actions -// is instead anchored on a per-workspace HMAC over the callback payload itself (see -// `verify_slack_payload`), which holds even when this check is a no-op. -#[cfg(feature = "oauth2")] -fn verify_slack_callback_signature(headers: &HeaderMap, body: &str) -> Result<(), Error> { - if let Some(sv) = crate::SLACK_SIGNING_SECRET.as_ref() { - let sig = headers - .get("X-Slack-Signature") - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - let ts = headers - .get("X-Slack-Request-Timestamp") - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - sv.verify(ts, body, sig) - .map_err(|_| Error::BadRequest("Slack signature verification failed".to_string()))?; +/// Slack's signature over a callback, checked once the workspace it belongs to is known. +#[cfg_attr(not(feature = "oauth2"), allow(dead_code))] +struct SlackCallbackSignature { + sig: String, + ts: String, + body: String, +} + +impl SlackCallbackSignature { + fn new(headers: &HeaderMap, body: &str) -> Self { + let header = |name: &str| { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string() + }; + Self { + sig: header("X-Slack-Signature"), + ts: header("X-Slack-Request-Timestamp"), + body: body.to_string(), + } } - Ok(()) +} + +// Transport-level check, which also defeats replay. It is NOT the primary defense: authorization of +// the sensitive actions is anchored on a per-workspace HMAC over the callback payload itself (see +// `verify_slack_payload`). Only an approval sent through the workspace's Slack connection comes from +// an app whose secret Windmill knows; any other resource may belong to any Slack app, so its +// callbacks are left to the HMAC. +#[cfg(feature = "oauth2")] +async fn verify_slack_callback_signature( + db: &DB, + w_id: &str, + resource_path: &str, + request: &SlackCallbackSignature, +) -> Result<(), Error> { + if resource_path != windmill_common::oauth2::WORKSPACE_SLACK_BOT_TOKEN_PATH { + return Ok(()); + } + crate::slack_agents::verify_approval_slack_request( + db, + w_id, + &request.sig, + &request.ts, + &request.body, + ) + .await } #[cfg(not(feature = "oauth2"))] -fn verify_slack_callback_signature(_headers: &HeaderMap, _body: &str) -> Result<(), Error> { +async fn verify_slack_callback_signature( + _db: &DB, + _w_id: &str, + _resource_path: &str, + _request: &SlackCallbackSignature, +) -> Result<(), Error> { Ok(()) } /// HMAC keyed on the per-workspace encryption key (the same trust anchor as resume-URL /// signatures). Used to authenticate the `/api/slack` callback payload itself so the /// unauthenticated route cannot be driven into decrypting arbitrary workspace variables, -/// regardless of whether `SLACK_SIGNING_SECRET` is configured. +/// whether or not a Slack signing secret is set. type SlackPayloadHmac = Hmac<Sha256>; /// Domain-separation tag prepended to every Slack-payload MAC. The workspace key is also used @@ -232,8 +265,7 @@ pub async fn slack_app_callback_handler( headers: HeaderMap, body: Bytes, ) -> Result<StatusCode, Error> { - let body_str = String::from_utf8_lossy(&body); - verify_slack_callback_signature(&headers, &body_str)?; + let request = SlackCallbackSignature::new(&headers, &String::from_utf8_lossy(&body)); let form_data: SlackFormData = serde_urlencoded::from_bytes(&body) .map_err(|e| Error::BadRequest(format!("invalid form data: {}", e)))?; @@ -244,10 +276,10 @@ pub async fn slack_app_callback_handler( match payload.r#type { PayloadType::ViewSubmission => { - handle_submission(authed, opt_tokened, db, &payload, "resume").await? + handle_submission(authed, opt_tokened, db, &payload, &request, "resume").await? } PayloadType::ViewClosed => { - handle_submission(authed, opt_tokened, db, &payload, "cancel").await? + handle_submission(authed, opt_tokened, db, &payload, &request, "cancel").await? } _ => { if let Some(actions) = &payload.actions { @@ -274,8 +306,8 @@ pub async fn slack_app_callback_handler( // payload was minted by `send_slack_message` with an HMAC over // (w_id, job_id, path) keyed on the workspace key. Without a valid // signature an unauthenticated caller cannot reach the decryption - // below for an arbitrary variable, even when SLACK_SIGNING_SECRET - // is unset. + // below for an arbitrary variable, even when no Slack signing + // secret is set. verify_slack_payload( &db, w_id, @@ -283,6 +315,7 @@ pub async fn slack_app_callback_handler( parsed_value.signature.as_deref(), ) .await?; + verify_slack_callback_signature(&db, w_id, path, &request).await?; // Map any lookup/decryption failure to a generic error: the // raw error echoes the probed `path`/`w_id` back, which would be @@ -411,6 +444,7 @@ async fn handle_submission( opt_tokened: OptTokened, db: DB, payload: &Payload, + request: &SlackCallbackSignature, action: &str, ) -> Result<(), Error> { let view = payload @@ -455,6 +489,7 @@ async fn handle_submission( // the resume/cancel and reach the decryption below with a swapped path. Require the // workspace-keyed HMAC minted when the modal was built. verify_slack_payload(&db, w_id, &[resource_path.as_bytes()], signature.as_deref()).await?; + verify_slack_callback_signature(&db, w_id, &resource_path, request).await?; // Use the common handler to process the resume/cancel action handle_resume_action( diff --git a/backend/windmill-api/src/token.rs b/backend/windmill-api/src/token.rs index ab22f2d15b..7428e586d6 100644 --- a/backend/windmill-api/src/token.rs +++ b/backend/windmill-api/src/token.rs @@ -105,6 +105,12 @@ fn build_standard_scope_domains() -> Vec<ScopeDomain> { "AI agent eval datasets and standalone runs", false, ), + ( + "slack_agents", + "Slack Agents", + "AI agents answering in Slack channels", + false, + ), ( "agent_workers", "Agent Workers", diff --git a/backend/windmill-api/src/triggers/http/handler.rs b/backend/windmill-api/src/triggers/http/handler.rs index 3d85904e50..5db6bf303d 100644 --- a/backend/windmill-api/src/triggers/http/handler.rs +++ b/backend/windmill-api/src/triggers/http/handler.rs @@ -34,10 +34,7 @@ use windmill_store::resources::try_get_resource_from_db_as; use windmill_trigger::TriggerMode; #[cfg(feature = "parquet")] -use { - crate::job_helpers_oss::get_workspace_s3_resource, - windmill_object_store::build_object_store_client, -}; +use crate::job_helpers_oss::get_workspace_s3_resource; /// Which router a request's CORS decision must be looked up in. /// @@ -624,7 +621,13 @@ async fn route_job( let s3_resource = s3_resource_opt.ok_or_else(|| { windmill_object_store::workspace_storage_misconfigured(config.storage.as_deref()) })?; - let s3_client = build_object_store_client(&s3_resource).await?; + let s3_client = windmill_object_store::fork_store::workspace_object_store( + &db, + &trigger.workspace_id, + config.storage.as_deref(), + &s3_resource, + ) + .await?; let path = if trigger.is_static_website { let subpath = params diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index 5b26cf57b8..8c742b6d64 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -194,6 +194,41 @@ async fn list_guests( Ok(Json(GuestList { usage, guests })) } +/// Shared while issuing an MCP OAuth grant, exclusive while revoking: no grant outlives a revoke. +pub(crate) async fn lock_mcp_oauth_grants( + conn: &mut sqlx::PgConnection, + email: &str, + exclusive: bool, +) -> std::result::Result<(), sqlx::Error> { + let q = if exclusive { + "SELECT pg_advisory_xact_lock(hashtext('mcp_oauth_grant:' || $1))" + } else { + "SELECT pg_advisory_xact_lock_shared(hashtext('mcp_oauth_grant:' || $1))" + }; + sqlx::query(q).bind(email).execute(conn).await?; + Ok(()) +} + +async fn revoke_mcp_oauth_grants(tx: &mut sqlx::PgConnection, email: &str) -> Result<()> { + lock_mcp_oauth_grants(&mut *tx, email, true).await?; + // The token trigger only notifies for sessions, so evict deleted MCP access tokens explicitly. + sqlx::query!( + "WITH revoked AS ( + DELETE FROM token WHERE email = $1 AND label LIKE 'mcp-oauth-%' RETURNING token_prefix + ), refresh AS ( + DELETE FROM mcp_oauth_refresh_token WHERE user_email = $1 + ), code AS ( + DELETE FROM mcp_oauth_server_code WHERE user_email = $1 + ) + INSERT INTO notify_event (channel, payload) + SELECT 'notify_token_invalidation', token_prefix FROM revoked", + email + ) + .execute(&mut *tx) + .await?; + Ok(()) +} + async fn set_password( Extension(db): Extension<DB>, Extension(argon2): Extension<Arc<Argon2<'_>>>, @@ -215,6 +250,9 @@ async fn set_password( ) .execute(&db) .await?; + let mut tx = db.begin().await?; + revoke_mcp_oauth_grants(&mut *tx, &email).await?; + tx.commit().await?; Ok(res) } @@ -235,6 +273,9 @@ async fn set_password_of_user( ) .execute(&db) .await?; + let mut tx = db.begin().await?; + revoke_mcp_oauth_grants(&mut *tx, &email).await?; + tx.commit().await?; Ok(res) } @@ -297,8 +338,21 @@ async fn rename_user( // keeps a like-named member of some other workspace out of it. if let Some(old_username) = old_instance_username.filter(|u| *u != ru.new_username) { let old_principal = windmill_common::users::username_to_permissioned_as(&old_username); - let new_principal = - windmill_common::users::username_to_permissioned_as(&ru.new_username); + let new_principal = windmill_common::users::username_to_permissioned_as(&ru.new_username); + // An agent records no address, so a workspace where the name is a member's own is + // left to the per-workspace sweep: there the principal is that member. + sqlx::query( + "UPDATE resource SET value = jsonb_set(value, '{on_behalf_of}', to_jsonb($1::text)) + WHERE resource_type = 'ai_agent' AND value->>'on_behalf_of' = $2 + AND NOT EXISTS (SELECT 1 FROM usr + WHERE usr.workspace_id = resource.workspace_id + AND usr.username = $3)", + ) + .bind(&new_principal) + .bind(&old_principal) + .bind(&old_username) + .execute(&mut *tx) + .await?; sqlx::query!( "UPDATE app SET policy = jsonb_set(policy, ARRAY['on_behalf_of'], to_jsonb($1::text)) WHERE policy->>'on_behalf_of' = $2 AND policy->>'on_behalf_of_email' = $3", @@ -617,6 +671,15 @@ async fn update_username_in_workpsace<'c>( ).execute(&mut **tx) .await?; + sqlx::query!( + r#"UPDATE native_trigger SET connection_path = REGEXP_REPLACE(connection_path, 'u/' || $2 || '/(.*)', 'u/' || $1 || '/\1') WHERE connection_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, + new_username, + old_username, + w_id + ) + .execute(&mut **tx) + .await?; + sqlx::query!( r#"UPDATE workspace_integrations SET resource_path = REGEXP_REPLACE(resource_path, 'u/' || $2 || '/(.*)', 'u/' || $1 || '/\1') WHERE resource_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, new_username, @@ -817,6 +880,17 @@ async fn update_username_in_workpsace<'c>( .execute(&mut **tx) .await?; + sqlx::query( + "UPDATE resource SET value = jsonb_set(value, '{on_behalf_of}', to_jsonb('u/' || $1)) + WHERE resource_type = 'ai_agent' AND value->>'on_behalf_of' = ('u/' || $2) + AND workspace_id = $3", + ) + .bind(new_username) + .bind(old_username) + .bind(w_id) + .execute(&mut **tx) + .await?; + // An app draft carries a copy of the deployed policy, so the rename must reach it // there too — same reason as the script/flow draft sweep above. sqlx::query!( @@ -1063,6 +1137,7 @@ async fn reset_password( ) .execute(&mut *tx) .await?; + revoke_mcp_oauth_grants(&mut *tx, &email).await?; // Audit log let audit_author = AuditAuthor { diff --git a/backend/windmill-api/src/workspace_settings_document.rs b/backend/windmill-api/src/workspace_settings_document.rs new file mode 100644 index 0000000000..f83ffcb4e8 --- /dev/null +++ b/backend/windmill-api/src/workspace_settings_document.rs @@ -0,0 +1,1950 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2026 + * 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. + */ + +//! The workspace settings document: the workspace's settings in the layout of the `settings.yaml` +//! that `wmill sync` writes, read and applied as one. `wmill sync push`, the settings page's YAML +//! mode and the AI tools all apply through [`apply_settings_document`], so the file layout and +//! the rules for applying it exist once. + +use axum::{ + extract::{Path, Query}, + Extension, Json, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use sqlx::PgConnection; +use std::collections::BTreeMap; +use windmill_api_workspaces::workspaces::{apply_settings_edit, SettingsEdit}; +use windmill_common::{ + datatable_roles::{ + read_role_catalog, DatatableRoleCluster, ADMIN_DATATABLE_ROLE, NO_DEFAULT_DATATABLE_ROLE, + }, + error::{Error, JsonResult, Result}, + utils::require_admin, + variables::build_crypt, + workspaces::{ + DataTable, DataTablePermissions, ProtectionRuleKind, ProtectionRules, + DATATABLE_TENANT_WILDCARD, DEV_WORKSPACE_LOCK_RULE_NAME, + }, + DB, +}; + +use crate::db::ApiAuthed; + +/// Stands for a secret a reader may not see. Sent back unchanged, it keeps the stored value. +pub const SENSITIVE_UNCHANGED: &str = "__SENSITIVE_AND_UNCHANGED__"; +use windmill_common::variables::SETTINGS_ENCRYPTED_PREFIX as ENCRYPTED_PREFIX; + +const SECRET_KEYS: &[&str] = &["slack_oauth_client_secret"]; + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum SecretMode { + /// Secrets as [`SENSITIVE_UNCHANGED`], for the UI and AI tools. + #[default] + Redacted, + /// Secrets encrypted with the workspace key, deterministically so that an unchanged secret + /// exports to the same text and a synced file does not change on every pull. + Encrypted, + Plain, +} + +#[derive(sqlx::FromRow)] +struct SettingsRow { + auto_invite: Option<Value>, + webhook: Option<String>, + error_handler: Option<Value>, + success_handler: Option<Value>, + ai_config: Option<Value>, + large_file_storage: Option<Value>, + git_sync: Option<Value>, + default_app: Option<String>, + default_scripts: Option<Value>, + name: Option<String>, + mute_critical_alerts: Option<bool>, + color: Option<String>, + operator_settings: Option<Value>, + datatable: Option<Value>, + slack_team_id: Option<String>, + slack_name: Option<String>, + slack_command_script: Option<String>, + slack_oauth_client_id: Option<String>, + slack_oauth_client_secret: Option<String>, + ducklake: Option<Value>, + dbt_warehouses: Option<Value>, + deploy_ui: Option<Value>, + teams_command_script: Option<String>, + public_app_execution_limit_per_minute: Option<i32>, + guest_access_enabled: Option<bool>, + guest_jwt_public_key: Option<String>, + guest_jwt_jwks_url: Option<String>, + error_handler_fallback_to_instance_alerts: Option<bool>, + add_admins_and_developers_to_forks: Option<bool>, +} + +/// Server-owned git sync state (the webhook secret and hook id, the synced sha, the admin +/// automatic pulls run as, what the credential check observed) never leaves the server: a +/// re-imported workspace must not inherit another install's hook, sync state or pull identity, +/// and in a synced file it would churn for no reason. +pub(crate) fn redact_git_sync(git_sync: Option<Value>) -> Option<Value> { + let mut git_sync = git_sync?; + if let Some(repos) = git_sync + .get_mut("repositories") + .and_then(|r| r.as_array_mut()) + { + for repo in repos { + if let Some(auto_pull) = repo.get_mut("auto_pull").and_then(|a| a.as_object_mut()) { + for field in [ + "webhook_secret", + "webhook_id", + "webhook_error", + "last_synced_sha", + "last_pull_status", + "enabled_by", + ] { + auto_pull.remove(field); + } + } + if let Some(repo) = repo.as_object_mut() { + repo.remove("credential"); + } + } + } + Some(git_sync) +} + +/// What `datatable_permissions` is resolved against. Role names by catalog id, per cluster: the +/// document names a data table role, the stored +/// permissions its catalog id, which differs from one instance to the next. `None` for a catalog +/// that could not be read, as on an edition without data table roles. +#[derive(Default)] +struct DatatableNames { + instance: Option<BTreeMap<String, String>>, + external_instance: Option<BTreeMap<String, String>>, + /// The `u/`, `g/` and `f/` tenants of the desired document that name no user, group or folder + /// of the workspace. The endpoint stores such a tenant as a grant that matches nobody, so the + /// plan is where a typo in a pushed file gets caught. + unknown_tenants: std::collections::BTreeSet<String>, +} + +impl DatatableNames { + async fn load(db: &DB, w_id: &str, datatable_permissions: &Value) -> Result<Self> { + let read = |cluster| async move { + read_role_catalog(db, cluster) + .await + .ok() + .map(|c| c.into_iter().map(|(id, r)| (id, r.name)).collect()) + }; + let tenants = datatable_permissions + .as_object() + .into_iter() + .flat_map(|m| m.values()) + .filter_map(|entry| entry.get("roles")?.as_object()) + .flat_map(|roles| roles.values()) + .filter_map(Value::as_array) + .flatten() + .filter_map(Value::as_str); + let (mut users, mut groups, mut folders) = (vec![], vec![], vec![]); + for tenant in tenants { + match tenant.split_once('/') { + Some(("u", n)) => users.push(n.to_string()), + Some(("g", n)) => groups.push(n.to_string()), + Some(("f", n)) => folders.push(n.to_string()), + _ => {} + } + } + let mut unknown_tenants = std::collections::BTreeSet::new(); + for (prefix, named, query) in [ + ( + "u", + users, + "SELECT username FROM usr WHERE workspace_id = $1 AND username = ANY($2)", + ), + ( + "g", + groups, + "SELECT name FROM group_ WHERE workspace_id = $1 AND name = ANY($2)", + ), + ( + "f", + folders, + "SELECT name FROM folder WHERE workspace_id = $1 AND name = ANY($2)", + ), + ] { + if named.is_empty() { + continue; + } + let existing: std::collections::HashSet<String> = + sqlx::query_scalar::<_, String>(query) + .bind(w_id) + .bind(&named) + .fetch_all(db) + .await? + .into_iter() + .collect(); + unknown_tenants.extend( + named + .into_iter() + .filter(|n| !existing.contains(n)) + .map(|n| format!("{prefix}/{n}")), + ); + } + Ok(Self { + instance: read(DatatableRoleCluster::Instance).await, + external_instance: read(DatatableRoleCluster::ExternalInstance).await, + unknown_tenants, + }) + } + + fn of(&self, cluster: DatatableRoleCluster) -> Option<&BTreeMap<String, String>> { + match cluster { + DatatableRoleCluster::Instance => self.instance.as_ref(), + DatatableRoleCluster::ExternalInstance => self.external_instance.as_ref(), + } + } +} + +/// The cluster whose roles a data table entry can be put under: only an entry that owns a +/// database on a cluster Windmill manages, and is not a clone, which its origin governs. +fn role_cluster_of(entry: &Value) -> Option<DatatableRoleCluster> { + let entry: DataTable = serde_json::from_value(entry.clone()).ok()?; + if entry.governed_by.is_some() { + return None; + } + DatatableRoleCluster::of(entry.database?.resource_type) +} + +/// Each data table's stored `permissions`, with roles named rather than given by catalog id. A +/// role the catalog no longer has keeps its id, so the document still shows it. +fn datatable_permissions_document(datatable: Option<&Value>, roles: &DatatableNames) -> Value { + let mut doc = Map::new(); + let entries = datatable + .and_then(|d| d.get("datatables")) + .and_then(Value::as_object); + for (name, entry) in entries.into_iter().flatten() { + let Some(permissions) = entry + .get("permissions") + .filter(|p| !p.is_null()) + .and_then(|p| serde_json::from_value::<DataTablePermissions>(p.clone()).ok()) + else { + continue; + }; + let names = role_cluster_of(entry).and_then(|c| roles.of(c)); + let name_of = |id: &str| { + names + .and_then(|n| n.get(id)) + .cloned() + .unwrap_or_else(|| id.to_string()) + }; + let default_role = match permissions.default_role() { + NO_DEFAULT_DATATABLE_ROLE => Value::Null, + id => Value::String(name_of(id)), + }; + let roles: Map<String, Value> = permissions + .roles + .iter() + .map(|(id, t)| (name_of(id), json!(t.tenants))) + .collect(); + doc.insert( + name.clone(), + json!({ "default_role": default_role, "roles": roles }), + ); + } + Value::Object(doc) +} + +/// Protection rules by name, their rules in a fixed order so a reordered file is no change. The +/// dev workspace lock is left out: the dev workspace setting creates and removes it. +async fn protection_rules_document(conn: &mut PgConnection, w_id: &str) -> Result<Value> { + let rows = sqlx::query_as::<_, (String, i32, Vec<String>, Vec<String>)>( + "SELECT name, rules, bypass_groups, bypass_users FROM workspace_protection_rule + WHERE workspace_id = $1 AND name <> $2 ORDER BY name", + ) + .bind(w_id) + .bind(DEV_WORKSPACE_LOCK_RULE_NAME) + .fetch_all(&mut *conn) + .await?; + Ok(Value::Object( + rows.into_iter() + .map(|(name, bits, bypass_groups, bypass_users)| { + let rules = ProtectionRules::from_bits_truncate(bits); + let rule = json!({ + "rules": rule_kinds(rules), + "bypass_groups": bypass_groups, + "bypass_users": bypass_users, + }); + (name, rule) + }) + .collect(), + )) +} + +fn rule_kinds(rules: ProtectionRules) -> Value { + json!(rules.kinds()) +} + +/// Reads no permission itself: the caller must have checked that the requester is an admin of +/// `w_id`, since `SecretMode::Plain` returns the workspace's secrets in plain text. +pub async fn load_settings_document( + conn: &mut PgConnection, + db: &DB, + w_id: &str, + secrets: SecretMode, +) -> Result<Map<String, Value>> { + let row = sqlx::query_as::<_, SettingsRow>( + r#"SELECT + auto_invite, webhook, error_handler, success_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, + teams_command_script, public_app_execution_limit_per_minute, guest_access_enabled, + guest_jwt_public_key, guest_jwt_jwks_url, error_handler_fallback_to_instance_alerts, + add_admins_and_developers_to_forks + FROM workspace_settings + LEFT JOIN workspace ON workspace.id = workspace_settings.workspace_id + WHERE workspace_id = $1"#, + ) + .bind(w_id) + .fetch_one(&mut *conn) + .await?; + + let slack_oauth_client_secret = match (row.slack_oauth_client_secret, secrets) { + (Some(s), SecretMode::Redacted) if !s.is_empty() => Some(SENSITIVE_UNCHANGED.to_string()), + (Some(s), SecretMode::Encrypted) if !s.is_empty() => { + let mc = build_crypt(db, w_id).await?; + Some(format!( + "{ENCRYPTED_PREFIX}{}", + mc.encrypt_str_deterministic(&s) + )) + } + (s, _) => s, + }; + + let has_permissions = row + .datatable + .as_ref() + .and_then(|d| d.get("datatables")) + .and_then(Value::as_object) + .is_some_and(|e| { + e.values() + .any(|e| e.get("permissions").is_some_and(|p| !p.is_null())) + }); + let role_names = if has_permissions { + DatatableNames::load(db, w_id, &Value::Null).await? + } else { + DatatableNames::default() + }; + let datatable_permissions = datatable_permissions_document(row.datatable.as_ref(), &role_names); + let protection_rules = protection_rules_document(&mut *conn, w_id).await?; + + // 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] = [ + ("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), + ("ai_config", row.ai_config, false), + ("large_file_storage", row.large_file_storage, false), + ("git_sync", redact_git_sync(row.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), + ( + "mute_critical_alerts", + row.mute_critical_alerts.map(Value::Bool), + false, + ), + ("color", row.color.map(Value::String), false), + ("operator_settings", row.operator_settings, false), + ( + "datatable", + windmill_common::workspaces::strip_datatable_permissions(row.datatable), + false, + ), + ("slack_team_id", row.slack_team_id.map(Value::String), false), + ("slack_name", row.slack_name.map(Value::String), false), + ( + "slack_command_script", + row.slack_command_script.map(Value::String), + false, + ), + ( + "slack_oauth_client_id", + row.slack_oauth_client_id.map(Value::String), + true, + ), + ( + "slack_oauth_client_secret", + slack_oauth_client_secret.map(Value::String), + true, + ), + ("ducklake", row.ducklake, true), + ("dbt_warehouses", row.dbt_warehouses, true), + ("deploy_ui", row.deploy_ui, true), + ( + "teams_command_script", + row.teams_command_script.map(Value::String), + true, + ), + ( + "public_app_execution_limit_per_minute", + row.public_app_execution_limit_per_minute.map(|n| json!(n)), + true, + ), + ( + "guest_access_enabled", + Some(json!(row.guest_access_enabled.unwrap_or(false))), + true, + ), + ( + "guest_jwt_public_key", + row.guest_jwt_public_key.map(Value::String), + true, + ), + ( + "guest_jwt_jwks_url", + row.guest_jwt_jwks_url.map(Value::String), + true, + ), + ( + "error_handler_fallback_to_instance_alerts", + Some(json!(row + .error_handler_fallback_to_instance_alerts + .unwrap_or(false))), + true, + ), + ( + "add_admins_and_developers_to_forks", + Some(json!(row + .add_admins_and_developers_to_forks + .unwrap_or(false))), + true, + ), + ("datatable_permissions", Some(datatable_permissions), true), + ("protection_rules", Some(protection_rules), true), + ]; + let mut doc = Map::new(); + for (key, value, always) in entries { + if always || value.is_some() { + doc.insert(key.to_string(), value.unwrap_or(Value::Null)); + } + } + Ok(doc) +} + +#[derive(Deserialize)] +pub struct GetSettingsDocumentQuery { + #[serde(default)] + secrets: SecretMode, +} + +pub async fn get_settings_document( + authed: ApiAuthed, + Extension(db): Extension<DB>, + Path(w_id): Path<String>, + Query(q): Query<GetSettingsDocumentQuery>, +) -> JsonResult<Map<String, Value>> { + require_admin(authed.is_admin, &authed.username)?; + if q.secrets == SecretMode::Plain { + return Err(Error::BadRequest( + "Plain secrets are only exported by the workspace archive".to_string(), + )); + } + let mut conn = db.acquire().await?; + Ok(Json( + load_settings_document(&mut conn, &db, &w_id, q.secrets).await?, + )) +} + +#[derive(Deserialize)] +pub struct ApplySettingsDocument { + document: Map<String, Value>, + /// Clear the settings the document omits that `wmill sync push` has always cleared when + /// settings.yaml omits them. Otherwise a setting the document omits is left as it is. + #[serde(default)] + replace: bool, + #[serde(default)] + dry_run: bool, +} + +#[derive(Serialize, Debug)] +pub struct SettingsChange { + key: String, + before: Value, + after: Value, +} + +#[derive(Serialize, Debug, Default)] +pub struct ApplySettingsDocumentResult { + changes: Vec<SettingsChange>, + applied: bool, + #[serde(skip_serializing_if = "Vec::is_empty")] + warnings: Vec<String>, +} + +/// Where one planned write goes: a settings endpoint of the workspaces crate, or one of the few +/// that live in this crate. +#[derive(Debug)] +enum Target { + Edit(SettingsEdit), + AiConfig, + MuteCriticalAlerts, + TeamsCommand, +} + +#[derive(Debug)] +struct Write { + target: Target, + body: Value, + /// The document keys this write applies, for error messages. + keys: Vec<String>, +} + +fn is_null(v: Option<&Value>) -> bool { + v.is_none_or(Value::is_null) +} + +/// The endpoints drop a null member rather than store it, so a document stating one would +/// otherwise differ from the stored value on every apply. A handler's `extra_args` is stored +/// verbatim and its nulls are passed to the job as arguments, so it is compared as is. +fn without_null_members(v: &Value) -> Value { + match v { + Value::Object(m) => Value::Object( + m.iter() + .filter(|(_, v)| !v.is_null()) + .map(|(k, v)| { + let v = if k == "extra_args" { + v.clone() + } else { + without_null_members(v) + }; + (k.clone(), v) + }) + .collect(), + ), + Value::Array(a) => Value::Array(a.iter().map(without_null_members).collect()), + v => v.clone(), + } +} + +fn same(a: Option<&Value>, b: Option<&Value>) -> bool { + match (a, b) { + (a, b) if is_null(a) && is_null(b) => true, + (Some(a), Some(b)) => without_null_members(a) == without_null_members(b), + _ => false, + } +} + +/// Switches the endpoint stores as `false` when cleared, compared as `false` when unset. +const FALSE_WHEN_UNSET: &[&str] = &[ + "mute_critical_alerts", + "guest_access_enabled", + "add_admins_and_developers_to_forks", + "error_handler_fallback_to_instance_alerts", +]; + +fn null_as_false(v: Option<Value>) -> Option<Value> { + v.map(|v| if v.is_null() { Value::Bool(false) } else { v }) +} + +fn redact(key: &str, v: Value) -> Value { + if SECRET_KEYS.contains(&key) && !v.is_null() { + json!(SENSITIVE_UNCHANGED) + } else { + v + } +} + +/// The auto-invite object without the instance group keys, which a separate endpoint owns. +fn domain_invite(v: Option<&Value>) -> Value { + let mut m = v.and_then(Value::as_object).cloned().unwrap_or_default(); + m.remove("instance_groups"); + m.remove("instance_groups_roles"); + Value::Object(m) +} + +struct Plan { + current: Map<String, Value>, + desired: Map<String, Value>, + replace: bool, + changes: Vec<SettingsChange>, + writes: Vec<Write>, + warnings: Vec<String>, +} + +impl Plan { + /// The document's value for a key that predates the document: under `replace`, an omitted + /// one counts as `null`, which clears it. + fn legacy(&self, key: &str) -> Option<Value> { + match self.desired.get(key) { + Some(v) => Some(v.clone()), + None if self.replace => Some(Value::Null), + None => None, + } + } + + /// The document's value for a key that is only managed when present. + fn managed(&self, key: &str) -> Option<Value> { + self.desired.get(key).cloned() + } + + fn differs(&self, key: &str, desired: &Value) -> bool { + let current = self.current.get(key).cloned(); + let current = if FALSE_WHEN_UNSET.contains(&key) { + null_as_false(Some(current.unwrap_or(Value::Null))) + } else { + current + }; + !same(current.as_ref(), Some(desired)) + } + + fn record(&mut self, key: &str, after: Value) { + let before = self.current.get(key).cloned().unwrap_or(Value::Null); + self.record_change(key, before, after); + } + + fn record_change(&mut self, key: &str, before: Value, after: Value) { + self.changes.push(SettingsChange { + key: key.to_string(), + before: redact(key, before), + after: redact(key, after), + }); + } + + /// A write applying the keys recorded since the previous one. + fn write(&mut self, target: Target, body: Value) { + let from = self.writes.iter().map(|w| w.keys.len()).sum::<usize>(); + let keys = self.changes[from..].iter().map(|c| c.key.clone()).collect(); + self.writes.push(Write { target, body, keys }); + } + + /// The common case: one key, one endpoint, a body built from the desired value. + fn simple( + &mut self, + key: &str, + desired: Option<Value>, + target: Target, + body: impl FnOnce(Value) -> Value, + ) { + if let Some(desired) = desired { + if self.differs(key, &desired) { + self.record(key, desired.clone()); + self.write(target, body(desired)); + } + } + } +} + +/// The plain value of a secret the document states, or `None` to keep the stored one: for the +/// placeholder, and for a value encrypted under another workspace's key, which a repo synced to +/// several workspaces carries. That one is reported rather than failing every other setting. +fn resolve_secret( + key: &str, + v: Value, + mc: &windmill_common::variables::WorkspaceCrypt, + warnings: &mut Vec<String>, +) -> Option<Value> { + match v.as_str() { + Some(SENSITIVE_UNCHANGED) => None, + Some(s) if s.starts_with(ENCRYPTED_PREFIX) => { + match mc.decrypt_str(&s[ENCRYPTED_PREFIX.len()..]) { + Ok(plain) => Some(Value::String(plain)), + Err(_) => { + warnings.push(format!( + "{key} was left unchanged: it is encrypted with another workspace's key. \ + Give this workspace its own settings file, export secrets in plain text \ + (plainSecrets), or share the workspace key (includeKey)." + )); + None + } + } + } + _ => Some(v), + } +} + +const KNOWN_KEYS: &[&str] = &[ + "auto_invite", + "webhook", + "error_handler", + "success_handler", + "ai_config", + "large_file_storage", + "git_sync", + "default_app", + "default_scripts", + "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", + "teams_command_script", + "public_app_execution_limit_per_minute", + "guest_access_enabled", + "guest_jwt_public_key", + "guest_jwt_jwks_url", + "error_handler_fallback_to_instance_alerts", + "add_admins_and_developers_to_forks", + "datatable_permissions", + "protection_rules", +]; + +fn string_list(v: Option<&Value>, what: &str) -> Result<Value> { + match v { + None | Some(Value::Null) => Ok(json!([])), + Some(Value::Array(a)) if a.iter().all(Value::is_string) => Ok(Value::Array(a.clone())), + _ => Err(Error::BadRequest(format!( + "{what} must be a list of strings" + ))), + } +} + +/// `datatable_permissions` as the endpoint stores it: an omitted default role is `admin`, and +/// `admin` is always one of the roles. A data table stated as null is not under roles, as an +/// omitted one is. A data table's error goes to `errors` and leaves it out of the result, so every +/// data table is checked before the document is refused. +fn normalize_datatable_permissions(v: &Value, errors: &mut Vec<String>) -> Result<Value> { + let entries = match v { + Value::Null => return Ok(json!({})), + Value::Object(m) => m, + _ => { + return Err(Error::BadRequest( + "datatable_permissions must map data tables to their roles".to_string(), + )) + } + }; + let mut out = Map::new(); + for (name, entry) in entries { + if entry.is_null() { + continue; + } + match normalize_datatable_entry(name, entry) { + Ok(entry) => { + out.insert(name.clone(), entry); + } + Err(e) => errors.push(error_message(e)), + } + } + Ok(Value::Object(out)) +} + +fn normalize_datatable_entry(name: &str, entry: &Value) -> Result<Value> { + let what = format!("datatable_permissions.{name}"); + let entry = entry + .as_object() + .ok_or_else(|| Error::BadRequest(format!("{what} must be an object or null")))?; + let default_role = match entry.get("default_role") { + None => json!(ADMIN_DATATABLE_ROLE), + Some(r) if r.is_null() || r.is_string() => r.clone(), + Some(_) => { + return Err(Error::BadRequest(format!( + "{what}.default_role must be a role name or null" + ))) + } + }; + let mut roles = Map::new(); + match entry.get("roles") { + None | Some(Value::Null) => {} + Some(Value::Object(r)) => { + for (role, tenants) in r { + let tenants = string_list(Some(tenants), &format!("{what}.roles.{role}"))?; + // The endpoint's own check, here so a dry run refuses it too. + for tenant in tenants.as_array().into_iter().flatten() { + let tenant = tenant.as_str().unwrap_or_default(); + let valid = tenant == DATATABLE_TENANT_WILDCARD + || matches!(tenant.split_once('/'), + Some(("u" | "g" | "f", rest)) if !rest.is_empty()); + if !valid { + return Err(Error::BadRequest(format!( + "Invalid tenant '{tenant}' in {what}.roles.{role}: expected \ + 'u/<user>', 'g/<group>', 'f/<folder>' or '*'" + ))); + } + } + roles.insert(role.clone(), tenants); + } + } + Some(_) => { + return Err(Error::BadRequest(format!( + "{what}.roles must map role names to their tenants" + ))) + } + } + roles + .entry(ADMIN_DATATABLE_ROLE.to_string()) + .or_insert(json!([])); + if let Some(role) = default_role.as_str() { + if !roles.contains_key(role) { + return Err(Error::BadRequest(format!( + "The default role '{role}' of {what} is not among its roles" + ))); + } + } + Ok(json!({ "default_role": default_role, "roles": roles })) +} + +fn error_message(e: Error) -> String { + match e { + Error::BadRequest(msg) => msg, + e => e.to_string(), + } +} + +/// Refuses the document with every data table's error at once. +fn datatable_permissions_errors(errors: Vec<String>) -> Result<()> { + match errors.len() { + 0 => Ok(()), + 1 => Err(Error::BadRequest(errors.into_iter().next().unwrap())), + _ => Err(Error::BadRequest(format!( + "Invalid datatable_permissions, nothing was applied:\n- {}", + errors.join("\n- ") + ))), + } +} + +/// `protection_rules` as it is stored: rules in their fixed order, the bypass lists stated. The +/// dev workspace lock is dropped with a warning, since the dev workspace setting owns it. +fn normalize_protection_rules(v: &Value, warnings: &mut Vec<String>) -> Result<Value> { + let rules = match v { + Value::Null => return Ok(json!({})), + Value::Object(m) => m, + _ => { + return Err(Error::BadRequest( + "protection_rules must map rule names to rules".to_string(), + )) + } + }; + let mut out = Map::new(); + for (name, rule) in rules { + if name == DEV_WORKSPACE_LOCK_RULE_NAME { + warnings.push(format!( + "protection_rules.{name} was left unchanged: the dev workspace setting manages it" + )); + continue; + } + if rule.is_null() { + continue; + } + let what = format!("protection_rules.{name}"); + let rule = rule + .as_object() + .ok_or_else(|| Error::BadRequest(format!("{what} must be an object or null")))?; + let kinds: Vec<ProtectionRuleKind> = + serde_json::from_value(rule.get("rules").cloned().unwrap_or(json!([]))) + .map_err(|e| Error::BadRequest(format!("Invalid {what}.rules: {e}")))?; + out.insert( + name.clone(), + json!({ + "rules": rule_kinds(ProtectionRules::from(&kinds)), + "bypass_groups": string_list(rule.get("bypass_groups"), &format!("{what}.bypass_groups"))?, + "bypass_users": string_list(rule.get("bypass_users"), &format!("{what}.bypass_users"))?, + }), + ); + } + Ok(Value::Object(out)) +} + +async fn plan(db: &DB, w_id: &str, desired: Map<String, Value>, replace: bool) -> Result<Plan> { + let current = { + let mut conn = db.acquire().await?; + load_settings_document(&mut conn, db, w_id, SecretMode::Plain).await? + }; + 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?, + _ => DatatableNames::default(), + }; + plan_document(current, desired, replace, &mc, &names) +} + +fn plan_document( + current: Map<String, Value>, + mut desired: Map<String, Value>, + replace: bool, + mc: &windmill_common::variables::WorkspaceCrypt, + names: &DatatableNames, +) -> Result<Plan> { + let mut warnings = vec![]; + let unknown: Vec<&str> = desired + .keys() + .map(String::as_str) + .filter(|k| !KNOWN_KEYS.contains(k)) + .collect(); + if !unknown.is_empty() { + warnings.push(format!("Ignored unknown settings: {}", unknown.join(", "))); + } + for key in ["auto_invite", "error_handler", "success_handler"] { + if desired + .get(key) + .is_some_and(|v| !v.is_null() && !v.is_object()) + { + return Err(Error::BadRequest(format!( + "{key} must be an object or null" + ))); + } + } + // The bodies built below coerce a mistyped value to `false` or to a delete, so a wrong type + // must be refused here rather than reach an endpoint as a destructive write. + let typed = |key: &str, v: Option<&Value>, ok: fn(&Value) -> bool, ty: &str| match v { + Some(v) if !v.is_null() && !ok(v) => { + Err(Error::BadRequest(format!("{key} must be {ty} or null"))) + } + _ => Ok(()), + }; + for key in FALSE_WHEN_UNSET { + typed(key, desired.get(*key), Value::is_boolean, "a boolean")?; + } + // The rest are refused by their endpoint anyway, but only after the writes before them + // went through, and after a dry run reported them as changes. + for key in [ + "slack_oauth_client_id", + "slack_oauth_client_secret", + "webhook", + "color", + "default_app", + "slack_command_script", + "teams_command_script", + "guest_jwt_public_key", + "guest_jwt_jwks_url", + ] { + typed(key, desired.get(key), Value::is_string, "a string")?; + } + typed( + "public_app_execution_limit_per_minute", + desired.get("public_app_execution_limit_per_minute"), + |v| v.as_i64().is_some_and(|n| i32::try_from(n).is_ok()), + "a 32-bit integer", + )?; + for (parent, key, ok, ty) in [ + ( + "auto_invite", + "enabled", + Value::is_boolean as fn(&Value) -> bool, + "a boolean", + ), + ("auto_invite", "operator", Value::is_boolean, "a boolean"), + ("auto_invite", "mode", Value::is_string, "a string"), + ( + "error_handler", + "muted_on_cancel", + Value::is_boolean, + "a boolean", + ), + ( + "error_handler", + "muted_on_user_path", + Value::is_boolean, + "a boolean", + ), + ] { + let v = desired.get(parent).and_then(|p| p.get(key)); + typed(&format!("{parent}.{key}"), v, ok, ty)?; + } + let mut datatable_errors = vec![]; + if let Some(v) = desired.get("datatable_permissions") { + let v = normalize_datatable_permissions(v, &mut datatable_errors)?; + desired.insert("datatable_permissions".to_string(), v); + } + if let Some(v) = desired.get("protection_rules") { + 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)); + } + } + } + for key in SECRET_KEYS { + if let Some(v) = desired.remove(*key) { + if let Some(v) = resolve_secret(key, v, mc, &mut warnings) { + desired.insert(key.to_string(), v); + } else if let Some(cur) = current.get(*key) { + desired.insert(key.to_string(), cur.clone()); + } + } + } + + let stated: Vec<String> = desired.keys().cloned().collect(); + let mut p = Plan { current, desired, replace, changes: vec![], writes: vec![], warnings }; + + let webhook = p.legacy("webhook"); + p.simple( + "webhook", + webhook, + Target::Edit(SettingsEdit::Webhook), + |v| json!({ "webhook": v }), + ); + + if let Some(auto_invite) = p.legacy("auto_invite") { + let desired_domain = domain_invite(Some(&auto_invite)); + if desired_domain != domain_invite(p.current.get("auto_invite")) { + if let Some(mode) = desired_domain.get("mode").and_then(Value::as_str) { + if mode != "invite" && mode != "add" { + return Err(Error::BadRequest( + r#"Invalid value for auto_invite.mode. Valid values are "invite" and "add""# + .to_string(), + )); + } + } + let mut after = if auto_invite.is_null() { + Value::Null + } else { + desired_domain.clone() + }; + if let (Some(after), Some(cur)) = ( + after.as_object_mut(), + p.current.get("auto_invite").and_then(Value::as_object), + ) { + for k in ["instance_groups", "instance_groups_roles"] { + if let Some(v) = cur.get(k) { + after.insert(k.to_string(), v.clone()); + } + } + } + p.record("auto_invite", after); + let enabled = desired_domain.get("enabled").and_then(Value::as_bool) == Some(true); + let body = if enabled { + json!({ + "operator": desired_domain.get("operator").and_then(Value::as_bool).unwrap_or(false), + "invite_all": true, + "auto_add": desired_domain.get("mode").and_then(Value::as_str) == Some("add"), + }) + } else { + json!({}) + }; + p.write(Target::Edit(SettingsEdit::AutoInvite), body); + } + // Clearing an instance group removes the members it granted, so only a document that + // states the groups manages them. + if let Some(groups) = auto_invite.get("instance_groups").filter(|g| !g.is_null()) { + let roles = auto_invite + .get("instance_groups_roles") + .filter(|r| !r.is_null()) + .cloned() + .unwrap_or(json!({})); + let cur = p.current.get("auto_invite"); + let cur_groups = cur.and_then(|c| c.get("instance_groups")); + let cur_roles = cur + .and_then(|c| c.get("instance_groups_roles")) + .filter(|r| !r.is_null()) + .cloned() + .unwrap_or(json!({})); + if !same(Some(groups), cur_groups) || roles != cur_roles { + p.record( + "auto_invite.instance_groups", + json!({ "instance_groups": groups, "instance_groups_roles": roles }), + ); + p.write( + Target::Edit(SettingsEdit::InstanceGroups), + json!({ "groups": groups, "roles": roles }), + ); + } + } + } + + let ai_config = p.legacy("ai_config"); + p.simple("ai_config", ai_config, Target::AiConfig, |v| { + if v.is_null() { + json!({}) + } else { + v + } + }); + + let fallback = null_as_false(p.managed("error_handler_fallback_to_instance_alerts")); + let fallback_changes = fallback + .as_ref() + .is_some_and(|f| p.differs("error_handler_fallback_to_instance_alerts", f)); + let error_handler = p.legacy("error_handler"); + let error_handler_changes = error_handler + .as_ref() + .is_some_and(|e| p.differs("error_handler", e)); + if error_handler_changes || fallback_changes { + let handler = if error_handler_changes { + error_handler.clone().unwrap_or(Value::Null) + } else { + p.current + .get("error_handler") + .cloned() + .unwrap_or(Value::Null) + }; + if error_handler_changes { + p.record("error_handler", handler.clone()); + } + if fallback_changes { + p.record( + "error_handler_fallback_to_instance_alerts", + fallback.clone().unwrap_or(Value::Null), + ); + } + p.write( + Target::Edit(SettingsEdit::ErrorHandler), + json!({ + "path": handler.get("path"), + "extra_args": handler.get("extra_args"), + "muted_on_cancel": handler.get("muted_on_cancel").and_then(Value::as_bool).unwrap_or(false), + "muted_on_user_path": handler.get("muted_on_user_path").and_then(Value::as_bool).unwrap_or(false), + "fallback_to_instance_alerts": if fallback_changes { fallback.and_then(|f| f.as_bool()) } else { None }, + }), + ); + } + + let success_handler = p.legacy("success_handler"); + p.simple( + "success_handler", + success_handler, + Target::Edit(SettingsEdit::SuccessHandler), + |v| json!({ "path": v.get("path"), "extra_args": v.get("extra_args") }), + ); + + let lfs = p.legacy("large_file_storage"); + p.simple( + "large_file_storage", + lfs, + Target::Edit(SettingsEdit::LargeFileStorage), + |v| json!({ "large_file_storage": v }), + ); + + let git_sync = p.legacy("git_sync"); + p.simple( + "git_sync", + git_sync, + Target::Edit(SettingsEdit::GitSync), + |v| json!({ "git_sync_settings": v }), + ); + + let default_scripts = p.legacy("default_scripts"); + p.simple( + "default_scripts", + default_scripts, + Target::Edit(SettingsEdit::DefaultScripts), + |v| v, + ); + + let default_app = p.legacy("default_app"); + p.simple( + "default_app", + default_app, + Target::Edit(SettingsEdit::DefaultApp), + |v| json!({ "default_app_path": v }), + ); + + // The display name is never applied: settings.yaml is shared across a repo's branches, so + // applying it would let one workspace's name overwrite another's. + + let mute = null_as_false(p.legacy("mute_critical_alerts")); + p.simple( + "mute_critical_alerts", + mute, + Target::MuteCriticalAlerts, + |v| json!({ "mute_critical_alerts": v }), + ); + + // `sync pull` omits the key for a workspace without a color, so an absent or null color + // means "not managed", never "clear". + let color = p.managed("color").filter(|c| !c.is_null()); + p.simple( + "color", + color, + Target::Edit(SettingsEdit::Color), + |v| json!({ "color": v }), + ); + + if let Some(desired) = p.managed("operator_settings").filter(|o| !o.is_null()) { + // The endpoint keeps the stored value of a write right the document omits, so an + // older document must not count as differing on those keys. + let mut compared = desired.clone(); + if let (Some(compared), Some(cur)) = ( + compared.as_object_mut(), + p.current + .get("operator_settings") + .and_then(Value::as_object), + ) { + for key in [ + "builder_flows", + "builder_apps", + "manage_schedules", + "manage_triggers", + ] { + if !compared.contains_key(key) { + if let Some(v) = cur.get(key) { + compared.insert(key.to_string(), v.clone()); + } + } + } + } + if p.differs("operator_settings", &compared) { + p.record("operator_settings", compared); + p.write(Target::Edit(SettingsEdit::OperatorSettings), desired); + } + } + + let datatable = p.legacy("datatable"); + p.simple( + "datatable", + datatable, + Target::Edit(SettingsEdit::Datatable), + |v| json!({ "settings": if v.is_null() { json!({ "datatables": {} }) } else { v } }), + ); + + // Stated as a whole: a data table the map omits is not under roles, a rule it omits is + // deleted. + if let Some(desired) = p.managed("datatable_permissions") { + plan_datatable_permissions(&mut p, desired, names, &mut datatable_errors); + } + datatable_permissions_errors(datatable_errors)?; + if let Some(desired) = p.managed("protection_rules") { + plan_protection_rules(&mut p, desired); + } + + let slack_command = p.legacy("slack_command_script"); + p.simple( + "slack_command_script", + slack_command, + Target::Edit(SettingsEdit::SlackCommand), + |v| json!({ "slack_command_script": v }), + ); + + let (client_id, client_secret) = ( + p.legacy("slack_oauth_client_id"), + p.legacy("slack_oauth_client_secret"), + ); + if client_id.is_some() || client_secret.is_some() { + let id = client_id.unwrap_or_else(|| { + p.current + .get("slack_oauth_client_id") + .cloned() + .unwrap_or(Value::Null) + }); + let secret = client_secret.unwrap_or_else(|| { + p.current + .get("slack_oauth_client_secret") + .cloned() + .unwrap_or(Value::Null) + }); + let id_changes = p.differs("slack_oauth_client_id", &id); + let secret_changes = p.differs("slack_oauth_client_secret", &secret); + if id_changes || secret_changes { + if id_changes { + p.record("slack_oauth_client_id", id.clone()); + } + if secret_changes { + p.record("slack_oauth_client_secret", secret.clone()); + } + let set = |v: &Value| v.as_str().is_some_and(|s| !s.is_empty()); + if set(&id) && set(&secret) { + p.write( + Target::Edit(SettingsEdit::SetSlackOAuth), + json!({ "slack_oauth_client_id": id, "slack_oauth_client_secret": secret }), + ); + } else { + p.write(Target::Edit(SettingsEdit::DeleteSlackOAuth), Value::Null); + } + } + } + + // Settings added to the document after `wmill sync` shipped are managed only when the + // document states them, so a settings.yaml written before them never clears them. + let ducklake = p.managed("ducklake"); + p.simple( + "ducklake", + ducklake, + Target::Edit(SettingsEdit::Ducklake), + |v| json!({ "settings": if v.is_null() { json!({ "ducklakes": {} }) } else { v } }), + ); + + let dbt = p.managed("dbt_warehouses"); + p.simple( + "dbt_warehouses", + dbt, + Target::Edit(SettingsEdit::DbtWarehouses), + |v| json!({ "dbt_warehouses": v }), + ); + + let deploy_ui = p.managed("deploy_ui"); + p.simple( + "deploy_ui", + deploy_ui, + Target::Edit(SettingsEdit::DeployUi), + |v| json!({ "deploy_ui_settings": v }), + ); + + let teams = p.managed("teams_command_script"); + p.simple( + "teams_command_script", + teams, + Target::TeamsCommand, + |v| json!({ "teams_command_script": v }), + ); + + let rate = p.managed("public_app_execution_limit_per_minute"); + p.simple( + "public_app_execution_limit_per_minute", + rate, + Target::Edit(SettingsEdit::PublicAppRateLimit), + |v| json!({ "public_app_execution_limit_per_minute": v }), + ); + + let guest_access = null_as_false(p.managed("guest_access_enabled")); + p.simple( + "guest_access_enabled", + guest_access, + Target::Edit(SettingsEdit::GuestAccess), + |v| json!({ "guest_access_enabled": v.as_bool().unwrap_or(false) }), + ); + + let (public_key, jwks_url) = ( + p.managed("guest_jwt_public_key"), + p.managed("guest_jwt_jwks_url"), + ); + if public_key.is_some() || jwks_url.is_some() { + // At most one of the two is set, so stating one clears the other. + let other = |stated: &Option<Value>, key: &str| { + if stated.as_ref().is_some_and(|v| !v.is_null()) { + Value::Null + } else { + p.current.get(key).cloned().unwrap_or(Value::Null) + } + }; + let (public_key, jwks_url) = ( + public_key + .clone() + .unwrap_or_else(|| other(&jwks_url, "guest_jwt_public_key")), + jwks_url + .clone() + .unwrap_or_else(|| other(&public_key, "guest_jwt_jwks_url")), + ); + let key_changes = p.differs("guest_jwt_public_key", &public_key); + let url_changes = p.differs("guest_jwt_jwks_url", &jwks_url); + if key_changes || url_changes { + if key_changes { + p.record("guest_jwt_public_key", public_key.clone()); + } + if url_changes { + p.record("guest_jwt_jwks_url", jwks_url.clone()); + } + p.write( + Target::Edit(SettingsEdit::GuestJwtKey), + json!({ "public_key": public_key, "jwks_url": jwks_url }), + ); + } + } + + let forks = null_as_false(p.managed("add_admins_and_developers_to_forks")); + p.simple( + "add_admins_and_developers_to_forks", + forks, + Target::Edit(SettingsEdit::AddAdminsAndDevelopersToForks), + |v| json!({ "add_admins_and_developers_to_forks": v.as_bool().unwrap_or(false) }), + ); + + // As `wmill sync push` always did: a document whose stated settings all match changes + // nothing, so a settings.yaml pulled before a setting was turned on in the UI does not + // clear it just for omitting it. + let changes_stated = p.changes.iter().any(|c| { + stated + .iter() + .any(|k| c.key == *k || c.key.starts_with(&format!("{k}."))) + }); + if replace && !changes_stated { + p.changes.clear(); + p.writes.clear(); + } + + // Refused here rather than by the endpoint, so a dry run already says so. + let unavailable: Vec<String> = p + .writes + .iter() + .filter(|w| !available_in_this_edition(&w.target)) + .flat_map(|w| w.keys.clone()) + .collect(); + if !unavailable.is_empty() { + return Err(Error::BadRequest(format!( + "Only available on Windmill Enterprise Edition: {}", + unavailable.join(", ") + ))); + } + + Ok(p) +} + +fn object_of(v: Option<&Value>) -> Map<String, Value> { + v.and_then(Value::as_object).cloned().unwrap_or_default() +} + +fn plan_datatable_permissions( + p: &mut Plan, + desired: Value, + names: &DatatableNames, + errors: &mut Vec<String>, +) { + let desired = object_of(Some(&desired)); + let current = object_of(p.current.get("datatable_permissions")); + // The data tables as they will be once the `datatable` write before this one has gone through. + let entries = object_of( + p.legacy("datatable") + .as_ref() + .or(p.current.get("datatable")) + .and_then(|d| d.get("datatables")), + ); + let tables: std::collections::BTreeSet<&String> = + desired.keys().chain(current.keys()).collect(); + for name in tables { + let (before, after) = (current.get(name), desired.get(name)); + if same(before, after) { + continue; + } + let entry = entries.get(name); + // A removed data table takes its roles with it. + if after.is_none() && entry.is_none() { + continue; + } + let body = match after { + None => json!({ "datatable": name, "permissioned": false }), + Some(after) => match datatable_permissions_write(name, entry, before, after, names) { + Ok(body) => body, + Err(e) => { + errors.extend(e); + continue; + } + }, + }; + p.record_change( + &format!("datatable_permissions.{name}"), + before.cloned().unwrap_or(Value::Null), + after.cloned().unwrap_or(Value::Null), + ); + p.write(Target::Edit(SettingsEdit::DatatablePermissions), body); + } +} + +/// The endpoint body that puts `name` under the roles of `after`, or everything wrong with it. +fn datatable_permissions_write( + name: &str, + entry: Option<&Value>, + before: Option<&Value>, + after: &Value, + names: &DatatableNames, +) -> std::result::Result<Value, Vec<String>> { + let Some(cluster) = entry.and_then(role_cluster_of) else { + return Err(vec![format!( + "Data table '{name}' cannot be put under roles: only a data table this workspace \ + governs, on a cluster Windmill manages, can" + )]); + }; + let Some(catalog) = names.of(cluster) else { + return Err(vec![ + "Data table roles are unavailable on this instance".to_string() + ]); + }; + let ids: BTreeMap<&str, &str> = catalog + .iter() + .map(|(id, name)| (name.as_str(), id.as_str())) + .collect(); + let mut errors = vec![]; + let mut id_of = |role: &str| -> String { + if role == ADMIN_DATATABLE_ROLE { + return role.to_string(); + } + ids.get(role).map(|id| id.to_string()).unwrap_or_else(|| { + errors.push(format!( + "Data table '{name}': '{role}' is not a data table role of the {} cluster", + cluster.as_str() + )); + String::new() + }) + }; + let default_role = match after.get("default_role").and_then(Value::as_str) { + None => NO_DEFAULT_DATATABLE_ROLE.to_string(), + Some(role) => id_of(role), + }; + let roles: Vec<Value> = object_of(after.get("roles")) + .iter() + .map(|(role, tenants)| json!({ "id": id_of(role), "tenants": tenants })) + .collect(); + // Only a tenant this change grants: one already stored that has since stopped naming anybody + // must not block every later push. + let granted = |v: Option<&Value>| -> std::collections::BTreeSet<String> { + object_of(v.and_then(|v| v.get("roles"))) + .values() + .filter_map(Value::as_array) + .flatten() + .filter_map(|t| Some(t.as_str()?.to_string())) + .collect() + }; + let already = granted(before); + for tenant in granted(Some(after)).difference(&already) { + if names.unknown_tenants.contains(tenant) { + let kind = match tenant.split_once('/') { + Some(("u", _)) => "user", + Some(("g", _)) => "group", + _ => "folder", + }; + errors.push(format!( + "Data table '{name}': tenant '{tenant}' names no {kind} of this workspace" + )); + } + } + if !errors.is_empty() { + return Err(errors); + } + Ok(json!({ + "datatable": name, + "permissioned": true, + "default_role": default_role, + "roles": roles, + })) +} + +fn plan_protection_rules(p: &mut Plan, desired: Value) { + let desired = object_of(Some(&desired)); + let current = object_of(p.current.get("protection_rules")); + let names: std::collections::BTreeSet<&String> = desired.keys().chain(current.keys()).collect(); + for name in names { + let (before, after) = (current.get(name), desired.get(name)); + if same(before, after) { + continue; + } + p.record_change( + &format!("protection_rules.{name}"), + before.cloned().unwrap_or(Value::Null), + after.cloned().unwrap_or(Value::Null), + ); + let rule = |r: &Value, name_key: &str| { + json!({ + name_key: name, + "rules": r.get("rules"), + "bypass_groups": r.get("bypass_groups"), + "bypass_users": r.get("bypass_users"), + }) + }; + match (before, after) { + (None, Some(after)) => p.write( + Target::Edit(SettingsEdit::CreateProtectionRule), + rule(after, "name"), + ), + (Some(_), Some(after)) => p.write( + Target::Edit(SettingsEdit::UpdateProtectionRule), + rule(after, "rule_name"), + ), + _ => p.write( + Target::Edit(SettingsEdit::DeleteProtectionRule), + json!({ "name": name }), + ), + } + } +} + +fn available_in_this_edition(target: &Target) -> bool { + match target { + Target::MuteCriticalAlerts + | Target::Edit(SettingsEdit::DefaultApp) + | Target::Edit(SettingsEdit::DeployUi) => cfg!(feature = "enterprise"), + Target::Edit(SettingsEdit::DatatablePermissions) => { + cfg!(all(feature = "private", feature = "enterprise")) + } + Target::TeamsCommand | Target::Edit(SettingsEdit::InstanceGroups) => { + cfg!(feature = "private") + } + _ => true, + } +} + +async fn execute(authed: &ApiAuthed, db: &DB, w_id: &str, write: Write) -> Result<Value> { + let (a, d, p) = ( + authed.clone(), + Extension(db.clone()), + Path(w_id.to_string()), + ); + let body_err = |e: serde_json::Error| { + Error::BadRequest(format!("Invalid value for {:?}: {e}", write.target)) + }; + match write.target { + Target::Edit(SettingsEdit::AutoInvite) => { + match apply_settings_edit( + a.clone(), + db.clone(), + w_id.to_string(), + SettingsEdit::AutoInvite, + write.body.clone(), + ) + .await + { + // Inviting every domain is refused on cloud, where only the workspace's own + // domain can be auto-invited. + Err(_) if write.body.get("invite_all") == Some(&json!(true)) => { + let mut body = write.body; + body["invite_all"] = json!(false); + apply_settings_edit( + a, + db.clone(), + w_id.to_string(), + SettingsEdit::AutoInvite, + body, + ) + .await + } + r => r, + } + } + Target::Edit(edit) => { + apply_settings_edit(a, db.clone(), w_id.to_string(), edit, write.body).await + } + Target::AiConfig => { + let body = serde_json::from_value(write.body).map_err(body_err)?; + let _ = crate::workspaces::edit_copilot_config(a, d, p, Json(body)).await?; + Ok(Value::Null) + } + #[cfg(feature = "enterprise")] + Target::MuteCriticalAlerts => { + let body = serde_json::from_value(write.body).map_err(body_err)?; + crate::workspaces::mute_critical_alerts(d, p, a, Json(body)).await?; + Ok(Value::Null) + } + #[cfg(not(feature = "enterprise"))] + Target::MuteCriticalAlerts => Err(crate::workspaces::mute_critical_alerts().await), + #[cfg(feature = "private")] + Target::TeamsCommand => { + let body = serde_json::from_value(write.body).map_err(body_err)?; + crate::teams_ee::edit_teams_command(a, d, p, Json(body)).await?; + Ok(Value::Null) + } + #[cfg(not(feature = "private"))] + Target::TeamsCommand => { + crate::teams_oss::edit_teams_command().await?; + Ok(Value::Null) + } + } +} + +/// Applies `document` as a list of writes, each through the endpoint that owns the setting, in +/// the order `wmill sync push` has always written them. The writes are not one transaction: a +/// failing one stops the apply, and the error names the settings already written. +pub async fn apply_settings_document( + authed: ApiAuthed, + Extension(db): Extension<DB>, + Path(w_id): Path<String>, + Json(req): Json<ApplySettingsDocument>, +) -> JsonResult<ApplySettingsDocumentResult> { + require_admin(authed.is_admin, &authed.username)?; + let p = plan(&db, &w_id, req.document, req.replace).await?; + let mut result = ApplySettingsDocumentResult { + changes: p.changes, + warnings: p.warnings, + ..Default::default() + }; + if req.dry_run || p.writes.is_empty() { + return Ok(Json(result)); + } + let mut written: Vec<String> = vec![]; + for write in p.writes { + let keys = write.keys.clone(); + let response = execute(&authed, &db, &w_id, write).await.map_err(|e| { + if written.is_empty() { + return e; + } + Error::Generic( + e.status_code(), + format!( + "{e} (settings already applied before this error: {})", + written.join(", ") + ), + ) + })?; + if let Some(stranded) = response + .get("stranded_references") + .and_then(Value::as_array) + .filter(|s| !s.is_empty()) + { + let refs = stranded + .iter() + .filter_map(|r| { + Some(format!( + "{}/{}", + r.get("workspace_id")?.as_str()?, + r.get("datatable")?.as_str()? + )) + }) + .collect::<Vec<_>>() + .join(", "); + result.warnings.push(format!( + "The removed data tables governed data tables in other workspaces, which no longer resolve: {refs}. A superadmin can point them somewhere else." + )); + } + written.extend(keys); + } + result.applied = true; + Ok(Json(result)) +} + +#[cfg(test)] +mod tests { + use super::*; + use windmill_common::variables::WorkspaceCrypt; + + fn doc(v: Value) -> Map<String, Value> { + v.as_object().unwrap().clone() + } + + fn keys(p: &Plan) -> Vec<&str> { + p.changes.iter().map(|c| c.key.as_str()).collect() + } + + fn current() -> Map<String, Value> { + doc(json!({ + "name": "ws", + "webhook": "https://a", + "large_file_storage": { "type": "S3Storage", "s3_resource_path": "f/s3" }, + "slack_oauth_client_id": "cid", + "slack_oauth_client_secret": "sekret", + "guest_jwt_public_key": "PEM", + "guest_jwt_jwks_url": null, + "public_app_execution_limit_per_minute": 5, + })) + } + + #[test] + fn replace_clears_an_omitted_setting_only_when_a_stated_one_changes() { + let mc = WorkspaceCrypt::new("k"); + let unchanged = doc(json!({ "name": "other", "webhook": "https://a" })); + let p = plan_document(current(), unchanged, true, &mc, &DatatableNames::default()).unwrap(); + assert!(p.writes.is_empty(), "{:?}", keys(&p)); + + let changed = doc(json!({ "webhook": "https://b" })); + let p = plan_document(current(), changed, true, &mc, &DatatableNames::default()).unwrap(); + assert_eq!( + keys(&p), + [ + "webhook", + "large_file_storage", + "slack_oauth_client_id", + "slack_oauth_client_secret" + ] + ); + assert!(!keys(&p).contains(&"public_app_execution_limit_per_minute")); + } + + #[test] + fn secrets_keep_their_value_for_the_placeholder_and_a_foreign_key() { + let mc = WorkspaceCrypt::new("k"); + let ours = format!( + "{ENCRYPTED_PREFIX}{}", + mc.encrypt_str_deterministic("sekret") + ); + let theirs = format!( + "{ENCRYPTED_PREFIX}{}", + WorkspaceCrypt::new("other").encrypt_str_deterministic("x") + ); + for secret in [SENSITIVE_UNCHANGED.to_string(), ours, theirs.clone()] { + let p = plan_document( + current(), + doc(json!({ "slack_oauth_client_secret": secret })), + false, + &mc, + &DatatableNames::default(), + ) + .unwrap(); + assert!(p.changes.is_empty(), "{secret}: {:?}", keys(&p)); + assert_eq!(p.warnings.len(), usize::from(secret == theirs)); + } + } + + #[test] + fn unset_switches_compare_as_false() { + let mc = WorkspaceCrypt::new("k"); + let d = doc(json!({ "mute_critical_alerts": false, "guest_access_enabled": null })); + assert!( + plan_document(current(), d, false, &mc, &DatatableNames::default()) + .unwrap() + .changes + .is_empty() + ); + } + + #[test] + fn stating_one_guest_key_clears_the_other() { + let mc = WorkspaceCrypt::new("k"); + let d = doc(json!({ "guest_jwt_jwks_url": "https://jwks" })); + let p = plan_document(current(), d, false, &mc, &DatatableNames::default()).unwrap(); + assert_eq!(p.writes.len(), 1); + assert_eq!( + p.writes[0].body, + json!({ "public_key": null, "jwks_url": "https://jwks" }) + ); + } + + #[test] + fn mistyped_values_are_refused_rather_than_coerced() { + let mc = WorkspaceCrypt::new("k"); + for d in [ + json!({ "guest_access_enabled": "true" }), + json!({ "add_admins_and_developers_to_forks": 1 }), + json!({ "slack_oauth_client_id": 123 }), + json!({ "error_handler": { "path": "f/x", "muted_on_cancel": "yes" } }), + json!({ "auto_invite": { "enabled": "true" } }), + json!({ "webhook": 123 }), + json!({ "public_app_execution_limit_per_minute": "5" }), + json!({ "public_app_execution_limit_per_minute": 1.5 }), + json!({ "public_app_execution_limit_per_minute": 2147483648i64 }), + ] { + assert!( + plan_document( + current(), + doc(d.clone()), + false, + &mc, + &DatatableNames::default() + ) + .is_err(), + "{d}" + ); + } + } + + #[test] + fn a_null_handler_argument_is_a_change() { + let mc = WorkspaceCrypt::new("k"); + let mut cur = current(); + cur.insert( + "error_handler".into(), + json!({ "path": "f/x", "extra_args": {}, "muted_on_cancel": false, "muted_on_user_path": false }), + ); + let d = doc(json!({ "error_handler": { "path": "f/x", "extra_args": { "input": null } } })); + let p = plan_document(cur.clone(), d, false, &mc, &DatatableNames::default()).unwrap(); + assert_eq!(keys(&p), ["error_handler"]); + + let d = doc(json!({ "error_handler": { "path": "f/x", "extra_args": {} } })); + let p = plan_document(cur, d, false, &mc, &DatatableNames::default()).unwrap(); + assert!(p.changes.is_empty(), "{:?}", keys(&p)); + } + + #[cfg(all(feature = "private", feature = "enterprise"))] + #[test] + fn datatable_roles_are_named_in_the_document_and_written_by_id() { + let mc = WorkspaceCrypt::new("k"); + let roles = DatatableNames { + instance: Some([("r1".to_string(), "readers".to_string())].into()), + ..Default::default() + }; + let mut cur = current(); + cur.insert( + "datatable".into(), + json!({ "datatables": { "main": { "database": { "resource_type": "instance", "resource_path": "main" } } } }), + ); + cur.insert( + "datatable_permissions".into(), + json!({ "main": { "default_role": "admin", "roles": { "admin": [], "readers": ["g/x"] } } }), + ); + + let d = doc( + json!({ "datatable_permissions": { "main": { "default_role": "readers", "roles": { "readers": ["g/x", "u/bob"] } } } }), + ); + let p = plan_document(cur.clone(), d, false, &mc, &roles).unwrap(); + assert_eq!(keys(&p), ["datatable_permissions.main"]); + assert_eq!( + p.writes[0].body, + json!({ "datatable": "main", "permissioned": true, "default_role": "r1", + "roles": [{ "id": "r1", "tenants": ["g/x", "u/bob"] }, { "id": "admin", "tenants": [] }] }) + ); + + let d = doc(json!({ "datatable_permissions": {} })); + let p = plan_document(cur.clone(), d, false, &mc, &roles).unwrap(); + assert_eq!( + p.writes[0].body, + json!({ "datatable": "main", "permissioned": false }) + ); + + let d = doc(json!({ "datatable_permissions": { "main": { "roles": { "writers": [] } } } })); + assert!(plan_document(cur.clone(), d, false, &mc, &roles).is_err()); + + let d = doc(json!({ "datatable_permissions": { "main": { "default_role": "readers" } } })); + assert!(plan_document(cur.clone(), d, false, &mc, &roles).is_err()); + + // A tenant that names nobody is refused when granted, not when already stored, and every + // data table's errors come back together. + let strict = DatatableNames { + instance: roles.instance.clone(), + unknown_tenants: ["g/x".to_string(), "u/bob".to_string()].into(), + ..Default::default() + }; + let d = doc( + json!({ "datatable_permissions": { "main": { "default_role": "readers", "roles": { "readers": ["g/x"] } } } }), + ); + assert_eq!( + keys(&plan_document(cur.clone(), d, false, &mc, &strict).unwrap()), + ["datatable_permissions.main"] + ); + let d = doc(json!({ "datatable_permissions": { + "main": { "roles": { "readers": ["g/x", "u/bob"], "writers": [] } }, + "other": { "roles": { "readers": ["x/y"] } }, + } })); + let e = plan_document(cur.clone(), d, false, &mc, &strict) + .err() + .unwrap() + .to_string(); + for part in [ + "tenant 'u/bob' names no user", + "'writers' is not a data table role", + "Invalid tenant 'x/y'", + ] { + assert!(e.contains(part), "{e}"); + } + + // Under replace an omitted `datatable` is cleared, taking its tables' roles with it. + let d = doc(json!({ "datatable_permissions": {} })); + let p = plan_document(cur.clone(), d, true, &mc, &roles).unwrap(); + assert!(!p + .writes + .iter() + .any(|w| matches!(w.target, Target::Edit(SettingsEdit::DatatablePermissions)))); + let d = doc(json!({ "datatable_permissions": { "main": {} } })); + assert!(plan_document(cur, d, true, &mc, &roles).is_err()); + } + + #[test] + fn protection_rules_are_diffed_by_name() { + let mc = WorkspaceCrypt::new("k"); + let mut cur = current(); + cur.insert( + "protection_rules".into(), + json!({ "old": { "rules": ["DisableWorkspaceForking"], "bypass_groups": [], "bypass_users": [] } }), + ); + let d = doc(json!({ "protection_rules": { + "new": { "rules": ["RestrictPublicRunSharing", "DisableDirectDeployment"] }, + "dev_workspace_lock": { "rules": [] }, + } })); + let p = plan_document(cur, d, false, &mc, &DatatableNames::default()).unwrap(); + assert_eq!(keys(&p), ["protection_rules.new", "protection_rules.old"]); + assert_eq!( + p.writes[0].body, + json!({ "name": "new", "rules": ["DisableDirectDeployment", "RestrictPublicRunSharing"], + "bypass_groups": [], "bypass_users": [] }) + ); + assert!(matches!( + p.writes[1].target, + Target::Edit(SettingsEdit::DeleteProtectionRule) + )); + assert_eq!(p.warnings.len(), 1); + } +} diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index 186cfbc291..6edf18ff07 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -61,6 +61,11 @@ pub fn workspaced_service() -> Router { ) .route("/tarball", get(crate::workspaces_export::tarball_workspace)) .route("/edit_copilot_config", post(edit_copilot_config)) + .route( + "/settings_document", + get(crate::workspace_settings_document::get_settings_document) + .post(crate::workspace_settings_document::apply_settings_document), + ) .route("/get_copilot_info", get(get_copilot_info)) .route("/critical_alerts", get(get_critical_alerts)) .route( @@ -85,14 +90,13 @@ pub fn workspaced_service() -> Router { router } -async fn edit_copilot_config( +pub(crate) async fn edit_copilot_config( authed: ApiAuthed, Extension(db): Extension<DB>, Path(w_id): Path<String>, - ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(ai_config): Json<AIConfig>, ) -> JsonResult<EditCopilotConfigResponse> { - require_admin(is_admin, &username)?; + require_admin(authed.is_admin, &authed.username)?; if let Some(ref custom_prompts) = ai_config.custom_prompts { for (mode, prompt) in custom_prompts.iter() { @@ -190,7 +194,7 @@ async fn edit_copilot_config( } #[derive(Serialize)] -struct EditCopilotConfigResponse { +pub(crate) struct EditCopilotConfigResponse { effective_ai_config: AIConfig, has_instance_ai_config: bool, uses_instance_ai_config: bool, @@ -321,7 +325,7 @@ pub struct MuteCriticalAlertRequest { } #[cfg(feature = "enterprise")] -async fn mute_critical_alerts( +pub(crate) async fn mute_critical_alerts( Extension(db): Extension<DB>, Path(w_id): Path<String>, authed: ApiAuthed, diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index 3c3e965248..87496fed45 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -359,6 +359,9 @@ pub(crate) struct ArchiveQueryParams { include_users: Option<bool>, include_groups: Option<bool>, include_settings: Option<bool>, + /// Opt-in, with `settings_version=v2`: state the data tables' roles in settings.json. Their + /// tenants name this workspace's users and groups, and their roles this instance's catalog. + include_datatable_permissions: Option<bool>, include_key: Option<bool>, include_workspace_dependencies: Option<bool>, /// Default `false`: data table migrations ship in the tarball unless the repo @@ -513,49 +516,6 @@ struct SimplifiedGroup { admins: Vec<String>, } -// V2 format: New grouped format -#[derive(Serialize)] -struct SimplifiedSettings { - #[serde(skip_serializing_if = "Option::is_none")] - auto_invite: Option<Value>, - #[serde(skip_serializing_if = "Option::is_none")] - webhook: Option<String>, - // Always serialize (including as `null`) so that `wmill sync pull` emits - // these fields in settings.yaml unconditionally. Makes round-trip - // bijective: YAML is the source of truth, absence/null = "clear remote", - // mirroring every other workspace setting. - error_handler: Option<Value>, - success_handler: Option<Value>, - #[serde(skip_serializing_if = "Option::is_none")] - ai_config: Option<serde_json::Value>, - #[serde(skip_serializing_if = "Option::is_none")] - large_file_storage: Option<Value>, - #[serde(skip_serializing_if = "Option::is_none")] - git_sync: Option<Value>, - #[serde(skip_serializing_if = "Option::is_none")] - default_app: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - default_scripts: Option<Value>, - name: String, - #[serde(skip_serializing_if = "Option::is_none")] - mute_critical_alerts: Option<bool>, - #[serde(skip_serializing_if = "Option::is_none")] - color: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - operator_settings: Option<serde_json::Value>, - #[serde(skip_serializing_if = "Option::is_none")] - datatable: Option<Value>, - #[serde(skip_serializing_if = "Option::is_none")] - slack_team_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - slack_name: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - slack_command_script: Option<String>, - // Always serialize (see note above on error_handler / success_handler). - slack_oauth_client_id: Option<String>, - slack_oauth_client_secret: Option<String>, -} - // V1 format: Legacy flat format for backward compatibility (matches main branch exactly) #[derive(Serialize)] struct SimplifiedSettingsLegacy { @@ -602,7 +562,6 @@ struct SettingsRow { auto_invite: Option<Value>, webhook: Option<String>, error_handler: Option<Value>, - success_handler: Option<Value>, ai_config: Option<serde_json::Value>, large_file_storage: Option<Value>, git_sync: Option<Value>, @@ -616,8 +575,6 @@ struct SettingsRow { slack_team_id: Option<String>, slack_name: Option<String>, slack_command_script: Option<String>, - slack_oauth_client_id: Option<String>, - slack_oauth_client_secret: Option<String>, } pub(crate) async fn tarball_workspace( @@ -638,6 +595,7 @@ pub(crate) async fn tarball_workspace( include_users, include_groups, include_settings, + include_datatable_permissions, include_key, include_workspace_dependencies, skip_datatable_migrations, @@ -936,7 +894,14 @@ pub(crate) async fn tarball_workspace( .fetch_all(&mut *tx) .await?; - for resource in resources { + for mut resource in resources { + // An agent's identity belongs to this workspace, and whoever writes it elsewhere gets + // their own, as `to_string_without_metadata` leaves out a flow's. + if resource.resource_type == "ai_agent" { + if let Some(obj) = resource.value.as_mut().and_then(|v| v.as_object_mut()) { + obj.remove("on_behalf_of"); + } + } let resource_str = &to_string_without_metadata(&resource, ExtraPermsBehavior::Drop, None).unwrap(); archive @@ -1003,11 +968,13 @@ pub(crate) async fn tarball_workspace( } if !skip_variables.unwrap_or(false) { + // `account` is left out: the variable endpoints ignore it, so a pushed spec carrying it + // 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, account, is_oauth, expires_at, labels FROM variable WHERE workspace_id = $1 AND expires_at IS NULL" + "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" } else { - "SELECT workspace_id, path, value, is_secret, description, extra_perms, 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, 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" }) .bind(&w_id) .fetch_all(&mut *tx) @@ -1593,7 +1560,6 @@ pub(crate) async fn tarball_workspace( auto_invite, webhook, error_handler, - success_handler, ai_config, large_file_storage, git_sync, @@ -1606,9 +1572,7 @@ pub(crate) async fn tarball_workspace( datatable, slack_team_id, slack_name, - slack_command_script, - slack_oauth_client_id, - slack_oauth_client_secret + slack_command_script FROM workspace_settings LEFT JOIN workspace ON workspace.id = workspace_settings.workspace_id WHERE workspace_id = $1"#, @@ -1617,73 +1581,23 @@ pub(crate) async fn tarball_workspace( .fetch_one(&mut *tx) .await?; - // Use v2 format only if explicitly requested, otherwise use v1 (legacy) for backward compatibility - // Server-owned state (the HMAC webhook secret + hook id/error, the - // synced-sha / last-pull status, the admin automatic pulls run as, and what - // the credential check observed) must never leave the server: keep it out of - // export archives and synced repos, and don't let a re-imported workspace - // inherit another install's hook/sync state or pull identity. - fn redact_git_sync_for_export(git_sync: Option<Value>) -> Option<Value> { - let mut git_sync = git_sync?; - if let Some(repos) = git_sync - .get_mut("repositories") - .and_then(|r| r.as_array_mut()) - { - for repo in repos { - if let Some(auto_pull) = - repo.get_mut("auto_pull").and_then(|a| a.as_object_mut()) - { - for field in [ - "webhook_secret", - "webhook_id", - "webhook_error", - "last_synced_sha", - "last_pull_status", - "enabled_by", - ] { - auto_pull.remove(field); - } - } - // What this install observed about its own credential: a token - // id and expiry, and a `checked_at` that moves on its own. - // None of it describes the workspace, and in a git-synced - // `wmill.yaml` it would churn the file for no reason. - if let Some(repo) = repo.as_object_mut() { - repo.remove("credential"); - } - } - } - Some(git_sync) - } - let settings_str = if settings_version.as_deref() == Some("v2") { - let settings = SimplifiedSettings { - auto_invite: row.auto_invite, - webhook: row.webhook, - error_handler: row.error_handler, - success_handler: row.success_handler, - ai_config: row.ai_config, - large_file_storage: row.large_file_storage, - git_sync: redact_git_sync_for_export(row.git_sync), - default_app: row.default_app, - default_scripts: row.default_scripts, - name: row.name.clone().unwrap_or_default(), - mute_critical_alerts: row.mute_critical_alerts, - color: row.color.clone(), - operator_settings: row.operator_settings.clone(), - datatable: windmill_common::workspaces::strip_datatable_permissions( - row.datatable.clone(), - ), - slack_team_id: row.slack_team_id.clone(), - slack_name: row.slack_name.clone(), - slack_command_script: row.slack_command_script.clone(), - slack_oauth_client_id: row.slack_oauth_client_id.clone(), - slack_oauth_client_secret: row.slack_oauth_client_secret.clone(), + let secrets = if export_plain_secrets { + crate::workspace_settings_document::SecretMode::Plain + } else { + crate::workspace_settings_document::SecretMode::Encrypted }; - serde_json::to_value(settings) - .map(|v| serde_json::to_string_pretty(&v).ok()) - .ok() - .flatten() + let mut doc = crate::workspace_settings_document::load_settings_document( + &mut *tx, &db, &w_id, secrets, + ) + .await?; + // A repo syncs its rules through protection-rules.yaml, so settings.yaml leaving + // them out keeps one writer for them. + doc.remove("protection_rules"); + if !include_datatable_permissions.unwrap_or(false) { + doc.remove("datatable_permissions"); + } + serde_json::to_string_pretty(&doc).ok() } else { // V1 (legacy) format: convert JSONB to flat fields (matches main branch exactly) let (auto_invite_enabled, auto_invite_as, auto_invite_mode) = @@ -1730,7 +1644,7 @@ pub(crate) async fn tarball_workspace( error_handler_muted_on_cancel, ai_config: row.ai_config, large_file_storage: row.large_file_storage, - git_sync: redact_git_sync_for_export(row.git_sync), + git_sync: crate::workspace_settings_document::redact_git_sync(row.git_sync), default_app: row.default_app, default_scripts: row.default_scripts, name: row.name.unwrap_or_default(), diff --git a/backend/windmill-audit/Cargo.toml b/backend/windmill-audit/Cargo.toml index 8c26615664..a1491b3b5d 100644 --- a/backend/windmill-audit/Cargo.toml +++ b/backend/windmill-audit/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_audit" path = "./src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] enterprise = ["windmill-common/enterprise"] diff --git a/backend/windmill-audit/src/audit_oss.rs b/backend/windmill-audit/src/audit_oss.rs index 02b075af9d..55babc1625 100644 --- a/backend/windmill-audit/src/audit_oss.rs +++ b/backend/windmill-audit/src/audit_oss.rs @@ -64,6 +64,22 @@ impl AuditAuthorable for AuditAuthor { } } +/// Where an audit row is written decides what a failed insert breaks. On a pool connection it +/// breaks nothing else, so it is logged and the action goes on. On a connection, which may hold +/// the action's transaction, the failure has aborted that transaction and its COMMIT would roll +/// back silently, so it is returned. +pub trait AuditExecutor<'c>: sqlx::Executor<'c, Database = sqlx::Postgres> { + const ISOLATED: bool; +} + +impl<'c> AuditExecutor<'c> for &'c sqlx::Pool<sqlx::Postgres> { + const ISOLATED: bool = true; +} + +impl<'c> AuditExecutor<'c> for &'c mut sqlx::PgConnection { + const ISOLATED: bool = false; +} + pub trait AuditAuthorable { fn username(&self) -> &str; fn email(&self) -> &str; @@ -75,7 +91,7 @@ pub trait AuditAuthorable { #[cfg(not(feature = "private"))] #[tracing::instrument(level = "trace", skip_all)] -pub async fn audit_log<'c, E: sqlx::Executor<'c, Database = Postgres>>( +pub async fn audit_log<'c, E: AuditExecutor<'c>>( _db: E, _author: &impl AuditAuthorable, mut _operation: &str, @@ -91,7 +107,7 @@ pub async fn audit_log<'c, E: sqlx::Executor<'c, Database = Postgres>>( /// AUTHORIZATION: records entries, checks nothing. The caller must have authorized `author` /// for the operation on every resource in `w_id` before calling. #[cfg(not(feature = "private"))] -pub async fn audit_log_many<'c, E: sqlx::Executor<'c, Database = Postgres>>( +pub async fn audit_log_many<'c, E: AuditExecutor<'c>>( _db: E, _author: &impl AuditAuthorable, _operation: &str, diff --git a/backend/windmill-autoscaling/Cargo.toml b/backend/windmill-autoscaling/Cargo.toml index fd5c5f4703..592529b2fe 100644 --- a/backend/windmill-autoscaling/Cargo.toml +++ b/backend/windmill-autoscaling/Cargo.toml @@ -7,6 +7,9 @@ edition.workspace = true [lib] name = "windmill_autoscaling" path = "./src/lib.rs" +# src/ has no unit tests or doctests: remove both lines before adding one, or cargo test never runs it. +test = false +doctest = false [features] enterprise = ["windmill-queue/enterprise", "windmill-common/enterprise"] diff --git a/backend/windmill-common/Cargo.toml b/backend/windmill-common/Cargo.toml index e447baf563..52c38908b5 100644 --- a/backend/windmill-common/Cargo.toml +++ b/backend/windmill-common/Cargo.toml @@ -46,6 +46,7 @@ chrono.workspace = true chrono-tz.workspace = true hex.workspace = true async-trait.workspace = true +http.workspace = true reqwest-middleware = { workspace = true } reqwest-retry = { workspace = true } rand.workspace = true @@ -110,6 +111,7 @@ strum.workspace = true strum_macros.workspace = true windmill-types.workspace = true url.workspace = true +ipnet.workspace = true urlencoding.workspace = true async-recursion.workspace = true pep440_rs.workspace = true diff --git a/backend/windmill-common/src/agent_workers.rs b/backend/windmill-common/src/agent_workers.rs index 30c84f5807..33304dec94 100644 --- a/backend/windmill-common/src/agent_workers.rs +++ b/backend/windmill-common/src/agent_workers.rs @@ -6,37 +6,83 @@ pub struct QueueInitJob { } use lazy_static::lazy_static; -use std::time::Duration; +use std::{path::PathBuf, time::Duration}; use reqwest_middleware::ClientBuilder; use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware}; use crate::{jwt::decode_without_verify, utils::configure_client, worker::HttpClient}; -/// Configuration required for agent mode. Both fields are mandatory when running in agent mode. +/// How an agent proves its identity to the server. +#[derive(Clone)] +pub enum AgentCredential { + /// `AGENT_TOKEN`: an agent token a superadmin minted. + Token(String), + /// `AGENT_IDENTITY_TOKEN_FILE`: a workload identity token the platform keeps rotating on + /// disk, re-read on every request. + IdentityTokenFile(PathBuf), +} + +/// Configuration required for agent mode: a credential and the server's URL. #[derive(Clone)] pub struct AgentConfig { - pub agent_token: String, + pub credential: AgentCredential, pub base_internal_url: String, } impl AgentConfig { pub fn from_env() -> Result<Self, AgentConfigError> { - let agent_token = - std::env::var("AGENT_TOKEN").map_err(|_| AgentConfigError::MissingAgentToken)?; + let credential = match ( + std::env::var("AGENT_TOKEN").ok(), + std::env::var(AGENT_IDENTITY_TOKEN_FILE_ENV).ok(), + ) { + (Some(_), Some(_)) => return Err(AgentConfigError::BothCredentials), + (Some(token), None) => AgentCredential::Token(token), + (None, Some(path)) => AgentCredential::IdentityTokenFile(PathBuf::from(path)), + (None, None) => return Err(AgentConfigError::MissingAgentToken), + }; let base_internal_url = std::env::var("BASE_INTERNAL_URL") .map_err(|_| AgentConfigError::MissingBaseInternalUrl)?; - Ok(Self { agent_token, base_internal_url }) + Ok(Self { credential, base_internal_url }) } pub fn build_http_client(&self, worker_suffix: &str) -> HttpClient { - build_agent_http_client(worker_suffix, &self.agent_token, &self.base_internal_url) + match &self.credential { + AgentCredential::Token(token) => { + build_agent_http_client(worker_suffix, token, &self.base_internal_url) + } + AgentCredential::IdentityTokenFile(path) => { + build_agent_http_client_inner(&self.base_internal_url, None, |builder| { + builder.with(IdentityTokenAuth { + path: path.clone(), + worker_suffix: worker_suffix.to_string(), + }) + }) + } + } + } + + /// With a workload identity token the worker group and tags are whatever rule the server + /// matched, so they are asked for before anything reads [`DECODED_AGENT_TOKEN`] (and + /// through it `WORKER_GROUP`). A no-op with a static agent token, which carries them. + pub async fn resolve_workload_identity(&self, worker_suffix: &str) -> anyhow::Result<()> { + if let AgentCredential::IdentityTokenFile(_) = self.credential { + let auth: AgentAuth = self + .build_http_client(worker_suffix) + .get("/api/agent_workers/whoami") + .await?; + let _ = AGENT_WORKLOAD_IDENTITY_AUTH.set(auth); + } + Ok(()) } } +pub const AGENT_IDENTITY_TOKEN_FILE_ENV: &str = "AGENT_IDENTITY_TOKEN_FILE"; + #[derive(Debug)] pub enum AgentConfigError { MissingAgentToken, + BothCredentials, MissingBaseInternalUrl, } @@ -46,7 +92,13 @@ impl std::fmt::Display for AgentConfigError { AgentConfigError::MissingAgentToken => { write!( f, - "AGENT_TOKEN environment variable is not set but required for agent mode" + "AGENT_TOKEN or AGENT_IDENTITY_TOKEN_FILE environment variable is required for agent mode" + ) + } + AgentConfigError::BothCredentials => { + write!( + f, + "AGENT_TOKEN and AGENT_IDENTITY_TOKEN_FILE are mutually exclusive, set only one" ) } AgentConfigError::MissingBaseInternalUrl => { @@ -61,17 +113,56 @@ impl std::fmt::Display for AgentConfigError { impl std::error::Error for AgentConfigError {} +/// Sets the bearer from the identity token file on each request, so a token the platform +/// rotated on disk is picked up without a restart. +struct IdentityTokenAuth { + path: PathBuf, + worker_suffix: String, +} + +#[async_trait::async_trait] +impl reqwest_middleware::Middleware for IdentityTokenAuth { + async fn handle( + &self, + mut req: reqwest::Request, + extensions: &mut http::Extensions, + next: reqwest_middleware::Next<'_>, + ) -> reqwest_middleware::Result<reqwest::Response> { + let token = tokio::fs::read_to_string(&self.path).await.map_err(|e| { + reqwest_middleware::Error::middleware(std::io::Error::new( + e.kind(), + format!( + "reading {AGENT_IDENTITY_TOKEN_FILE_ENV} {}: {e}", + self.path.display() + ), + )) + })?; + let header = format!( + "Bearer {AGENT_WORKLOAD_IDENTITY_PREFIX}{}{AGENT_WORKLOAD_IDENTITY_SEPARATOR}{}", + self.worker_suffix, + token.trim() + ) + .parse() + .map_err(reqwest_middleware::Error::middleware)?; + req.headers_mut() + .insert(reqwest::header::AUTHORIZATION, header); + next.run(req, extensions).await + } +} + lazy_static! { pub static ref DECODED_AGENT_TOKEN: Option<AgentAuth> = { let agent_token = std::env::var("AGENT_TOKEN"); if let Ok(token) = agent_token { decode_without_verify::<AgentAuth>(token.trim_start_matches(AGENT_JWT_PREFIX)).ok() } else { - None + AGENT_WORKLOAD_IDENTITY_AUTH.get().cloned() } }; } +static AGENT_WORKLOAD_IDENTITY_AUTH: std::sync::OnceLock<AgentAuth> = std::sync::OnceLock::new(); + #[derive(Clone, Debug, Deserialize, Serialize)] pub struct AgentAuth { pub worker_group: String, @@ -81,11 +172,29 @@ pub struct AgentAuth { } pub const AGENT_JWT_PREFIX: &str = "jwt_agent_"; +/// `wid_agent_<worker suffix>:<workload identity JWT>`. A worker suffix may contain `_` (a +/// `WORKER_SUFFIX` label) and a JWT may contain `_` (base64url) but neither contains `:`. +pub const AGENT_WORKLOAD_IDENTITY_PREFIX: &str = "wid_agent_"; +pub const AGENT_WORKLOAD_IDENTITY_SEPARATOR: char = ':'; pub fn build_agent_http_client( worker_suffix: &str, agent_token: &str, base_internal_url: &str, +) -> HttpClient { + let token = format!( + "{}{}_{}", + AGENT_JWT_PREFIX, + worker_suffix, + agent_token.trim_start_matches(AGENT_JWT_PREFIX) + ); + build_agent_http_client_inner(base_internal_url, Some(token), |builder| builder) +} + +fn build_agent_http_client_inner( + base_internal_url: &str, + static_token: Option<String>, + with_auth: impl FnOnce(ClientBuilder) -> ClientBuilder, ) -> HttpClient { let client = ClientBuilder::new( configure_client( @@ -101,16 +210,12 @@ pub fn build_agent_http_client( "User-Agent", // Replace with your desired header name "Windmill-Agent/1.0".parse().unwrap(), // Replace with your desired header value ); - let token = format!( - "{}{}_{}", - AGENT_JWT_PREFIX, - worker_suffix, - agent_token.trim_start_matches(AGENT_JWT_PREFIX) - ); - headers.insert( - "Authorization", - format!("Bearer {}", token).parse().unwrap(), - ); + if let Some(token) = static_token { + headers.insert( + "Authorization", + format!("Bearer {}", token).parse().unwrap(), + ); + } headers }) .build() @@ -118,10 +223,12 @@ pub fn build_agent_http_client( ) .with(RetryTransientMiddleware::new_with_policy( ExponentialBackoff::builder().build_with_max_retries(5), - )) - .build(); + )); - HttpClient { client, base_internal_url: base_internal_url.to_string() } + HttpClient { + client: with_auth(client).build(), + base_internal_url: base_internal_url.to_string(), + } } #[derive(Deserialize, Serialize)] diff --git a/backend/windmill-common/src/agent_workload_identity.rs b/backend/windmill-common/src/agent_workload_identity.rs new file mode 100644 index 0000000000..437b867063 --- /dev/null +++ b/backend/windmill-common/src/agent_workload_identity.rs @@ -0,0 +1,475 @@ +//! Agent worker authentication with a workload identity token: a JWT the platform the agent +//! runs on issues to it (a Kubernetes projected service account token, a SPIFFE JWT-SVID, ...), +//! verified against an issuer the superadmin trusts in `agent_workload_identity_issuers`. +//! The worker group and tags come from the first rule whose claims the token matches, never +//! from the token's own say, so the tag checks the static agent token path applies hold as is. + +use std::collections::HashMap; +use std::sync::Arc; + +use jsonwebtoken::{jwk::Jwk, Algorithm, DecodingKey, Validation}; +use serde_json::Value; + +use crate::error::Result; +use crate::guest_jwt::{cached_jwks_keys, jwk_algorithms, parse_jwks_keys, JwksSource}; +use crate::instance_config::{AgentWorkloadIdentityIssuer, AgentWorkloadIdentityRule}; + +/// A projected service account token is about 1 KB; refused past this before any work. +const MAX_TOKEN_LEN: usize = 16 * 1024; + +/// Why a token was refused. `issuer` is set once the token named a trusted one. The reason +/// goes to the server log and the audit log, never to the client. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Refusal { + pub issuer: Option<String>, + /// The token's `sub`, set only once its signature verified, so a caller cannot vary it. + pub verified_subject: Option<String>, + pub reason: String, +} + +impl Refusal { + fn new(issuer: Option<&str>, reason: impl Into<String>) -> Self { + Self { issuer: issuer.map(str::to_string), verified_subject: None, reason: reason.into() } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkloadIdentity { + pub issuer: String, + pub subject: Option<String>, + pub worker_group: String, + pub tags: Vec<String>, + pub exp: u64, +} + +/// The setting's raw value, as both the settings API and the declarative sync receive it. +pub fn validate_issuers_value(value: &Value) -> std::result::Result<(), String> { + let issuers: Vec<AgentWorkloadIdentityIssuer> = + serde_json::from_value(value.clone()).map_err(|e| e.to_string())?; + validate_issuers(&issuers) +} + +/// Rejects what would make a rule set ambiguous or a rule match more than intended. Runtime +/// matching skips a rule without claims too, so a setting planted around this check cannot +/// admit every token of an issuer. +pub fn validate_issuers( + issuers: &[AgentWorkloadIdentityIssuer], +) -> std::result::Result<(), String> { + let mut seen = std::collections::HashSet::new(); + for (i, cfg) in issuers.iter().enumerate() { + let at = format!("issuer #{} ({})", i + 1, cfg.issuer); + if cfg.issuer.trim().is_empty() { + return Err(format!("issuer #{}: issuer is required", i + 1)); + } + if !seen.insert(cfg.issuer.as_str()) { + return Err(format!("{at}: listed more than once")); + } + if cfg.audience.trim().is_empty() { + return Err(format!("{at}: audience is required")); + } + match (&cfg.jwks, &cfg.jwks_uri) { + (Some(jwks), _) if !jwks.trim().is_empty() => { + parse_jwks_keys(jwks.as_bytes()).map_err(|e| format!("{at}: inline JWKS: {e}"))?; + } + (_, Some(uri)) if !uri.trim().is_empty() => { + if !(uri.starts_with("https://") || uri.starts_with("http://")) { + return Err(format!("{at}: jwks_uri must be an http(s) URL")); + } + } + _ => { + if !(cfg.issuer.starts_with("https://") || cfg.issuer.starts_with("http://")) { + return Err(format!( + "{at}: without jwks_uri or an inline JWKS, the issuer must be an http(s) URL \ + serving OIDC discovery" + )); + } + } + } + if cfg.rules.is_empty() { + return Err(format!("{at}: at least one rule is required")); + } + for (j, rule) in cfg.rules.iter().enumerate() { + let at = format!("{at}, rule #{}", j + 1); + if rule.claims.is_empty() + || rule + .claims + .iter() + .any(|(k, v)| k.trim().is_empty() || v.trim().is_empty()) + { + return Err(format!( + "{at}: at least one claim to match is required, each with a path and a value" + )); + } + if rule.worker_group.trim().is_empty() + || rule.worker_group.contains(char::is_whitespace) + { + return Err(format!("{at}: worker group is required and has no spaces")); + } + if rule.tags.is_empty() || rule.tags.iter().any(|t| t.trim().is_empty()) { + return Err(format!("{at}: at least one non-empty tag is required")); + } + } + } + Ok(()) +} + +/// The value at a dot separated claim path. At each level the longest key that prefixes the +/// remaining path is tried first, so `kubernetes.io.namespace` reaches `namespace` under the +/// `kubernetes.io` key. +fn claim_at<'a>(value: &'a Value, path: &str) -> Option<&'a Value> { + let obj = value.as_object()?; + if let Some(v) = obj.get(path) { + return Some(v); + } + path.match_indices('.') + .rev() + .find_map(|(i, _)| claim_at(obj.get(&path[..i])?, &path[i + 1..])) +} + +fn claim_matches(claims: &Value, path: &str, expected: &str) -> bool { + match claim_at(claims, path) { + Some(Value::String(s)) => s == expected, + Some(v @ (Value::Number(_) | Value::Bool(_))) => v.to_string() == expected, + _ => false, + } +} + +pub fn matching_rule<'a>( + rules: &'a [AgentWorkloadIdentityRule], + claims: &Value, +) -> Option<&'a AgentWorkloadIdentityRule> { + rules.iter().find(|rule| { + !rule.claims.is_empty() + && rule + .claims + .iter() + .all(|(path, expected)| claim_matches(claims, path, expected)) + }) +} + +/// The header's `alg`, read raw: `jsonwebtoken` cannot parse `none`, and a refusal should say +/// which algorithm was offered rather than that the header is malformed. +fn header_alg(token: &str) -> std::result::Result<Algorithm, String> { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + let header = token.split('.').next().unwrap_or_default(); + let header: Value = URL_SAFE_NO_PAD + .decode(header.trim_end_matches('=')) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .ok_or("malformed JWT header")?; + let alg = header.get("alg").and_then(Value::as_str).unwrap_or(""); + match alg.parse::<Algorithm>() { + Ok( + a @ (Algorithm::RS256 + | Algorithm::RS384 + | Algorithm::RS512 + | Algorithm::PS256 + | Algorithm::PS384 + | Algorithm::PS512 + | Algorithm::ES256 + | Algorithm::ES384), + ) => Ok(a), + _ => Err(format!( + "alg {alg:?} is not accepted, only RS*, PS* and ES*" + )), + } +} + +/// Verify `token` against the trusted `issuers` and map it to a worker group and tags. +pub async fn verify( + issuers: &[AgentWorkloadIdentityIssuer], + token: &str, +) -> std::result::Result<WorkloadIdentity, Refusal> { + if token.len() > MAX_TOKEN_LEN { + return Err(Refusal::new(None, "token is too long")); + } + let alg = header_alg(token).map_err(|r| Refusal::new(None, r))?; + let kid = jsonwebtoken::decode_header(token) + .map_err(|e| Refusal::new(None, format!("malformed JWT header: {e}")))? + .kid; + let unverified: Value = crate::jwt::decode_without_verify(token) + .map_err(|e| Refusal::new(None, format!("malformed JWT payload: {e}")))?; + let iss = unverified.get("iss").and_then(Value::as_str).unwrap_or(""); + let cfg = issuers + .iter() + .find(|c| c.issuer == iss) + .ok_or_else(|| Refusal::new(None, format!("issuer {iss:?} is not trusted")))?; + let refuse = |reason: String| Refusal::new(Some(&cfg.issuer), reason); + + let keys = issuer_keys(cfg) + .await + .map_err(|e| refuse(format!("signing keys unavailable: {e}")))?; + let candidates: Vec<&Jwk> = match &kid { + Some(kid) => keys.get(kid).into_iter().collect(), + None => keys.values().collect(), + }; + let candidates: Vec<&Jwk> = candidates + .into_iter() + .filter(|jwk| jwk_algorithms(jwk).is_some_and(|algs| algs.contains(&alg))) + .collect(); + if candidates.is_empty() { + return Err(refuse(match &kid { + Some(kid) => format!("no {alg:?} signing key with kid {kid:?} in the issuer's JWKS"), + None => format!("no {alg:?} signing key in the issuer's JWKS"), + })); + } + + let mut validation = Validation::new(alg); + validation.set_audience(&[&cfg.audience]); + validation.set_issuer(&[&cfg.issuer]); + validation.set_required_spec_claims(&["exp", "iss", "aud"]); + validation.validate_nbf = true; + let mut last_err = None; + let mut claims = None; + for jwk in candidates { + let key = match DecodingKey::from_jwk(jwk) { + Ok(key) => key, + Err(e) => { + last_err = Some(e.to_string()); + continue; + } + }; + match jsonwebtoken::decode::<Value>(token, &key, &validation) { + Ok(data) => { + claims = Some(data.claims); + break; + } + Err(e) => last_err = Some(describe_jwt_error(&e, &cfg.audience)), + } + } + let claims = claims.ok_or_else(|| refuse(last_err.unwrap_or_default()))?; + + let subject = claims + .get("sub") + .and_then(Value::as_str) + .map(str::to_string); + let rule = matching_rule(&cfg.rules, &claims).ok_or_else(|| Refusal { + verified_subject: subject.clone(), + ..refuse(format!( + "no rule matches the token (sub {})", + subject.as_deref().unwrap_or("<none>") + )) + })?; + Ok(WorkloadIdentity { + issuer: cfg.issuer.clone(), + subject, + worker_group: rule.worker_group.clone(), + tags: rule.tags.clone(), + exp: claims.get("exp").and_then(Value::as_u64).unwrap_or(0), + }) +} + +fn describe_jwt_error(e: &jsonwebtoken::errors::Error, audience: &str) -> String { + use jsonwebtoken::errors::ErrorKind; + match e.kind() { + ErrorKind::InvalidSignature => "signature does not verify".to_string(), + ErrorKind::ExpiredSignature => "token is expired".to_string(), + ErrorKind::ImmatureSignature => "token is not valid yet (nbf)".to_string(), + ErrorKind::InvalidAudience => format!("aud does not include {audience:?}"), + ErrorKind::InvalidIssuer => "iss does not match".to_string(), + ErrorKind::MissingRequiredClaim(c) => format!("required claim {c} is missing"), + _ => format!("token refused: {e}"), + } +} + +async fn issuer_keys(cfg: &AgentWorkloadIdentityIssuer) -> Result<Arc<HashMap<String, Jwk>>> { + if let Some(jwks) = cfg.jwks.as_deref().filter(|j| !j.trim().is_empty()) { + return Ok(Arc::new(parse_jwks_keys(jwks.as_bytes())?)); + } + cached_jwks_keys(&JwksSource::TrustedIssuer { + issuer: cfg.issuer.clone(), + jwks_uri: cfg.jwks_uri.clone(), + }) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use jsonwebtoken::{EncodingKey, Header}; + use serde_json::json; + use std::collections::BTreeMap; + + // Throwaway P-256 keys, used nowhere else. `JWKS` publishes the first one. + const PRIV: &str = "-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgu7FqG4gEcV8logVI +B6/Lc5aFA8jxKmix7Jdcr/acid2hRANCAATV+8XhZlMNXkRAy7FZ4nbQkBOechNv +a2U/COEBOOauFRMd0NB5bi2DAqO1r/h7eN7fLMGYLXVPlSvNdJuYL4F8 +-----END PRIVATE KEY-----"; + const OTHER_PRIV: &str = "-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcU92tN6g4nOPoxQm +6KLBG0mtRL4gZ6/BNcrNPXiRammhRANCAASu64yo/1u80XgfncT4r9QMORMHZjDM +Sa6vBkm9YSs4MMV6K5vCIyow+i712HgUL/FNZ55GJVwXF77JffommMAk +-----END PRIVATE KEY-----"; + const JWKS: &str = r#"{"keys":[{"kty":"EC","crv":"P-256","alg":"ES256","use":"sig","kid":"k1", + "x":"1fvF4WZTDV5EQMuxWeJ20JATnnITb2tlPwjhATjmrhU", + "y":"Ex3Q0HluLYMCo7Wv-Ht43t8swZgtdU-VK810m5gvgXw"}]}"#; + const ISS: &str = "https://kubernetes.default.svc.cluster.local"; + + fn issuer() -> AgentWorkloadIdentityIssuer { + AgentWorkloadIdentityIssuer { + issuer: ISS.to_string(), + jwks_uri: None, + jwks: Some(JWKS.to_string()), + audience: "windmill".to_string(), + rules: vec![ + AgentWorkloadIdentityRule { + claims: BTreeMap::from([( + "sub".to_string(), + "system:serviceaccount:agents:gpu".to_string(), + )]), + worker_group: "gpu".to_string(), + tags: vec!["gpu".to_string()], + }, + AgentWorkloadIdentityRule { + claims: BTreeMap::from([( + "kubernetes.io.namespace".to_string(), + "agents".to_string(), + )]), + worker_group: "agents".to_string(), + tags: vec!["dbt".to_string(), "etl".to_string()], + }, + ], + } + } + + fn claims(sub: &str, ns: &str) -> Value { + let now = jsonwebtoken::get_current_timestamp(); + json!({ + "iss": ISS, "aud": ["windmill"], "sub": sub, "exp": now + 600, "iat": now, "nbf": now, + "kubernetes.io": {"namespace": ns, "serviceaccount": {"name": "x"}}, + }) + } + + fn sign(claims: &Value, pem: &str, alg: Algorithm) -> String { + let mut header = Header::new(alg); + header.kid = Some("k1".to_string()); + jsonwebtoken::encode( + &header, + claims, + &EncodingKey::from_ec_pem(pem.as_bytes()).unwrap(), + ) + .unwrap() + } + + async fn reason(token: &str) -> String { + verify(&[issuer()], token).await.unwrap_err().reason + } + + #[tokio::test] + async fn first_matching_rule_decides_group_and_tags() { + let id = verify( + &[issuer()], + &sign( + &claims("system:serviceaccount:agents:gpu", "agents"), + PRIV, + Algorithm::ES256, + ), + ) + .await + .unwrap(); + assert_eq!( + (id.worker_group.as_str(), id.tags), + ("gpu", vec!["gpu".to_string()]) + ); + // A dotted key (`kubernetes.io`) is reached through a nested path. + let id = verify( + &[issuer()], + &sign( + &claims("system:serviceaccount:agents:etl", "agents"), + PRIV, + Algorithm::ES256, + ), + ) + .await + .unwrap(); + assert_eq!(id.worker_group, "agents"); + assert!(reason(&sign( + &claims("system:serviceaccount:other:etl", "other"), + PRIV, + Algorithm::ES256 + )) + .await + .starts_with("no rule matches")); + } + + #[tokio::test] + async fn refuses_wrong_iss_aud_exp_key_and_alg() { + let ok = claims("system:serviceaccount:agents:gpu", "agents"); + let with = |k: &str, v: Value| { + let mut c = ok.clone(); + c[k] = v; + sign(&c, PRIV, Algorithm::ES256) + }; + assert!(reason(&with("iss", json!("https://evil"))) + .await + .contains("not trusted")); + assert!(reason(&with("aud", json!("other"))).await.contains("aud")); + assert!(reason(&with("exp", json!(1_000_000))) + .await + .contains("expired")); + assert!(reason(&sign(&ok, OTHER_PRIV, Algorithm::ES256)) + .await + .contains("signature")); + + let hs = jsonwebtoken::encode( + &Header::new(Algorithm::HS256), + &ok, + &EncodingKey::from_secret(b"s"), + ) + .unwrap(); + assert!(reason(&hs).await.contains("not accepted")); + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + let none = format!( + "{}.{}.", + URL_SAFE_NO_PAD.encode(br#"{"alg":"none","kid":"k1"}"#), + URL_SAFE_NO_PAD.encode(ok.to_string()) + ); + assert!(reason(&none).await.contains("not accepted")); + } + + #[tokio::test] + async fn failed_key_fetches_are_throttled() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let hits = Arc::new(AtomicUsize::new(0)); + let counted = hits.clone(); + tokio::spawn(async move { + while let Ok((conn, _)) = listener.accept().await { + counted.fetch_add(1, Ordering::SeqCst); + drop(conn); + } + }); + let mut cfg = issuer(); + cfg.issuer = format!("https://throttle-test-{}", addr.port()); + cfg.jwks = None; + cfg.jwks_uri = Some(format!("http://{addr}/jwks")); + let mut c = claims("system:serviceaccount:agents:gpu", "agents"); + c["iss"] = json!(cfg.issuer); + for _ in 0..3 { + let refusal = verify(&[cfg.clone()], &sign(&c, PRIV, Algorithm::ES256)) + .await + .unwrap_err(); + assert!( + refusal.reason.contains("signing keys unavailable"), + "{}", + refusal.reason + ); + } + assert_eq!(hits.load(Ordering::SeqCst), 1); + } + + #[test] + fn rule_without_claims_never_matches() { + let rules = vec![AgentWorkloadIdentityRule { + claims: BTreeMap::new(), + worker_group: "g".to_string(), + tags: vec!["t".to_string()], + }]; + assert!(matching_rule(&rules, &json!({"sub": "x"})).is_none()); + let mut cfg = issuer(); + cfg.rules = rules; + assert!(validate_issuers(&[cfg]).is_err()); + } +} diff --git a/backend/windmill-common/src/apps.rs b/backend/windmill-common/src/apps.rs index 1686c20d2b..9ce4326212 100644 --- a/backend/windmill-common/src/apps.rs +++ b/backend/windmill-common/src/apps.rs @@ -34,14 +34,23 @@ pub fn app_value_has_inline_script(value: &Value) -> bool { } } -/// Every workspace runnable the app value points a component at, as `(is_flow, path)`. +/// What a path runnable of an app runs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum AppRunnableKind { + Script, + Flow, + /// A saved AI agent (an `ai_agent` resource), run as one chat turn. + Agent, +} + +/// Every workspace runnable the app value points a component at, as `(kind, path)`. /// /// This is what the deployed bundle actually asks `execute_component` to run: it resolves a /// `runnable_id` against the stored `runnables` and sends `{runType}/{path}`. The policy's /// triggerables are a separate surface, so both have to be authorized. Anything not plain text /// is refused, never skipped: the editor compares `type` loosely (`["path"] == "path"`) and /// interpolates `path` (`["u/x"]` → `u/x`), so a skipped entry still runs. -pub fn app_value_runnable_paths(value: &Value) -> error::Result<Vec<(bool, String)>> { +pub fn app_value_runnable_paths(value: &Value) -> error::Result<Vec<(AppRunnableKind, String)>> { let bad = |msg: String| error::Error::BadRequest(msg); let runnables = match value.get("runnables") { None | Some(Value::Null) => return Ok(vec![]), @@ -61,16 +70,35 @@ pub fn app_value_runnable_paths(value: &Value) -> error::Result<Vec<(bool, Strin let Some(Value::String(path)) = runnable.get("path") else { return Err(bad(format!("Runnable {id} must name its path as text"))); }; - let is_flow = match runnable.get("runType") { - Some(Value::String(t)) if t == "flow" => true, - Some(Value::String(t)) if t == "script" || t == "hubscript" => false, + let kind = match runnable.get("runType") { + Some(Value::String(t)) if t == "flow" => AppRunnableKind::Flow, + Some(Value::String(t)) if t == "script" || t == "hubscript" => AppRunnableKind::Script, + Some(Value::String(t)) if t == "agent" => AppRunnableKind::Agent, _ => return Err(bad(format!("Unsupported runType for runnable {path}"))), }; - out.push((is_flow, path.clone())); + out.push((kind, path.clone())); } Ok(out) } +pub const RAW_APP_RUNNABLES: &str = "runnables"; + +/// The `runnables` map of a raw app, when `value` is it. An inline script's dependency-map node +/// id is its closest enclosing component `id`; raw app runnables carry none, so each is keyed by +/// its name in this map instead, or a relock could not tell them apart. +pub fn raw_app_runnables_map<'a>( + key: &str, + value: &'a Value, + container_id: Option<&String>, +) -> Option<&'a serde_json::Map<String, Value>> { + match value { + Value::Object(runnables) if container_id.is_none() && key == RAW_APP_RUNNABLES => { + Some(runnables) + } + _ => None, + } +} + /// Traverse FlowValue while invoking provided by caller callback on leafs // #[async_recursion::async_recursion(?Send)] pub fn traverse_app_inline_scripts< @@ -109,7 +137,15 @@ pub fn traverse_app_inline_scripts< )?; } } else { - for (_, value) in object { + for (key, value) in object { + if let Some(runnables) = + raw_app_runnables_map(key, value, container_id.as_ref()) + { + for (id, runnable) in runnables { + traverse_app_inline_scripts(runnable, Some(id.clone()), cb)?; + } + continue; + } traverse_app_inline_scripts( value, object diff --git a/backend/windmill-common/src/auth.rs b/backend/windmill-common/src/auth.rs index 24d9c99661..78d3b98c48 100644 --- a/backend/windmill-common/src/auth.rs +++ b/backend/windmill-common/src/auth.rs @@ -509,7 +509,7 @@ async fn fetch_authed_from_permissioned_as_inner( let member = match permissioned_as.split_once('/') { Some(("u", name)) => { sqlx::query!( - "SELECT is_admin, operator, email FROM usr where username = $1 AND \ + "SELECT is_admin, operator, workspace_role, email FROM usr where username = $1 AND \ workspace_id = $2 AND disabled = false", name, &w_id @@ -552,7 +552,14 @@ async fn fetch_authed_from_permissioned_as_inner( let (is_admin, is_operator) = if is_super_admin { (true, false) } else if let Some(m) = member.as_ref() { - (m.is_admin, m.operator) + let restricted = crate::roles::member_is_restricted( + &mut *conn, + w_id, + m.workspace_role.clone(), + m.operator, + ) + .await?; + (m.is_admin, restricted && !m.is_admin) } else { return Err(Error::NotFound(format!( "user {name} not found in workspace {w_id}" diff --git a/backend/windmill-common/src/ee_oss.rs b/backend/windmill-common/src/ee_oss.rs index 82f99c54fb..24607574f1 100644 --- a/backend/windmill-common/src/ee_oss.rs +++ b/backend/windmill-common/src/ee_oss.rs @@ -72,9 +72,17 @@ pub async fn check_seat_cap_for_reactivation( } #[cfg(all(feature = "enterprise", not(feature = "private")))] -pub async fn check_seat_cap_for_operator_builder( - _db: &DB, - _w_id: &str, +pub struct SeatCapBaseline; + +#[cfg(all(feature = "enterprise", not(feature = "private")))] +pub async fn seat_cap_baseline(_db: &DB) -> anyhow::Result<SeatCapBaseline> { + Ok(SeatCapBaseline) +} + +#[cfg(all(feature = "enterprise", not(feature = "private")))] +pub async fn check_seat_cap_for_role_change( + _baseline: &SeatCapBaseline, + _tx: &mut sqlx::PgConnection, ) -> anyhow::Result<Option<String>> { Ok(None) } diff --git a/backend/windmill-common/src/error.rs b/backend/windmill-common/src/error.rs index a54e9eb399..dd8ec49f92 100644 --- a/backend/windmill-common/src/error.rs +++ b/backend/windmill-common/src/error.rs @@ -289,9 +289,9 @@ pub fn error_source_chain(e: &dyn std::error::Error) -> String { msg } -impl IntoResponse for Error { - fn into_response(self) -> axum::response::Response { - let status = match self { +impl Error { + pub fn status_code(&self) -> axum::http::StatusCode { + match self { Self::NotFound(_) => axum::http::StatusCode::NOT_FOUND, Self::NotAuthorized(_) => axum::http::StatusCode::UNAUTHORIZED, Self::RequireAdmin(_) | Self::PermissionDenied(_) => axum::http::StatusCode::FORBIDDEN, @@ -301,9 +301,15 @@ impl IntoResponse for Error { | Self::JsonErr(_) | Self::QuotaExceeded(_) => axum::http::StatusCode::BAD_REQUEST, Self::BadGateway(_) => axum::http::StatusCode::BAD_GATEWAY, - Self::Generic(status_code, _) => status_code, + Self::Generic(status_code, _) => *status_code, _ => axum::http::StatusCode::INTERNAL_SERVER_ERROR, - }; + } + } +} + +impl IntoResponse for Error { + fn into_response(self) -> axum::response::Response { + let status = self.status_code(); let e = &self; diff --git a/backend/windmill-common/src/flow_conversations.rs b/backend/windmill-common/src/flow_conversations.rs index bde4d5ccce..12b3daf2ec 100644 --- a/backend/windmill-common/src/flow_conversations.rs +++ b/backend/windmill-common/src/flow_conversations.rs @@ -14,6 +14,11 @@ use crate::utils::truncate_with_ellipsis; /// Changing it detaches every memory stored under a string memory id. const MEMORY_ID_NAMESPACE: Uuid = Uuid::from_u128(0x6f1c2d4e_8a3b_5c7d_9e0f_1a2b3c4d5e6f); +/// Owns anonymous app visitors' agent conversations, as `created_by` names an anonymous app run. +/// It is also a valid username, so listing conversations leaves these out: a user of that name +/// would otherwise list every anonymous visitor's. +pub const ANONYMOUS_CONVERSATION_OWNER: &str = "anonymous"; + /// Memory is stored and carried in `flow_status.memory_id` as a uuid, which names the same memory /// wherever it is passed, as a chat conversation id must. Any other string names a memory through a /// name-based (v5) uuid scoped to its workspace and flow, so the same key in two flows or two @@ -64,12 +69,13 @@ pub async fn get_or_create_conversation_with_id( w_id: &str, flow_path: &str, username: &str, + is_admin: bool, title: &str, conversation_id: Uuid, is_test: bool, ) -> Result<FlowConversation> { if let Some(existing) = lock_conversation(tx, w_id, conversation_id).await? { - let existing = same_kind(existing, is_test)?; + let existing = same_kind(owned(existing, username, is_admin)?, is_test)?; refuse_running_turn(tx, conversation_id).await?; return Ok(existing); } @@ -106,7 +112,7 @@ pub async fn get_or_create_conversation_with_id( "conversation {conversation_id} belongs to another workspace" )) })?; - let existing = same_kind(existing, is_test)?; + let existing = same_kind(owned(existing, username, is_admin)?, is_test)?; refuse_running_turn(tx, conversation_id).await?; Ok(existing) } @@ -176,6 +182,19 @@ pub async fn running_turns<'e, E: sqlx::PgExecutor<'e>>( .collect()) } +/// The `see_own` policy on `flow_conversation`, for a turn whose transaction bypasses it: a run +/// as a flow's or agent's `on_behalf_of` takes the conversation in a root transaction, and must +/// not continue, or read the memory of, another user's conversation. +fn owned(existing: FlowConversation, username: &str, is_admin: bool) -> Result<FlowConversation> { + if is_admin || existing.created_by == username { + return Ok(existing); + } + Err(crate::error::Error::BadRequest(format!( + "conversation {} belongs to another user; start a new conversation", + existing.id + ))) +} + /// `memory_id` is the caller's to choose, so a preview run could name a deployed /// conversation and the reverse. A conversation's kind is fixed at creation and nothing /// would show the mixing afterwards, so the turn is refused before it starts. diff --git a/backend/windmill-common/src/fork_storage.rs b/backend/windmill-common/src/fork_storage.rs new file mode 100644 index 0000000000..8d2797ab3c --- /dev/null +++ b/backend/windmill-common/src/fork_storage.rs @@ -0,0 +1,394 @@ +//! Where a workspace's object keys live in a storage it may share with its fork ancestors. +//! +//! A fork that isolates a storage (a `fork_storage_namespace` row) writes logical key `k` at +//! `__wm_forks/<fork segment>/k` and reads `k` from the first of its own and its ancestors' +//! locations that has it. Everything under `__wm_forks/` is reachable only through the areas of +//! the workspace's own chain, which is also what DuckLake's fork namespaces live in: those keys +//! arrive already prefixed and pass through unchanged. + +use std::sync::Arc; + +use quick_cache::sync::Cache; + +use crate::error::{Error, Result}; +use crate::workspaces::{ + fork_data_dir_segment, lfs_entry_storage_ref, workspace_with_fork_ancestors, + FORK_DUCKLAKE_DATA_DIR, +}; +use crate::DB; + +/// How one workspace's logical keys map to physical keys in one of its storages. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ForkStorageKeys { + /// Prefix of every write: `__wm_forks/<segment>/` in an isolating fork, `""` otherwise. + write_prefix: String, + /// Prefixes a read tries in order, nearest first. + read_prefixes: Vec<String>, + /// `__wm_forks/<segment>/` of this workspace, writable as is. + own_area: String, + /// The fork areas of this workspace and its ancestors, readable as is. + readable_areas: Vec<String>, +} + +/// A listing request translated to the physical storage. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListMapping { + pub physical_prefix: String, + /// Stripped from each physical key to get the logical key returned to the caller. + strip: String, + /// Logical keys under `__wm_forks/` are dropped: reached only by listing that area itself. + hide_fork_area: bool, +} + +impl ListMapping { + /// Whether listed keys differ from what the caller may see: false lets a listing response + /// be passed through untouched. + pub fn rewrites(&self) -> bool { + let fork_dir = format!("{FORK_DUCKLAKE_DATA_DIR}/"); + !self.strip.is_empty() + || (self.hide_fork_area && fork_dir.starts_with(&self.physical_prefix)) + } + + /// The physical form of a logical key of this listing, e.g. a start-after cursor. + pub fn physical_key(&self, logical: &str) -> String { + format!("{}{logical}", self.strip) + } + + /// The logical key for a listed physical key, or None when the caller must not see it. + pub fn logical_key(&self, physical: &str) -> Option<String> { + let logical = physical.strip_prefix(self.strip.as_str())?; + if self.hide_fork_area && in_fork_area(logical) { + return None; + } + Some(logical.to_string()) + } +} + +fn in_fork_area(key: &str) -> bool { + key == FORK_DUCKLAKE_DATA_DIR + || key + .strip_prefix(FORK_DUCKLAKE_DATA_DIR) + .is_some_and(|rest| rest.starts_with('/')) +} + +fn fork_area(w_id: &str) -> String { + format!("{FORK_DUCKLAKE_DATA_DIR}/{}/", fork_data_dir_segment(w_id)) +} + +fn forbidden(key: &str) -> Error { + Error::NotAuthorized(format!( + "`{key}` is in another workspace's fork namespace (`{FORK_DUCKLAKE_DATA_DIR}/`)" + )) +} + +/// The S3 proxy forwards mapped keys in a URL, where clients and signers collapse `.` and `..` +/// segments: `<fork area>../../k` would reach the parent's `k`. +fn reject_dot_segments(key: &str) -> Result<&str> { + let key = key.trim_start_matches('/'); + if key.split('/').any(|s| s == "." || s == "..") { + return Err(Error::BadRequest(format!( + "`{key}` has a `.` or `..` path segment, which object storage keys may not use here" + ))); + } + Ok(key) +} + +/// One workspace of a fork chain, nearest first, as the storage mapping needs it. +#[derive(Debug, Clone)] +pub struct ChainLink { + pub w_id: String, + /// Identity of the storage in this workspace's settings (`lfs_entry_storage_ref`). + pub storage_ref: Option<String>, + pub isolated: bool, +} + +impl ForkStorageKeys { + /// The mapping of `chain[0]`. Reads stop at the first ancestor whose storage is a different + /// one: its keys are not in this bucket. + pub fn from_chain(chain: &[ChainLink]) -> Self { + let own = &chain[0]; + let mut read_prefixes = vec![]; + let mut reached_root = true; + for link in chain { + if link.storage_ref != own.storage_ref { + reached_root = false; + break; + } + if link.isolated { + read_prefixes.push(fork_area(&link.w_id)); + } + } + if reached_root || read_prefixes.is_empty() { + read_prefixes.push(String::new()); + } + Self { + write_prefix: read_prefixes[0].clone(), + read_prefixes, + own_area: fork_area(&own.w_id), + readable_areas: chain.iter().map(|l| fork_area(&l.w_id)).collect(), + } + } + + /// Whether logical keys map to themselves (only the fork-area boundary applies). + pub fn is_identity(&self) -> bool { + self.write_prefix.is_empty() && self.read_prefixes.len() == 1 + } + + /// The same mapping with reads confined to the workspace's own copy, for sizing what a + /// write replaces: an ancestor's object is not overwritten by it. + pub fn own_only(&self) -> Self { + Self { read_prefixes: vec![self.write_prefix.clone()], ..self.clone() } + } + + /// The physical key a write (put, multipart, delete, copy destination) of `key` goes to. + pub fn write_key(&self, key: &str) -> Result<String> { + let key = reject_dot_segments(key)?; + if in_fork_area(key) { + return if key.starts_with(&self.own_area) { + Ok(key.to_string()) + } else { + Err(forbidden(key)) + }; + } + Ok(format!("{}{key}", self.write_prefix)) + } + + /// The physical keys a read of `key` tries, in order, until one exists. + pub fn read_keys(&self, key: &str) -> Result<Vec<String>> { + let key = reject_dot_segments(key)?; + if in_fork_area(key) { + return if self.readable_areas.iter().any(|a| key.starts_with(a)) { + Ok(vec![key.to_string()]) + } else { + Err(forbidden(key)) + }; + } + Ok(self + .read_prefixes + .iter() + .map(|p| format!("{p}{key}")) + .collect()) + } + + /// A listing of `prefix` covers this workspace's own objects only: an isolating fork does + /// not list its ancestors' keys. + pub fn list(&self, prefix: &str) -> Result<ListMapping> { + let prefix = reject_dot_segments(prefix)?; + if in_fork_area(prefix) { + if !self.readable_areas.iter().any(|a| prefix.starts_with(a)) { + return Err(forbidden(prefix)); + } + return Ok(ListMapping { + physical_prefix: prefix.to_string(), + strip: String::new(), + hide_fork_area: false, + }); + } + Ok(ListMapping { + physical_prefix: format!("{}{prefix}", self.write_prefix), + strip: self.write_prefix.clone(), + hide_fork_area: true, + }) + } +} + +lazy_static::lazy_static! { + static ref FORK_STORAGE_KEYS_CACHE: Cache<(String, String), (Arc<ForkStorageKeys>, i64)> = + Cache::new(1000); +} + +/// Drop the cached mappings of a workspace whose isolation rows or lineage just changed. +pub fn invalidate_fork_storage_keys_cache(w_id: &str) { + FORK_STORAGE_KEYS_CACHE.retain(|(w, _), _| w != w_id); +} + +pub fn clear_fork_storage_keys_cache() { + FORK_STORAGE_KEYS_CACHE.clear(); +} + +/// `storage` as the registry keys it: '' for the default storage. +pub fn registry_storage_name(storage: Option<&str>) -> &str { + match storage { + None | Some("") | Some(windmill_types::s3::DEFAULT_STORAGE) => "", + Some(s) => s, + } +} + +/// The storage entry named `storage` (registry form) in a `large_file_storage` value. +pub fn lfs_entry<'a>(lfs: &'a serde_json::Value, storage: &str) -> Option<&'a serde_json::Value> { + if storage.is_empty() { + Some(lfs) + } else { + lfs.get("secondary_storage")?.get(storage) + } +} + +/// How `w_id`'s keys map in `storage`. Reads lineage for any `w_id` with no authorization +/// check, like [`workspace_with_fork_ancestors`]: callers must be authorized for `w_id`. +pub async fn fork_storage_keys( + db: &DB, + w_id: &str, + storage: Option<&str>, +) -> Result<Arc<ForkStorageKeys>> { + let storage = registry_storage_name(storage).to_string(); + let cache_key = (w_id.to_string(), storage.clone()); + let now = chrono::Utc::now().timestamp(); + if let Some((keys, expiry)) = FORK_STORAGE_KEYS_CACHE.get(&cache_key) { + if expiry > now { + return Ok(keys); + } + } + + let chain_ids = workspace_with_fork_ancestors(db, w_id).await?; + let rows = sqlx::query!( + r#"SELECT c.id AS "id!", ws.large_file_storage, + EXISTS (SELECT 1 FROM fork_storage_namespace f + WHERE f.workspace_id = c.id AND f.storage = $2) AS "isolated!" + FROM unnest($1::text[]) WITH ORDINALITY AS c(id, ord) + LEFT JOIN workspace_settings ws ON ws.workspace_id = c.id + ORDER BY c.ord"#, + &chain_ids, + &storage, + ) + .fetch_all(db) + .await?; + let chain: Vec<ChainLink> = rows + .into_iter() + .map(|r| ChainLink { + storage_ref: r + .large_file_storage + .as_ref() + .and_then(|lfs| lfs_entry(lfs, &storage)) + .and_then(lfs_entry_storage_ref), + w_id: r.id, + isolated: r.isolated, + }) + .collect(); + let keys = Arc::new(if chain.is_empty() { + ForkStorageKeys::from_chain(&[ChainLink { + w_id: w_id.to_string(), + storage_ref: None, + isolated: false, + }]) + } else { + ForkStorageKeys::from_chain(&chain) + }); + FORK_STORAGE_KEYS_CACHE.insert(cache_key, (keys.clone(), now + 30)); + Ok(keys) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn link(w_id: &str, storage_ref: &str, isolated: bool) -> ChainLink { + ChainLink { w_id: w_id.into(), storage_ref: Some(storage_ref.into()), isolated } + } + + #[test] + fn root_is_identity_but_keeps_fork_areas_out_of_reach() { + let keys = ForkStorageKeys::from_chain(&[link("prod", "S3Storage:u/a/s3", false)]); + assert!(keys.is_identity()); + assert_eq!(keys.write_key("a/b.csv").unwrap(), "a/b.csv"); + assert_eq!(keys.read_keys("a/b.csv").unwrap(), vec!["a/b.csv"]); + let other = fork_area("wm-fork-x"); + assert!(keys.write_key(&format!("{other}a")).is_err()); + assert!(keys.read_keys(&format!("{other}a")).is_err()); + let listing = keys.list("").unwrap(); + assert_eq!(listing.logical_key("a/b.csv").as_deref(), Some("a/b.csv")); + assert_eq!(listing.logical_key(&format!("{other}a")), None); + } + + #[test] + fn isolated_fork_writes_its_area_and_reads_through_to_ancestors() { + let s = "S3Storage:u/a/s3"; + let chain = [ + link("wm-fork-b", s, true), + link("wm-fork-a", s, false), + link("dev", s, true), + link("prod", s, false), + ]; + let keys = ForkStorageKeys::from_chain(&chain); + let (b, dev) = (fork_area("wm-fork-b"), fork_area("dev")); + assert_eq!(keys.write_key("x.csv").unwrap(), format!("{b}x.csv")); + assert_eq!( + keys.read_keys("x.csv").unwrap(), + vec![ + format!("{b}x.csv"), + format!("{dev}x.csv"), + "x.csv".to_string() + ] + ); + // DuckLake's own fork namespace and its ancestors' pass through as is. + assert_eq!( + keys.write_key(&format!("{b}lake/f")).unwrap(), + format!("{b}lake/f") + ); + let a = fork_area("wm-fork-a"); + assert_eq!( + keys.read_keys(&format!("{a}lake/f")).unwrap(), + vec![format!("{a}lake/f")] + ); + assert!(keys.write_key(&format!("{a}lake/f")).is_err()); + let listing = keys.list("dir/").unwrap(); + assert_eq!(listing.physical_prefix, format!("{b}dir/")); + assert_eq!( + listing.logical_key(&format!("{b}dir/f")).as_deref(), + Some("dir/f") + ); + } + + #[test] + fn shared_fork_writes_where_its_parent_does() { + let s = "S3Storage:u/a/s3"; + let keys = ForkStorageKeys::from_chain(&[ + link("wm-fork-c", s, false), + link("dev", s, true), + link("prod", s, false), + ]); + let dev = fork_area("dev"); + assert_eq!(keys.write_key("x").unwrap(), format!("{dev}x")); + assert_eq!( + keys.read_keys("x").unwrap(), + vec![format!("{dev}x"), "x".into()] + ); + } + + #[test] + fn reads_stop_at_an_ancestor_on_another_storage() { + let keys = ForkStorageKeys::from_chain(&[ + link("wm-fork-d", "S3Storage:u/a/other", true), + link("prod", "S3Storage:u/a/s3", false), + ]); + let d = fork_area("wm-fork-d"); + assert_eq!(keys.read_keys("x").unwrap(), vec![format!("{d}x")]); + } + + #[test] + fn dot_segments_cannot_climb_out_of_the_fork_area() { + let keys = ForkStorageKeys::from_chain(&[ + link("wm-fork-a", "S3Storage:u/a/s3", true), + link("prod", "S3Storage:u/a/s3", false), + ]); + let area = fork_area("wm-fork-a"); + assert!(keys.write_key("../../prod.csv").is_err()); + assert!(keys.write_key(&format!("{area}lake/../../x")).is_err()); + assert!(keys.read_keys("a/./b").is_err()); + assert!(keys.list("..").is_err()); + assert_eq!( + keys.write_key("a..b/c.d").unwrap(), + format!("{area}a..b/c.d") + ); + } + + #[test] + fn fork_area_match_is_on_whole_segments() { + assert!(in_fork_area("__wm_forks/a")); + assert!(!in_fork_area("__wm_forks_backup/a")); + let keys = ForkStorageKeys::from_chain(&[link("prod", "S3Storage:u/a/s3", false)]); + assert_eq!( + keys.write_key("__wm_forks_backup/a").unwrap(), + "__wm_forks_backup/a" + ); + } +} diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 2bc303f5c2..ce33a8aefe 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -10,6 +10,10 @@ pub const FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING: &str = "fork_workspace_tag_append_fork_suffix"; pub const PREVIEW_TAGS_OVERRIDE_SETTING: &str = "preview_tags_override"; pub const DEPENDENCY_JOB_TAG_SETTING: &str = "dependency_job_tag"; +pub const DEV_WORKSPACE_ISOLATION_SETTING: &str = "dev_workspace_isolation"; +pub const DEV_WORKSPACE_TAG_SUFFIX_SETTING: &str = "dev_workspace_tag_suffix"; +pub const DEV_WORKSPACE_ISOLATION_SKIP_NON_DEV_FORKS_SETTING: &str = + "dev_workspace_isolation_skip_non_dev_forks"; pub const BASE_URL_SETTING: &str = "base_url"; pub const WS_BASE_URL_SETTING: &str = "ws_base_url"; pub const OAUTH_SETTING: &str = "oauths"; @@ -64,6 +68,7 @@ pub const EXTERNAL_INSTANCE_PG_SETTING: &str = "external_instance_pg"; pub const INSTANCE_PG_DISABLED_SETTING: &str = "instance_pg_disabled"; pub const EXTERNAL_INSTANCE_PG_STATE_SETTING: &str = "external_instance_pg_state"; pub const TIMEOUT_WAIT_RESULT_SETTING: &str = "timeout_wait_result"; +pub const AGENT_WORKLOAD_IDENTITY_ISSUERS_SETTING: &str = "agent_workload_identity_issuers"; pub const UNIQUE_ID_SETTING: &str = "uid"; pub const DISABLE_STATS_SETTING: &str = "disable_stats"; @@ -315,6 +320,21 @@ pub fn validate_accent_color(value: &serde_json::Value) -> Result<(), String> { Ok(()) } +/// Validate a non-empty [`DEV_WORKSPACE_TAG_SUFFIX_SETTING`] value. Every writer must call +/// it: a suffix that fails it is ignored at load, so the workers would listen on a suffix +/// the setting does not show. +pub fn validate_dev_workspace_tag_suffix(value: &serde_json::Value) -> Result<(), String> { + let s = value + .as_str() + .ok_or_else(|| "must be a string".to_string())?; + if !crate::worker::is_valid_dev_workspace_tag_suffix(s.trim()) { + return Err( + "may only contain letters, digits, '-', '_' and '.', up to 32 characters".to_string(), + ); + } + Ok(()) +} + /// The settings every signed-in browser reads on each full page load. #[derive(serde::Serialize, Clone, Debug, Default, PartialEq)] pub struct InstanceUi { @@ -412,6 +432,9 @@ pub const WORKSPACE_REGISTRIES_SETTING: &str = "workspace_registries"; pub const RESTART_COORDINATION_SETTING: &str = "_restart_coordination"; pub const ALERT_CONFIG_SETTING: &str = "alert_job_queue_waiting"; +/// The instance's workspace roles (EE), a list of `crate::roles::RoleDefinition`. +pub const WORKSPACE_ROLES_SETTING: &str = "workspace_roles"; + // Workspace fairness: cloud-only mechanism that caps any single workspace at // `workspace_fairness_max_percent`% of the shared worker pool once it has been // occupying it for more than `workspace_fairness_duration_secs` seconds. See @@ -620,6 +643,7 @@ pub fn parse_allowed_origins_setting( pub const ENV_SETTINGS: &[&str] = &[ "DISABLE_NSJAIL", + "DISABLE_BUN_INSTALL_SANDBOX", "MODE", "NUM_WORKERS", "METRICS_ADDR", @@ -924,6 +948,7 @@ pub fn workspace_integration_oauth_key(client_name: &str) -> &str { pub fn workspace_integration_token_endpoint(client_name: &str, base_url: &str) -> String { match client_name { "google" => "https://oauth2.googleapis.com/token".to_string(), + "github" => "https://github.com/login/oauth/access_token".to_string(), _ => format!("{}/apps/oauth2/api/v1/token", base_url), } } @@ -932,6 +957,7 @@ pub fn workspace_integration_token_endpoint(client_name: &str, base_url: &str) - pub fn workspace_integration_auth_endpoint(client_name: &str, base_url: &str) -> String { match client_name { "google" => "https://accounts.google.com/o/oauth2/v2/auth".to_string(), + "github" => "https://github.com/login/oauth/authorize".to_string(), _ => format!("{}/apps/oauth2/authorize", base_url), } } diff --git a/backend/windmill-common/src/guest_jwt.rs b/backend/windmill-common/src/guest_jwt.rs index dc0407163d..aa308e7c9e 100644 --- a/backend/windmill-common/src/guest_jwt.rs +++ b/backend/windmill-common/src/guest_jwt.rs @@ -328,7 +328,6 @@ pub const MAX_GUEST_JWT_LEN: usize = 8 * 1024; /// self-hosted internal (http/private) issuer that works for `jwt_ext_` works for guests too. /// The body is read with a cap so a hostile endpoint cannot exhaust memory. pub async fn fetch_jwks(url: &str) -> Result<HashMap<String, Jwk>> { - use futures::StreamExt; if url.len() > MAX_JWKS_URL_LEN { return Err(Error::BadRequest(format!( "JWKS URL is longer than {MAX_JWKS_URL_LEN} bytes" @@ -356,6 +355,13 @@ pub async fn fetch_jwks(url: &str) -> Result<HashMap<String, Jwk>> { } .and_then(|r| r.error_for_status()) .map_err(|e| Error::BadRequest(format!("could not fetch JWKS: {e}")))?; + parse_jwks_keys(&read_capped_body(resp).await?) +} + +/// A JWKS (or OIDC discovery) response body, refused past `JWKS_MAX_BYTES` rather than +/// buffered: the server behind a configured URL may be hostile. +pub(crate) async fn read_capped_body(resp: reqwest::Response) -> Result<Vec<u8>> { + use futures::StreamExt; let mut stream = resp.bytes_stream(); let mut body: Vec<u8> = Vec::new(); while let Some(chunk) = stream.next().await { @@ -367,7 +373,7 @@ pub async fn fetch_jwks(url: &str) -> Result<HashMap<String, Jwk>> { } body.extend_from_slice(&chunk); } - parse_jwks_keys(&body) + Ok(body) } /// The usable signing keys in a JWKS body, by `kid`. Each key is parsed on its own and @@ -378,7 +384,7 @@ pub async fn fetch_jwks(url: &str) -> Result<HashMap<String, Jwk>> { /// A key is kept only if its material actually decodes (`DecodingKey::from_jwk`): jsonwebtoken /// carries `n`/`e`/`x`/`y` as strings and defers decoding to auth time, so without this a JWKS /// whose only key is malformed would be accepted at save time and fail every token later. -fn parse_jwks_keys(body: &[u8]) -> Result<HashMap<String, Jwk>> { +pub(crate) fn parse_jwks_keys(body: &[u8]) -> Result<HashMap<String, Jwk>> { let set: serde_json::Value = serde_json::from_slice(body) .map_err(|e| Error::BadRequest(format!("JWKS is not JSON: {e}")))?; let entries = set @@ -422,13 +428,75 @@ fn parse_jwks_keys(body: &[u8]) -> Result<HashMap<String, Jwk>> { fn servable(entry: Arc<JwksEntry>) -> Result<Arc<JwksEntry>> { if entry.keys.is_empty() { Err(Error::NotAuthorized( - "guest JWT refused: the JWKS issuer is unreachable".to_string(), + "the JWKS issuer is unreachable".to_string(), )) } else { Ok(entry) } } +/// Where a cached JWKS comes from. Both kinds share the cache, its single-flight refresh and +/// its staleness bounds; they differ only in how a fetch reaches the issuer. +#[derive(Clone)] +pub enum JwksSource { + /// A workspace guest key URL: SSRF-restricted unless it is the instance issuer + /// (`fetch_jwks`). + GuestUrl(String), + /// An issuer a superadmin trusts for agent workload identity. Trusted like + /// `JWT_EXT_JWKS_URL`, so fetched with the permissive client: an in-cluster or + /// self-signed issuer has to work. Without `jwks_uri`, OIDC discovery finds it. + TrustedIssuer { issuer: String, jwks_uri: Option<String> }, +} + +impl JwksSource { + fn cache_key(&self) -> String { + match self { + JwksSource::GuestUrl(url) => url.clone(), + JwksSource::TrustedIssuer { issuer, jwks_uri } => { + format!("issuer:{issuer}\n{}", jwks_uri.as_deref().unwrap_or("")) + } + } + } + + async fn fetch(&self) -> Result<HashMap<String, Jwk>> { + match self { + JwksSource::GuestUrl(url) => fetch_jwks(url).await, + JwksSource::TrustedIssuer { issuer, jwks_uri } => { + let jwks_uri = match jwks_uri.as_deref().filter(|u| !u.trim().is_empty()) { + Some(uri) => uri.to_string(), + None => { + let discovery = format!( + "{}/.well-known/openid-configuration", + issuer.trim_end_matches('/') + ); + let doc: serde_json::Value = + serde_json::from_slice(&get_trusted(&discovery).await?).map_err( + |e| Error::BadRequest(format!("OIDC discovery is not JSON: {e}")), + )?; + doc.get("jwks_uri") + .and_then(|u| u.as_str()) + .ok_or_else(|| { + Error::BadRequest("OIDC discovery has no jwks_uri".to_string()) + })? + .to_string() + } + }; + parse_jwks_keys(&get_trusted(&jwks_uri).await?) + } + } + } +} + +async fn get_trusted(url: &str) -> Result<Vec<u8>> { + let resp = crate::utils::HTTP_CLIENT_PERMISSIVE + .get(url) + .send() + .await + .and_then(|r| r.error_for_status()) + .map_err(|e| Error::BadRequest(format!("fetching {url}: {e}")))?; + read_capped_body(resp).await +} + /// A held single-flight lock for one JWKS URL. Dropping it removes the URL from /// `JWKS_FETCH_LOCKS` once no other holder remains, so the registry never keeps a lock past /// its fetch and stays bounded by the number of fetches in flight, not by URLs ever seen. @@ -468,13 +536,14 @@ impl Drop for JwksFetchLock { /// Refresh a URL's JWKS off the request path, under the single-flight lock. A held /// lock means a refresh is already running, so this is a no-op. A failed refresh /// leaves the served stale keys in place rather than dropping them. -fn spawn_jwks_refresh(url: String) { +fn spawn_jwks_refresh(source: JwksSource) { tokio::spawn(async move { + let url = source.cache_key(); let fetch_lock = JwksFetchLock::acquire(&url); let Ok(_guard) = fetch_lock.lock.try_lock() else { return; }; - match fetch_jwks(&url).await { + match source.fetch().await { Ok(keys) => { let now = Instant::now(); JWKS_CACHE.insert( @@ -486,7 +555,7 @@ fn spawn_jwks_refresh(url: String) { }), ); } - Err(e) => tracing::warn!("guest JWKS background refresh failed for {url}: {e:#}"), + Err(e) => tracing::warn!("JWKS background refresh failed for {url}: {e:#}"), } }); } @@ -498,7 +567,9 @@ fn spawn_jwks_refresh(url: String) { /// entry that reads as "issuer unreachable", so an unreachable issuer is hit at most /// once per `JWKS_NEGATIVE_TTL`. Fetches follow a schedule, never a per-request, /// attacker-chosen `kid`. -async fn cached_jwks(url: &str) -> Result<Arc<JwksEntry>> { +async fn cached_jwks(source: &JwksSource) -> Result<Arc<JwksEntry>> { + let key = source.cache_key(); + let url = key.as_str(); if let Some(entry) = JWKS_CACHE.get(url) { // Keys past JWKS_MAX_STALE are never served, even while revalidating: a stale re-serve // bumps expires_at but keeps fetched_at, so a persistently failing refresh would @@ -519,7 +590,7 @@ async fn cached_jwks(url: &str) -> Result<Arc<JwksEntry>> { fetched_at: entry.fetched_at, }); JWKS_CACHE.insert(url.to_string(), served.clone()); - spawn_jwks_refresh(url.to_string()); + spawn_jwks_refresh(source.clone()); return Ok(served); } } @@ -535,7 +606,7 @@ async fn cached_jwks(url: &str) -> Result<Arc<JwksEntry>> { return servable(entry); } } - match fetch_jwks(url).await { + match source.fetch().await { Ok(keys) => { let now = Instant::now(); let entry = Arc::new(JwksEntry { @@ -573,7 +644,12 @@ pub async fn jwks_key_for(url: &str, token: &str) -> Result<(DecodingKey, Vec<Al let kid = header.kid.ok_or_else(|| { Error::NotAuthorized("guest JWT refused: no kid in the header".to_string()) })?; - let entry = cached_jwks(url).await?; + let entry = cached_jwks(&JwksSource::GuestUrl(url.to_string())) + .await + .map_err(|e| match e { + Error::NotAuthorized(m) => Error::NotAuthorized(format!("guest JWT refused: {m}")), + e => e, + })?; let jwk = entry.keys.get(&kid).ok_or_else(|| { Error::NotAuthorized(format!("guest JWT refused: kid {kid} is not in the JWKS")) })?; @@ -585,6 +661,12 @@ pub async fn jwks_key_for(url: &str, token: &str) -> Result<(DecodingKey, Vec<Al Ok((key, algs)) } +/// The keys `source` currently serves, through the shared cache. An unknown `kid` is refused +/// against them by the caller, never refetched, for the reason `jwks_key_for` gives. +pub async fn cached_jwks_keys(source: &JwksSource) -> Result<Arc<HashMap<String, Jwk>>> { + Ok(cached_jwks(source).await?.keys.clone()) +} + /// Verify `token` for `w_id` against whatever key the workspace configured. A PEM key /// ignores `kid`; a JWKS selects by it. pub async fn verify_for_workspace(db: &DB, w_id: &str, token: &str) -> Result<GuestJwtClaims> { @@ -950,7 +1032,9 @@ y9rTR828ADcaZ63Ej1oL4GcqmGhODxCLy1YKKcy0FHzChqPMV6g=\n\ for _ in 0..10 { let u = url.clone(); handles.push(tokio::spawn(async move { - cached_jwks(&u).await.map(|e| e.keys.len()) + cached_jwks(&JwksSource::GuestUrl(u)) + .await + .map(|e| e.keys.len()) })); } for h in handles { @@ -1013,7 +1097,7 @@ y9rTR828ADcaZ63Ej1oL4GcqmGhODxCLy1YKKcy0FHzChqPMV6g=\n\ }), ); assert!( - cached_jwks(&within).await.is_ok(), + cached_jwks(&JwksSource::GuestUrl(within)).await.is_ok(), "stale keys within the grace window are still served" ); @@ -1030,7 +1114,7 @@ y9rTR828ADcaZ63Ej1oL4GcqmGhODxCLy1YKKcy0FHzChqPMV6g=\n\ }), ); assert!( - cached_jwks(&beyond).await.is_err(), + cached_jwks(&JwksSource::GuestUrl(beyond)).await.is_err(), "keys past the grace window fail closed once the refresh fails" ); unsafe { std::env::remove_var("ALLOW_PRIVATE_GUEST_JWKS_URLS") }; diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index d19de41dcc..9fe43e0fe0 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -263,6 +263,10 @@ pub struct GlobalSettings { #[serde(skip_serializing_if = "Option::is_none")] pub default_tags_per_workspace: Option<bool>, #[serde(skip_serializing_if = "Option::is_none")] + pub dev_workspace_isolation: Option<bool>, + #[serde(skip_serializing_if = "Option::is_none")] + pub dev_workspace_isolation_skip_non_dev_forks: Option<bool>, + #[serde(skip_serializing_if = "Option::is_none")] pub preview_tags_override: Option<bool>, #[serde(skip_serializing_if = "Option::is_none")] pub disable_hub: Option<bool>, @@ -279,6 +283,8 @@ pub struct GlobalSettings { #[serde(skip_serializing_if = "Option::is_none")] pub dependency_job_tag: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] + pub dev_workspace_tag_suffix: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] pub ws_base_url: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub github_app_webhook_base_url: Option<String>, @@ -362,6 +368,8 @@ pub struct GlobalSettings { pub custom_instance_pg_databases: Option<CustomInstancePgDatabases>, #[serde(skip_serializing_if = "Option::is_none")] pub external_instance_pg: Option<ExternalInstancePg>, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_workload_identity_issuers: Option<Vec<AgentWorkloadIdentityIssuer>>, // Opaque settings (EE-private structs or no clear schema) #[serde(skip_serializing_if = "Option::is_none")] @@ -732,6 +740,42 @@ pub struct TeamsChannel { pub channel_name: String, } +// --------------------------------------------------------------------------- +// Agent worker workload identity +// --------------------------------------------------------------------------- + +/// An issuer whose workload identity tokens (Kubernetes projected service account tokens, +/// SPIFFE JWT-SVIDs, ...) an agent worker may authenticate with instead of an agent token. +#[derive(Deserialize, Serialize, Clone, Debug, Default, PartialEq)] +#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))] +pub struct AgentWorkloadIdentityIssuer { + /// Must equal the token's `iss` exactly. + pub issuer: String, + /// Where the signing keys are published. Defaults to the `jwks_uri` of the issuer's OIDC + /// discovery document. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub jwks_uri: Option<String>, + /// The JWKS document itself, as JSON text, for an issuer this instance cannot reach. + /// Takes precedence over `jwks_uri`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub jwks: Option<String>, + /// Must be one of the token's `aud`. + pub audience: String, + /// Tried in order; the first whose claims all match decides the worker group and tags. + #[serde(default)] + pub rules: Vec<AgentWorkloadIdentityRule>, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default, PartialEq)] +#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))] +pub struct AgentWorkloadIdentityRule { + /// Claim path (dot separated, a key that itself contains dots such as `kubernetes.io` + /// is matched whole) to the exact value it must hold. + pub claims: BTreeMap<String, String>, + pub worker_group: String, + pub tags: Vec<String>, +} + // --------------------------------------------------------------------------- // DB oversize alert // --------------------------------------------------------------------------- @@ -962,6 +1006,8 @@ pub struct WorkerGroupConfig { #[serde(skip_serializing_if = "Option::is_none")] pub env_vars_allowlist: Option<Vec<String>>, #[serde(skip_serializing_if = "Option::is_none")] + pub disable_job_proxy: Option<bool>, + #[serde(skip_serializing_if = "Option::is_none")] pub min_alive_workers_alert_threshold: Option<u32>, #[serde(skip_serializing_if = "Option::is_none")] pub autoscaling: Option<AutoscalingConfig>, @@ -970,6 +1016,9 @@ pub struct WorkerGroupConfig { /// Workers of the group pull no new job while this is set; queued jobs wait. #[serde(skip_serializing_if = "Option::is_none")] pub paused: Option<bool>, + /// The group listens to its tags suffixed with the dev workspace tag suffix, and only to them. + #[serde(skip_serializing_if = "Option::is_none")] + pub serves_dev_workspaces: Option<bool>, /// Object store this group's dependency cache uses instead of the instance one. Same shape /// as the instance `object_store_cache_config`. #[serde(skip_serializing_if = "Option::is_none")] @@ -1184,15 +1233,17 @@ fn mask_nested_sensitive(key: &str, value: &serde_json::Value) -> serde_json::Va } } // Settings that are maps-of-objects where each child has a sensitive sub-field. - const NESTED_MAP_SENSITIVE: &[(&str, &str)] = &[("oauths", "secret")]; - for &(parent_key, child_field) in NESTED_MAP_SENSITIVE { + const NESTED_MAP_SENSITIVE: &[(&str, &[&str])] = &[("oauths", &["secret", "signing_secret"])]; + for &(parent_key, child_fields) in NESTED_MAP_SENSITIVE { if key == parent_key { if let serde_json::Value::Object(entries) = value { let mut masked = entries.clone(); for (_entry_key, entry_val) in masked.iter_mut() { if let serde_json::Value::Object(ref mut obj) = entry_val { - if let Some(v) = obj.get(child_field) { - obj.insert(child_field.to_string(), redact_json_value(v)); + for &child_field in child_fields { + if let Some(v) = obj.get(child_field) { + obj.insert(child_field.to_string(), redact_json_value(v)); + } } } } @@ -1499,6 +1550,21 @@ pub async fn sync_global_settings_declarative( .map_err(|e| anyhow::anyhow!("{accent_key}: {e}"))?, } + let issuers_key = crate::global_settings::AGENT_WORKLOAD_IDENTITY_ISSUERS_SETTING; + match desired.get(issuers_key) { + None | Some(serde_json::Value::Null) => {} + Some(issuers) => crate::agent_workload_identity::validate_issuers_value(issuers) + .map_err(|e| anyhow::anyhow!("{issuers_key}: {e}"))?, + } + + let suffix_key = crate::global_settings::DEV_WORKSPACE_TAG_SUFFIX_SETTING; + match desired.get(suffix_key) { + None | Some(serde_json::Value::Null) => {} + Some(serde_json::Value::String(s)) if s.trim().is_empty() => {} + Some(suffix) => crate::global_settings::validate_dev_workspace_tag_suffix(suffix) + .map_err(|e| anyhow::anyhow!("{suffix_key}: {e}"))?, + } + // An origin list that cannot be parsed is dropped at boot, leaving the // empty default — which is no restriction at all. Rejecting it here is what // keeps a typo in a ConfigMap from silently widening CORS instance-wide. @@ -1511,9 +1577,22 @@ pub async fn sync_global_settings_declarative( .map_err(|e| anyhow::anyhow!("{max_expiration_key}: {e}"))?; let mut diff = diff_global_settings(current, desired, ApplyMode::Replace); + let roles_key = crate::global_settings::WORKSPACE_ROLES_SETTING; + let roles_written = diff.upserts.iter().any(|(k, _)| k == roles_key) + || diff.deletes.iter().any(|k| k == roles_key); + if roles_written { + crate::roles::validate_instance_roles_write(db, desired.get(roles_key)) + .await + .map_err(|e| anyhow::anyhow!("{roles_key}: {e}"))?; + } crate::external_instance_pg::write_external_instance_pg_from_diff(db, &mut diff, observer) .await?; apply_settings_diff(db, &diff, observer).await?; + if roles_written { + crate::roles::after_instance_roles_write(db) + .await + .map_err(|e| anyhow::anyhow!("{roles_key}: {e}"))?; + } Ok(()) } @@ -2837,10 +2916,12 @@ mod tests { #[test] fn format_setting_value_redacts_oauth_secrets() { let val = serde_json::json!({ - "google": {"id": "client-id", "secret": "my-super-secret-12345"} + "google": {"id": "client-id", "secret": "my-super-secret-12345"}, + "slack": {"id": "s", "secret": "s", "signing_secret": "slack-signing-secret-678"} }); let formatted = format_setting_value("oauths", &val); assert!(!formatted.contains("my-super-secret-12345")); + assert!(!formatted.contains("slack-signing-secret-678")); assert!(formatted.contains("client-id")); assert!(formatted.contains("****")); } diff --git a/backend/windmill-common/src/jobs.rs b/backend/windmill-common/src/jobs.rs index cd65603d4f..d7190ff45f 100644 --- a/backend/windmill-common/src/jobs.rs +++ b/backend/windmill-common/src/jobs.rs @@ -483,7 +483,6 @@ pub struct RunInlinePreviewScriptFnParams { pub worker_name: String, pub conn: crate::worker::Connection, pub client: AuthedClient, - pub job_dir: String, pub worker_dir: String, pub killpill_rx: tokio::sync::broadcast::Receiver<()>, } @@ -507,7 +506,6 @@ pub struct RunInlineScriptFnParams { pub worker_name: String, pub conn: crate::worker::Connection, pub client: AuthedClient, - pub job_dir: String, pub worker_dir: String, pub killpill_rx: tokio::sync::broadcast::Receiver<()>, pub user_db: Option<(crate::db::UserDB, crate::db::Authed)>, diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 58a477d7b9..1fdfe72de7 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -27,6 +27,7 @@ use scripts::ScriptLang; use sqlx::{Acquire, Postgres}; pub mod agent_workers; +pub mod agent_workload_identity; pub mod apps; pub mod assets; pub mod audit; @@ -73,10 +74,11 @@ pub use feature_usage_oss as feature_usage; pub mod flow_conversations; pub mod flow_status; pub mod flows; +pub mod fork_storage; pub mod folders; pub mod global_settings; -pub mod hub_resource_types; pub mod guest_jwt; +pub mod hub_resource_types; pub mod indexer; pub mod instance_config; pub mod job_metrics; @@ -126,6 +128,7 @@ pub mod query_builders; pub mod queue; pub mod queue_metrics; pub mod result_stream; +pub mod roles; pub mod runnable_job_stats; pub mod runnable_settings; pub mod runnables; @@ -170,6 +173,9 @@ pub const DEFAULT_SERVICE_LOG_RETENTION_SECS: i64 = 60 * 60 * 24 * 14; // 2 week pub const DEFAULT_OTEL_TRACES_RETENTION_SECS: i64 = 60 * 60 * 24 * 7; // 1 week retention period for HTTP request spans 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"]; /// 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` @@ -2333,13 +2339,13 @@ pub async fn legacy_on_behalf_of_email( /// Shared by [`ScriptHashInfo::on_behalf_of`] and [`FlowVersionInfo::on_behalf_of`]. /// -/// Reads identity data through the non-RLS pool and enforces nothing itself: it answers who a -/// row already says it runs as. Callers must have authorized `w_id` — and the row they read it +/// Reads identity tables, which carry no RLS (a user transaction reads them as the pool does), +/// and enforces nothing itself: it answers who a row already says it runs as. Callers must have authorized `w_id` — and the row they read it /// from — before dispatching a job with what it returns. -pub async fn on_behalf_of_from_permissioned_as( +pub async fn on_behalf_of_from_permissioned_as<'c>( permissioned_as: Option<&str>, w_id: &str, - db: &DB, + db: impl sqlx::PgExecutor<'c>, ) -> error::Result<Option<jobs::OnBehalfOf>> { let Some(permissioned_as) = permissioned_as else { return Ok(None); @@ -2438,6 +2444,10 @@ pub fn get_latest_deployed_hash_for_path<'e>( ScriptHash(hash), ); } else { + // Release the user-db connection first: holding it while taking a + // second one from the same pool starves the pool under a burst of + // requests for paths the caller cannot see. + drop(conn); let mut conn = db2.acquire().await?; let exists = get_latest_script_hash(&mut *conn, script_path, w_id) .await? @@ -2842,6 +2852,7 @@ where CachedFlowPath(path.to_string()), ); } else { + drop(conn); let mut conn = db.acquire().await?; let exists = get_latest_flow_version_for_path(&mut *conn, w_id, path) .await? @@ -3024,9 +3035,8 @@ async fn get_latest_flow_version_for_path<'e, E: sqlx::PgExecutor<'e>>( Ok(version) } -pub async fn get_latest_hash_for_path<'c, E: sqlx::PgExecutor<'c>>( - db: E, - db2: &DB, +pub async fn get_latest_hash_for_path( + db: &mut sqlx::PgConnection, w_id: &str, script_path: &str, require_locked: bool, @@ -3057,13 +3067,13 @@ pub async fn get_latest_hash_for_path<'c, E: sqlx::PgExecutor<'c>>( w_id, require_locked ) - .fetch_optional(db) + .fetch_optional(&mut *db) .await?; let script = utils::not_found_if_none(r_o, "script", script_path)?; let on_behalf_of = - on_behalf_of_from_permissioned_as(script.on_behalf_of.as_deref(), w_id, db2).await?; + on_behalf_of_from_permissioned_as(script.on_behalf_of.as_deref(), w_id, db).await?; Ok(( scripts::ScriptHash(script.hash), diff --git a/backend/windmill-common/src/min_version.rs b/backend/windmill-common/src/min_version.rs index 70878e3713..3719abd31c 100644 --- a/backend/windmill-common/src/min_version.rs +++ b/backend/windmill-common/src/min_version.rs @@ -30,6 +30,11 @@ pub const MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES: VC = vc(1, 822, 0, "Restricted // 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"); +// 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 +// the release this ships in. +pub const MIN_VERSION_SUPPORTS_ED25519_LICENSE: VC = vc(1, 838, 0, "Ed25519 license keys"); pub const MIN_VERSION_SUPPORTS_NODE_DEBOUNCING: VC = vc(1, 658, 0, "Flow node debouncing"); pub const MIN_VERSION_SUPPORTS_TOKEN_HASH: VC = vc(1, 659, 0, "Token hash storage"); pub const MIN_VERSION_SUPPORTS_SYNC_JOBS_DEBOUNCING: VC = vc(1, 602, 0, "Sync jobs debouncing"); diff --git a/backend/windmill-common/src/oauth2.rs b/backend/windmill-common/src/oauth2.rs index faa1f1b942..04a8a6f227 100644 --- a/backend/windmill-common/src/oauth2.rs +++ b/backend/windmill-common/src/oauth2.rs @@ -21,6 +21,15 @@ pub const GLOBAL_SLACK_BOT_TOKEN_PATH: &str = "f/slack_bot/global_bot_token"; pub const GLOBAL_TEAMS_BOT_TOKEN_PATH: &str = "f/teams_bot/global_bot_token"; pub const GLOBAL_TEAMS_API_TOKEN_PATH: &str = "f/teams_bot/global_api_token"; + +/// Key under the `oauths` setting holding the Teams Entra app (id, secret, tenant). One app +/// serves the Enterprise bot and Graph connect for the `ms_teams` resource type. +pub const TEAMS_OAUTH_KEY: &str = "ms_teams"; + +/// Pre-rename key for the same entry. The 20261016120000 migration copies it to `ms_teams` +/// and keeps it so servers still on the old version work during a rolling upgrade. +/// TODO: drop this fallback once every supported version reads `ms_teams`. +pub const LEGACY_TEAMS_OAUTH_KEY: &str = "teams"; lazy_static::lazy_static! { pub static ref REQUIRE_PREEXISTING_USER_FOR_OAUTH: AtomicBool = AtomicBool::new(std::env::var("REQUIRE_PREEXISTING_USER_FOR_OAUTH") diff --git a/backend/windmill-common/src/roles.rs b/backend/windmill-common/src/roles.rs new file mode 100644 index 0000000000..b3fd9937f4 --- /dev/null +++ b/backend/windmill-common/src/roles.rs @@ -0,0 +1,998 @@ +//! Workspace roles: what a non-admin member may author, manage and see. +//! +//! `developer` and `operator` are the default roles. An EE instance defines its own list in the +//! [`WORKSPACE_ROLES_SETTING`] global setting (at least one role), and a workspace may override any +//! role's capabilities, or on EE define roles of its own, in `workspace_settings.role_overrides`. +//! Workspace admin is not a role: it is the `is_admin` flag on top of whichever role a member has, +//! and holds every capability. See `docs/workspace-roles.md`. +//! +//! A member's role is `usr.workspace_role`, or, while that is NULL, `operator` / `developer` from the +//! legacy `usr.operator` flag. `ApiAuthed::is_operator` means "this member's role is restricted", +//! computed at authentication from the role, never from `usr.operator` alone: every gate fast-paths +//! on it, so a stale flag would let a restricted member through. + +use std::{collections::HashMap, sync::Arc}; + +use quick_cache::sync::Cache; +use serde::{Deserialize, Serialize}; + +use crate::{ + db::Authable, + error::{Error, Result}, + global_settings::WORKSPACE_ROLES_SETTING, + DB, +}; + +pub const DEVELOPER_ROLE: &str = "developer"; +pub const OPERATOR_ROLE: &str = "operator"; + +/// How much of a kind of runnable a role may author. +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub enum AuthorLevel { + #[default] + None, + /// Assemble already-deployed runnables, with no code of one's own. A composed flow is checked + /// in full server-side; a composed full-code app is forced into sandbox isolation. See + /// `docs/operator-builder-rights.md`. + Compose, + Full, +} + +/// Pages a role sees. Not enforced: hiding a page never stopped an API call, and the enforced +/// rights are the other fields of [`RoleCapabilities`]. +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] +pub struct RoleVisibility { + pub runs: bool, + pub schedules: bool, + pub resources: bool, + pub variables: bool, + pub assets: bool, + pub triggers: bool, + pub audit_logs: bool, + pub groups: bool, + pub folders: bool, + pub workers: bool, +} + +impl RoleVisibility { + pub const ALL: Self = Self { + runs: true, + schedules: true, + resources: true, + variables: true, + assets: true, + triggers: true, + audit_logs: true, + groups: true, + folders: true, + workers: true, + }; + pub const NONE: Self = Self { + runs: false, + schedules: false, + resources: false, + variables: false, + assets: false, + triggers: false, + audit_logs: false, + groups: false, + folders: false, + workers: false, + }; +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] +pub struct RoleCapabilities { + /// Author scripts and run code of one's own on a worker: script previews, dependency jobs, + /// dynamic selects, resolving a job's code, data table migrations. `Full` flows or apps + /// require it, since both carry inline code. + pub code: bool, + pub flows: AuthorLevel, + pub apps: AuthorLevel, + pub manage_schedules: bool, + pub manage_triggers: bool, + pub visibility: RoleVisibility, +} + +impl RoleCapabilities { + pub const FULL: Self = Self { + code: true, + flows: AuthorLevel::Full, + apps: AuthorLevel::Full, + manage_schedules: true, + manage_triggers: true, + visibility: RoleVisibility::ALL, + }; + + /// The `operator` role as it stood before roles were configurable, and the base any other + /// role's unset keys fall back to. + pub const OPERATOR: Self = Self { + code: false, + flows: AuthorLevel::None, + apps: AuthorLevel::None, + manage_schedules: true, + manage_triggers: true, + visibility: RoleVisibility::ALL, + }; + + /// For a restricted caller whose role cannot be resolved: a guest, a removed member's job, a + /// role name no longer defined. + pub const NONE: Self = Self { + code: false, + flows: AuthorLevel::None, + apps: AuthorLevel::None, + manage_schedules: false, + manage_triggers: false, + visibility: RoleVisibility::NONE, + }; + + /// What a role named `name` holds before any configuration applies. + pub fn base_for(name: &str) -> Self { + if name == DEVELOPER_ROLE { + Self::FULL + } else { + Self::OPERATOR + } + } + + /// Whether a member of this role is gated at all. Every gate skips an unrestricted caller + /// without reading its role, which is what `ApiAuthed::is_operator` carries. Visibility is + /// left out: it only hides pages, and must not turn a full author into an operator for the + /// raw `is_operator` checks, job permissions and credential caches keyed on it. + pub fn is_restricted(&self) -> bool { + Self { visibility: RoleVisibility::ALL, ..*self } != Self::FULL + } + + /// Whether the role authors deployable artifacts and so costs a full seat rather than half. + /// Composing counts: there is no half-author. + pub fn is_author(&self) -> bool { + self.code || self.flows != AuthorLevel::None || self.apps != AuthorLevel::None + } + + pub fn has_manage(&self, kind: crate::workspaces::ManageKind) -> bool { + match kind { + crate::workspaces::ManageKind::Schedules => self.manage_schedules, + crate::workspaces::ManageKind::Triggers => self.manage_triggers, + } + } + + pub fn level(&self, kind: crate::workspaces::BuilderKind) -> AuthorLevel { + match kind { + crate::workspaces::BuilderKind::Flows => self.flows, + crate::workspaces::BuilderKind::Apps => self.apps, + } + } + + fn apply(&mut self, patch: &RoleCapabilitiesPatch) { + if let Some(v) = patch.code { + self.code = v; + } + if let Some(v) = patch.flows { + self.flows = v; + } + if let Some(v) = patch.apps { + self.apps = v; + } + if let Some(v) = patch.manage_schedules { + self.manage_schedules = v; + } + if let Some(v) = patch.manage_triggers { + self.manage_triggers = v; + } + if let Some(v) = &patch.visibility { + v.apply(&mut self.visibility); + } + } + + /// A `Full` flow or app carries inline code, so granting one without `code` would be a way + /// around refusing `code`. + pub fn validate(&self, role: &str) -> Result<()> { + if !self.code && (self.flows == AuthorLevel::Full || self.apps == AuthorLevel::Full) { + return Err(Error::BadRequest(format!( + "role `{role}`: full flow or app authoring requires the code capability" + ))); + } + Ok(()) + } +} + +/// A partial [`RoleCapabilities`]: an unset key falls through to the layer below (workspace +/// override, then instance definition, then [`RoleCapabilities::base_for`]). Stored settings are +/// patches rather than full values so that a key added later reads as "never configured". +#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)] +pub struct RoleCapabilitiesPatch { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub code: Option<bool>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flows: Option<AuthorLevel>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub apps: Option<AuthorLevel>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub manage_schedules: Option<bool>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub manage_triggers: Option<bool>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub visibility: Option<RoleVisibilityPatch>, +} + +#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)] +pub struct RoleVisibilityPatch { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runs: Option<bool>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub schedules: Option<bool>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resources: Option<bool>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub variables: Option<bool>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub assets: Option<bool>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub triggers: Option<bool>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub audit_logs: Option<bool>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub groups: Option<bool>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub folders: Option<bool>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workers: Option<bool>, +} + +impl RoleVisibilityPatch { + fn apply(&self, v: &mut RoleVisibility) { + macro_rules! set { + ($($f:ident),*) => { $( if let Some(x) = self.$f { v.$f = x; } )* }; + } + set!( + runs, schedules, resources, variables, assets, triggers, audit_logs, groups, folders, + workers + ); + } +} + +/// The smallest patch that turns `base` into `target`: only the keys that differ. +pub fn diff_capabilities( + base: &RoleCapabilities, + target: &RoleCapabilities, +) -> RoleCapabilitiesPatch { + fn d<T: PartialEq + Copy>(b: T, t: T) -> Option<T> { + (b != t).then_some(t) + } + let (b, t) = (&base.visibility, &target.visibility); + let visibility = RoleVisibilityPatch { + runs: d(b.runs, t.runs), + schedules: d(b.schedules, t.schedules), + resources: d(b.resources, t.resources), + variables: d(b.variables, t.variables), + assets: d(b.assets, t.assets), + triggers: d(b.triggers, t.triggers), + audit_logs: d(b.audit_logs, t.audit_logs), + groups: d(b.groups, t.groups), + folders: d(b.folders, t.folders), + workers: d(b.workers, t.workers), + }; + RoleCapabilitiesPatch { + code: d(base.code, target.code), + flows: d(base.flows, target.flows), + apps: d(base.apps, target.apps), + manage_schedules: d(base.manage_schedules, target.manage_schedules), + manage_triggers: d(base.manage_triggers, target.manage_triggers), + visibility: (visibility != RoleVisibilityPatch::default()).then_some(visibility), + } +} + +/// One entry of the instance's [`WORKSPACE_ROLES_SETTING`]. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct RoleDefinition { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option<String>, + #[serde(default)] + pub capabilities: RoleCapabilitiesPatch, +} + +/// One entry of `workspace_settings.role_overrides`, keyed by role name. Naming a role the +/// instance does not define adds a workspace-only role. +#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)] +pub struct RoleOverride { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option<String>, + #[serde(default)] + pub capabilities: RoleCapabilitiesPatch, +} + +pub type RoleOverrides = HashMap<String, RoleOverride>; + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RoleSource { + Instance, + Workspace, +} + +/// A role as it applies in one workspace. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct EffectiveRole { + pub name: String, + pub label: String, + pub capabilities: RoleCapabilities, + pub source: RoleSource, + /// Whether the workspace overrides this role. + pub overridden: bool, +} + +/// Whether stored instance roles and workspace-only roles apply: on any EE build. A CE build has +/// `developer` and `operator`, which a workspace may still override. +fn custom_roles_built() -> bool { + cfg!(feature = "enterprise") +} + +/// Whether instance roles and workspace-only roles may be changed: an EE build whose license +/// verified. A lapsed license freezes them instead of dropping them, since falling back to the +/// defaults would hand a restricted `developer` everything. The `cfg` is load-bearing: CE's +/// `LICENSE_KEY_VALID` is always `true`. +pub fn custom_roles_enabled() -> bool { + #[cfg(feature = "enterprise")] + { + crate::ee_oss::LICENSE_KEY_VALID.load(std::sync::atomic::Ordering::Relaxed) + } + #[cfg(not(feature = "enterprise"))] + { + false + } +} + +pub fn default_instance_roles() -> Vec<RoleDefinition> { + vec![ + RoleDefinition { + name: DEVELOPER_ROLE.to_string(), + label: Some("Developer".to_string()), + capabilities: RoleCapabilitiesPatch::default(), + }, + RoleDefinition { + name: OPERATOR_ROLE.to_string(), + label: Some("Operator".to_string()), + capabilities: RoleCapabilitiesPatch::default(), + }, + ] +} + +/// Role names appear in `u/`-free contexts only (settings, group mappings, the `usr` row), but are +/// also shown as-is and compared verbatim, so they are kept to a slug. +pub fn validate_role_name(name: &str) -> Result<()> { + let ok = !name.is_empty() + && name.len() <= 50 + && name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') + && name != "admin"; + if ok { + Ok(()) + } else { + Err(Error::BadRequest(format!( + "invalid role name `{name}`: use 1-50 lowercase letters, digits, `_` or `-`, and not `admin`" + ))) + } +} + +/// Parses and validates a [`WORKSPACE_ROLES_SETTING`] value: at least one role, unique valid +/// names, capabilities that pass [`RoleCapabilities::validate`]. +pub fn parse_instance_roles(value: &serde_json::Value) -> Result<Vec<RoleDefinition>> { + let roles: Vec<RoleDefinition> = serde_json::from_value(value.clone()) + .map_err(|e| Error::BadRequest(format!("invalid workspace roles: {e}")))?; + if roles.is_empty() { + return Err(Error::BadRequest( + "at least one workspace role must be defined".to_string(), + )); + } + let mut seen = std::collections::HashSet::new(); + for r in &roles { + validate_role_name(&r.name)?; + if !seen.insert(r.name.as_str()) { + return Err(Error::BadRequest(format!("duplicate role `{}`", r.name))); + } + let mut caps = RoleCapabilities::base_for(&r.name); + caps.apply(&r.capabilities); + caps.validate(&r.name)?; + } + Ok(roles) +} + +/// The workspace's roles: the instance's, in their order and with any override applied, then the +/// workspace-only ones by name. Order is precedence where one is needed (instance group mapping). +pub fn effective_roles( + instance: &[RoleDefinition], + overrides: &RoleOverrides, +) -> Result<Vec<EffectiveRole>> { + let mut out = Vec::with_capacity(instance.len() + overrides.len()); + for def in instance { + let mut caps = RoleCapabilities::base_for(&def.name); + caps.apply(&def.capabilities); + let ovr = overrides.get(&def.name); + if let Some(o) = ovr { + caps.apply(&o.capabilities); + } + out.push(EffectiveRole { + label: ovr + .and_then(|o| o.label.clone()) + .or_else(|| def.label.clone()) + .unwrap_or_else(|| def.name.clone()), + name: def.name.clone(), + capabilities: caps, + source: RoleSource::Instance, + overridden: ovr.is_some(), + }); + } + if custom_roles_built() { + let mut extra: Vec<_> = overrides + .iter() + .filter(|(n, _)| !instance.iter().any(|d| &d.name == *n)) + .collect(); + extra.sort_by(|a, b| a.0.cmp(b.0)); + for (name, o) in extra { + let mut caps = RoleCapabilities::base_for(name); + caps.apply(&o.capabilities); + out.push(EffectiveRole { + name: name.clone(), + label: o.label.clone().unwrap_or_else(|| name.clone()), + capabilities: caps, + source: RoleSource::Workspace, + overridden: true, + }); + } + } + Ok(out) +} + +/// Validates a workspace's `role_overrides` against the instance roles. +pub fn validate_overrides(instance: &[RoleDefinition], overrides: &RoleOverrides) -> Result<()> { + for name in overrides.keys() { + validate_role_name(name)?; + if !custom_roles_built() && !instance.iter().any(|d| &d.name == name) { + return Err(Error::BadRequest(format!( + "role `{name}` is not defined: adding workspace roles requires Windmill Enterprise Edition" + ))); + } + } + for r in effective_roles(instance, overrides)? { + r.capabilities.validate(&r.name)?; + } + Ok(()) +} + +/// Refuses, while [`custom_roles_enabled`] is false, a `role_overrides` write that adds, edits or +/// relabels a workspace-only role. Removing one, and overriding instance roles, stay allowed. +pub fn check_frozen_overrides( + instance: &[RoleDefinition], + previous: &RoleOverrides, + next: &RoleOverrides, +) -> Result<()> { + if custom_roles_enabled() { + return Ok(()); + } + for (name, o) in next { + if !instance.iter().any(|d| &d.name == name) && previous.get(name) != Some(o) { + return Err(Error::BadRequest(format!( + "changing workspace role `{name}` requires a valid Windmill Enterprise Edition license" + ))); + } + } + Ok(()) +} + +/// The role a member gets when nothing names one (a legacy non-operator payload, an unmapped +/// instance group, an admin's underlying role): `developer` while it is defined, else the first +/// role, since an instance may drop `developer` and `set_workspace_role` refuses unknown names. +pub fn default_role_name<'a>(names: impl IntoIterator<Item = &'a str>) -> String { + let mut first = None; + for name in names { + if name == DEVELOPER_ROLE { + return DEVELOPER_ROLE.to_string(); + } + first.get_or_insert(name); + } + first.unwrap_or(DEVELOPER_ROLE).to_string() +} + +/// The role a `usr` row holds: its `workspace_role`, or the legacy one its `operator` flag names. +pub fn resolve_role_name(workspace_role: Option<String>, operator: bool) -> String { + workspace_role.unwrap_or_else(|| { + if operator { + OPERATOR_ROLE.to_string() + } else { + DEVELOPER_ROLE.to_string() + } + }) +} + +/// SQL for the role a `usr` / `workspace_invite` row aliased `t` holds; mirrors +/// [`resolve_role_name`]. +pub const ROLE_NAME_SQL: &str = + "COALESCE(t.workspace_role, CASE WHEN t.operator THEN 'operator' ELSE 'developer' END)"; + +lazy_static::lazy_static! { + static ref INSTANCE_ROLES: Cache<(), (Arc<Vec<RoleDefinition>>, i64)> = Cache::new(1); + static ref WORKSPACE_ROLES: Cache<String, (Arc<Vec<EffectiveRole>>, i64)> = Cache::new(1000); + static ref MEMBER_ROLE: Cache<(String, String), (Option<String>, i64)> = Cache::new(10000); +} + +const ROLE_CACHE_TTL_SECS: i64 = 60; + +/// The instance's role list. A CE build, or an instance that never configured one, has the +/// default roles. Cached 60s, dropped on every replica when the setting changes. +pub async fn instance_roles(db: &DB) -> Result<Arc<Vec<RoleDefinition>>> { + if let Some(roles) = cached_instance_roles() { + return Ok(roles); + } + let mut conn = db.acquire().await?; + instance_roles_on(&mut conn).await +} + +fn cached_instance_roles() -> Option<Arc<Vec<RoleDefinition>>> { + let now = chrono::Utc::now().timestamp(); + INSTANCE_ROLES + .get(&()) + .filter(|(_, expiry)| *expiry > now) + .map(|(r, _)| r) +} + +async fn instance_roles_on(conn: &mut sqlx::PgConnection) -> Result<Arc<Vec<RoleDefinition>>> { + if let Some(roles) = cached_instance_roles() { + return Ok(roles); + } + let roles = if custom_roles_built() { + let stored = sqlx::query_scalar!( + "SELECT value FROM global_settings WHERE name = $1", + WORKSPACE_ROLES_SETTING + ) + .fetch_optional(&mut *conn) + .await?; + match stored { + Some(v) => parse_instance_roles(&v).unwrap_or_else(|e| { + tracing::error!("ignoring invalid {WORKSPACE_ROLES_SETTING} setting: {e:#}"); + default_instance_roles() + }), + None => default_instance_roles(), + } + } else { + default_instance_roles() + }; + let roles = Arc::new(roles); + INSTANCE_ROLES.insert( + (), + ( + roles.clone(), + chrono::Utc::now().timestamp() + ROLE_CACHE_TTL_SECS, + ), + ); + Ok(roles) +} + +pub async fn load_role_overrides<'c, E: sqlx::PgExecutor<'c>>( + db: E, + workspace_id: &str, +) -> Result<RoleOverrides> { + let v: Option<Option<serde_json::Value>> = sqlx::query_scalar!( + "SELECT role_overrides FROM workspace_settings WHERE workspace_id = $1", + workspace_id + ) + .fetch_optional(db) + .await?; + match v.flatten() { + Some(v) => serde_json::from_value(v).map_err(|e| { + Error::internal_err(format!("invalid role overrides of {workspace_id}: {e:#}")) + }), + None => Ok(RoleOverrides::new()), + } +} + +fn cached_workspace_roles(workspace_id: &str) -> Option<Arc<Vec<EffectiveRole>>> { + let now = chrono::Utc::now().timestamp(); + WORKSPACE_ROLES + .get(workspace_id) + .filter(|(_, expiry)| *expiry > now) + .map(|(r, _)| r) +} + +/// The roles of `workspace_id`. Cached 60s; `role_overrides` writes and the instance setting drop +/// the entry on every replica. +/// +/// Call it before opening an RLS transaction: on a cache miss it takes a connection from the root +/// pool, and a second pooled connection held alongside a transaction self-deadlocks on a +/// one-connection pool. A caller that already holds a connection uses [`workspace_roles_on`]. +pub async fn workspace_roles(db: &DB, workspace_id: &str) -> Result<Arc<Vec<EffectiveRole>>> { + if let Some(roles) = cached_workspace_roles(workspace_id) { + return Ok(roles); + } + let mut conn = db.acquire().await?; + workspace_roles_on(&mut conn, workspace_id).await +} + +pub async fn workspace_roles_on( + conn: &mut sqlx::PgConnection, + workspace_id: &str, +) -> Result<Arc<Vec<EffectiveRole>>> { + if let Some(roles) = cached_workspace_roles(workspace_id) { + return Ok(roles); + } + let instance = instance_roles_on(&mut *conn).await?; + let overrides = load_role_overrides(&mut *conn, workspace_id).await?; + let roles = Arc::new(effective_roles(&instance, &overrides)?); + WORKSPACE_ROLES.insert( + workspace_id.to_string(), + ( + roles.clone(), + chrono::Utc::now().timestamp() + ROLE_CACHE_TTL_SECS, + ), + ); + Ok(roles) +} + +pub async fn find_role(db: &DB, workspace_id: &str, name: &str) -> Result<Option<EffectiveRole>> { + Ok(workspace_roles(db, workspace_id) + .await? + .iter() + .find(|r| r.name == name) + .cloned()) +} + +/// Capabilities of role `name` in `workspace_id`; [`RoleCapabilities::NONE`] for a name no longer +/// defined, so a deleted role fails closed. +pub async fn role_capabilities_by_name( + db: &DB, + workspace_id: &str, + name: &str, +) -> Result<RoleCapabilities> { + Ok(find_role(db, workspace_id, name) + .await? + .map(|r| r.capabilities) + .unwrap_or(RoleCapabilities::NONE)) +} + +/// Whether a member holding the role of this `usr` row is restricted: what authentication puts in +/// `ApiAuthed::is_operator`. Runs on the caller's connection, which authentication already holds. +pub async fn member_is_restricted( + conn: &mut sqlx::PgConnection, + workspace_id: &str, + workspace_role: Option<String>, + operator: bool, +) -> Result<bool> { + let name = resolve_role_name(workspace_role, operator); + Ok(workspace_roles_on(conn, workspace_id) + .await? + .iter() + .find(|r| r.name == name) + .map(|r| r.capabilities) + .unwrap_or(RoleCapabilities::NONE) + .is_restricted()) +} + +/// [`member_is_restricted`] for a caller holding no connection. +pub async fn member_is_restricted_db( + db: &DB, + workspace_id: &str, + workspace_role: Option<String>, + operator: bool, +) -> Result<bool> { + let name = resolve_role_name(workspace_role, operator); + Ok(role_capabilities_by_name(db, workspace_id, &name) + .await? + .is_restricted()) +} + +async fn member_role(db: &DB, workspace_id: &str, username: &str) -> Result<Option<String>> { + let now = chrono::Utc::now().timestamp(); + let key = (workspace_id.to_string(), username.to_string()); + if let Some((role, expiry)) = MEMBER_ROLE.get(&key) { + if expiry > now { + return Ok(role); + } + } + let row = sqlx::query!( + "SELECT workspace_role, operator FROM usr WHERE workspace_id = $1 AND username = $2", + workspace_id, + username + ) + .fetch_optional(db) + .await?; + let role = row.map(|r| resolve_role_name(r.workspace_role, r.operator)); + MEMBER_ROLE.insert(key, (role.clone(), now + ROLE_CACHE_TTL_SECS)); + Ok(role) +} + +/// What the caller may do in `workspace_id`. An admin, and any caller authentication did not mark +/// restricted, holds everything; a restricted caller holds its role's capabilities, or none if it +/// has no membership (a guest, a removed member's job). `workspace_id` MUST be the route's. +/// +/// Call it before opening an RLS transaction; see [`workspace_roles`]. +pub async fn caller_capabilities<A: Authable + ?Sized>( + db: &DB, + workspace_id: &str, + authed: &A, +) -> Result<RoleCapabilities> { + if authed.is_admin() || !authed.is_operator() { + return Ok(RoleCapabilities::FULL); + } + match member_role(db, workspace_id, authed.username()).await? { + Some(role) => role_capabilities_by_name(db, workspace_id, &role).await, + None => Ok(RoleCapabilities::NONE), + } +} + +pub fn invalidate_workspace_roles_cache(workspace_id: &str) { + WORKSPACE_ROLES.remove(workspace_id); +} + +pub const ROLE_OVERRIDES_CHANGE_CHANNEL: &str = "notify_role_overrides_change"; + +/// Tells every replica to drop its cached roles for the workspace. Call it in the transaction +/// that writes `role_overrides`: a replica that missed it keeps authorizing whatever the workspace +/// just withdrew until its entry expires. +pub async fn notify_role_overrides_change( + conn: &mut sqlx::PgConnection, + workspace_id: &str, +) -> Result<()> { + sqlx::query("INSERT INTO notify_event (channel, payload) VALUES ($1, $2)") + .bind(ROLE_OVERRIDES_CHANGE_CHANNEL) + .bind(workspace_id) + .execute(conn) + .await?; + Ok(()) +} + +pub fn invalidate_all_roles_cache() { + INSTANCE_ROLES.clear(); + WORKSPACE_ROLES.clear(); + MEMBER_ROLE.clear(); +} + +pub fn invalidate_member_role_cache(workspace_id: &str, username: &str) { + MEMBER_ROLE.remove(&(workspace_id.to_string(), username.to_string())); +} + +/// Refuses removing role `name` while a member, a pending invite, the domain auto-invite or an +/// instance group mapping still names it: in `w_id`, or, for an instance role (`w_id = None`), in +/// any workspace, and while any workspace still overrides it. +pub async fn ensure_role_unused(db: &DB, w_id: Option<&str>, name: &str) -> Result<()> { + let users = sqlx::query_scalar!( + "SELECT COUNT(*) FROM usr t WHERE ($1::text IS NULL OR t.workspace_id = $1) AND + COALESCE(t.workspace_role, CASE WHEN t.operator THEN 'operator' ELSE 'developer' END) = $2", + w_id, + name + ) + .fetch_one(db) + .await? + .unwrap_or(0); + let invites = sqlx::query_scalar!( + "SELECT COUNT(*) FROM workspace_invite t WHERE ($1::text IS NULL OR t.workspace_id = $1) AND + COALESCE(t.workspace_role, CASE WHEN t.operator THEN 'operator' ELSE 'developer' END) = $2", + w_id, + name + ) + .fetch_one(db) + .await? + .unwrap_or(0); + let settings = sqlx::query_scalar!( + "SELECT COUNT(*) FROM workspace_settings WHERE ($1::text IS NULL OR workspace_id = $1) AND ( + jsonb_typeof(auto_invite) = 'object' AND COALESCE(auto_invite->>'role', + CASE WHEN (auto_invite->>'operator')::boolean THEN 'operator' ELSE 'developer' END) = $2 + OR EXISTS (SELECT 1 FROM jsonb_each_text( + CASE WHEN jsonb_typeof(auto_invite->'instance_groups_roles') = 'object' + THEN auto_invite->'instance_groups_roles' ELSE '{}'::jsonb END) g + WHERE g.value = $2))", + w_id, + name + ) + .fetch_one(db) + .await? + .unwrap_or(0); + if users + invites + settings > 0 { + return Err(Error::BadRequest(format!( + "role `{name}` is still held by {users} member(s) and {invites} pending invite(s), or named by {settings} workspace auto-invite or instance group mapping(s): reassign them first" + ))); + } + // An override left behind would silently re-base onto `base_for` instead of the instance + // definition it was written against. + if w_id.is_none() { + let overriding = sqlx::query_scalar!( + "SELECT COUNT(*) FROM workspace_settings WHERE COALESCE(role_overrides ? $1, false)", + name + ) + .fetch_one(db) + .await? + .unwrap_or(0); + if overriding > 0 { + return Err(Error::BadRequest(format!( + "role `{name}` is overridden in {overriding} workspace(s): remove those overrides first" + ))); + } + } + Ok(()) +} + +/// Checks a write of the `workspace_roles` setting (`None` or null resets to the defaults) before +/// it is stored. Every writer of the setting must call it, the declarative config sync included. +pub async fn validate_instance_roles_write( + db: &DB, + value: Option<&serde_json::Value>, +) -> Result<()> { + let current = instance_roles(db).await?; + let next = match value { + None | Some(serde_json::Value::Null) => default_instance_roles(), + Some(v) => parse_instance_roles(v)?, + }; + // Resetting stays open so a downgraded instance can always drop the setting. + if !custom_roles_enabled() && *current != next && next != default_instance_roles() { + return Err(Error::BadRequest( + "changing workspace roles requires a valid Windmill Enterprise Edition license" + .to_string(), + )); + } + for removed in current + .iter() + .filter(|r| !next.iter().any(|n| n.name == r.name)) + { + ensure_role_unused(db, None, &removed.name).await?; + } + // An override valid on the old definitions can merge into an invalid role on the new ones + // (`flows: full` kept while the instance withdraws `code`), which the gates would honour. + let stored: Vec<(String, serde_json::Value)> = sqlx::query_as( + "SELECT workspace_id, role_overrides FROM workspace_settings WHERE role_overrides IS NOT NULL", + ) + .fetch_all(db) + .await?; + for (w, overrides) in stored { + let overrides: RoleOverrides = serde_json::from_value(overrides) + .map_err(|e| Error::internal_err(format!("invalid role overrides of {w}: {e:#}")))?; + validate_overrides(&next, &overrides).map_err(|e| { + Error::BadRequest(format!( + "workspace {w}'s role overrides would become invalid: {e}" + )) + })?; + } + Ok(()) +} + +/// Side effects of a stored `workspace_roles` write, owed by every writer. The seat table is read +/// by billing on every replica, so it is rebuilt here rather than on the notify that drops the +/// role caches. +pub async fn after_instance_roles_write(db: &DB) -> Result<()> { + invalidate_all_roles_cache(); + let mut tx = db.begin().await?; + sync_role_seats(&mut tx, None).await?; + tx.commit().await?; + Ok(()) +} + +/// Rewrites `role_seat`, the per-role author flag billing SQL joins on, from the current role +/// configuration: the instance rows (`workspace_id = ''`) and those of every workspace with an +/// override, or only `workspace_id`'s when given. Run after any write to either setting; billing +/// reads nothing else about roles. Reads on `tx` only: a second pooled connection held alongside +/// it self-deadlocks on a one-connection pool. +pub async fn sync_role_seats<'c>( + tx: &mut sqlx::Transaction<'c, sqlx::Postgres>, + workspace_id: Option<&str>, +) -> Result<()> { + let instance = instance_roles_on(&mut **tx).await?; + let workspaces: Vec<(String, Option<serde_json::Value>)> = match workspace_id { + Some(w) => sqlx::query_as( + "SELECT $1::text, (SELECT role_overrides FROM workspace_settings WHERE workspace_id = $1)", + ) + .bind(w) + .fetch_all(&mut **tx) + .await?, + None => { + sqlx::query("DELETE FROM role_seat").execute(&mut **tx).await?; + let rows = effective_roles(&instance, &RoleOverrides::new())?; + write_role_seats(tx, "", &rows).await?; + sqlx::query_as( + "SELECT workspace_id, role_overrides FROM workspace_settings WHERE role_overrides IS NOT NULL", + ) + .fetch_all(&mut **tx) + .await? + } + }; + for (w, overrides) in workspaces { + sqlx::query("DELETE FROM role_seat WHERE workspace_id = $1") + .bind(&w) + .execute(&mut **tx) + .await?; + let overrides: RoleOverrides = match overrides { + Some(v) => serde_json::from_value(v).map_err(|e| { + Error::internal_err(format!("invalid role overrides of {w}: {e:#}")) + })?, + None => continue, + }; + let rows: Vec<_> = effective_roles(&instance, &overrides)? + .into_iter() + .filter(|r| r.overridden) + .collect(); + write_role_seats(tx, &w, &rows).await?; + } + Ok(()) +} + +async fn write_role_seats<'c>( + tx: &mut sqlx::Transaction<'c, sqlx::Postgres>, + workspace_id: &str, + roles: &[EffectiveRole], +) -> Result<()> { + let names: Vec<&str> = roles.iter().map(|r| r.name.as_str()).collect(); + let authors: Vec<bool> = roles.iter().map(|r| r.capabilities.is_author()).collect(); + sqlx::query( + "INSERT INTO role_seat (workspace_id, role, author) + SELECT $1, n, a FROM unnest($2::text[], $3::bool[]) AS x(n, a)", + ) + .bind(workspace_id) + .bind(&names) + .bind(&authors) + .execute(&mut **tx) + .await?; + Ok(()) +} + +/// SQL for "the membership row aliased `t` consumes a full (author) seat": an admin always does, +/// whatever role it holds underneath; otherwise from `role_seat`, the workspace's row for the role, +/// else the instance's, else the legacy rule that only a developer is an author. `t` carries +/// `workspace_id`, `is_admin`, `operator` and `workspace_role`. Shared by every seat count so the +/// displayed, enforced and invoiced numbers agree. +pub const AUTHOR_SEAT_SQL: &str = "t.is_admin OR COALESCE( + (SELECT rs.author FROM role_seat rs WHERE rs.workspace_id = t.workspace_id + AND rs.role = COALESCE(t.workspace_role, CASE WHEN t.operator THEN 'operator' ELSE 'developer' END)), + (SELECT rs.author FROM role_seat rs WHERE rs.workspace_id = '' + AND rs.role = COALESCE(t.workspace_role, CASE WHEN t.operator THEN 'operator' ELSE 'developer' END)), + NOT t.operator)"; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_legacy_roles() { + let roles = effective_roles(&default_instance_roles(), &RoleOverrides::new()).unwrap(); + assert_eq!(roles[0].capabilities, RoleCapabilities::FULL); + assert!(!roles[0].capabilities.is_restricted()); + assert_eq!(roles[1].capabilities, RoleCapabilities::OPERATOR); + assert!(roles[1].capabilities.is_restricted()); + assert!(!roles[1].capabilities.is_author()); + + let hidden_page = RoleCapabilities { + visibility: RoleVisibility { workers: false, ..RoleVisibility::ALL }, + ..RoleCapabilities::FULL + }; + assert!(!hidden_page.is_restricted()); + } + + #[test] + fn override_layers_over_instance_definition() { + let instance: Vec<RoleDefinition> = serde_json::from_value(serde_json::json!([ + {"name": "developer"}, + {"name": "operator", "capabilities": {"manage_triggers": false, "visibility": {"runs": false}}} + ])) + .unwrap(); + let overrides: RoleOverrides = serde_json::from_value(serde_json::json!({ + "operator": {"capabilities": {"flows": "compose", "visibility": {"workers": false}}} + })) + .unwrap(); + let op = effective_roles(&instance, &overrides).unwrap().remove(1); + assert!(op.overridden); + assert_eq!(op.capabilities.flows, AuthorLevel::Compose); + assert!(!op.capabilities.manage_triggers); + assert!(!op.capabilities.visibility.runs); + assert!(!op.capabilities.visibility.workers); + assert!(op.capabilities.visibility.folders); + assert!(op.capabilities.is_author()); + } + + #[test] + fn full_authoring_requires_code() { + let err = parse_instance_roles(&serde_json::json!([ + {"name": "builder", "capabilities": {"flows": "full"}} + ])); + assert!(err.is_err()); + assert!(parse_instance_roles(&serde_json::json!([])).is_err()); + assert!(parse_instance_roles(&serde_json::json!([{"name": "admin"}])).is_err()); + } +} diff --git a/backend/windmill-common/src/scopes.rs b/backend/windmill-common/src/scopes.rs index 35f4497fac..2f58485896 100644 --- a/backend/windmill-common/src/scopes.rs +++ b/backend/windmill-common/src/scopes.rs @@ -291,6 +291,7 @@ pub enum ScopeDomain { OAuth, AI, AiEvals, // AI agent eval datasets + SlackAgents, // AI agents answering in Slack channels Indexer, Teams, // Microsoft Teams integration @@ -351,6 +352,7 @@ impl ScopeDomain { Self::OAuth => "oauth", Self::AI => "ai", Self::AiEvals => "ai_evals", + Self::SlackAgents => "slack_agents", Self::Capture => "capture", Self::Drafts => "drafts", Self::Favorites => "favorites", @@ -406,6 +408,7 @@ impl ScopeDomain { "oauth" => Some(Self::OAuth), "ai" => Some(Self::AI), "ai_evals" => Some(Self::AiEvals), + "slack_agents" => Some(Self::SlackAgents), "indexer" | "srch" => Some(Self::Indexer), "teams" => Some(Self::Teams), "native_triggers" => Some(Self::NativeTriggers), diff --git a/backend/windmill-common/src/ssrf.rs b/backend/windmill-common/src/ssrf.rs index 583b1e81ab..c95137a8bb 100644 --- a/backend/windmill-common/src/ssrf.rs +++ b/backend/windmill-common/src/ssrf.rs @@ -197,12 +197,6 @@ pub async fn validate_url_for_ssrf(url: &str) -> Result<ValidatedTarget, SsrfVal Ok(ValidatedTarget { host: host.to_string(), addrs }) } -pub fn allow_private_mcp_server_urls() -> bool { - std::env::var(ALLOW_PRIVATE_MCP_SERVER_URLS_ENV) - .ok() - .is_some_and(|v| v == "true" || v == "1") -} - pub fn allow_private_saml_metadata_urls() -> bool { std::env::var(ALLOW_PRIVATE_SAML_METADATA_URLS_ENV) .ok() @@ -311,22 +305,22 @@ pub async fn validate_guest_jwks_url(url: &str) -> Result<ValidatedTarget, SsrfV validate_url_for_ssrf(url).await } +/// Check an MCP server URL, allowing the private hosts `ALLOW_PRIVATE_MCP_SERVER_URLS` names +/// (`true`/`1` for all of them, or a [`PrivateHostAllowance`] list). pub async fn validate_mcp_server_url(url: &str) -> Result<ValidatedTarget, SsrfValidationError> { - let parsed = - url::Url::parse(url).map_err(|e| SsrfValidationError::InvalidUrl(e.to_string()))?; - - match parsed.scheme() { - "http" | "https" => {} - scheme => return Err(SsrfValidationError::DisallowedScheme(scheme.to_string())), + // Read per call rather than cached, so warn about bad entries once, not on every connect. + static WARN_INVALID: std::sync::Once = std::sync::Once::new(); + let value = std::env::var(ALLOW_PRIVATE_MCP_SERVER_URLS_ENV).unwrap_or_default(); + let (allowance, invalid) = PrivateHostAllowance::parse(&value); + if !invalid.is_empty() { + WARN_INVALID.call_once(|| { + tracing::warn!( + "Ignoring {ALLOW_PRIVATE_MCP_SERVER_URLS_ENV} entries {invalid:?}: expected a \ + host, host:port, IP address or CIDR range" + ) + }); } - - let host = parsed.host_str().ok_or(SsrfValidationError::MissingHost)?; - - if allow_private_mcp_server_urls() { - return Ok(ValidatedTarget::unpinned(host)); - } - - validate_url_for_ssrf(url).await + validate_url_for_ssrf_allowing(url, &allowance).await } /// Save-time check only: the webhook is sent later from another task that resolves @@ -369,8 +363,9 @@ pub async fn validate_mcp_server_url_for_bad_request( pub fn mcp_ssrf_error_message(e: &SsrfValidationError) -> String { match e { SsrfValidationError::Private { .. } => format!( - "{e}. If you need to use private/internal MCP server URLs, \ - set the {ALLOW_PRIVATE_MCP_SERVER_URLS_ENV}=true environment variable" + "{e}. To use a private/internal MCP server, add its host (host, host:port or \ + CIDR range) to the comma-separated {ALLOW_PRIVATE_MCP_SERVER_URLS_ENV} environment \ + variable, or set it to true to allow every private host" ), _ => e.to_string(), } @@ -396,6 +391,188 @@ pub fn webhook_ssrf_error_message(e: &SsrfValidationError) -> String { } } +/// Which private hosts an `ALLOW_PRIVATE_*` opt-in lets a URL reach. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum PrivateHostAllowance { + /// Private hosts are refused. + #[default] + None, + /// Every private host is allowed; only the URL's scheme and host are checked (`true` / `1`). + All, + /// Only the private hosts these entries name are allowed. + Hosts(Vec<AllowedPrivateHost>), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AllowedPrivateHost { + /// A host name, trusted whatever it resolves to. + Name { name: String, port: Option<u16> }, + /// An address or network a URL's host may resolve into. + Net { net: ipnet::IpNet, port: Option<u16> }, +} + +impl AllowedPrivateHost { + fn parse(entry: &str) -> Option<Self> { + if let Ok(net) = entry.parse::<ipnet::IpNet>() { + return Some(Self::Net { net: net.trunc(), port: None }); + } + let unbracketed = entry + .strip_prefix('[') + .and_then(|e| e.strip_suffix(']')) + .unwrap_or(entry); + if let Ok(ip) = unbracketed.parse::<IpAddr>() { + return Some(Self::Net { net: ip.into(), port: None }); + } + if let Ok(addr) = entry.parse::<SocketAddr>() { + return Some(Self::Net { net: addr.ip().into(), port: Some(addr.port()) }); + } + let (host, port) = match entry.rsplit_once(':') { + Some((host, port)) => (host, Some(port.parse::<u16>().ok()?)), + None => (entry, None), + }; + // Normalized the way `url::Url` normalizes the host it is compared against. An address + // reaching here is a loose WHATWG form (`10` is 0.0.0.10, not 10/8) and a `*` is not a + // wildcard, so both are refused rather than read as something the operator never meant. + match url::Host::parse(host.trim_end_matches('.')).ok()? { + url::Host::Domain(name) if !name.is_empty() && !name.contains('*') => { + Some(Self::Name { name, port }) + } + _ => None, + } + } +} + +fn port_allowed(allowed: Option<u16>, port: u16) -> bool { + allowed.map_or(true, |p| p == port) +} + +impl PrivateHostAllowance { + /// `true`/`1` allows every private host; anything else is a comma-separated list of + /// `host`, `host:port`, IP address, `ip:port` (`[v6]:port`) or CIDR entries. Also returns + /// the entries that parse as none of these, which allow nothing. + pub fn parse(value: &str) -> (Self, Vec<String>) { + match value.trim() { + "true" | "1" => return (Self::All, vec![]), + "" | "false" | "0" => return (Self::None, vec![]), + _ => {} + } + let mut hosts = vec![]; + let mut invalid = vec![]; + for entry in value.split(',').map(str::trim).filter(|e| !e.is_empty()) { + match AllowedPrivateHost::parse(entry) { + Some(host) => hosts.push(host), + None => invalid.push(entry.to_string()), + } + } + let allowance = if hosts.is_empty() { + Self::None + } else { + Self::Hosts(hosts) + }; + (allowance, invalid) + } + + pub fn from_env(var: &str) -> Self { + let Ok(value) = std::env::var(var) else { + return Self::None; + }; + let (allowance, invalid) = Self::parse(&value); + for entry in invalid { + tracing::warn!( + "Ignoring {var} entry {entry:?}: expected a host, host:port, IP address or CIDR range" + ); + } + allowance + } + + fn allows_name(&self, name: &str, port: u16) -> bool { + let name = name.trim_end_matches('.'); + matches!(self, Self::Hosts(hosts) if hosts.iter().any(|h| matches!(h, + AllowedPrivateHost::Name { name: n, port: p } if n == name && port_allowed(*p, port)))) + } + + fn allows_ip(&self, ip: &IpAddr, port: u16) -> bool { + // Judged by where it is delivered: an allowed v6 range such as an operator's NAT64 + // prefix must not carry a request on to an embedded private v4 like 169.254.169.254. + let ip = &match ip { + // The local-use NAT64 prefix places its v4 by a prefix length only the site knows, + // so the destination cannot be judged and no network entry admits it. + IpAddr::V6(v6) if v6.segments()[..3] == [0x0064, 0xff9b, 0x0001] => return false, + // `::1` and `::` sit inside the IPv4-compatible block but are v6's own. + IpAddr::V6(v6) if !v6.is_loopback() && !v6.is_unspecified() => { + embedded_ipv4(v6).map_or(*ip, IpAddr::V4) + } + IpAddr::V6(_) => *ip, + IpAddr::V4(_) => *ip, + }; + matches!(self, Self::Hosts(hosts) if hosts.iter().any(|h| matches!(h, + AllowedPrivateHost::Net { net, port: p } if net.contains(ip) && port_allowed(*p, port)))) + } +} + +/// [`validate_url_for_ssrf`], except that the URL may also reach the private hosts +/// `allowance` names. +/// +/// A host matched by name is trusted whatever it resolves to, so it is left unpinned. Any +/// other host must resolve only to public addresses or ones inside an allowed network, and +/// those addresses are pinned as usual. +pub async fn validate_url_for_ssrf_allowing( + url: &str, + allowance: &PrivateHostAllowance, +) -> Result<ValidatedTarget, SsrfValidationError> { + if *allowance == PrivateHostAllowance::None { + return validate_url_for_ssrf(url).await; + } + + let parsed = + url::Url::parse(url).map_err(|e| SsrfValidationError::InvalidUrl(e.to_string()))?; + let port = match parsed.scheme() { + "http" | "https" => parsed.port_or_known_default().unwrap_or(80), + scheme => return Err(SsrfValidationError::DisallowedScheme(scheme.to_string())), + }; + let host_str = parsed.host_str().ok_or(SsrfValidationError::MissingHost)?; + + if *allowance == PrivateHostAllowance::All { + return Ok(ValidatedTarget::unpinned(host_str)); + } + + let ip = match parsed.host().ok_or(SsrfValidationError::MissingHost)? { + url::Host::Domain(name) => { + if allowance.allows_name(name, port) { + return Ok(ValidatedTarget::unpinned(host_str)); + } + None + } + url::Host::Ipv4(ip) => Some(IpAddr::V4(ip)), + url::Host::Ipv6(ip) => Some(IpAddr::V6(ip)), + }; + + if let Some(ip) = ip { + if is_private_ip(&ip) && !allowance.allows_ip(&ip, port) { + return Err(SsrfValidationError::Private { resolved: false }); + } + return Ok(ValidatedTarget::unpinned(host_str)); + } + + let addrs: Vec<SocketAddr> = tokio::net::lookup_host(format!("{host_str}:{port}")) + .await + .map_err(|e| SsrfValidationError::ResolutionFailed { + host: host_str.to_string(), + source: e.to_string(), + })? + .collect(); + if addrs.is_empty() { + return Err(SsrfValidationError::NoAddresses(host_str.to_string())); + } + for addr in &addrs { + if is_private_ip(&addr.ip()) && !allowance.allows_ip(&addr.ip(), port) { + return Err(SsrfValidationError::Private { resolved: true }); + } + } + + Ok(ValidatedTarget { host: host_str.to_string(), addrs }) +} + /// Whether `ip` is loopback, private, link-local, or otherwise not a public /// internet destination. The one predicate every server-side outbound-URL guard /// (SSRF checks, git remotes, object storage tests) judges addresses with. @@ -688,6 +865,25 @@ mod tests { assert!(validate_mcp_server_url("http://10.0.0.1/foo").await.is_ok()); } + #[tokio::test] + async fn validate_mcp_server_url_allows_only_listed_private_hosts() { + let _lock = TEST_ENV_LOCK.lock().await; + let _guard = PrivateMcpServerUrlsEnvGuard::set(Some("10.1.0.0/16, mcp.internal:8080")); + + assert!(validate_mcp_server_url("http://10.1.2.3/mcp").await.is_ok()); + assert!(validate_mcp_server_url("http://mcp.internal:8080/mcp") + .await + .is_ok()); + assert!(matches!( + validate_mcp_server_url("http://10.2.0.1/mcp").await, + Err(SsrfValidationError::Private { resolved: false }) + )); + assert!(matches!( + validate_mcp_server_url("http://169.254.169.254/").await, + Err(SsrfValidationError::Private { resolved: false }) + )); + } + #[tokio::test] async fn validate_mcp_server_url_keeps_syntax_guards_when_private_urls_are_allowed() { let _lock = TEST_ENV_LOCK.lock().await; @@ -711,9 +907,7 @@ mod tests { let private_error = validate_mcp_server_url("http://127.0.0.1/foo") .await .unwrap_err(); - assert!( - mcp_ssrf_error_message(&private_error).contains("ALLOW_PRIVATE_MCP_SERVER_URLS=true") - ); + assert!(mcp_ssrf_error_message(&private_error).contains(ALLOW_PRIVATE_MCP_SERVER_URLS_ENV)); let invalid_error = validate_mcp_server_url("localhost:11434/v1") .await @@ -877,4 +1071,66 @@ mod tests { !saml_ssrf_error_message(&invalid_error).contains(ALLOW_PRIVATE_SAML_METADATA_URLS_ENV) ); } + + #[test] + fn private_host_allowance_parses_flags_and_entries() { + assert_eq!( + PrivateHostAllowance::parse("true").0, + PrivateHostAllowance::All + ); + assert_eq!( + PrivateHostAllowance::parse("false").0, + PrivateHostAllowance::None + ); + let (parsed, invalid) = + PrivateHostAllowance::parse("Gateway.Internal., 10.1.0.0/16, x:y, 10, *.internal"); + // A bare `10` would otherwise be 0.0.0.10, and `*` is no wildcard. + assert_eq!(invalid, ["x:y", "10", "*.internal"]); + let PrivateHostAllowance::Hosts(hosts) = parsed else { + panic!("{parsed:?}") + }; + assert_eq!( + hosts[0], + AllowedPrivateHost::Name { name: "gateway.internal".into(), port: None } + ); + } + + #[tokio::test] + async fn private_host_allowance_admits_only_listed_hosts() { + let a = PrivateHostAllowance::parse( + "localhost:8080, 10.1.0.0/16, 127.0.0.2:9000, [::1]:443, 64:ff9b::/96, 64:ff9b:1::/48", + ) + .0; + let ok = |u: &'static str| { + let a = a.clone(); + async move { validate_url_for_ssrf_allowing(u, &a).await.is_ok() } + }; + // Trusted by name on its port only, so nothing to pin. + let target = validate_url_for_ssrf_allowing("http://localhost:8080/v1", &a) + .await + .unwrap(); + assert!(target.pinned_addrs().is_empty()); + assert!(!ok("http://localhost:8081/v1").await); + assert!(ok("http://10.1.2.3/v1").await); + assert!(ok("http://[::ffff:10.1.2.3]/v1").await); + assert!(!ok("http://10.2.0.1/v1").await); + assert!(ok("http://127.0.0.2:9000/v1").await); + assert!(!ok("http://127.0.0.2/v1").await); + assert!(ok("https://[::1]/v1").await); + assert!(!ok("http://[::1]/v1").await); + // An allowed v6 range does not reach the private v4 it embeds. + assert!(!ok("http://[64:ff9b::a9fe:a9fe]/").await); + assert!(!ok("http://[64:ff9b:1::a9fe:a9fe]/").await); + assert!(!ok("http://169.254.169.254/latest/meta-data").await); + assert!(ok("http://8.8.8.8/v1").await); + } + + #[tokio::test] + async fn private_host_allowance_pins_hosts_resolved_into_allowed_nets() { + let a = PrivateHostAllowance::parse("127.0.0.0/8, ::1").0; + let target = validate_url_for_ssrf_allowing("http://localhost:1234/", &a) + .await + .unwrap(); + assert!(!target.pinned_addrs().is_empty()); + } } diff --git a/backend/windmill-common/src/user_drafts.rs b/backend/windmill-common/src/user_drafts.rs index fa869bd253..8bee972a56 100644 --- a/backend/windmill-common/src/user_drafts.rs +++ b/backend/windmill-common/src/user_drafts.rs @@ -18,6 +18,7 @@ pub use windmill_types::user_drafts::DraftUserRef; use crate::db::DB; use crate::error::Result; +use crate::workspaces::ManageKind; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -160,6 +161,21 @@ impl UserDraftItemKind { } } + /// The withdrawable operator right that governs deploying this kind, which is + /// therefore also the right to draft it. Every trigger kind maps here, native ones + /// included: `gate_operator_writes` covers their routers as well. + pub fn manage_kind(&self) -> Option<ManageKind> { + use UserDraftItemKind::*; + match self { + TriggerSchedule => Some(ManageKind::Schedules), + TriggerWebhook | TriggerDefaultEmail | TriggerEmail | TriggerHttp + | TriggerWebsocket | TriggerPostgres | TriggerKafka | TriggerNats | TriggerMqtt + | TriggerAmqp | TriggerSqs | TriggerGcp | TriggerAzure | TriggerPoll | TriggerCli + | TriggerNextcloud | TriggerGoogle | TriggerGithub => Some(ManageKind::Triggers), + Script | Flow | App | RawApp | Resource | Variable | DataPipeline => None, + } + } + /// The `draft.value` key holding the user-typed target path — where a /// deploy of this draft would land when the user staged a rename. A script /// draft round-trips its own `path`; flows and apps write a separate diff --git a/backend/windmill-common/src/users.rs b/backend/windmill-common/src/users.rs index 1b696746b0..277a2632f8 100644 --- a/backend/windmill-common/src/users.rs +++ b/backend/windmill-common/src/users.rs @@ -379,44 +379,54 @@ async fn get_email_from_permissioned_as_inner<'c>( } } -/// Compute the highest-precedence workspace role for a user across all their instance groups. +/// The highest-precedence workspace role a user holds across their instance groups. /// -/// Precedence: admin (3) > developer (2) > operator (1). -/// Returns `(best_group_name, is_admin, is_operator)`. +/// `admin` ranks first, then the workspace's roles in `role_order` (earlier is higher). A group +/// with no mapped role grants `roles::default_role_name`; a role name not in `role_order` ranks +/// last. Returns `(best_group_name, is_admin, role)`, where `role` is the role the membership +/// holds and, for an admin, the one it keeps under the admin flag. pub fn compute_highest_workspace_role( user_igroups: &[String], ws_configured_groups: &[String], ws_roles: &std::collections::HashMap<String, String>, -) -> (String, bool, bool) { - let mut best_group = String::new(); - let mut best_precedence = 0u8; + role_order: &[String], +) -> (String, bool, String) { + let rank = |role: &str| -> usize { + if role == "admin" { + 0 + } else { + 1 + role_order + .iter() + .position(|r| r == role) + .unwrap_or(role_order.len()) + } + }; + let default_role = crate::roles::default_role_name(role_order.iter().map(String::as_str)); + let role_of = |group: &str| -> String { + ws_roles + .get(group) + .cloned() + .unwrap_or_else(|| default_role.clone()) + }; + let mut best: Option<(usize, String)> = None; for group in user_igroups { if !ws_configured_groups.contains(group) { continue; } - let default_role = "developer".to_string(); - let role = ws_roles.get(group).unwrap_or(&default_role); - let precedence = match role.as_str() { - "admin" => 3u8, - "operator" => 1, - _ => 2, - }; - if precedence > best_precedence { - best_precedence = precedence; - best_group = group.clone(); + let r = rank(&role_of(group)); + if best.as_ref().map_or(true, |(b, _)| r < *b) { + best = Some((r, group.clone())); } } - let default_role = "developer".to_string(); - let best_role_str = ws_roles.get(&best_group).unwrap_or(&default_role); - let (is_admin, is_operator) = match best_role_str.as_str() { - "admin" => (true, false), - "operator" => (false, true), - _ => (false, false), - }; - - (best_group, is_admin, is_operator) + let best_group = best.map(|(_, g)| g).unwrap_or_default(); + let role = role_of(&best_group); + if role == "admin" { + (best_group, true, default_role) + } else { + (best_group, false, role) + } } pub fn truncate_token(token: &str) -> String { @@ -478,62 +488,46 @@ mod tests { } } - #[test] - fn test_compute_highest_workspace_role_admin_wins() { - let user_groups = vec!["ops".to_string(), "admins".to_string()]; - let ws_groups = vec!["ops".to_string(), "admins".to_string()]; - let mut roles = std::collections::HashMap::new(); - roles.insert("ops".to_string(), "operator".to_string()); - roles.insert("admins".to_string(), "admin".to_string()); - - let (group, is_admin, is_operator) = - compute_highest_workspace_role(&user_groups, &ws_groups, &roles); - assert_eq!(group, "admins"); - assert!(is_admin); - assert!(!is_operator); + fn order() -> Vec<String> { + vec![ + "developer".to_string(), + "builder".to_string(), + "operator".to_string(), + ] } #[test] - fn test_compute_highest_workspace_role_developer_over_operator() { - let user_groups = vec!["devs".to_string(), "ops".to_string()]; - let ws_groups = vec!["devs".to_string(), "ops".to_string()]; + fn test_compute_highest_workspace_role_follows_role_order() { + let user_groups = vec!["ops".to_string(), "builders".to_string()]; + let ws_groups = user_groups.clone(); let mut roles = std::collections::HashMap::new(); - roles.insert("devs".to_string(), "developer".to_string()); roles.insert("ops".to_string(), "operator".to_string()); + roles.insert("builders".to_string(), "builder".to_string()); + assert_eq!( + compute_highest_workspace_role(&user_groups, &ws_groups, &roles, &order()), + ("builders".to_string(), false, "builder".to_string()) + ); - let (group, is_admin, is_operator) = - compute_highest_workspace_role(&user_groups, &ws_groups, &roles); - assert_eq!(group, "devs"); - assert!(!is_admin); - assert!(!is_operator); + roles.insert("ops".to_string(), "admin".to_string()); + assert_eq!( + compute_highest_workspace_role(&user_groups, &ws_groups, &roles, &order()), + ("ops".to_string(), true, "developer".to_string()) + ); } #[test] - fn test_compute_highest_workspace_role_skips_unconfigured_groups() { - let user_groups = vec!["admins".to_string(), "other".to_string()]; - let ws_groups = vec!["ops".to_string()]; // admins not configured for this workspace - let mut roles = std::collections::HashMap::new(); - roles.insert("admins".to_string(), "admin".to_string()); - roles.insert("ops".to_string(), "operator".to_string()); - - let (group, is_admin, is_operator) = - compute_highest_workspace_role(&user_groups, &ws_groups, &roles); - // No user groups match ws_configured_groups, so best_group stays empty - assert_eq!(group, ""); - assert!(!is_admin); - assert!(!is_operator); - } - - #[test] - fn test_compute_highest_workspace_role_defaults_to_developer() { - let user_groups = vec!["team".to_string()]; + fn test_compute_highest_workspace_role_unmapped_group_is_developer() { + let user_groups = vec!["team".to_string(), "other".to_string()]; let ws_groups = vec!["team".to_string()]; - let roles = std::collections::HashMap::new(); // no role configured → developer - - let (group, is_admin, is_operator) = - compute_highest_workspace_role(&user_groups, &ws_groups, &roles); - assert_eq!(group, "team"); - assert!(!is_admin); - assert!(!is_operator); + assert_eq!( + compute_highest_workspace_role(&user_groups, &ws_groups, &Default::default(), &order()), + ("team".to_string(), false, "developer".to_string()) + ); + // An instance that dropped `developer` falls back to its first role. + let no_dev = vec!["builder".to_string(), "operator".to_string()]; + assert_eq!( + compute_highest_workspace_role(&user_groups, &ws_groups, &Default::default(), &no_dev), + ("team".to_string(), false, "builder".to_string()) + ); } } diff --git a/backend/windmill-common/src/utils.rs b/backend/windmill-common/src/utils.rs index 116a15303e..c034edc203 100644 --- a/backend/windmill-common/src/utils.rs +++ b/backend/windmill-common/src/utils.rs @@ -124,8 +124,10 @@ lazy_static::lazy_static! { if std::env::var("BASE_INTERNAL_URL").is_err() { panic!("BASE_INTERNAL_URL is required in agent mode") } - if std::env::var("AGENT_TOKEN").is_err() { - println!("AGENT_TOKEN is not passed. This is required for the agent to work and contains the JWT to authenticate with the server.") + if std::env::var("AGENT_TOKEN").is_err() + && std::env::var(crate::agent_workers::AGENT_IDENTITY_TOKEN_FILE_ENV).is_err() + { + println!("Neither AGENT_TOKEN nor AGENT_IDENTITY_TOKEN_FILE is passed. One is required for the agent to authenticate with the server.") } #[cfg(not(feature = "enterprise"))] diff --git a/backend/windmill-common/src/variables.rs b/backend/windmill-common/src/variables.rs index b7a2f19d86..512bd555f0 100644 --- a/backend/windmill-common/src/variables.rs +++ b/backend/windmill-common/src/variables.rs @@ -10,19 +10,130 @@ use crate::db::{Authable, UserDB}; use crate::error::{self, Error}; use crate::scripts::ScriptHash; use crate::secret_backend::{get_secret_value, is_external_stored_value}; -use crate::utils::WarnAfterExt; -use crate::worker::Connection; +use crate::utils::{strip_json_nul, WarnAfterExt}; +use crate::worker::{to_raw_value, Connection}; use crate::workspace_crypt::CipherFormat; pub use crate::workspace_crypt::WorkspaceCrypt; use crate::{worker::WORKER_GROUP, BASE_URL, DB}; use chrono::{SecondsFormat, Utc}; use quick_cache::sync::Cache; use serde::{Deserialize, Serialize}; +use serde_json::value::RawValue; +use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; lazy_static::lazy_static! { pub static ref SECRET_SALT: Option<String> = std::env::var("SECRET_SALT").ok(); static ref RESERVED_WM_VAR_NAME: regex::Regex = regex::Regex::new(r"^WM_[A-Z_]+$").unwrap(); + static ref RE_RES_VAR: regex::Regex = + regex::Regex::new(r#"\$(var|jsonvar|res|encrypted)\:"#).unwrap(); +} + +/// Whether a job argument, as JSON text, holds a `$var:`/`$jsonvar:`/`$res:`/`$encrypted:` +/// reference, which makes the worker interpolate the whole argument with the job's token, +/// `$`-prefixed reserved variables included. +pub fn arg_needs_interpolation(json: &str) -> bool { + RE_RES_VAR.is_match(json) +} + +/// Whether the worker would interpolate `arg`, which an untrusted user controls on a job that +/// runs as someone else; `true` for one that cannot be read and may hold a reference. +pub fn untrusted_arg_interpolates(arg: &str) -> bool { + may_reference(arg) && decoded(arg).map_or(true, |v| interpolates(&v)) +} + +/// `args`, which an untrusted user controls on a job that runs as someone else, with every one the +/// worker would interpolate rewritten so it no longer is. The others keep their exact text. +pub fn omit_untrusted_interpolation( + args: HashMap<String, Box<RawValue>>, + resource_omitted: &str, +) -> HashMap<String, Box<RawValue>> { + args.into_iter() + .map(|(k, v)| { + if !may_reference(v.get()) { + return (k, v); + } + let v = match decoded(v.get()) { + Some(d) if !interpolates(&d) => v, + Some(mut d) => { + omit(&mut d, resource_omitted); + to_raw_value(&d) + } + None => to_raw_value( + &"This argument has been omitted for security reasons: it could not be checked", + ), + }; + (k, v) + }) + .collect() +} + +/// Without a `$`, spelled out or escaped, an argument holds no reference however jsonb rewrites +/// it, so it needs no decoding, even one serde_json cannot read. +fn may_reference(arg: &str) -> bool { + arg.contains('$') || arg.contains("\\u") +} + +/// `arg` as its jsonb column will hold it: the NUL stripped as `push` strips it, escapes decoded, +/// the last copy of a duplicate key kept. `None` for what serde_json cannot read, which jsonb may +/// still store (`1e400`). +fn decoded(arg: &str) -> Option<serde_json::Value> { + serde_json::from_str(&strip_json_nul(arg)).ok() +} + +/// The worker's own test on the whole argument, plus a reserved `WM_*` key or value at any depth, +/// which a flow step can move into an argument the worker interpolates. A workspace or worker env +/// var name moved that way is still resolved: those names are not known on every path. +fn interpolates(v: &serde_json::Value) -> bool { + fn reserved_name(v: &serde_json::Value) -> bool { + match v { + serde_json::Value::String(s) => reserved(s).is_some(), + serde_json::Value::Array(a) => a.iter().any(reserved_name), + serde_json::Value::Object(m) => m + .iter() + .any(|(k, v)| reserved(k).is_some() || reserved_name(v)), + _ => false, + } + } + arg_needs_interpolation(&v.to_string()) || reserved_name(v) +} + +/// Keys included, since the worker's test reads them too. +fn omit(v: &mut serde_json::Value, resource_omitted: &str) { + match v { + serde_json::Value::String(s) => *s = omitted(s, resource_omitted), + serde_json::Value::Array(a) => a.iter_mut().for_each(|v| omit(v, resource_omitted)), + serde_json::Value::Object(m) => { + *m = std::mem::take(m) + .into_iter() + .map(|(k, mut v)| { + omit(&mut v, resource_omitted); + (omitted(&k, resource_omitted), v) + }) + .collect() + } + _ => {} + } +} + +/// The reserved variable a whole `$WM_*` value names, which the worker replaces with its value. +fn reserved(s: &str) -> Option<&str> { + s.strip_prefix('$') + .filter(|n| RESERVED_WM_VAR_NAME.is_match(n)) +} + +fn omitted(s: &str, resource_omitted: &str) -> String { + let variable = "The following variable has been omitted for security reasons: "; + if let Some(n) = reserved(s) { + return format!("{variable}{n}"); + } + RE_RES_VAR + .replace_all(s, |c: ®ex::Captures| match &c[1] { + "res" => resource_omitted, + "encrypted" => "The following encrypted value has been omitted for security reasons: ", + _ => variable, + }) + .into_owned() } /// Escape a string so it can be safely embedded inside a single-quoted JS @@ -288,9 +399,14 @@ pub async fn generate_approval_token( job_id: uuid::Uuid, db: &DB, ) -> crate::error::Result<String> { + let key = get_workspace_key(w_id, db).await?; + approval_token_from_key(&key, job_id) +} + +/// The token [`generate_approval_token`] mints, from a workspace key already in hand. +pub fn approval_token_from_key(key: &str, job_id: uuid::Uuid) -> crate::error::Result<String> { use hmac::{Hmac, Mac}; use sha2::Sha256; - let key = get_workspace_key(w_id, db).await?; let mut mac = Hmac::<Sha256>::new_from_slice(key.as_bytes()) .map_err(|e| crate::Error::internal_err(format!("HMAC key error: {e}")))?; mac.update(job_id.as_bytes()); @@ -349,30 +465,38 @@ pub async fn get_secret_value_as_admin( ))); }; - let r = if variable.is_secret { - let value = variable.value; - if !value.is_empty() { - if is_external_stored_value(&value) { - get_secret_value(db, w_id, &variable.path, &value).await? - } else { - let mc = build_crypt(db, w_id).await?; - decrypt(&mc, value).map_err(|e| { - crate::error::Error::internal_err(format!( - "Error decrypting variable {}: {}", - variable.path, e - )) - })? - } - } else { - "".to_string() - } + if variable.is_secret { + decrypt_secret_value(db, w_id, &variable.path, variable.value).await } else { - variable.value - }; - - Ok(r) + Ok(variable.value) + } } +/// Plaintext of a secret variable's stored `value`, read from the external secret backend when it +/// holds a reference to one. Bypasses variable permissions: callers MUST have authorized the +/// reader before returning the plaintext to them. +pub async fn decrypt_secret_value( + db: &DB, + w_id: &str, + path: &str, + value: String, +) -> crate::error::Result<String> { + if value.is_empty() { + Ok(value) + } else if is_external_stored_value(&value) { + get_secret_value(db, w_id, path, &value).await + } else { + let mc = build_crypt(db, w_id).await?; + decrypt(&mc, value).map_err(|e| { + crate::error::Error::internal_err(format!("Error decrypting variable {}: {}", path, e)) + }) + } +} + +/// Marks a workspace setting's secret written into the settings document encrypted with the +/// workspace key, as `wmill sync pull` writes it. +pub const SETTINGS_ENCRYPTED_PREFIX: &str = "$encrypted:"; + pub fn encrypt(mc: &WorkspaceCrypt, value: &str) -> String { mc.encrypt_str(value) } @@ -968,9 +1092,48 @@ pub async fn get_variable_or_self_as<T: Authable + Sync>( #[cfg(test)] mod tests { use super::{ - can_bind_as_prologue_const, decrypt, escape_js_single_quoted, is_valid_js_identifier, - DECRYPT_FAILED, + arg_needs_interpolation, can_bind_as_prologue_const, decrypt, escape_js_single_quoted, + is_valid_js_identifier, omit_untrusted_interpolation, DECRYPT_FAILED, }; + + #[test] + fn omits_every_argument_the_worker_would_interpolate() { + let omit = |s: &str| { + let arg = serde_json::value::RawValue::from_string(s.to_string()).unwrap(); + omit_untrusted_interpolation( + [("a".to_string(), arg)].into(), + "The following resource has been omitted for security reasons: ", + )["a"] + .get() + .to_string() + }; + for kept in [ + r#"{"$match": {"c": "$USD", "_id": "$category"}, "p": 1.10, "id": 18446744073709551617}"#, + r#""$10.00""#, + r#""$WM_TOKEN ""#, + ] { + assert_eq!(omit(kept), kept); + } + for hit in [ + r#""$var:u/a/s""#, + r#""\u0024res:u/a/r""#, + r#"{"key": "$API_KEY", "note": "cost $var:x"}"#, + r#"{"$encrypted:abc": 1}"#, + r#"["$WM_TOKEN"]"#, + r#"{"$WM_TOKEN": 1}"#, + r#""$va\u0000r:x""#, + r#"{"a": "ok", "a": "\u0024jsonvar:x"}"#, + ] { + let out = omit(hit); + assert!( + !arg_needs_interpolation(&out) && !out.contains("$WM_"), + "{hit}: {out}" + ); + assert!(out.contains("omitted for security reasons"), "{hit}: {out}"); + } + assert!(omit(r#"{"n": 1e400, "r": "$var:x"}"#).contains("could not be checked")); + assert_eq!(omit(r#"{"n": 1e400}"#), r#"{"n": 1e400}"#); + } use crate::error::Error; use crate::workspace_crypt::WorkspaceCrypt; use base64::Engine; diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index b8e4ce50e6..e6a44054d9 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -451,6 +451,15 @@ lazy_static::lazy_static! { pub static ref FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX: AtomicBool = AtomicBool::new(false); pub static ref PREVIEW_TAGS_OVERRIDE: AtomicBool = AtomicBool::new(false); pub static ref DEPENDENCY_JOB_TAG: arc_swap::ArcSwap<Option<String>> = arc_swap::ArcSwap::from_pointee(None); + /// Whether the jobs of dev workspaces and forks are queued on suffixed tags. + pub static ref DEV_WORKSPACE_ISOLATION: AtomicBool = AtomicBool::new(false); + /// Whether a fork whose lineage holds no dev workspace runs on its parent's workers. + pub static ref DEV_WORKSPACE_ISOLATION_SKIP_NON_DEV_FORKS: AtomicBool = AtomicBool::new(false); + /// The suffix of those tags. Groups serving dev workspaces listen with it whether isolation is + /// on or not, so turning it off leaves no job already queued on a suffixed tag unserved. + pub static ref DEV_WORKSPACE_TAG_SUFFIX: arc_swap::ArcSwap<String> = arc_swap::ArcSwap::from_pointee(DEFAULT_DEV_WORKSPACE_TAG_SUFFIX.to_string()); + /// Whether this process's worker group serves dev workspaces, set when its config is loaded. + pub static ref WORKER_SERVES_DEV_WORKSPACES: AtomicBool = AtomicBool::new(false); pub static ref MAX_TIMEOUT: u64 = std::env::var("TIMEOUT") .ok() @@ -475,6 +484,7 @@ lazy_static::lazy_static! { additional_python_paths: Default::default(), pip_local_dependencies: Default::default(), env_vars: Default::default(), + disable_job_proxy: false, native_mode: false, object_store_cache_config: Default::default(), paused: false, @@ -1190,6 +1200,7 @@ pub struct RlangAnnotations { pub renv_verbose: bool, pub renv_install_verbose: bool, pub sandbox: bool, + pub no_network: bool, } #[annotations("#")] @@ -1205,6 +1216,7 @@ pub struct PythonAnnotations { pub py314: bool, pub py315: bool, pub sandbox: bool, + pub no_network: bool, } #[annotations("//")] @@ -1315,6 +1327,7 @@ impl SqlAnnotations { pub struct BashAnnotations { pub docker: bool, pub sandbox: bool, + pub no_network: bool, } impl BashAnnotations { @@ -2223,14 +2236,22 @@ pub async fn fetch_raw_script_from_app_query( loc: &'static Location<'_>, ) -> error::Result<RawScript> { sqlx::query!( - "SELECT lock, code FROM app_script WHERE id = $1 LIMIT 1", + "SELECT lock, code, modules AS \"modules: serde_json::Value\" + FROM app_script WHERE id = $1 LIMIT 1", id, ) .fetch_optional(db) .await .map_err(Into::into) .and_then(unwrap_or_error(&loc, "Application script", id)) - .map(|r| RawScript { content: r.code, lock: r.lock, meta: None, modules: None }) + .and_then(|r| { + Ok(RawScript { + content: r.code, + lock: r.lock, + meta: None, + modules: r.modules.map(serde_json::from_value).transpose()?, + }) + }) } /// Returns the number of jobs the row already accounted for: non-zero when a worker of the @@ -2410,9 +2431,56 @@ pub async fn update_worker_ping_main_loop_query( // occupancy_rate = $3, memory_usage = $4, wm_memory_usage = $5, vcpus = COALESCE($7, vcpus), // memory = COALESCE($8, memory), occupancy_rate_15s = $9, occupancy_rate_5m = $10, occupancy_rate_30m = $11 WHERE worker = $6", -const MAX_TAG_LEN: usize = 50; +/// The length of `v2_job.tag`. +pub const MAX_TAG_LEN: usize = 50; const HASH_SUFFIX_LEN: usize = 16; +pub const DEFAULT_DEV_WORKSPACE_TAG_SUFFIX: &str = "-dev"; + +/// The suffix new jobs of dev workspaces and forks get, `None` while isolation is off. +pub fn dev_workspace_tag_suffix() -> Option<String> { + DEV_WORKSPACE_ISOLATION + .load(std::sync::atomic::Ordering::Relaxed) + .then(|| DEV_WORKSPACE_TAG_SUFFIX.load().to_string()) +} + +/// Whether a workspace's jobs are isolated, given that it has a parent or a generated fork id +/// (`is_dev_or_fork`) and whether a dev workspace is on its ancestor chain, itself included +/// (`in_dev_lineage`). Leaves out only the forks of a non-dev workspace, and only when the +/// instance opts out of isolating them. +pub fn is_isolated_workspace(is_dev_or_fork: bool, in_dev_lineage: bool) -> bool { + is_dev_or_fork + && (in_dev_lineage + || !DEV_WORKSPACE_ISOLATION_SKIP_NON_DEV_FORKS + .load(std::sync::atomic::Ordering::Relaxed)) +} + +/// Worker tags are pasted into the pull queries as SQL literals and listed comma-separated, so +/// the suffix must not carry a quote, a comma or whitespace. +pub fn is_valid_dev_workspace_tag_suffix(suffix: &str) -> bool { + !suffix.is_empty() + && suffix.len() <= 32 + && suffix + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) +} + +/// `tag` as the workers serving dev workspaces listen to it. Idempotent, because a tag inherited +/// from a dev job (a flow step, an agent's tool) already carries the suffix. +pub fn with_dev_workspace_suffix(tag: &str, suffix: &str) -> String { + if tag.ends_with(suffix) { + tag.to_string() + } else { + format!("{tag}{suffix}") + } +} + +/// Whether `tag` has the shape of a [`dedicated_worker_tag`] too long to keep whole. Its prefix +/// alone does not tell whose it is: two workspaces may share it. +pub fn is_hashed_dedicated_worker_tag(tag: &str) -> bool { + tag.len() == MAX_TAG_LEN && tag.as_bytes().get(MAX_TAG_LEN - 1 - HASH_SUFFIX_LEN) == Some(&b'#') +} + pub fn dedicated_worker_tag(workspace_id: &str, path: &str) -> String { let full_tag = format!("{}:{}", workspace_id, path); if full_tag.len() <= MAX_TAG_LEN { @@ -2573,8 +2641,32 @@ pub async fn load_worker_config( }) .unwrap_or_else(|| DEFAULT_TAGS.clone()); + // A group serving dev workspaces listens to the suffixed form of its tags only, so it never + // takes a root workspace's job. Dedicated workers are placed by hand and keep their tags. + let dev_suffix = (config.serves_dev_workspaces == Some(true) + && dedicated_worker.is_none() + && dedicated_workers.is_none()) + .then(|| DEV_WORKSPACE_TAG_SUFFIX.load().to_string()); + WORKER_SERVES_DEV_WORKSPACES.store(dev_suffix.is_some(), std::sync::atomic::Ordering::Relaxed); + let worker_tags = match &dev_suffix { + Some(suffix) => worker_tags + .iter() + .map(|t| with_dev_workspace_suffix(t, suffix)) + .unique() + .collect_vec(), + None => worker_tags, + }; + let mut priority_tags_sorted: Vec<PriorityTags> = Vec::new(); - let priority_tags_map = config.priority_tags.unwrap_or_else(HashMap::new); + let priority_tags_map: HashMap<String, u8> = match &dev_suffix { + Some(suffix) => config + .priority_tags + .unwrap_or_default() + .into_iter() + .map(|(t, p)| (with_dev_workspace_suffix(&t, suffix), p)) + .collect(), + None => config.priority_tags.unwrap_or_else(HashMap::new), + }; if priority_tags_map.len() > 0 { let mut all_tags_set: HashSet<String> = HashSet::from_iter(worker_tags.clone()); @@ -2676,6 +2768,7 @@ pub async fn load_worker_config( .additional_python_paths .or_else(|| load_additional_python_paths_from_env()), env_vars: resolved_env_vars, + disable_job_proxy: config.disable_job_proxy.unwrap_or(false), native_mode, object_store_cache_config: config.object_store_cache_config, paused: config.paused.unwrap_or(false), @@ -2720,6 +2813,93 @@ pub fn load_whitelist_env_vars_from_env() -> std::vec::IntoIter<String> { .into_iter() } +/// Warns, by name only, about each `WHITELIST_ENVS` entry that looks like a credential: +/// every job on the worker can read what is listed there. +pub fn warn_secret_shaped_whitelist_envs() { + for name in load_whitelist_env_vars_from_env().sorted().unique() { + let value = std::env::var(&name).unwrap_or_default(); + if looks_like_secret(&name, &value) { + tracing::warn!( + "WHITELIST_ENVS forwards {name}, which looks like a secret, to every job on \ + this worker, where any script can read it. Store it as a secret variable or \ + resource, or in a secret backend, and remove it from WHITELIST_ENVS" + ); + } + } +} + +fn looks_like_secret(name: &str, value: &str) -> bool { + const SECRET_WORDS: &[&str] = &[ + "TOKEN", + "TOKENS", + "SECRET", + "SECRETS", + "PASSWORD", + "PASSWD", + "KEY", + "APIKEY", + "CREDENTIAL", + "CREDENTIALS", + "AUTH", + "PRIVATE", + ]; + let name_matches = name + .split(|c: char| !c.is_ascii_alphanumeric()) + .any(|word| SECRET_WORDS.contains(&word.to_ascii_uppercase().as_str())); + if name_matches { + return true; + } + + let value = value.trim(); + let is_private_pem = value.contains("-----BEGIN ") && value.contains("PRIVATE KEY-----"); + let is_jwt = value.starts_with("eyJ") && { + let parts: Vec<&str> = value.split('.').collect(); + parts.len() == 3 + && parts.iter().all(|p| { + !p.is_empty() + && p.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '=') + }) + }; + let has_url_password = url::Url::parse(value).is_ok_and(|u| u.password().is_some()); + is_private_pem || is_jwt || has_url_password || is_high_entropy(value) +} + +/// A single token whose longest run between separators is 24+ chars mixing letters and digits +/// with more than 4 bits of entropy per char: above what hex digests, UUIDs and words reach. +/// Scoring the whole value instead flags pod names and cloud resource paths, whose entropy +/// grows with their length. +fn is_high_entropy(value: &str) -> bool { + if !value + .chars() + .all(|c| c.is_ascii_alphanumeric() || "+/=_-".contains(c)) + { + return false; + } + let Some(run) = value.split(['-', '_', '/']).max_by_key(|s| s.len()) else { + return false; + }; + if run.len() < 24 + || !run.chars().any(|c| c.is_ascii_digit()) + || !run.chars().any(|c| c.is_ascii_alphabetic()) + { + return false; + } + let mut counts = HashMap::<char, usize>::new(); + for c in run.chars() { + *counts.entry(c).or_default() += 1; + } + let len = run.len() as f64; + let entropy: f64 = counts + .values() + .map(|&n| { + let p = n as f64 / len; + -p * p.log2() + }) + .sum(); + entropy > 4.0 +} + pub fn load_env_vars( iter: impl Iterator<Item = String>, env_vars_static: &HashMap<String, String>, @@ -2767,9 +2947,11 @@ pub struct WorkerConfigOpt { pub pip_local_dependencies: Option<Vec<String>>, pub env_vars_static: Option<HashMap<String, String>>, pub env_vars_allowlist: Option<Vec<String>>, + pub disable_job_proxy: Option<bool>, pub native_mode: Option<bool>, pub object_store_cache_config: Option<serde_json::Value>, pub paused: Option<bool>, + pub serves_dev_workspaces: Option<bool>, } impl Default for WorkerConfigOpt { @@ -2787,9 +2969,11 @@ impl Default for WorkerConfigOpt { pip_local_dependencies: Default::default(), env_vars_static: Default::default(), env_vars_allowlist: Default::default(), + disable_job_proxy: Default::default(), native_mode: Default::default(), object_store_cache_config: Default::default(), paused: Default::default(), + serves_dev_workspaces: Default::default(), } } } @@ -2807,6 +2991,8 @@ pub struct WorkerConfig { pub additional_python_paths: Option<Vec<String>>, pub pip_local_dependencies: Option<Vec<String>>, pub env_vars: HashMap<String, String>, + /// Job runs get none of the worker's `HTTP(S)_PROXY`/`NO_PROXY`; dependency installs keep them. + pub disable_job_proxy: bool, pub native_mode: bool, /// Object store this group's dependency cache uses instead of the instance one, as stored /// in the group config. Raw JSON: `windmill-common` cannot depend on the object store crate @@ -2819,8 +3005,8 @@ pub struct WorkerConfig { impl std::fmt::Debug for WorkerConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "WorkerConfig {{ worker_tags: {:?}, priority_tags_sorted: {:?}, dedicated_worker: {:?}, dedicated_workers: {:?}, init_bash: {:?}, periodic_script_bash: {:?}, periodic_script_interval_seconds: {:?}, cache_clear: {:?}, additional_python_paths: {:?}, pip_local_dependencies: {:?}, env_vars: {:?}, native_mode: {:?}, paused: {:?}, object_store_cache_config: {} }}", - self.worker_tags, self.priority_tags_sorted, self.dedicated_worker, self.dedicated_workers, self.init_bash, self.periodic_script_bash, self.periodic_script_interval_seconds, self.cache_clear, self.additional_python_paths, self.pip_local_dependencies, self.env_vars.iter().map(|(k, v)| format!("{}: {}{} ({} chars)", k, &v[..3.min(v.len())], "***", v.len())).collect::<Vec<String>>().join(", "), self.native_mode, self.paused, + write!(f, "WorkerConfig {{ worker_tags: {:?}, priority_tags_sorted: {:?}, dedicated_worker: {:?}, dedicated_workers: {:?}, init_bash: {:?}, periodic_script_bash: {:?}, periodic_script_interval_seconds: {:?}, cache_clear: {:?}, additional_python_paths: {:?}, pip_local_dependencies: {:?}, env_vars: {:?}, disable_job_proxy: {:?}, native_mode: {:?}, paused: {:?}, object_store_cache_config: {} }}", + self.worker_tags, self.priority_tags_sorted, self.dedicated_worker, self.dedicated_workers, self.init_bash, self.periodic_script_bash, self.periodic_script_interval_seconds, self.cache_clear, self.additional_python_paths, self.pip_local_dependencies, self.env_vars.iter().map(|(k, v)| format!("{}: {}{} ({} chars)", k, &v[..3.min(v.len())], "***", v.len())).collect::<Vec<String>>().join(", "), self.disable_job_proxy, self.native_mode, self.paused, // holds bucket credentials self.object_store_cache_config.as_ref().map(|_| "***").unwrap_or("None")) } @@ -3016,6 +3202,57 @@ mod tests { use super::*; use std::collections::HashMap; + #[test] + fn whitelist_env_secret_heuristic() { + let cases = [ + ("GITHUB_TOKEN", "x", true), + ("aws_secret_access_key", "", true), + ("DB-PASSWORD", "x", true), + ("OPENAI_APIKEY", "x", true), + ("MY_CREDENTIALS", "x", true), + ("PRIVATE_REGISTRY", "x", true), + ("MONKEY", "banana", false), + ("KEYCLOAK_URL", "https://kc.example.com", false), + ("TOKENIZER_PATH", "/opt/tok", false), + ( + "SSH_CFG", + "-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----", + true, + ), + ( + "CA_CERT", + "-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----", + false, + ), + ( + "JWT_LIKE", + "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJl", + true, + ), + ("DB_URL", "postgres://user:hunter2@db:5432/app", true), + ("PROXY", "http://proxy.local:3128", false), + ("PROXY_USER", "http://bob@proxy.local:3128", false), + ("OPAQUE", "app_51HxQ2vLk9Tq8ZpR3mNcY7wB4aD6eF0gJ", true), + ("GIT_SHA", "3f786850e387550fdab836ed7e6dc881de23001b", false), + ("RUN_ID", "123e4567-e89b-12d3-a456-426614174000", false), + ( + "SOME_PATH", + "/usr/local/lib/python3.11/site-packages/x1", + false, + ), + ("REGION", "eu-west-1", false), + ("HOSTNAME", "windmill-workers-7d9f8c6b5-x2k4p", false), + ( + "TOPIC", + "projects/my-gcp-project-123/locations/europe-west1", + false, + ), + ]; + for (name, value, expected) in cases { + assert_eq!(looks_like_secret(name, value), expected, "{name}={value}"); + } + } + #[cfg(not(windows))] #[test] fn cgroup_cpu_stat_is_normalized_to_microseconds() { @@ -3606,6 +3843,22 @@ mod tests { assert!(combined.prepare); } + #[test] + fn test_no_network_annotation() { + assert!(PythonAnnotations::parse("# sandbox\n# no_network\ndef main(): pass").no_network); + assert!(!PythonAnnotations::parse("# sandbox\ndef main(): pass").no_network); + assert!(RlangAnnotations::parse("#no_network\nmain <- function() {}").no_network); + assert!( + TypeScriptAnnotations::parse("// no_network\nexport function main() {}").no_network + ); + let bash = "# sandbox alpine\n# no_network\necho hi"; + assert!(BashAnnotations::parse(bash).no_network); + assert_eq!( + BashAnnotations::sandbox_image(bash), + Some("alpine".to_string()) + ); + } + #[test] fn test_python_sandbox_annotation_with_other_annotations() { let content = "# no_cache\n# sandbox\ndef main():\n pass"; diff --git a/backend/windmill-common/src/workspace_crypt.rs b/backend/windmill-common/src/workspace_crypt.rs index 6bdea28391..b8114c3abb 100644 --- a/backend/windmill-common/src/workspace_crypt.rs +++ b/backend/windmill-common/src/workspace_crypt.rs @@ -21,6 +21,7 @@ use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng}; use aes_gcm::{Aes256Gcm, Key, Nonce}; use base64::Engine; use hkdf::Hkdf; +use hmac::{Hmac, Mac}; use magic_crypt::{MagicCrypt256, MagicCryptTrait}; use sha2::Sha256; @@ -36,6 +37,7 @@ const NARROW_VARIABLE_VALUE_WIDTH: usize = 15000; pub const MAX_BYTES_OVERHEAD: usize = V2_MAGIC.len() + NONCE_LEN + BLOCK + TAG_LEN; const AEAD_KEY_INFO: &[u8] = b"windmill workspace secret aes-256-gcm v1"; +const SYNTHETIC_NONCE_KEY_INFO: &[u8] = b"windmill workspace secret synthetic nonce v1"; const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD; @@ -79,6 +81,7 @@ impl CipherFormat { pub struct WorkspaceCrypt { legacy: MagicCrypt256, aead: Aes256Gcm, + synthetic_nonce_key: [u8; 32], } /// Code that drives `magic_crypt` directly on this cipher (app link secrets) keeps the legacy @@ -99,9 +102,14 @@ impl WorkspaceCrypt { Hkdf::<Sha256>::new(None, crypt_key.as_bytes()) .expand(AEAD_KEY_INFO, &mut aead_key) .expect("32 bytes is a valid HKDF-SHA256 output length"); + let mut synthetic_nonce_key = [0u8; 32]; + Hkdf::<Sha256>::new(None, crypt_key.as_bytes()) + .expand(SYNTHETIC_NONCE_KEY_INFO, &mut synthetic_nonce_key) + .expect("32 bytes is a valid HKDF-SHA256 output length"); Self { legacy: magic_crypt::new_magic_crypt!(crypt_key, 256), aead: Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&aead_key)), + synthetic_nonce_key, } } @@ -132,6 +140,18 @@ impl WorkspaceCrypt { } } + /// A v2 value whose nonce is an HMAC of the plaintext, so the same plaintext always gives + /// the same ciphertext. For values written into synced files, where a fresh nonce on every + /// export would show as a change. It reveals that two values are equal, nothing more; it + /// decrypts with [`Self::decrypt_str`] like any v2 value. + pub fn encrypt_str_deterministic(&self, plaintext: &str) -> String { + let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(&self.synthetic_nonce_key) + .expect("HMAC accepts any key length"); + mac.update(plaintext.as_bytes()); + let tag = mac.finalize().into_bytes(); + B64.encode(self.seal_with_nonce(plaintext.as_bytes(), Nonce::from_slice(&tag[..NONCE_LEN]))) + } + pub fn decrypt_str(&self, ciphertext: &str) -> Result<String, DecryptFailure> { let bytes = B64 .decode(ciphertext) @@ -175,18 +195,21 @@ impl WorkspaceCrypt { } fn seal(&self, plaintext: &[u8]) -> Vec<u8> { - let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + self.seal_with_nonce(plaintext, &Aes256Gcm::generate_nonce(&mut OsRng)) + } + + fn seal_with_nonce(&self, plaintext: &[u8], nonce: &Nonce<<Aes256Gcm as AeadCore>::NonceSize>) -> Vec<u8> { let pad = BLOCK - plaintext.len() % BLOCK; let mut padded = Vec::with_capacity(plaintext.len() + pad); padded.extend_from_slice(plaintext); padded.resize(plaintext.len() + pad, pad as u8); let sealed = self .aead - .encrypt(&nonce, padded.as_slice()) + .encrypt(nonce, padded.as_slice()) .expect("AES-GCM encryption fails only past 64 GiB of plaintext"); let mut out = Vec::with_capacity(V2_MAGIC.len() + NONCE_LEN + sealed.len()); out.extend_from_slice(&V2_MAGIC); - out.extend_from_slice(&nonce); + out.extend_from_slice(nonce); out.extend_from_slice(&sealed); out } @@ -234,6 +257,17 @@ mod tests { } } + #[test] + fn deterministic_is_stable_per_plaintext_and_key() { + let wc = WorkspaceCrypt::new(KEY); + let a = wc.encrypt_str_deterministic("secret"); + assert_eq!(a, wc.encrypt_str_deterministic("secret")); + assert_ne!(a, wc.encrypt_str_deterministic("secret2")); + assert_ne!(a, WorkspaceCrypt::new("other-key").encrypt_str_deterministic("secret")); + assert!(wc.is_v2(&a)); + assert_eq!(wc.decrypt_str(&a).unwrap(), "secret"); + } + #[test] fn legacy_cbc_still_decrypts() { let wc = WorkspaceCrypt::new(KEY); diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 444a340c3f..3b5d78c5b7 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -7,8 +7,10 @@ use strum::AsRefStr; use crate::{ datatable_roles::{ADMIN_DATATABLE_ROLE, CUSTOM_INSTANCE_USER}, + db::Authable, error::{self, to_anyhow, Error, Result}, get_database_url, + roles::{caller_capabilities, AuthorLevel, RoleCapabilities}, secret_backend::{get_secret_value, is_external_stored_value}, utils::{get_custom_pg_instance_password, get_custom_pg_instance_replication_password}, variables::{build_crypt, decrypt}, @@ -73,6 +75,7 @@ bitflags::bitflags! { const RESTRICT_ANONYMOUS_APP_DEPLOYMENT = 1 << 3; const RESTRICT_PUBLIC_RUN_SHARING = 1 << 4; const RESTRICT_GUEST_APP_DEPLOYMENT = 1 << 5; + const REQUIRE_ISOLATED_FORKS = 1 << 6; } } @@ -86,6 +89,7 @@ pub enum ProtectionRuleKind { RestrictAnonymousAppDeployment, RestrictPublicRunSharing, RestrictGuestAppDeployment, + RequireIsolatedForks, } impl ProtectionRuleKind { @@ -109,6 +113,7 @@ impl ProtectionRuleKind { ProtectionRuleKind::RestrictGuestAppDeployment => { ProtectionRules::RESTRICT_GUEST_APP_DEPLOYMENT } + ProtectionRuleKind::RequireIsolatedForks => ProtectionRules::REQUIRE_ISOLATED_FORKS, } } @@ -130,6 +135,9 @@ impl ProtectionRuleKind { ProtectionRuleKind::RestrictGuestAppDeployment => { "Opening an app to guests (anyone who can sign in) is restricted in this workspace" } + ProtectionRuleKind::RequireIsolatedForks => { + "Forks of this workspace must isolate its data: no shared object storage or ducklake, and every data table cloned" + } } } } @@ -144,6 +152,15 @@ impl From<&Vec<ProtectionRuleKind>> for ProtectionRules { } } +impl ProtectionRules { + /// The rules set, in declaration order. + pub fn kinds(self) -> Vec<ProtectionRuleKind> { + <ProtectionRuleKind as strum::IntoEnumIterator>::iter() + .filter(|k| self.contains(k.flag())) + .collect() + } +} + #[derive(Serialize, Deserialize, Debug, Default)] pub struct WorkspaceGitSyncSettings { #[serde(skip_serializing_if = "Option::is_none")] @@ -184,7 +201,7 @@ pub enum ObjectType { DatatableMigration, } -pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/29151/sync-script-to-git-repo-windmill"; +pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/29785/sync-script-to-git-repo-windmill"; /// Hub script that applies a repository's state back into a workspace /// (the repo → Windmill / "pull" direction). Same script the UI runs from @@ -192,7 +209,7 @@ pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/29151/sync-script-to-git-repo /// ignores the slug, so the slug is kept free of characters that would be /// percent-encoded into the run URL (a `:` becomes `%3A`, which some hardened /// reverse proxies reject as double-encoding when the client re-encodes it). -pub const GIT_SYNC_PULL_SCRIPT_PATH: &str = "hub/29150/git-sync-init-repository-windmill"; +pub const GIT_SYNC_PULL_SCRIPT_PATH: &str = "hub/29784/git-sync-init-repository-windmill"; /// Prefix used to identify fork workspaces. A workspace whose id starts with this string is a /// fork of another workspace. @@ -1045,9 +1062,9 @@ pub async fn guest_app_admits<'c, E: sqlx::Executor<'c, Database = sqlx::Postgre } /// Billable members of `w_id` and the seats they cost, as -/// `ceil(developers + (operators + service_accounts)/2)`. A service account is half a seat whatever -/// its role; a disabled member is not billed. In a workspace that granted operators builder rights, -/// every operator counts as a developer. +/// `ceil(authors + (non_authors + service_accounts)/2)`. A service account is half a seat whatever +/// its role; a disabled member is not billed. A member is an author when its role writes or +/// composes scripts, flows or apps (`crate::roles::AUTHOR_SEAT_SQL`). /// /// The workspace is invoiced by a job outside this codebase that counts the same rows with its own /// SQL. The two must be changed together: this rule disagreeing with that one is what bills a @@ -1056,28 +1073,23 @@ pub async fn guest_app_admits<'c, E: sqlx::Executor<'c, Database = sqlx::Postgre /// Unauthenticated metering helper: reads member counts for any `w_id`, so callers must already be /// authorized for that workspace (or run in trusted server-side code). pub async fn billable_seats(db: &crate::DB, w_id: &str) -> Result<BillableSeats> { - let row = sqlx::query!( - r#"SELECT - COUNT(*) FILTER (WHERE NOT operator AND NOT disabled AND NOT is_service_account) AS "developers!", - COUNT(*) FILTER (WHERE operator AND NOT disabled AND NOT is_service_account) AS "operators!", - COUNT(*) FILTER (WHERE is_service_account AND NOT disabled) AS "service_accounts!" - FROM usr WHERE workspace_id = $1"#, - w_id - ) + let (developers, operators, service_accounts): (i64, i64, i64) = sqlx::query_as(&format!( + "SELECT + COUNT(*) FILTER (WHERE ({author}) AND NOT disabled AND NOT is_service_account), + COUNT(*) FILTER (WHERE NOT ({author}) AND NOT disabled AND NOT is_service_account), + COUNT(*) FILTER (WHERE is_service_account AND NOT disabled) + FROM usr t WHERE workspace_id = $1", + author = crate::roles::AUTHOR_SEAT_SQL + )) + .bind(w_id) .fetch_one(db) .await .map_err(|e| Error::internal_err(format!("counting billable seats of {w_id}: {e:#}")))?; - let (developers, operators) = if operator_builder_rights(db, w_id).await?.any() { - (row.developers + row.operators, 0) - } else { - (row.developers, row.operators) - }; Ok(BillableSeats { developers, operators, - service_accounts: row.service_accounts, - seats: ((developers as f64) + 0.5 * ((operators + row.service_accounts) as f64)).ceil() - as i64, + service_accounts, + seats: ((developers as f64) + 0.5 * ((operators + service_accounts) as f64)).ceil() as i64, }) } @@ -1197,25 +1209,10 @@ pub fn invalidate_protection_rules_cache(workspace_id: &str) { PROTECTION_RULES_CACHE.remove(workspace_id); } -// Operator rights cache +// Role rights. What a restricted caller may author or manage comes from its role; see +// `crate::roles`. The gates below keep the operator vocabulary of the call sites that predate roles. -lazy_static::lazy_static! { - static ref OPERATOR_RIGHTS_CACHE: Cache<String, (OperatorRights, i64)> = Cache::new(1000); -} - -/// Every operator right of a workspace, read and cached together because they share one -/// `operator_settings` column and one invalidation. The two groups have opposite polarity: -/// the builder rights are granted on request and cost a seat, the `manage` rights are held by -/// default and cost nothing. -#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] -pub struct OperatorRights { - pub builder: OperatorBuilderRights, - pub manage: OperatorManageRights, -} - -/// The two things a workspace can let its operators compose. Granted independently: composing a -/// flow is fully checked server-side, while a full-code app is a browser-built bundle no check can -/// read, so an admin may reasonably want the first without the second. +/// What a caller may compose or author, per kind, at least at the `Compose` level. #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] pub struct OperatorBuilderRights { pub flows: bool, @@ -1223,8 +1220,8 @@ pub struct OperatorBuilderRights { } /// Which capability a gate needs. Naming a kind at each call site is what keeps the two rights -/// from collapsing back into one: a gate that takes "either" authorizes an app write on a -/// flows-only workspace. +/// from collapsing back into one: a gate that takes "either" authorizes an app write for a role +/// that may only compose flows. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum BuilderKind { Flows, @@ -1232,8 +1229,8 @@ pub enum BuilderKind { } impl OperatorBuilderRights { - /// Whether either right is granted. Only for surfaces that are genuinely not per-kind: the - /// seat a membership costs, and listing drafts. + /// Whether either right is granted. Only for surfaces that are genuinely not per-kind, such + /// as listing drafts. pub fn any(&self) -> bool { self.flows || self.apps } @@ -1244,23 +1241,9 @@ impl OperatorBuilderRights { BuilderKind::Apps => self.apps, } } -} -/// Writes an operator may perform unless the workspace withdraws them. Unlike the visibility -/// flags beside them in `operator_settings`, these are enforced; unlike a right a workspace -/// grants, they are held by default and cost no seat. -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -pub struct OperatorManageRights { - pub schedules: bool, - pub triggers: bool, -} - -/// Granted unless explicitly withdrawn. Every path that materializes these rights without a -/// stored setting - a workspace with no `workspace_settings` row included - has to land here, or -/// an upgrade silently revokes what operators could do the day before. -impl Default for OperatorManageRights { - fn default() -> Self { - Self { schedules: true, triggers: true } + pub fn from_capabilities(caps: &RoleCapabilities) -> Self { + Self { flows: caps.flows != AuthorLevel::None, apps: caps.apps != AuthorLevel::None } } } @@ -1291,121 +1274,136 @@ impl ManageKind { } } -impl OperatorManageRights { - pub fn has(&self, kind: ManageKind) -> bool { - match kind { - ManageKind::Schedules => self.schedules, - ManageKind::Triggers => self.triggers, - } - } -} - -/// What operators of this workspace may still write. Per workspace, not per user. -/// -/// Read on every gated write, so it is cached with a 60s TTL. This gates writes, so a withdrawal -/// cannot wait out that TTL on the rest of the fleet: an `AFTER UPDATE OF operator_settings` -/// trigger publishes `notify_operator_settings_change` and every server drops its entry through -/// `process_notify_event`. [`invalidate_operator_rights_cache`] is the local half of that, and -/// what a test flipping the setting directly has to call itself. -/// -/// Call it before opening an RLS transaction: it takes a connection from the root pool, and a -/// second pooled connection held alongside a transaction self-deadlocks on a one-connection pool. -async fn operator_rights(db: &DB, workspace_id: &str) -> Result<OperatorRights> { - let now = chrono::Utc::now().timestamp(); - - if let Some((rights, expiry)) = OPERATOR_RIGHTS_CACHE.get(workspace_id) { - if expiry > now { - return Ok(rights); - } - } - - // The builder key defaults to false, a right the workspace has to grant; the manage keys to - // true, rights it has to withdraw. An absent key means "never configured" for both, so the - // defaults have to differ here rather than at the call sites. - let row = sqlx::query!( - "SELECT COALESCE((operator_settings->>'builder_flows')::boolean, false) AS \"flows!\", - COALESCE((operator_settings->>'builder_apps')::boolean, false) AS \"apps!\", - COALESCE((operator_settings->>'manage_schedules')::boolean, true) AS \"schedules!\", - COALESCE((operator_settings->>'manage_triggers')::boolean, true) AS \"triggers!\" - FROM workspace_settings WHERE workspace_id = $1", - workspace_id - ) - .fetch_optional(db) - .await - .map_err(|e| { - Error::internal_err(format!( - "Failed to fetch operator settings for {workspace_id}: {e:#}" - )) - })?; - - let rights = row - .map(|r| OperatorRights { - builder: OperatorBuilderRights { flows: r.flows, apps: r.apps }, - manage: OperatorManageRights { schedules: r.schedules, triggers: r.triggers }, - }) - .unwrap_or_default(); - - OPERATOR_RIGHTS_CACHE.insert(workspace_id.to_string(), (rights, now + 60)); - - Ok(rights) -} - -/// What operators of this workspace may compose out of already-deployed runnables. Per workspace, -/// not per user: every operator gets whatever is granted, and consumes a full seat for it. -pub async fn operator_builder_rights(db: &DB, workspace_id: &str) -> Result<OperatorBuilderRights> { - Ok(operator_rights(db, workspace_id).await?.builder) -} - -/// Gate for a write only a workspace that granted the builder right covering `kind` lets operators -/// perform. `action` completes "Operators cannot {action} for security reasons". -pub async fn check_operator_can_build( +/// What the caller may compose or author, per kind. Everything for an unrestricted caller. +pub async fn operator_builder_rights<A: Authable + ?Sized>( db: &DB, workspace_id: &str, - is_operator: bool, + authed: &A, +) -> Result<OperatorBuilderRights> { + Ok(OperatorBuilderRights::from_capabilities( + &caller_capabilities(db, workspace_id, authed).await?, + )) +} + +/// Whether the caller may save a draft of `kind`: one they could deploy. A resource or variable +/// always, since their routes do not gate roles; a flow or full-code app at the `Compose` level or +/// above, a low-code app at `Full`, a schedule or trigger under the matching manage right, a +/// script or data pipeline with `code`. +pub async fn operator_can_draft<A: Authable + ?Sized>( + db: &DB, + workspace_id: &str, + authed: &A, + kind: crate::user_drafts::UserDraftItemKind, +) -> Result<bool> { + use crate::user_drafts::UserDraftItemKind; + if matches!( + kind, + UserDraftItemKind::Resource | UserDraftItemKind::Variable + ) { + return Ok(true); + } + let caps = caller_capabilities(db, workspace_id, authed).await?; + // Exhaustive on purpose: a new kind must pick the capability it needs, or every non-admin + // (unrestricted developers included) is refused its drafts. + Ok(match kind { + UserDraftItemKind::Flow => caps.flows != AuthorLevel::None, + UserDraftItemKind::RawApp => caps.apps != AuthorLevel::None, + UserDraftItemKind::Script | UserDraftItemKind::DataPipeline => caps.code, + UserDraftItemKind::App => caps.apps == AuthorLevel::Full, + UserDraftItemKind::Resource | UserDraftItemKind::Variable => true, + UserDraftItemKind::TriggerSchedule + | UserDraftItemKind::TriggerWebhook + | UserDraftItemKind::TriggerDefaultEmail + | UserDraftItemKind::TriggerEmail + | UserDraftItemKind::TriggerHttp + | UserDraftItemKind::TriggerWebsocket + | UserDraftItemKind::TriggerPostgres + | UserDraftItemKind::TriggerKafka + | UserDraftItemKind::TriggerNats + | UserDraftItemKind::TriggerMqtt + | UserDraftItemKind::TriggerAmqp + | UserDraftItemKind::TriggerSqs + | UserDraftItemKind::TriggerGcp + | UserDraftItemKind::TriggerAzure + | UserDraftItemKind::TriggerPoll + | UserDraftItemKind::TriggerCli + | UserDraftItemKind::TriggerNextcloud + | UserDraftItemKind::TriggerGoogle + | UserDraftItemKind::TriggerGithub => { + kind.manage_kind().is_some_and(|m| caps.has_manage(m)) + } + }) +} + +/// Gate for a flow or app write. Refuses a caller whose role holds no right on `kind`, and returns +/// the level it does hold: at `Compose` the caller MUST then run the composition-only checks, and +/// only at `Full` may skip them. `action` completes "Your role cannot {action}". +pub async fn check_operator_can_build<A: Authable + ?Sized>( + db: &DB, + workspace_id: &str, + authed: &A, kind: BuilderKind, action: &str, -) -> Result<()> { - if is_operator && !operator_builder_rights(db, workspace_id).await?.has(kind) { +) -> Result<AuthorLevel> { + let level = caller_capabilities(db, workspace_id, authed) + .await? + .level(kind); + if level == AuthorLevel::None { return Err(Error::PermissionDenied(format!( - "Operators cannot {action} for security reasons" + "Your role cannot {action} in this workspace" + ))); + } + Ok(level) +} + +/// Gate for a write that runs or stores code of the caller's own on a worker: scripts, previews, +/// dependency jobs, dynamic selects. `action` completes "Your role cannot {action}". +pub async fn check_role_can_code<A: Authable + ?Sized>( + db: &DB, + workspace_id: &str, + authed: &A, + action: &str, +) -> Result<()> { + if !caller_capabilities(db, workspace_id, authed).await?.code { + return Err(Error::PermissionDenied(format!( + "Your role cannot {action} in this workspace" ))); } Ok(()) } -/// Whether a membership consumes an operator (half) seat rather than an author seat. Either -/// builder right makes an operator an author of deployable artifacts, so both weigh the same full -/// seat: there is no half-author. +/// Whether a member holding role `role` in `workspace_id` consumes an operator (half) seat rather +/// than an author seat: a role that composes counts as an author, there is no half-author. pub async fn consumes_operator_seat( db: &DB, workspace_id: &str, - is_operator: bool, + is_admin: bool, + role: &str, ) -> Result<bool> { - Ok(is_operator && !operator_builder_rights(db, workspace_id).await?.any()) + Ok(!is_admin + && !crate::roles::role_capabilities_by_name(db, workspace_id, role) + .await? + .is_author()) } -/// Invalidate the operator rights cache for a workspace -pub fn invalidate_operator_rights_cache(workspace_id: &str) { - OPERATOR_RIGHTS_CACHE.remove(workspace_id); -} - -/// Gate for a write operators may perform unless the workspace withdrew it. Prefer layering +/// Gate for a write a role may perform unless its configuration withdraws it. Prefer layering /// `gate_operator_writes` on a whole router over calling this per handler; see -/// `docs/operator-write-rights.md`. Both `workspace_id` and `is_operator` MUST come from the -/// authenticated request (the route's workspace, `ApiAuthed::is_operator`): passing `false` skips -/// the check, and another workspace's id checks the wrong settings. -pub async fn check_operator_can_manage( +/// `docs/operator-write-rights.md`. `workspace_id` MUST be the route's workspace. +pub async fn check_operator_can_manage<A: Authable + ?Sized>( db: &DB, workspace_id: &str, - is_operator: bool, + authed: &A, kind: ManageKind, ) -> Result<()> { - if is_operator && !operator_rights(db, workspace_id).await?.manage.has(kind) { + if !caller_capabilities(db, workspace_id, authed) + .await? + .has_manage(kind) + { // 403, not 401: the caller is authenticated and simply lacks the right. The frontend reads // an uncaught 401 as a dead session and logs the user out, so `NotAuthorized` here would - // eject an operator from the app instead of telling them why. + // eject the user from the app instead of telling them why. return Err(Error::PermissionDenied(format!( - "Operators cannot manage {} in this workspace", + "Your role cannot manage {} in this workspace", kind.noun() ))); } @@ -1547,9 +1545,10 @@ pub struct DataTable { /// which is how data tables behaved before roles existed. Only meaningful on a terminal entry; /// a pointer is governed by what it points at. /// - /// Never leaves the instance: stripped from the workspace export and ignored on import, since - /// tenants are workspace-scoped names and syncing them would make repo write access a second - /// door onto the access decision. + /// Stripped from the `datatable` setting wherever it is read or written as a whole. It leaves + /// the instance only as the settings document's `datatable_permissions`, which `wmill sync` + /// carries only when the repo opts in: tenants are workspace-scoped names, and syncing them + /// makes repo write access a second door onto the access decision. #[serde(default, skip_serializing_if = "Option::is_none")] pub permissions: Option<DataTablePermissions>, } @@ -1775,7 +1774,23 @@ pub async fn managed_database_uses( dbname: &str, exempt: Option<(&str, &str)>, ) -> Result<Vec<String>> { - let (exempt_workspace, exempt_name) = exempt.unzip(); + match exempt { + Some((workspace_id, name)) => { + managed_database_uses_except(conn, kind, dbname, workspace_id, &[name]).await + } + None => managed_database_uses_except(conn, kind, dbname, "", &[]).await, + } +} + +/// [`managed_database_uses`], exempting every entry of `workspace_id` in `names`: the data tables +/// of a fork that share one copy stop using it together. +pub async fn managed_database_uses_except( + conn: &mut sqlx::PgConnection, + kind: DataTableCatalogResourceType, + dbname: &str, + workspace_id: &str, + names: &[&str], +) -> Result<Vec<String>> { Ok(sqlx::query_scalar::<_, String>( "WITH entries AS ( SELECT ws.workspace_id::text AS workspace_id, dt.key AS name, dt.value @@ -1789,7 +1804,7 @@ pub async fn managed_database_uses( AND value->'database'->>'resource_path' = $2 ) SELECT format('data table ''%s'' in workspace ''%s''', name, workspace_id) FROM naming - WHERE $3::text IS NULL OR NOT (workspace_id = $3 AND name = $4) + WHERE NOT (workspace_id = $3 AND name = ANY($4)) UNION ALL SELECT format('data table ''%s'' in workspace ''%s'', which points at the one in ''%s''', e.name, e.workspace_id, n.workspace_id) @@ -1812,8 +1827,8 @@ pub async fn managed_database_uses( ) .bind(kind.as_ref()) .bind(dbname) - .bind(exempt_workspace) - .bind(exempt_name) + .bind(workspace_id) + .bind(names) .fetch_all(&mut *conn) .await?) } @@ -2951,6 +2966,8 @@ lazy_static::lazy_static! { /// broadcasts on `FORK_LINEAGE_CHANGE_CHANNEL`. A process that receives no broadcast — an agent /// worker polls no notify events — has only the TTL, and takes the shorter one. static ref ROOT_WORKSPACE_CACHE: Cache<String, (String, i64)> = Cache::new(5000); + /// workspace id -> ((dev or fork, dev lineage), expiry ts), swept with the cache above. + static ref DEV_LINEAGE_CACHE: Cache<String, ((bool, bool), i64)> = Cache::new(5000); } const ROOT_WORKSPACE_CACHE_TTL_S: i64 = 300; @@ -2969,12 +2986,59 @@ const ROOT_WORKSPACE_UNRESOLVED_CACHE_TTL_S: i64 = 30; /// `windmill_queue::tags::invalidate_fork_parent_cache`. pub fn invalidate_root_workspace_cache(w_id: &str) { ROOT_WORKSPACE_CACHE.remove(w_id); + DEV_LINEAGE_CACHE.remove(w_id); } /// Drop every cached root workspace: the answer depends on the whole ancestor chain, so a mutation /// that reshapes the tree moves an unbounded set of descendants. pub fn clear_root_workspace_cache() { ROOT_WORKSPACE_CACHE.clear(); + DEV_LINEAGE_CACHE.clear(); +} + +/// Whether `w_id` has a parent or a generated fork id, and whether a dev workspace is on its +/// ancestor chain: the inputs of `worker::is_isolated_workspace`, as `windmill_queue::tags` reads +/// them, for callers outside that crate. `None` when the lookup failed, which is not cached. +pub async fn dev_lineage(db: &crate::DB, w_id: &str) -> Option<(bool, bool)> { + let now = chrono::Utc::now().timestamp(); + if let Some((lineage, expiry)) = DEV_LINEAGE_CACHE.get(w_id) { + if expiry > now { + return Some(lineage); + } + } + let (has_parent, in_dev_lineage) = sqlx::query_as::<_, (Option<bool>, Option<bool>)>( + r#" + WITH RECURSIVE chain AS ( + SELECT id, parent_workspace_id, is_dev_workspace, deleted, 0 AS depth + FROM workspace WHERE id = $1 + UNION ALL + SELECT w.id, w.parent_workspace_id, w.is_dev_workspace, w.deleted, chain.depth + 1 + FROM workspace w + JOIN chain ON w.id = chain.parent_workspace_id + WHERE chain.depth < 20 + ) + SELECT + (SELECT parent_workspace_id IS NOT NULL FROM chain WHERE depth = 0), + (SELECT bool_or(is_dev_workspace + OR (parent_workspace_id IS NULL AND id LIKE 'wm-fork-%') + OR (deleted AND parent_workspace_id IS NOT NULL)) + FROM chain) + "#, + ) + .bind(w_id) + .fetch_one(db) + .await + .map_err(|e| tracing::warn!("failed to resolve the dev lineage of {w_id}: {e:#}")) + .ok()?; + let lineage = ( + w_id.starts_with(WM_FORK_PREFIX) || has_parent == Some(true), + in_dev_lineage != Some(false), + ); + DEV_LINEAGE_CACHE.insert( + w_id.to_string(), + (lineage, now + ROOT_WORKSPACE_CACHE_TTL_S), + ); + Some(lineage) } /// Nearest ancestor-or-self of `w_id` that is an environment of its own: a root ("prod") workspace diff --git a/backend/windmill-common/tests/aws_sm_integration.rs b/backend/windmill-common/tests/aws_sm_integration.rs index 3b9aa2ece8..1030dc4d50 100644 --- a/backend/windmill-common/tests/aws_sm_integration.rs +++ b/backend/windmill-common/tests/aws_sm_integration.rs @@ -12,43 +12,24 @@ //! localstack/localstack:3.8 //! ``` //! -//! 2. Run the tests: +//! 2. Run the tests (they are `#[ignore]`d, so a plain `cargo test` skips them): //! //! ```bash -//! RUN_AWS_SM_TESTS=1 cargo test -p windmill-common --features private,enterprise \ -//! aws_sm_integration -- --nocapture +//! cargo test -p windmill-common --features private,enterprise \ +//! --test aws_sm_integration -- --ignored --nocapture //! ``` //! //! ## Environment variables //! -//! - `RUN_AWS_SM_TESTS=1` - Required to run the tests //! - `AWS_SM_ENDPOINT` - LocalStack endpoint (default: http://localhost:4566) //! - `AWS_SM_REGION` - AWS region (default: us-east-1) #[cfg(all(feature = "private", feature = "enterprise"))] mod tests { use windmill_common::secret_backend::{ - test_aws_sm_connection, AwsSecretsManagerBackend, AwsSecretsManagerSettings, - SecretBackend, + test_aws_sm_connection, AwsSecretsManagerBackend, AwsSecretsManagerSettings, SecretBackend, }; - fn should_run_aws_sm_tests() -> bool { - std::env::var("RUN_AWS_SM_TESTS") - .map(|v| v == "1" || v.to_lowercase() == "true") - .unwrap_or(false) - } - - macro_rules! skip_if_no_aws_sm { - () => { - if !should_run_aws_sm_tests() { - println!("Skipping test: RUN_AWS_SM_TESTS=1 not set"); - println!("To run: RUN_AWS_SM_TESTS=1 cargo test -p windmill-common --features private,enterprise aws_sm_integration -- --nocapture"); - println!("Requires: docker run -d -p 4566:4566 -e SERVICES=secretsmanager localstack/localstack:3.8"); - return; - } - }; - } - fn test_settings() -> AwsSecretsManagerSettings { AwsSecretsManagerSettings { region: std::env::var("AWS_SM_REGION").unwrap_or_else(|_| "us-east-1".to_string()), @@ -63,16 +44,16 @@ mod tests { } #[tokio::test] + #[ignore = "requires LocalStack secretsmanager at AWS_SM_ENDPOINT"] async fn test_connection() { - skip_if_no_aws_sm!(); let result = test_aws_sm_connection(&test_settings()).await; assert!(result.is_ok(), "Connection test failed: {:?}", result.err()); println!(" ✓ Connection test passed"); } #[tokio::test] + #[ignore = "requires LocalStack secretsmanager at AWS_SM_ENDPOINT"] async fn test_create_and_get_secret() { - skip_if_no_aws_sm!(); let backend = AwsSecretsManagerBackend::new_with_client(test_settings()) .await .unwrap(); @@ -81,16 +62,22 @@ mod tests { .set_secret("test-ws", "u/admin/my_secret", "super-secret-value") .await .unwrap(); - let value = backend.get_secret("test-ws", "u/admin/my_secret").await.unwrap(); + let value = backend + .get_secret("test-ws", "u/admin/my_secret") + .await + .unwrap(); assert_eq!(value, "super-secret-value"); - backend.delete_secret("test-ws", "u/admin/my_secret").await.unwrap(); + backend + .delete_secret("test-ws", "u/admin/my_secret") + .await + .unwrap(); println!(" ✓ Create + Get + Delete roundtrip passed"); } #[tokio::test] + #[ignore = "requires LocalStack secretsmanager at AWS_SM_ENDPOINT"] async fn test_update_secret() { - skip_if_no_aws_sm!(); let backend = AwsSecretsManagerBackend::new_with_client(test_settings()) .await .unwrap(); @@ -117,8 +104,8 @@ mod tests { } #[tokio::test] + #[ignore = "requires LocalStack secretsmanager at AWS_SM_ENDPOINT"] async fn test_delete_and_verify_gone() { - skip_if_no_aws_sm!(); let backend = AwsSecretsManagerBackend::new_with_client(test_settings()) .await .unwrap(); @@ -142,8 +129,8 @@ mod tests { } #[tokio::test] + #[ignore = "requires LocalStack secretsmanager at AWS_SM_ENDPOINT"] async fn test_delete_nonexistent_is_ok() { - skip_if_no_aws_sm!(); let backend = AwsSecretsManagerBackend::new_with_client(test_settings()) .await .unwrap(); @@ -160,8 +147,8 @@ mod tests { } #[tokio::test] + #[ignore = "requires LocalStack secretsmanager at AWS_SM_ENDPOINT"] async fn test_get_nonexistent_returns_not_found() { - skip_if_no_aws_sm!(); let backend = AwsSecretsManagerBackend::new_with_client(test_settings()) .await .unwrap(); @@ -178,8 +165,8 @@ mod tests { } #[tokio::test] + #[ignore = "requires LocalStack secretsmanager at AWS_SM_ENDPOINT"] async fn test_unicode_and_special_chars() { - skip_if_no_aws_sm!(); let backend = AwsSecretsManagerBackend::new_with_client(test_settings()) .await .unwrap(); @@ -203,8 +190,8 @@ mod tests { } #[tokio::test] + #[ignore = "requires LocalStack secretsmanager at AWS_SM_ENDPOINT"] async fn test_workspace_isolation() { - skip_if_no_aws_sm!(); let backend = AwsSecretsManagerBackend::new_with_client(test_settings()) .await .unwrap(); @@ -244,8 +231,8 @@ mod tests { } #[tokio::test] + #[ignore = "requires LocalStack secretsmanager at AWS_SM_ENDPOINT"] async fn test_backend_name() { - skip_if_no_aws_sm!(); let backend = AwsSecretsManagerBackend::new_with_client(test_settings()) .await .unwrap(); diff --git a/backend/windmill-common/tests/azure_kv_integration.rs b/backend/windmill-common/tests/azure_kv_integration.rs index 7606508abc..110678bd84 100644 --- a/backend/windmill-common/tests/azure_kv_integration.rs +++ b/backend/windmill-common/tests/azure_kv_integration.rs @@ -23,11 +23,12 @@ //! - Trust the emulator's certificate, or //! - Set `AZURE_KV_ALLOW_INSECURE=true` to skip TLS verification (test only) //! -//! 3. Run the tests: +//! 3. Run the tests (they are `#[ignore]`d, so a plain `cargo test` skips them; +//! `AZURE_KV_URL` overrides the default https://localhost:4997): //! //! ```bash -//! RUN_AZURE_KV_TESTS=1 cargo test -p windmill-common --features private,enterprise \ -//! azure_kv_integration -- --nocapture +//! cargo test -p windmill-common --features private,enterprise \ +//! --test azure_kv_integration -- --ignored --nocapture //! ``` //! //! ## Emulator authentication @@ -41,22 +42,6 @@ mod tests { test_azure_kv_connection, AzureKeyVaultBackend, AzureKeyVaultSettings, SecretBackend, }; - fn should_run_azure_kv_tests() -> bool { - std::env::var("RUN_AZURE_KV_TESTS") - .map(|v| v == "1" || v.to_lowercase() == "true") - .unwrap_or(false) - } - - macro_rules! skip_if_no_azure_kv { - () => { - if !should_run_azure_kv_tests() { - println!("Skipping test: RUN_AZURE_KV_TESTS=1 not set"); - println!("To run Azure KV tests: RUN_AZURE_KV_TESTS=1 cargo test ..."); - return; - } - }; - } - /// A minimal valid JWT token (the emulator doesn't verify signatures). /// Format: base64(header).base64(payload).base64(signature) fn dummy_jwt_token() -> String { @@ -82,9 +67,8 @@ mod tests { } #[tokio::test] + #[ignore = "requires an Azure Key Vault emulator at AZURE_KV_URL"] async fn test_azure_kv_connection_with_token() { - skip_if_no_azure_kv!(); - let settings = azure_kv_settings(); println!("Testing Azure KV connection..."); println!(" URL: {}", settings.vault_url); @@ -99,9 +83,8 @@ mod tests { } #[tokio::test] + #[ignore = "requires an Azure Key Vault emulator at AZURE_KV_URL"] async fn test_azure_kv_crud() { - skip_if_no_azure_kv!(); - let backend = create_backend(); let workspace_id = "test-crud"; let path = "test-secret"; @@ -160,9 +143,8 @@ mod tests { } #[tokio::test] + #[ignore = "requires an Azure Key Vault emulator at AZURE_KV_URL"] async fn test_azure_kv_not_found() { - skip_if_no_azure_kv!(); - let backend = create_backend(); let result = backend @@ -173,9 +155,8 @@ mod tests { } #[tokio::test] + #[ignore = "requires an Azure Key Vault emulator at AZURE_KV_URL"] async fn test_azure_kv_secret_name_encoding() { - skip_if_no_azure_kv!(); - let backend = create_backend(); let workspace_id = "demo"; let path = "u/admin/my_secret_key"; diff --git a/backend/windmill-common/tests/billing_workspace.rs b/backend/windmill-common/tests/billing_workspace.rs index ccc013d47a..3e962f5840 100644 --- a/backend/windmill-common/tests/billing_workspace.rs +++ b/backend/windmill-common/tests/billing_workspace.rs @@ -125,13 +125,12 @@ async fn paid_seats_and_fork_count(db: Pool<Postgres>) { ); sqlx::query( - "INSERT INTO workspace_settings (workspace_id, operator_settings) - VALUES ('seat-root', '{\"builder_flows\": true}')", + "INSERT INTO role_seat (workspace_id, role, author) VALUES ('seat-root', 'operator', true)", ) .execute(&db) .await .unwrap(); - windmill_common::workspaces::invalidate_operator_rights_cache("seat-root"); + windmill_common::roles::invalidate_all_roles_cache(); let breakdown = billable_seats(&db, "seat-root").await.unwrap(); assert_eq!( ( @@ -141,22 +140,29 @@ async fn paid_seats_and_fork_count(db: Pool<Postgres>) { breakdown.seats ), (4, 0, 1, 5), - "builder rights bill every operator as a developer, service accounts stay half a seat" + "a role that composes bills its members as developers, service accounts stay half a seat" ); + sqlx::query("UPDATE role_seat SET author = false WHERE workspace_id = 'seat-root'") + .execute(&db) + .await + .unwrap(); + windmill_common::roles::invalidate_all_roles_cache(); + let breakdown = billable_seats(&db, "seat-root").await.unwrap(); + assert_eq!( + (breakdown.developers, breakdown.operators, breakdown.seats), + (2, 2, 4), + "a workspace override that authors nothing bills its members at half a seat" + ); + + // An admin holds everything, so it is a full seat whatever role it keeps underneath. sqlx::query( - "UPDATE workspace_settings SET operator_settings = '{\"builder_apps\": true}' - WHERE workspace_id = 'seat-root'", + "UPDATE usr SET is_admin = true WHERE workspace_id = 'seat-root' AND email = 'op1@w.dev'", ) .execute(&db) .await .unwrap(); - windmill_common::workspaces::invalidate_operator_rights_cache("seat-root"); let breakdown = billable_seats(&db, "seat-root").await.unwrap(); - assert_eq!( - (breakdown.developers, breakdown.operators, breakdown.seats), - (4, 0, 5), - "the app right alone also bills every operator as a developer" - ); + assert_eq!((breakdown.developers, breakdown.operators), (3, 1)); insert_ws(&db, "seat-fork1", Some("seat-root"), false).await; insert_ws(&db, "seat-fork2", Some("seat-root"), false).await; diff --git a/backend/windmill-common/tests/instance_group_auto_add.rs b/backend/windmill-common/tests/instance_group_auto_add.rs index 95b20cc842..963b6e7a79 100644 --- a/backend/windmill-common/tests/instance_group_auto_add.rs +++ b/backend/windmill-common/tests/instance_group_auto_add.rs @@ -440,8 +440,13 @@ mod tests { .and_then(|ig| serde_json::from_value(ig).ok()) .unwrap_or_default(); - let (best_group, is_admin, is_operator) = - compute_highest_workspace_role(&user_igroups, &ws_configured_groups, &ws_roles); + let (best_group, is_admin, role) = compute_highest_workspace_role( + &user_igroups, + &ws_configured_groups, + &ws_roles, + &["developer".to_string(), "operator".to_string()], + ); + let is_operator = role == "operator"; let instance_group_source = json!({ "source": "instance_group", @@ -528,8 +533,13 @@ mod tests { let ws_roles: std::collections::HashMap<String, String> = serde_json::from_value(roles).unwrap(); - let (best_group, is_admin, is_operator) = - compute_highest_workspace_role(&user_igroups, &ws_configured_groups, &ws_roles); + let (best_group, is_admin, role) = compute_highest_workspace_role( + &user_igroups, + &ws_configured_groups, + &ws_roles, + &["developer".to_string(), "operator".to_string()], + ); + let is_operator = role == "operator"; let instance_group_source = json!({"source": "instance_group", "group": &best_group}); sqlx::query!( diff --git a/backend/windmill-common/tests/secret_backend_integration.rs b/backend/windmill-common/tests/secret_backend_integration.rs index ff41f3acf7..a94a67e336 100644 --- a/backend/windmill-common/tests/secret_backend_integration.rs +++ b/backend/windmill-common/tests/secret_backend_integration.rs @@ -3,24 +3,26 @@ //! These tests require: //! 1. A PostgreSQL database (handled by sqlx test framework) //! 2. A running HashiCorp Vault instance -//! 3. The RUN_VAULT_TESTS=1 environment variable to be set +//! +//! They are `#[ignore]`d, so a plain `cargo test` skips them without creating a +//! database; pass `--ignored` to run them. //! //! Environment variables: -//! - RUN_VAULT_TESTS=1 - Required to run the tests //! - VAULT_ADDR - Vault server address (default: http://127.0.0.1:8200) //! - VAULT_TOKEN - Static token for static token tests (default: test-root-token) //! - BASE_URL - Windmill instance URL for JWT tests (default: http://localhost:8000) //! //! Run tests (static token mode): //! ```bash -//! RUN_VAULT_TESTS=1 VAULT_TOKEN=your-token cargo test -p windmill \ -//! secret_backend_integration --features private,enterprise -- --nocapture +//! VAULT_TOKEN=your-token cargo test -p windmill-common \ +//! --test secret_backend_integration --features private,enterprise -- --ignored --nocapture //! ``` //! //! Run tests (JWT mode - requires Windmill instance running for JWKS endpoint): //! ```bash -//! RUN_VAULT_TESTS=1 BASE_URL=http://localhost:8000 cargo test -p windmill \ -//! secret_backend_integration --features private,enterprise,openidconnect -- --nocapture +//! BASE_URL=http://localhost:8000 cargo test -p windmill-common \ +//! --test secret_backend_integration --features private,enterprise,openidconnect \ +//! -- --ignored --nocapture //! ``` #[cfg(all(feature = "private", feature = "enterprise"))] @@ -62,12 +64,6 @@ mod tests { } } - fn should_run_vault_tests() -> bool { - std::env::var("RUN_VAULT_TESTS") - .map(|v| v == "1" || v.to_lowercase() == "true") - .unwrap_or(false) - } - #[cfg(feature = "openidconnect")] async fn setup_base_url() { let base_url = @@ -75,16 +71,6 @@ mod tests { windmill_common::BASE_URL.store(std::sync::Arc::new(base_url)); } - macro_rules! skip_if_no_vault { - () => { - if !should_run_vault_tests() { - println!("Skipping test: RUN_VAULT_TESTS=1 not set"); - println!("To run vault tests: RUN_VAULT_TESTS=1 cargo test ..."); - return; - } - }; - } - fn vault_settings_static_token() -> VaultSettings { VaultSettings { address: std::env::var("VAULT_ADDR") @@ -120,9 +106,8 @@ mod tests { /// Test Vault connection with static token #[sqlx::test(migrations = "../migrations", fixtures("base", "secret_backend"))] + #[ignore = "requires a Vault server at VAULT_ADDR"] async fn test_vault_connection_static_token(db: Pool<Postgres>) { - skip_if_no_vault!(); - let settings = vault_settings_static_token(); println!("Testing Vault connection with static token..."); println!(" Address: {}", settings.address); @@ -138,9 +123,8 @@ mod tests { /// Test basic CRUD operations with static token #[sqlx::test(migrations = "../migrations", fixtures("base", "secret_backend"))] + #[ignore = "requires a Vault server at VAULT_ADDR"] async fn test_vault_crud_static_token(_db: Pool<Postgres>) { - skip_if_no_vault!(); - let settings = vault_settings_static_token(); let backend = VaultBackend::new(settings); @@ -199,8 +183,8 @@ mod tests { /// Test Vault connection with JWT authentication #[cfg(feature = "openidconnect")] #[sqlx::test(migrations = "../migrations", fixtures("base", "secret_backend"))] + #[ignore = "requires a Vault server at VAULT_ADDR and a Windmill instance at BASE_URL"] async fn test_vault_connection_jwt(db: Pool<Postgres>) { - skip_if_no_vault!(); setup_base_url().await; let settings = vault_settings_jwt(); @@ -224,8 +208,8 @@ mod tests { /// Test basic CRUD operations with JWT authentication #[cfg(feature = "openidconnect")] #[sqlx::test(migrations = "../migrations", fixtures("base", "secret_backend"))] + #[ignore = "requires a Vault server at VAULT_ADDR and a Windmill instance at BASE_URL"] async fn test_vault_crud_jwt(db: Pool<Postgres>) { - skip_if_no_vault!(); setup_base_url().await; let settings = vault_settings_jwt(); @@ -269,9 +253,8 @@ mod tests { /// Test migration from database to Vault #[sqlx::test(migrations = "../migrations", fixtures("base", "secret_backend"))] + #[ignore = "requires a Vault server at VAULT_ADDR"] async fn test_migrate_db_to_vault(db: Pool<Postgres>) { - skip_if_no_vault!(); - let settings = vault_settings_static_token(); test_vault_connection(&settings, Some(&db)) @@ -331,9 +314,8 @@ mod tests { /// Test migration from Vault back to database #[sqlx::test(migrations = "../migrations", fixtures("base", "secret_backend"))] + #[ignore = "requires a Vault server at VAULT_ADDR"] async fn test_migrate_vault_to_db(db: Pool<Postgres>) { - skip_if_no_vault!(); - let settings = vault_settings_static_token(); test_vault_connection(&settings, Some(&db)) @@ -396,8 +378,8 @@ mod tests { /// Test renaming a variable path in Vault #[sqlx::test(migrations = "../migrations", fixtures("base", "secret_backend"))] + #[ignore = "requires a Vault server at VAULT_ADDR"] async fn test_variable_rename(db: Pool<Postgres>) { - skip_if_no_vault!(); let _ = &db; // suppress unused warning let settings = vault_settings_static_token(); @@ -468,9 +450,8 @@ mod tests { /// Test full round-trip: DB -> Vault -> DB with verification #[sqlx::test(migrations = "../migrations", fixtures("base", "secret_backend"))] + #[ignore = "requires a Vault server at VAULT_ADDR"] async fn test_full_round_trip(db: Pool<Postgres>) { - skip_if_no_vault!(); - let settings = vault_settings_static_token(); test_vault_connection(&settings, Some(&db)) @@ -532,9 +513,8 @@ mod tests { /// Test that workspace isolation is maintained #[sqlx::test(migrations = "../migrations", fixtures("base", "secret_backend"))] + #[ignore = "requires a Vault server at VAULT_ADDR"] async fn test_workspace_isolation(db: Pool<Postgres>) { - skip_if_no_vault!(); - let settings = vault_settings_static_token(); let backend = VaultBackend::new(settings.clone()); diff --git a/backend/windmill-common/tests/workspace_roles_declarative.rs b/backend/windmill-common/tests/workspace_roles_declarative.rs new file mode 100644 index 0000000000..b3ab777310 --- /dev/null +++ b/backend/windmill-common/tests/workspace_roles_declarative.rs @@ -0,0 +1,80 @@ +#![cfg(feature = "enterprise")] + +use std::collections::BTreeMap; + +use sqlx::{Pool, Postgres}; +use windmill_common::global_settings::WORKSPACE_ROLES_SETTING; +use windmill_common::instance_config::sync_global_settings_declarative; + +/// The declarative config sync writes `workspace_roles` without the HTTP settings handler, so it +/// must validate the value and rebuild `role_seat` itself, or billing misprices the new roles. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn declarative_workspace_roles_validates_and_rebuilds_seats(db: Pool<Postgres>) { + windmill_common::roles::invalidate_all_roles_cache(); + let desired = + |roles: serde_json::Value| BTreeMap::from([(WORKSPACE_ROLES_SETTING.to_string(), roles)]); + + sync_global_settings_declarative( + &db, + &BTreeMap::new(), + &desired(serde_json::json!([{"name": "x", "capabilities": {"flows": "full"}}])), + &(), + ) + .await + .expect_err("full flows without code must be refused"); + + sync_global_settings_declarative( + &db, + &BTreeMap::new(), + &desired(serde_json::json!([ + {"name": "developer"}, + {"name": "builder", "capabilities": {"flows": "compose"}}, + {"name": "operator"} + ])), + &(), + ) + .await + .unwrap(); + let author: bool = sqlx::query_scalar( + "SELECT author FROM role_seat WHERE workspace_id = '' AND role = 'builder'", + ) + .fetch_one(&db) + .await + .unwrap(); + assert!(author, "a role that composes flows costs a full seat"); + + // A workspace grants `builder` full flows on top of an instance definition that codes; the + // instance then withdrawing `code` would leave that workspace with inline code and no code. + sync_global_settings_declarative( + &db, + &BTreeMap::new(), + &desired(serde_json::json!([ + {"name": "developer"}, + {"name": "builder", "capabilities": {"code": true, "flows": "compose"}}, + {"name": "operator"} + ])), + &(), + ) + .await + .unwrap(); + sqlx::query( + "UPDATE workspace_settings SET role_overrides = + '{\"builder\": {\"capabilities\": {\"flows\": \"full\"}}}' + WHERE workspace_id = 'test-workspace'", + ) + .execute(&db) + .await + .unwrap(); + sync_global_settings_declarative( + &db, + &BTreeMap::new(), + &desired(serde_json::json!([ + {"name": "developer"}, + {"name": "builder", "capabilities": {"code": false, "flows": "compose"}}, + {"name": "operator"} + ])), + &(), + ) + .await + .expect_err("an instance change that invalidates a workspace override must be refused"); +} diff --git a/backend/windmill-common/tests/workspace_roles_license.rs b/backend/windmill-common/tests/workspace_roles_license.rs new file mode 100644 index 0000000000..88aa3593cb --- /dev/null +++ b/backend/windmill-common/tests/workspace_roles_license.rs @@ -0,0 +1,70 @@ +#![cfg(feature = "enterprise")] +//! Flips the process-wide `LICENSE_KEY_VALID`, so it keeps a test binary of its own. + +use std::sync::atomic::Ordering; + +use sqlx::{Pool, Postgres}; +use windmill_common::ee_oss::LICENSE_KEY_VALID; +use windmill_common::roles::{ + self, check_frozen_overrides, instance_roles, load_role_overrides, + validate_instance_roles_write, RoleOverrides, +}; + +/// A lapsed license freezes the configured roles: falling back to the defaults would give a +/// restricted `developer` everything and strand the members of workspace-only roles. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn lapsed_license_freezes_roles(db: Pool<Postgres>) { + let restricted = serde_json::json!([ + {"name": "developer", "capabilities": {"code": false, "flows": "none", "apps": "none"}} + ]); + sqlx::query("INSERT INTO global_settings (name, value) VALUES ('workspace_roles', $1)") + .bind(&restricted) + .execute(&db) + .await + .unwrap(); + sqlx::query( + "UPDATE workspace_settings SET role_overrides = '{\"auditor\": {\"label\": \"Auditor\"}}' + WHERE workspace_id = 'test-workspace'", + ) + .execute(&db) + .await + .unwrap(); + + LICENSE_KEY_VALID.store(false, Ordering::Relaxed); + roles::invalidate_all_roles_cache(); + + let ws = roles::workspace_roles(&db, "test-workspace").await.unwrap(); + let dev = ws.iter().find(|r| r.name == "developer").unwrap(); + assert!( + !dev.capabilities.code, + "the restricted developer must not widen" + ); + assert!( + ws.iter().any(|r| r.name == "auditor"), + "workspace-only roles keep applying" + ); + + let changed = serde_json::json!([{"name": "developer"}, {"name": "scripter"}]); + validate_instance_roles_write(&db, Some(&changed)) + .await + .expect_err("changing instance roles needs a license"); + validate_instance_roles_write(&db, Some(&restricted)) + .await + .expect("resaving the stored roles is a no-op"); + validate_instance_roles_write(&db, None) + .await + .expect("resetting to the defaults stays open"); + + let instance = instance_roles(&db).await.unwrap(); + let previous = load_role_overrides(&db, "test-workspace").await.unwrap(); + let mut relabelled = previous.clone(); + relabelled.get_mut("auditor").unwrap().label = Some("Reader".to_string()); + check_frozen_overrides(&instance, &previous, &relabelled) + .expect_err("editing a workspace-only role needs a license"); + check_frozen_overrides(&instance, &previous, &previous).expect("unchanged entries pass"); + check_frozen_overrides(&instance, &previous, &RoleOverrides::new()) + .expect("removing a workspace-only role stays open"); + + LICENSE_KEY_VALID.store(true, Ordering::Relaxed); + check_frozen_overrides(&instance, &previous, &relabelled).expect("licensed again"); +} diff --git a/backend/windmill-dep-map/Cargo.toml b/backend/windmill-dep-map/Cargo.toml index a93376d167..699ef89f53 100644 --- a/backend/windmill-dep-map/Cargo.toml +++ b/backend/windmill-dep-map/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_dep_map" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] diff --git a/backend/windmill-dep-map/src/lib.rs b/backend/windmill-dep-map/src/lib.rs index aab50795a9..1d47056cf9 100644 --- a/backend/windmill-dep-map/src/lib.rs +++ b/backend/windmill-dep-map/src/lib.rs @@ -2,6 +2,7 @@ pub mod ci_tests; #[cfg(feature = "private")] pub mod ci_tests_ee; pub mod lock_hash; +pub mod raw_app_runnables; pub mod scoped_dependency_map; pub mod trigger_dependents; pub mod workspace_dependencies; diff --git a/backend/windmill-dep-map/src/raw_app_runnables.rs b/backend/windmill-dep-map/src/raw_app_runnables.rs new file mode 100644 index 0000000000..87b148d53c --- /dev/null +++ b/backend/windmill-dep-map/src/raw_app_runnables.rs @@ -0,0 +1,160 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; + +use serde_json::{from_value, Value}; +use windmill_common::scripts::{ScriptLang, ScriptModule}; + +use crate::{extract_referenced_paths, extract_relative_imports}; + +/// A raw app's inline runnables, keyed by the path a relative import of one resolves to: +/// `<app path>/<runnable id>`, the path the runnable itself runs under. A runnable ships +/// the ones it imports as its modules, so they resolve from the job dir at run time. +#[derive(Default)] +pub struct RawAppRunnables(HashMap<String, (String, ScriptLang, String)>); + +impl RawAppRunnables { + pub fn from_value(app_path: &str, value: &Value) -> Self { + let mut runnables = HashMap::new(); + if let Some(Value::Object(by_id)) = value.get("runnables") { + for (id, runnable) in by_id { + let Some(script) = runnable.get("inlineScript") else { + continue; + }; + let content = script.get("content").and_then(Value::as_str); + let language = script + .get("language") + .and_then(|l| from_value::<ScriptLang>(l.clone()).ok()); + if let (Some(content), Some(language)) = (content, language) { + // `main.ts` is the Bun entrypoint's own file in the job dir, so a + // runnable with that id cannot ship as a module next to it. + let ext = module_extension(language); + if ext.is_some() && !(ext == Some("ts") && id == "main") { + runnables.insert( + format!("{app_path}/{id}"), + (id.clone(), language, content.to_string()), + ); + } + } + } + } + Self(runnables) + } + + /// Whether an import of `path` by `language` code resolves to a runnable shipped as a + /// module, rather than to the workspace script at that path. + pub fn resolves(&self, path: &str, language: ScriptLang) -> bool { + self.0.get(path).is_some_and(|(_, sibling_language, _)| { + module_extension(*sibling_language) == module_extension(language) + }) + } + + /// The runnables `content` imports, directly or through one another. + pub fn modules_of( + &self, + app_path: &str, + content: &str, + language: ScriptLang, + ) -> Option<BTreeMap<String, ScriptModule>> { + let ext = module_extension(language)?; + let mut queue = + extract_relative_imports(content, &format!("{app_path}/app"), &Some(language)) + .unwrap_or_default(); + let mut visited = HashSet::new(); + let mut modules = BTreeMap::new(); + while let Some(path) = queue.pop() { + if !visited.insert(path.clone()) { + continue; + } + let Some((id, sibling_language, sibling_content)) = self.0.get(&path) else { + continue; + }; + if module_extension(*sibling_language) != Some(ext) { + continue; + } + queue.extend( + extract_relative_imports(sibling_content, &path, &Some(*sibling_language)) + .unwrap_or_default(), + ); + modules.insert( + format!("{id}.{ext}"), + ScriptModule { + content: sibling_content.clone(), + language: *sibling_language, + lock: None, + }, + ); + } + (!modules.is_empty()).then_some(modules) + } + + /// What an app inline script depends on, as its dependency-map node records it. An import + /// satisfied by another runnable is not a dependency on the workspace script at that path, + /// but what that runnable imports is: the script's lock covers its modules, so a change + /// there must relock it and fail its skip check. + pub fn referenced_paths( + &self, + app_path: &str, + content: &str, + language: ScriptLang, + ) -> Option<Vec<String>> { + let base = format!("{app_path}/app"); + let own = extract_referenced_paths(content, &base, Some(language)); + let Some(modules) = self.modules_of(app_path, content, language) else { + return own.map(|paths| { + paths + .into_iter() + .filter(|p| !self.resolves(p, language)) + .collect() + }); + }; + let mut paths = own.unwrap_or_default(); + for module in modules.values() { + paths.extend( + extract_referenced_paths(&module.content, &base, Some(module.language)) + .unwrap_or_default(), + ); + } + paths.retain(|p| !self.resolves(p, language)); + paths.sort(); + paths.dedup(); + Some(paths) + } +} + +/// The languages whose executors resolve a relative import against a module file. +pub fn module_extension(language: ScriptLang) -> Option<&'static str> { + match language { + ScriptLang::Bun | ScriptLang::Bunnative => Some("ts"), + ScriptLang::Python3 => Some("py"), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn modules_follow_imports_between_runnables_of_one_language() { + let value = json!({ "runnables": { + "main": { "inlineScript": { "language": "bun", "content": "import { a } from './a'" } }, + "a": { "inlineScript": { "language": "bun", "content": "import { b } from './b.ts'" } }, + "b": { "inlineScript": { "language": "bun", "content": "export const b = 1" } }, + "unused": { "inlineScript": { "language": "bun", "content": "export const u = 1" } }, + "py": { "inlineScript": { "language": "python3", "content": "x = 1" } }, + }}); + let runnables = RawAppRunnables::from_value("u/admin/app", &value); + + let modules = runnables + .modules_of( + "u/admin/app", + "import { a } from './a'\nimport { p } from './py'", + ScriptLang::Bun, + ) + .unwrap(); + assert_eq!(modules.keys().collect::<Vec<_>>(), vec!["a.ts", "b.ts"]); + assert!(runnables + .modules_of("u/admin/app", "export const x = 1", ScriptLang::Bun) + .is_none()); + } +} diff --git a/backend/windmill-dep-map/src/scoped_dependency_map.rs b/backend/windmill-dep-map/src/scoped_dependency_map.rs index ad1093bb76..e57273e43f 100644 --- a/backend/windmill-dep-map/src/scoped_dependency_map.rs +++ b/backend/windmill-dep-map/src/scoped_dependency_map.rs @@ -11,6 +11,8 @@ use windmill_common::{ use std::collections::HashSet; +use crate::raw_app_runnables::RawAppRunnables; + lazy_static::lazy_static! { pub static ref WMDEBUG_NO_DMAP_DISSOLVE: bool = std::env::var("WMDEBUG_NO_DMAP_DISSOLVE").is_ok(); } @@ -506,15 +508,19 @@ SELECT importer_node_id, imported_path, imported_lockfile_hash let mut dmap = ScopedDependencyMap::fetch(w_id, &r.path, "app", db).await?; let mut tx = db.begin().await?; let mut to_process = vec![]; + let runnables = RawAppRunnables::from_value(&r.path, &value); traverse_app_inline_scripts(&value, None, &mut |ais, id| { - to_process.push(( - crate::extract_referenced_paths( + let referenced_paths = match ais.language { + Some(language) => { + runnables.referenced_paths(&r.path, &ais.content, language) + } + None => crate::extract_referenced_paths( &ais.content, &(r.path.clone() + "/app"), - ais.language, + None, ), - id, - )); + }; + to_process.push((referenced_paths, id)); Ok(()) })?; diff --git a/backend/windmill-dep-map/src/trigger_dependents.rs b/backend/windmill-dep-map/src/trigger_dependents.rs index 48e151736a..45671bb132 100644 --- a/backend/windmill-dep-map/src/trigger_dependents.rs +++ b/backend/windmill-dep-map/src/trigger_dependents.rs @@ -12,6 +12,11 @@ use windmill_queue::PushIsolationLevel; use crate::scoped_dependency_map::{DependencyDependent, ScopedDependencyMap}; +/// A `components_to_relock` entry asking an app relock to relock every component of the version +/// it runs on. It travels in the list itself so that debouncing, which concatenates the lists of +/// the relocks it merges, cannot drop it. +pub const RELOCK_ALL_APP_COMPONENTS: &str = "*"; + lazy_static::lazy_static! { static ref DEPENDENCY_JOB_DEBOUNCE_DELAY: usize = std::env::var("DEPENDENCY_JOB_DEBOUNCE_DELAY").ok().and_then(|flag| flag.parse().ok()).unwrap_or( if cfg!(test) { 15 } else { 5 } @@ -141,12 +146,13 @@ pub async fn trigger_dependents_to_recompute_dependencies( }, "app" => match sqlx::query_scalar!( - "SELECT id FROM app_version WHERE app_id = (SELECT id FROM app WHERE path = $1 AND workspace_id = $2) ORDER BY created_at DESC LIMIT 1", + "SELECT versions[array_upper(versions, 1)] FROM app WHERE path = $1 AND workspace_id = $2", importer_path, w_id ) .fetch_optional(&mut *tx) .await? + .flatten() { Some(version) => { tracing::debug!("Handling app dependency update for: {}", importer_path); diff --git a/backend/windmill-duckdb-ffi-internal/Cargo.lock b/backend/windmill-duckdb-ffi-internal/Cargo.lock index f92bb6fda1..74cd7b8a18 100644 --- a/backend/windmill-duckdb-ffi-internal/Cargo.lock +++ b/backend/windmill-duckdb-ffi-internal/Cargo.lock @@ -460,19 +460,11 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "diffy" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10aec8f7f9393bd6a4f2762be0ceb012d3cbe2478987258cc9960de148561914" -dependencies = [ - "hashbrown 0.17.1", -] - [[package]] name = "duckdb" -version = "1.10505.0" -source = "git+https://github.com/windmill-labs/duckdb-rs?rev=7190adfcf58624e7e9a7a2a1000bac18fed64c19#7190adfcf58624e7e9a7a2a1000bac18fed64c19" +version = "1.10506.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3472b9152d879c5534353b20854e43525b2c78b2dbba156711e9939d30278b4" dependencies = [ "arrow", "cast", @@ -547,12 +539,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - [[package]] name = "funty" version = "2.0.0" @@ -644,7 +630,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash 0.1.5", + "foldhash", ] [[package]] @@ -652,9 +638,6 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -dependencies = [ - "foldhash 0.2.0", -] [[package]] name = "hashlink" @@ -813,11 +796,11 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libduckdb-sys" -version = "1.10505.0" -source = "git+https://github.com/windmill-labs/duckdb-rs?rev=7190adfcf58624e7e9a7a2a1000bac18fed64c19#7190adfcf58624e7e9a7a2a1000bac18fed64c19" +version = "1.10506.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aac3459f2f7f0221d5be9114fa77ff5877040c8de456d3b986a680579fe202b" dependencies = [ "cc", - "diffy", "flate2", "pkg-config", "serde", diff --git a/backend/windmill-duckdb-ffi-internal/Cargo.toml b/backend/windmill-duckdb-ffi-internal/Cargo.toml index 73e132a2d7..ba78e3522d 100644 --- a/backend/windmill-duckdb-ffi-internal/Cargo.toml +++ b/backend/windmill-duckdb-ffi-internal/Cargo.toml @@ -11,10 +11,9 @@ chrono = "0.4.41" # identical DECIMAL. Building one by hand would mean reimplementing that # f64 → (mantissa, scale) inference. # -# Not crates.io: a fork of duckdb-rs 1.10505.0 carrying two patches to the bundled engine — -# see docs/duckdb-isolation.md for why, and for the exit path back to the crates.io crate. -# Pinned by rev, not branch, so `cargo update` cannot move the engine under us. -duckdb = { git = "https://github.com/windmill-labs/duckdb-rs", rev = "7190adfcf58624e7e9a7a2a1000bac18fed64c19", features = [ +# Pinned exactly, so `cargo update` cannot move the engine (and the prebuilt extensions it +# must match) under us. +duckdb = { version = "=1.10506.0", features = [ "bundled", "rust_decimal", ] } @@ -25,5 +24,6 @@ serde = { version = "1.0", features = ["derive"] } # to avoid re-parsing every row. serde_json = { version = "^1.0.151", features = ["preserve_order", "raw_value"] } -[lib] -crate-type = ["cdylib"] +[[bin]] +name = "windmill_duckdb_runner" +path = "src/bin/windmill_duckdb_runner.rs" diff --git a/backend/windmill-duckdb-ffi-internal/README_DEV.md b/backend/windmill-duckdb-ffi-internal/README_DEV.md index 0c34ebc069..f5c5d61083 100644 --- a/backend/windmill-duckdb-ffi-internal/README_DEV.md +++ b/backend/windmill-duckdb-ffi-internal/README_DEV.md @@ -1,8 +1,8 @@ -This crate is compiled separately because it causes nasty issues when compiled with the deno_core feature flag enabled (lib c++ interactions). +This crate is compiled separately from the backend workspace because the bundled DuckDB's libc++ +clashes with `deno_core`'s when both live in one process. -The main issue was : -Errors in DuckDB always worked correctly, except when attached to a Ducklake and when the deno_core feature flag was set. -For example: +The symptom was opaque errors. DuckDB errors worked, except when attached to a DuckLake with the +`deno_core` feature on: ```sql ATTACH 'ducklake' AS dl; USE dl; @@ -10,6 +10,9 @@ CREATE TABLE IF NOT EXISTS t (x string not null); INSERT INTO t VALUES (NULL); ``` -causes `Constraint Error: NOT NULL constraint failed: t.x` normally, but here we see `Unknown exception in ExecutorTask::Execute`. This opaque errors comes directly from the C++ DuckDB library : https://github.com/duckdb/duckdb/blob/f99fed1e0b16a842573f9dad529f6c170a004f6e/src/parallel/executor_task.cpp#L58 +gave `Unknown exception in ExecutorTask::Execute` instead of `Constraint Error: NOT NULL +constraint failed: t.x`. The opaque error comes from the C++ DuckDB library: +https://github.com/duckdb/duckdb/blob/f99fed1e0b16a842573f9dad529f6c170a004f6e/src/parallel/executor_task.cpp#L58 -To solve this, we compile duckdb separately from the main backend crate and call it with FFI. It has to be loaded dynamically, if it is loaded statically it will still share lib c++ with deno_core and cause issues. +The worker never links this crate: it runs `windmill_duckdb_runner` (`src/bin`) as a separate +process for every job. See `docs/duckdb-execution.md`. diff --git a/backend/windmill-duckdb-ffi-internal/build_dev.sh b/backend/windmill-duckdb-ffi-internal/build_dev.sh index dab70398a8..c334baa6ae 100755 --- a/backend/windmill-duckdb-ffi-internal/build_dev.sh +++ b/backend/windmill-duckdb-ffi-internal/build_dev.sh @@ -31,22 +31,21 @@ else echo "duckdb-ffi: shared cache $CARGO_TARGET_DIR" # The cache key covers only the dependency inputs (Cargo.lock + build.rs), so # the expensive bundled-DuckDB build is shared even when this crate's own - # source differs between checkouts. But the shared dir's uplifted cdylib is a + # source differs between checkouts. But the shared dir's runner binary is a # single fixed-name artifact written by whichever worktree built last. Touch - # our sources so cargo re-links THIS checkout's cdylib and re-uplifts it + # our sources so cargo re-links THIS checkout's runner and re-uplifts it # before we copy — the copied artifact then always matches this worktree, # never a sibling's. The bundled dependency is a separate crate and stays # cached, so this only costs a ~1s relink. find src -type f -exec touch {} + fi -CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release -p windmill_duckdb_ffi_internal +cargo build --release -p windmill_duckdb_ffi_internal mkdir -p ../target/debug/ -# Install through a rename rather than writing over the destination: a running worker -# has the cdylib mapped, and rewriting those pages under it kills the process with -# SIGBUS. A rename swaps the directory entry and leaves the old inode alone. -for artifact in "$CARGO_TARGET_DIR/release/"libwindmill_duckdb_ffi_internal.*; do - dest="../target/debug/$(basename "$artifact")" - cp "$artifact" "$dest.tmp" - mv -f "$dest.tmp" "$dest" -done +# The worker looks for the runner next to its own binary. Install through a rename rather +# than writing over the destination: a runner may be executing, and rewriting its pages under +# it kills the process with SIGBUS. A rename swaps the directory entry and leaves the old +# inode alone. +dest="../target/debug/windmill_duckdb_runner" +cp "$CARGO_TARGET_DIR/release/windmill_duckdb_runner" "$dest.tmp" +mv -f "$dest.tmp" "$dest" diff --git a/backend/windmill-duckdb-ffi-internal/src/bin/windmill_duckdb_runner.rs b/backend/windmill-duckdb-ffi-internal/src/bin/windmill_duckdb_runner.rs new file mode 100644 index 0000000000..38052a9537 --- /dev/null +++ b/backend/windmill-duckdb-ffi-internal/src/bin/windmill_duckdb_runner.rs @@ -0,0 +1,122 @@ +//! Runs one DuckDB job, so the engine lives in a process of its own under the worker's job +//! isolation rather than in the worker. +//! +//! The worker writes one JSON [`Request`] to stdin and reads the answer from +//! `Request::result_path`: a first line holding the column order as JSON (empty when there is +//! none), then the collected results, or `ERROR <json-encoded message>`. +//! +//! stdout/stderr are not protocol: the worker keeps only their tail, and reports it when the +//! runner dies without writing a result. + +use std::io::{Read, Write}; + +use serde::Deserialize; +use windmill_duckdb_ffi_internal::{prepare, run, Arg, Connection}; + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +enum Mode { + Run, + Prepare, + /// `INSTALL` only, into the extension cache the jail later mounts read-only. Never loads + /// anything, so it runs no extension code. + Install, +} + +#[derive(Deserialize)] +struct Request { + mode: Mode, + blocks: Vec<String>, + #[serde(default)] + job_args: Vec<Arg>, + #[serde(default)] + token: String, + #[serde(default)] + base_internal_url: String, + #[serde(default)] + w_id: String, + #[serde(default)] + memory_limit: String, + #[serde(default)] + temp_directory: String, + #[serde(default)] + max_result_size: usize, + #[serde(default)] + collect_last_only: bool, + #[serde(default)] + collect_first_row_only: bool, + result_path: String, +} + +fn non_empty(s: &str) -> Option<String> { + (!s.is_empty()).then(|| s.to_string()) +} + +fn install(blocks: &[String]) -> Result<String, String> { + let conn = duckdb::Connection::open_in_memory().map_err(|e| e.to_string())?; + for b in blocks { + conn.execute_batch(b).map_err(|e| e.to_string())?; + } + Ok("[]".to_string()) +} + +/// The result string and the column order as JSON. +fn execute(req: Request) -> (String, Option<String>) { + let conn = Connection { + token: &req.token, + base_internal_url: &req.base_internal_url, + w_id: &req.w_id, + memory_limit: non_empty(&req.memory_limit), + temp_directory: non_empty(&req.temp_directory), + }; + let outcome = match req.mode { + Mode::Install => install(&req.blocks).map(|r| (r, None)), + Mode::Prepare => prepare(&req.blocks, conn).map(|r| (r, None)), + Mode::Run => run( + &req.blocks, + req.job_args, + conn, + req.max_result_size, + req.collect_last_only, + req.collect_first_row_only, + ) + .map(|(r, order)| { + let order = order.map(|o| serde_json::to_string(&o).unwrap_or_else(|_| "[]".into())); + (r, order) + }), + }; + outcome.unwrap_or_else(|e| { + let msg = serde_json::to_string(&e).unwrap_or_else(|_| "\"Unknown DuckDB error\"".into()); + (format!("ERROR {msg}"), None) + }) +} + +fn main() { + let mut input = String::new(); + if let Err(e) = std::io::stdin().read_to_string(&mut input) { + eprintln!("windmill_duckdb_runner: could not read the request: {e}"); + std::process::exit(2); + } + let req: Request = match serde_json::from_str(&input) { + Ok(r) => r, + Err(e) => { + eprintln!("windmill_duckdb_runner: invalid request: {e}"); + std::process::exit(2); + } + }; + drop(input); + + let result_path = req.result_path.clone(); + let (result, column_order) = execute(req); + let write = || -> std::io::Result<()> { + let mut f = std::io::BufWriter::new(std::fs::File::create(&result_path)?); + f.write_all(column_order.as_deref().unwrap_or("").as_bytes())?; + f.write_all(b"\n")?; + f.write_all(result.as_bytes())?; + f.flush() + }; + if let Err(e) = write() { + eprintln!("windmill_duckdb_runner: could not write the result to {result_path}: {e}"); + std::process::exit(2); + } +} diff --git a/backend/windmill-duckdb-ffi-internal/src/lib.rs b/backend/windmill-duckdb-ffi-internal/src/lib.rs index 69820b285f..0de7d3bd79 100644 --- a/backend/windmill-duckdb-ffi-internal/src/lib.rs +++ b/backend/windmill-duckdb-ffi-internal/src/lib.rs @@ -1,9 +1,9 @@ -use std::{ - collections::HashMap, - ffi::{c_char, c_uint, CStr, CString}, - ptr::null_mut, - sync::LazyLock, -}; +//! DuckDB execution for windmill jobs. The worker never loads this crate: it runs +//! `windmill_duckdb_runner` (src/bin), which calls [`run`] / [`prepare`] in a process of its +//! own, under the worker's job isolation. Kept out of the backend workspace because the +//! bundled engine's libc++ clashes with `deno_core`'s in one process. + +use std::{collections::HashMap, sync::LazyLock}; use duckdb::{core::LogicalTypeId, params_from_iter, types::TimeUnit, Row}; use regex::Regex; @@ -11,22 +11,6 @@ use rust_decimal::{prelude::FromPrimitive, Decimal}; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; -// Worker passes "" for "no override" — saves an extra C string nullability dance. -// Returns an owned String so the value outlives the raw pointer's lifetime. -fn ptr_to_opt_str(ptr: *const c_char) -> Result<Option<String>, String> { - if ptr.is_null() { - return Ok(None); - } - let s = unsafe { CStr::from_ptr(ptr) } - .to_str() - .map_err(|e| format!("Invalid string in duckdb ffi: {}", e))?; - Ok(if s.is_empty() { - None - } else { - Some(s.to_owned()) - }) -} - #[derive(Deserialize, Clone, Debug, PartialEq, Default)] pub struct Arg { pub name: String, @@ -34,95 +18,69 @@ pub struct Arg { pub json_value: serde_json::Value, } -// Freeing from the caller side crashes the runtime with jemalloc enabled (EXIT CODE 11 SEGFAULT) -#[unsafe(no_mangle)] -pub extern "C" fn free_cstr(string: *mut c_char) -> () { - if string.is_null() { - return; - } - unsafe { - let _ = CString::from_raw(string); - } +/// Everything a connection is set up with before the script's first block runs. +pub struct Connection<'a> { + /// The job token: the S3/GCS secrets against the workspace's S3 proxy are derived from it. + pub token: &'a str, + pub base_internal_url: &'a str, + pub w_id: &'a str, + pub memory_limit: Option<String>, + /// Where DuckDB spills; the job dir, so the spill is cleaned up with the job. + pub temp_directory: Option<String>, } -#[unsafe(no_mangle)] -pub extern "C" fn get_version() -> c_uint { - // Increment when making breaking changes to the FFI interface. - // The windmill worker will check that the version matches or else refuse to call - // the FFI functions to avoid undefined behavior. - return 3; -} - -#[unsafe(no_mangle)] -pub extern "C" fn run_duckdb_ffi( - query_block_list: *const *const c_char, - query_block_list_count: usize, - job_args: *const c_char, - token: *const c_char, - base_internal_url: *const c_char, - w_id: *const c_char, - memory_limit: *const c_char, - temp_directory: *const c_char, - // 0 means unbounded; the worker sends its own budget-derived cap. +/// Runs the blocks in order on one in-memory connection and returns every collected result, +/// serialized, plus the last statement's column order. `max_result_size` 0 is unbounded. +pub fn run( + blocks: &[String], + job_args: Vec<Arg>, + conn: Connection<'_>, max_result_size: usize, - column_order_ptr: *mut *mut c_char, collect_last_only: bool, collect_first_row_only: bool, -) -> *mut c_char { - let resource_limits = match (ptr_to_opt_str(memory_limit), ptr_to_opt_str(temp_directory)) { - (Ok(m), Ok(t)) => Ok(ResourceLimits::from_ffi(m, t)), - (Err(e), _) | (_, Err(e)) => Err(e), - }; - let (r, column_order) = match convert_args( - query_block_list, - query_block_list_count, - job_args, - token, - base_internal_url, - w_id, +) -> Result<(String, Option<Vec<String>>), String> { + let limits = ResourceLimits::new(conn.memory_limit, conn.temp_directory); + run_duckdb_internal( + blocks.iter().map(String::as_str), + blocks.len(), + job_args_to_values(job_args), + conn.token, + conn.base_internal_url, + conn.w_id, + limits, + max_result_size, + collect_last_only, + collect_first_row_only, ) - .and_then(|args| resource_limits.map(|r| (args, r))) - .and_then( - |((query_block_list, job_args, token, base_internal_url, w_id), limits)| { - run_duckdb_internal( - query_block_list, - query_block_list_count, - job_args, - token, - base_internal_url, - w_id, - limits, - max_result_size, - collect_last_only, - collect_first_row_only, - ) - }, - ) { - Ok(result) => result, - Err(err) => { - let err = serde_json::to_string(&err) - .unwrap_or_else(|_| "Unknown error in duckdb ffi lib".to_string()); - (format!("ERROR {}", err), None) - } - }; +} - unsafe { - if let Some(column_order) = column_order { - let column_order = - serde_json::to_string(&column_order).unwrap_or_else(|_| "[]".to_string()); - let c_column_order = - CString::new(column_order).unwrap_or_else(|_| CString::new("[]").unwrap()); - *column_order_ptr = c_column_order.into_raw(); - } else { - *column_order_ptr = null_mut(); - } - } - // CString::into_raw because it needs to outlive this function call. - // The caller is responsible for freeing the memory through CString::from_raw. - CString::new(r).map(|s| s.into_raw()).unwrap_or_else(|e| { - println!("Failed to allocate error string in duckdb ffi lib: {:?}", e); - null_mut() - }) +/// Describes each non-setup block's result columns without running it. +pub fn prepare(blocks: &[String], conn: Connection<'_>) -> Result<String, String> { + let limits = ResourceLimits::new(conn.memory_limit, conn.temp_directory); + prepare_duckdb_internal( + blocks.iter().map(String::as_str).collect(), + conn.token, + conn.base_internal_url, + conn.w_id, + limits, + ) +} + +fn job_args_to_values(job_args: Vec<Arg>) -> HashMap<String, duckdb::types::Value> { + job_args + .into_iter() + .map(|arg| { + let duckdb_value = json_value_to_duckdb_value(&arg.json_value, &arg.arg_type) + .unwrap_or_else(|e| { + println!( + "Error converting job_arg {} to duckdb value: {}", + arg.name, e + ); + duckdb::types::Value::Null + }); + (arg.name, duckdb_value) + }) + .collect() } #[derive(Serialize, Debug)] @@ -187,47 +145,6 @@ fn replace_params_with_null(query: &str) -> String { PARAM_RE.replace_all(query, "NULL").to_string() } -#[unsafe(no_mangle)] -pub extern "C" fn prepare_duckdb_ffi( - query_block_list: *const *const c_char, - query_block_list_count: usize, - token: *const c_char, - base_internal_url: *const c_char, - w_id: *const c_char, - memory_limit: *const c_char, - temp_directory: *const c_char, -) -> *mut c_char { - let resource_limits = match (ptr_to_opt_str(memory_limit), ptr_to_opt_str(temp_directory)) { - (Ok(m), Ok(t)) => Ok(ResourceLimits::from_ffi(m, t)), - (Err(e), _) | (_, Err(e)) => Err(e), - }; - let r = match convert_prepare_args( - query_block_list, - query_block_list_count, - token, - base_internal_url, - w_id, - ) - .and_then(|args| resource_limits.map(|r| (args, r))) - .and_then( - |((query_block_list, token, base_internal_url, w_id), limits)| { - prepare_duckdb_internal(query_block_list, token, base_internal_url, w_id, limits) - }, - ) { - Ok(result) => result, - Err(err) => { - let err = serde_json::to_string(&err) - .unwrap_or_else(|_| "Unknown error in duckdb ffi lib".to_string()); - format!("ERROR {}", err) - } - }; - - CString::new(r).map(|s| s.into_raw()).unwrap_or_else(|e| { - println!("Failed to allocate error string in duckdb ffi lib: {:?}", e); - null_mut() - }) -} - #[derive(Clone, Default)] struct ResourceLimits { memory_limit: Option<String>, @@ -236,11 +153,10 @@ struct ResourceLimits { } impl ResourceLimits { - // The spill cap comes from the environment rather than from an FFI parameter: this - // cdylib is versioned separately from the worker and agent workers ship it out of band, - // so a signature change costs an ABI bump and a coordinated redeploy for what is a - // config value. Unset keeps DuckDB's default of 90% of the temp volume's free space. - fn from_ffi(memory_limit: Option<String>, temp_directory: Option<String>) -> Self { + // The spill cap is the worker's DUCKDB_MAX_TEMP_DIRECTORY_SIZE, passed through to the + // runner's environment. Unset keeps DuckDB's default of 90% of the temp volume's free + // space. A script can raise it, as it can write the job dir any other way. + fn new(memory_limit: Option<String>, temp_directory: Option<String>) -> Self { let max_temp_directory_size = std::env::var("DUCKDB_MAX_TEMP_DIRECTORY_SIZE") .ok() .map(|v| v.trim().to_string()) @@ -253,10 +169,9 @@ fn sql_single_quote(s: &str) -> String { s.replace('\'', "''") } -// Bounds memory so DuckDB spills before blowing the cgroup cap and getting the worker -// SIGKILLed, and bounds the spill so one query cannot fill its disk. Spill goes to the job -// dir when set, so it is cleaned up with the job; either way the directory and its cap are -// then locked for the rest of the connection. +// Bounds memory so DuckDB spills before blowing the cgroup cap and getting the job +// SIGKILLed, and sets a default bound on the spill. Spill goes to the job dir when set, so it +// is cleaned up with the job. fn configure_duckdb_resource_limits( conn: &duckdb::Connection, limits: &ResourceLimits, @@ -301,11 +216,7 @@ fn configure_duckdb_resource_limits( ) })?; } - // Freeze both before any script statement can touch them. A remote temp directory makes - // `~TemporaryDirectoryHandle` throw out of a `noexcept` destructor — `std::terminate`, - // taking this in-process worker and every job on it down, uncatchable from Rust — and a - // cap a script can raise bounds nothing. - run("SET lock_temp_directory=true;\n") + Ok(()) } fn setup_duckdb_connection( @@ -353,39 +264,6 @@ fn setup_duckdb_connection( .map_err(|e| format!("Error setting up S3 secret: {}", e.to_string())) } -fn convert_prepare_args<'a>( - query_block_list: *const *const c_char, - query_block_list_count: usize, - token: *const c_char, - base_internal_url: *const c_char, - w_id: *const c_char, -) -> Result<(Vec<&'a str>, &'a str, &'a str, &'a str), String> { - let query_block_list = unsafe { - std::slice::from_raw_parts(query_block_list, query_block_list_count) - .iter() - .map(|q| { - CStr::from_ptr(*q).to_str().unwrap_or_else(|e| { - println!( - "Invalid query_block string pointer in duckdb ffi: {}", - e.to_string() - ); - "Invalid query_block string pointer in duckdb ffi" - }) - }) - .collect::<Vec<_>>() - }; - let token = unsafe { CStr::from_ptr(token) } - .to_str() - .map_err(|e| format!("Invalid token string: {}", e.to_string()))?; - let base_internal_url = unsafe { CStr::from_ptr(base_internal_url) } - .to_str() - .map_err(|e| format!("Invalid base_internal_url string: {}", e.to_string()))?; - let w_id = unsafe { CStr::from_ptr(w_id) } - .to_str() - .map_err(|e| format!("Invalid w_id string: {}", e.to_string()))?; - Ok((query_block_list, token, base_internal_url, w_id)) -} - fn prepare_duckdb_internal( query_block_list: Vec<&str>, token: &str, @@ -453,67 +331,6 @@ fn prepare_duckdb_internal( serde_json::to_string(&results).map_err(|e| e.to_string()) } -fn convert_args<'a>( - query_block_list: *const *const c_char, - query_block_list_count: usize, - job_args: *const c_char, - token: *const c_char, - base_internal_url: *const c_char, - w_id: *const c_char, -) -> Result< - ( - impl Iterator<Item = &'a str>, - HashMap<String, duckdb::types::Value>, - &'a str, - &'a str, - &'a str, - ), - String, -> { - let query_block_list = unsafe { - std::slice::from_raw_parts(query_block_list, query_block_list_count) - .iter() - .map(|q| { - CStr::from_ptr(*q).to_str().unwrap_or_else(|e| { - println!( - "Invalid query_block string pointer in duckdb ffi: {}", - e.to_string() - ); - "Invalid query_block string pointer in duckdb ffi" - }) - }) - }; - let job_args_str = unsafe { CStr::from_ptr(job_args) } - .to_str() - .map_err(|e| format!("Invalid job_args string: {}", e.to_string()))?; - let job_args: Vec<Arg> = serde_json::from_str(job_args_str) - .map_err(|e| format!("Invalid job_args JSON: {}", e.to_string()))?; - let job_args: HashMap<String, duckdb::types::Value> = job_args - .into_iter() - .map(|arg| { - let duckdb_value = json_value_to_duckdb_value(&arg.json_value, &arg.arg_type) - .unwrap_or_else(|e| { - println!( - "Error converting job_arg {} to duckdb value: {}", - arg.name, e - ); - duckdb::types::Value::Null - }); - (arg.name, duckdb_value) - }) - .collect(); - let token = unsafe { CStr::from_ptr(token) } - .to_str() - .map_err(|e| format!("Invalid token string: {}", e.to_string()))?; - let base_internal_url = unsafe { CStr::from_ptr(base_internal_url) } - .to_str() - .map_err(|e| format!("Invalid base_internal_url string: {}", e.to_string()))?; - let w_id = unsafe { CStr::from_ptr(w_id) } - .to_str() - .map_err(|e| format!("Invalid w_id string: {}", e.to_string()))?; - Ok((query_block_list, job_args, token, base_internal_url, w_id)) -} - // TODO : Better error return to leverage different error kinds on the worker side fn run_duckdb_internal<'a>( query_block_list: impl Iterator<Item = &'a str>, @@ -1230,8 +1047,8 @@ mod temporal_json_tests { // Numbers too wide for a JSON number are rendered as strings. DECIMAL runs to // 38 digits and UHUGEINT to 2^128-1; rendering either through a type that - // cannot hold it aborts the whole worker rather than erroring, because the - // panic escapes an `extern "C"` frame and those cannot unwind. + // cannot hold it panics, which kills the runner and fails the job with no + // engine message to show for it. #[test] fn wide_numbers_render_as_strings_without_losing_precision() { let conn = duckdb::Connection::open_in_memory().unwrap(); @@ -1677,214 +1494,3 @@ fn string_to_duckdb_time(s: &str) -> Result<duckdb::types::Value, String> { time.num_seconds_from_midnight() as i64, )) } - -// The bundled engine is not stock (docs/duckdb-isolation.md). These guard the ways an engine -// bump can drop the patches without anything else noticing. -#[cfg(test)] -mod patched_engine_tests { - use super::*; - - /// Too large to be answered within the `memory_limit` `spill_attempt` sets, so the engine - /// has to reach the temp directory to finish it. - const SPILLING_QUERY: &str = "CREATE TABLE t AS SELECT * FROM range(1000000)"; - - /// Every connection is locked at setup, so no script statement can move the temp - /// directory onto a remote filesystem — where the spill fails and the - /// `~TemporaryDirectoryHandle` cleaning up throws out of a `noexcept` destructor, - /// aborting the worker and every job colocated on it. - #[test] - fn configured_connections_cannot_have_their_temp_directory_moved() { - let temp_dir = std::env::temp_dir().join(format!( - "windmill_duckdb_locked_{}_{:?}", - std::process::id(), - std::thread::current().id() - )); - std::fs::create_dir_all(&temp_dir).unwrap(); - - let conn = duckdb::Connection::open_in_memory().unwrap(); - configure_duckdb_resource_limits( - &conn, - &ResourceLimits { - memory_limit: Some("2MB".to_string()), - temp_directory: Some(temp_dir.display().to_string()), - max_temp_directory_size: None, - }, - ) - .unwrap(); - - for stmt in [ - "SET temp_directory='s3://bucket/tmp'", - "SET temp_directory TO 's3://bucket/tmp'", - "PRAGMA temp_directory='s3://bucket/tmp'", - "RESET temp_directory", - ] { - let err = conn - .execute_batch(stmt) - .expect_err(&format!("engine accepted `{stmt}` on a locked connection")); - assert!( - err.to_string().contains("temp directory has been locked"), - "unexpected refusal for `{stmt}`: {err}" - ); - } - - // The lock must not cost the spilling it exists to protect. - let spilled = conn.execute_batch(SPILLING_QUERY); - std::fs::remove_dir_all(&temp_dir).ok(); - spilled.expect("a locked temp directory must still be usable for spilling"); - } - - /// `DUCKDB_MAX_TEMP_DIRECTORY_SIZE` is the only thing standing between one query and the - /// worker's whole disk, so pin that a configured connection really does stop at it, names - /// the setting when it does, and leaves nothing behind for the next job to trip over. - #[test] - fn spilling_past_the_configured_cap_fails_the_query_and_cleans_up() { - let temp_dir = std::env::temp_dir().join(format!( - "windmill_duckdb_capped_{}_{:?}", - std::process::id(), - std::thread::current().id() - )); - std::fs::create_dir_all(&temp_dir).unwrap(); - - let spill_under_cap = |cap: &str| { - let conn = duckdb::Connection::open_in_memory().unwrap(); - configure_duckdb_resource_limits( - &conn, - &ResourceLimits { - memory_limit: Some("2MB".to_string()), - temp_directory: Some(temp_dir.display().to_string()), - max_temp_directory_size: Some(cap.to_string()), - }, - ) - .unwrap(); - conn.execute_batch(SPILLING_QUERY) - .map_err(|e| e.to_string()) - }; - - let err = spill_under_cap("1MB").expect_err("the cap did not stop the spill"); - // Verbatim because the worker matches on it (`SPILL_CAP_ENGINE_MARKER` in - // `duckdb_executor.rs`) to correct the engine's advice to raise the cap, which the - // lock refuses. An engine bump that rewords this has to update that too. - assert!( - err.contains("This limit was set by the 'max_temp_directory_size' setting"), - "the refusal must name the setting an operator can act on, got: {err}" - ); - let leftover: Vec<_> = std::fs::read_dir(&temp_dir) - .unwrap() - .filter_map(|e| e.ok().map(|e| e.path())) - .collect(); - assert!( - leftover.is_empty(), - "a query killed by the cap must not leave its spill behind: {leftover:?}" - ); - - // DuckDB compresses temp files, so the failure above is only evidence of the cap if - // the same query gets through once the cap is out of the way. - let generous = spill_under_cap("1GB"); - std::fs::remove_dir_all(&temp_dir).ok(); - generous.expect("the query must still spill and finish under a cap that fits it"); - } - - fn spill_attempt(temp_dir: &std::path::Path, lock: bool) -> Result<(), String> { - let conn = duckdb::Connection::open_in_memory().unwrap(); - conn.execute_batch(&format!( - "SET threads=2; SET memory_limit='2MB'; SET temp_directory='{}';", - temp_dir.display() - )) - .unwrap(); - if lock { - conn.execute_batch("SET lock_temp_directory=true;") - .map_err(|e| format!("lock_temp_directory rejected: {e}"))?; - } - conn.execute_batch("SET disabled_filesystems='LocalFileSystem';") - .unwrap(); - conn.execute_batch(SPILLING_QUERY) - .map_err(|e| e.to_string()) - } - - #[test] - fn locking_the_temp_directory_keeps_spilling_available() { - let temp_dir = std::env::temp_dir().join(format!( - "windmill_duckdb_spill_{}_{:?}", - std::process::id(), - std::thread::current().id() - )); - std::fs::create_dir_all(&temp_dir).unwrap(); - - // Without the lock the fence reaches the buffer manager too, which is both the - // stock engine's behaviour and the proof that this query really does spill — - // otherwise the assertion below would pass on an unpatched engine. - let unlocked = spill_attempt(&temp_dir, false).expect_err("query did not need to spill"); - assert!( - unlocked.contains("LocalFileSystem has been disabled"), - "expected the fence to stop the spill, got: {unlocked}" - ); - - let locked = spill_attempt(&temp_dir, true); - std::fs::remove_dir_all(&temp_dir).ok(); - locked.expect("a locked temp directory must stay usable behind disabled_filesystems"); - } - - /// The exemption above is only sound while neither the spill's destination nor its size - /// can be changed: repointing the directory is a write primitive through the very fence - /// it is exempt from, and raising the cap leaves the disk unbounded. A rebase can drop - /// either half while leaving the spill test green, so pin both. - #[test] - fn locking_the_temp_directory_makes_it_immutable() { - let conn = duckdb::Connection::open_in_memory().unwrap(); - conn.execute_batch("SET temp_directory='/tmp/windmill_duckdb_lock_probe';") - .unwrap(); - conn.execute_batch("SET lock_temp_directory=true;") - .expect("the patched engine must accept lock_temp_directory"); - - for stmt in [ - "SET temp_directory='/tmp/windmill_duckdb_elsewhere';", - "RESET temp_directory;", - "SET max_temp_directory_size='100GB';", - "RESET max_temp_directory_size;", - "SET lock_temp_directory=false;", - "RESET lock_temp_directory;", - ] { - let err = conn.execute_batch(stmt).expect_err(&format!( - "`{stmt}` must be refused once the directory is locked" - )); - assert!( - err.to_string().contains("locked"), - "expected a lock refusal for `{stmt}`, got: {err}" - ); - } - } - - // The engine loads the *prebuilt* extensions from extensions.duckdb.org, which are - // compiled against the struct layouts of the release it claims to be. A patch that - // moves a member those extensions reach does not fail — the extension reads the - // neighbouring field and LOAD blocks forever — so this is a timeout, not an assertion. - #[test] - fn prebuilt_extensions_still_load() { - let conn = duckdb::Connection::open_in_memory().unwrap(); - // Fetching the extension needs egress, which loading it back — the part this guards — - // does not. Skipping keeps a developer offline from seeing a failure they can't act on, - // but never in CI, where a skip and a pass would be indistinguishable. - if let Err(e) = conn.execute_batch("INSTALL httpfs;") { - assert!( - std::env::var_os("CI").is_none(), - "could not install httpfs, so the extension-load guard did not run: {e}" - ); - eprintln!("skipping: INSTALL httpfs failed ({e})"); - return; - } - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - tx.send( - conn.execute_batch("LOAD httpfs;") - .map_err(|e| e.to_string()), - ) - }); - match rx.recv_timeout(std::time::Duration::from_secs(120)) { - Ok(r) => r.expect("could not load httpfs"), - Err(_) => panic!( - "LOAD httpfs did not return: the patched engine's struct layout no longer matches \ - the release the prebuilt extensions were built against" - ), - } - } -} diff --git a/backend/windmill-git-sync/Cargo.toml b/backend/windmill-git-sync/Cargo.toml index 2b691ee48e..40752660f1 100644 --- a/backend/windmill-git-sync/Cargo.toml +++ b/backend/windmill-git-sync/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_git_sync" path = "./src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] private = ["windmill-common/private", "windmill-dep-map/private", "windmill-audit/private"] diff --git a/backend/windmill-indexer/Cargo.toml b/backend/windmill-indexer/Cargo.toml index 09b2bbe5b1..a90d51c21d 100644 --- a/backend/windmill-indexer/Cargo.toml +++ b/backend/windmill-indexer/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_indexer" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] diff --git a/backend/windmill-jseval/Cargo.toml b/backend/windmill-jseval/Cargo.toml index b2f7217404..ea5b22d3b4 100644 --- a/backend/windmill-jseval/Cargo.toml +++ b/backend/windmill-jseval/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_jseval" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] diff --git a/backend/windmill-macros/Cargo.toml b/backend/windmill-macros/Cargo.toml index 5d7d12cc6a..bb4e5c0928 100644 --- a/backend/windmill-macros/Cargo.toml +++ b/backend/windmill-macros/Cargo.toml @@ -6,6 +6,9 @@ edition.workspace = true [lib] proc-macro = true +# src/ has no unit tests or doctests: remove both lines before adding one, or cargo test never runs it. +test = false +doctest = false [dependencies] proc-macro2.workspace = true diff --git a/backend/windmill-mcp/Cargo.toml b/backend/windmill-mcp/Cargo.toml index 2c07b92d44..aac854024f 100644 --- a/backend/windmill-mcp/Cargo.toml +++ b/backend/windmill-mcp/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_mcp" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] diff --git a/backend/windmill-mcp/src/client/mod.rs b/backend/windmill-mcp/src/client/mod.rs index 6f3928e112..49e71d9734 100644 --- a/backend/windmill-mcp/src/client/mod.rs +++ b/backend/windmill-mcp/src/client/mod.rs @@ -88,8 +88,9 @@ impl McpClient { // and does not legitimately rely on redirects. .redirect(reqwest::redirect::Policy::none()); // Pin DNS to the address validated above so the connect cannot rebind to - // an internal IP between the check and the request. Empty addrs (IP literal - // or ALLOW_PRIVATE_MCP_SERVER_URLS) leave resolution untouched. + // an internal IP between the check and the request. Empty addrs (IP literal, + // or a host ALLOW_PRIVATE_MCP_SERVER_URLS allows by name or by `true`) leave + // resolution untouched. if !validated.addrs.is_empty() { client_builder = client_builder.resolve_to_addrs(&validated.host, &validated.addrs); } diff --git a/backend/windmill-mcp/src/common/scope.rs b/backend/windmill-mcp/src/common/scope.rs index 418458d4fe..42327215ae 100644 --- a/backend/windmill-mcp/src/common/scope.rs +++ b/backend/windmill-mcp/src/common/scope.rs @@ -20,9 +20,26 @@ pub struct McpScopeConfig { pub granular: bool, /// Hub app filter (if any) pub hub_apps: Option<String>, + /// The workspaces a token without a bound workspace may act in + /// (`mcp:workspaces:a,b`); `None` is every workspace its owner belongs to. + pub workspaces: Option<Vec<String>>, + /// `mcp:read_run`: reads, plus running deployed scripts and flows, and nothing else. + pub read_run: bool, + /// `mcp:read_only`: reads alone, as the token's read-only flag does. An OAuth grant + /// carries scopes and no flag, so this is how a sign-in asks for read-only access. + pub read_only: bool, + /// No endpoint tools on any URL: set by `mcp:read_only`, `mcp:read_run` and + /// `mcp:runnables_as_tools`, and by the server on `/api/mcp` for a selection. + pub no_endpoints: bool, } impl McpScopeConfig { + pub fn allows_workspace(&self, workspace_id: &str) -> bool { + self.workspaces + .as_ref() + .is_none_or(|ws| ws.iter().any(|w| w == workspace_id)) + } + /// Whether the token grants access to *any* concrete resource of this type by /// path. Used to decide whether to advertise the run-by-path tools in /// multi-workspace mode (a `mcp:scripts:*`-only token should see @@ -68,6 +85,21 @@ impl McpScopeConfig { /// semantics), this requires every requested pattern to be covered by some /// caller pattern — so `mcp:scripts:f/x` cannot widen into `mcp:scripts:*`. pub fn contains(&self, requested: &McpScopeConfig) -> bool { + if let Some(caller_ws) = self.workspaces.as_ref() { + match requested.workspaces.as_ref() { + Some(req_ws) if req_ws.iter().all(|w| caller_ws.contains(w)) => {} + _ => return false, + } + } + if self.read_only && !requested.read_only { + return false; + } + if self.read_run && !(requested.read_run || requested.read_only) { + return false; + } + if self.no_endpoints && !requested.no_endpoints { + return false; + } if self.all { return true; } @@ -125,6 +157,10 @@ fn pattern_covers(caller: &str, requested: &str) -> bool { && requested_base.as_bytes().get(prefix.len()) == Some(&b'/')) } +/// Marks a selection made as "scripts and flows as tools": it gets no endpoint tools on any +/// URL, which an older selection, carrying none of the newer scopes, still does. +pub const RUNNABLES_AS_TOOLS_SCOPE: &str = "mcp:runnables_as_tools"; + /// Parse MCP scopes from token scope strings pub fn parse_mcp_scopes(scopes: &[String]) -> Result<McpScopeConfig, String> { let mut config = McpScopeConfig::default(); @@ -180,6 +216,31 @@ pub fn parse_mcp_scopes(scopes: &[String]) -> Result<McpScopeConfig, String> { continue; } + if scope == "mcp:read_run" { + config.read_run = true; + config.no_endpoints = true; + continue; + } + + if scope == "mcp:read_only" { + config.read_only = true; + config.no_endpoints = true; + continue; + } + + if scope == RUNNABLES_AS_TOOLS_SCOPE { + config.no_endpoints = true; + continue; + } + + if let Some(ids) = scope.strip_prefix("mcp:workspaces:") { + config + .workspaces + .get_or_insert_with(Vec::new) + .extend(parse_resource_list(ids)); + continue; + } + if let Some(resources) = scope.strip_prefix("mcp:endpoints:") { // New granular endpoint scope: mcp:endpoints:name1,name2 config.endpoints.extend(parse_resource_list(resources)); @@ -409,4 +470,38 @@ mod tests { // mcp:all grants all endpoints. assert!(cfg(&["mcp:all"]).contains(&cfg(&["mcp:endpoints:getResource"]))); } + + #[test] + fn test_workspaces_scope() { + let two = cfg(&["mcp:all", "mcp:workspaces:dev,staging"]); + assert!(two.allows_workspace("dev") && !two.allows_workspace("prod")); + assert!(cfg(&["mcp:all"]).allows_workspace("prod")); + // A workspace list only narrows: it cannot be dropped or widened by a grant. + assert!(two.contains(&cfg(&["mcp:all", "mcp:workspaces:dev"]))); + assert!(!two.contains(&cfg(&["mcp:all", "mcp:workspaces:dev,prod"]))); + assert!(!two.contains(&cfg(&["mcp:all"]))); + assert!(cfg(&["mcp:all"]).contains(&two)); + } + + #[test] + fn test_read_run_scope_only_narrows() { + let read_run = cfg(&["mcp:all", "mcp:read_run"]); + assert!(read_run.read_run && read_run.all); + assert!(read_run.contains(&cfg(&["mcp:all", "mcp:read_run"]))); + assert!(!read_run.contains(&cfg(&["mcp:all"]))); + assert!(read_run.contains(&cfg(&["mcp:all", "mcp:read_only"]))); + let read_only = cfg(&["mcp:all", "mcp:read_only"]); + assert!(!read_only.contains(&cfg(&["mcp:all", "mcp:read_run"]))); + } + + #[test] + fn test_runnables_as_tools_scope_only_narrows() { + let fenced = cfg(&["mcp:favorites", "mcp:runnables_as_tools"]); + assert!(fenced.no_endpoints); + assert!(cfg(&["mcp:all", "mcp:read_only"]).no_endpoints); + assert!(cfg(&["mcp:all", "mcp:read_run"]).no_endpoints); + assert!(fenced.contains(&cfg(&["mcp:favorites", "mcp:runnables_as_tools"]))); + assert!(!fenced.contains(&cfg(&["mcp:favorites"]))); + assert!(cfg(&["mcp:all"]).contains(&fenced)); + } } diff --git a/backend/windmill-mcp/src/common/transform.rs b/backend/windmill-mcp/src/common/transform.rs index a477b0bd0d..174ea77974 100644 --- a/backend/windmill-mcp/src/common/transform.rs +++ b/backend/windmill-mcp/src/common/transform.rs @@ -4,7 +4,7 @@ //! to make them compatible with MCP tool naming requirements. use super::types::SchemaType; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use windmill_common::utils::calculate_hash; /// Max tool name length. The MCP spec allows 64 chars, but some clients @@ -44,6 +44,89 @@ pub fn transform_path(path: &str, type_str: &str) -> String { format!("{}-{}{}", upper_prefix, truncated, hash_suffix) } +/// Name every script and flow a token can see, in the same order as `items` +/// (`(item_type, path)` pairs). +/// +/// An item is named after its last path segment, with parent segments prepended +/// (`billing_send_email`) only while that name is shared with another item or with a +/// `reserved` name. A name that is still shared once every segment is used falls back +/// to the `s-`/`f-` encoding of `transform_path`, which is unique per item. +/// +/// A short name is never one `parse_tool_prefix` accepts, so a call can tell the two +/// schemes apart. Resolving a short name relies on an item's name depending only on +/// the items whose last segment is one of `short_name_last_segments(name)`: keep any +/// new disambiguation step confined to candidates that end with the item's own +/// last segment. +pub fn assign_tool_names(items: &[(&str, &str)], reserved: &HashSet<&str>) -> Vec<String> { + let candidates: Vec<Vec<String>> = items + .iter() + .map(|(item_type, path)| name_candidates(item_type, path, reserved)) + .collect(); + let mut depth = vec![0usize; items.len()]; + loop { + let mut holders: HashMap<&str, usize> = HashMap::new(); + for (c, d) in candidates.iter().zip(&depth) { + *holders.entry(c[*d].as_str()).or_default() += 1; + } + let next: Vec<usize> = candidates + .iter() + .zip(&depth) + .map(|(c, d)| { + if holders[c[*d].as_str()] > 1 && d + 1 < c.len() { + d + 1 + } else { + *d + } + }) + .collect(); + if next == depth { + break; + } + depth = next; + } + candidates + .into_iter() + .zip(depth) + .map(|(mut c, d)| c.swap_remove(d)) + .collect() +} + +/// Every name `path` may get, shortest first, ending with its `transform_path` encoding. +fn name_candidates(item_type: &str, path: &str, reserved: &HashSet<&str>) -> Vec<String> { + let segments: Vec<&str> = path.split('/').collect(); + let mut candidates: Vec<String> = (1..=segments.len()) + .map(|k| segments[segments.len() - k..].join("_")) + .filter(|name| { + !name.is_empty() + && name.len() <= MAX_PATH_LENGTH + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + && !reserved.contains(name.as_str()) + && parse_tool_prefix(name).is_err() + }) + .collect(); + candidates.push(transform_path(path, item_type)); + candidates +} + +/// Whether `name` uses the `s-`/`f-`/`hs-` encoding rather than a short name. +pub fn is_encoded_tool_name(name: &str) -> bool { + parse_tool_prefix(name).is_ok() +} + +/// The last path segments an item named `name` by `assign_tool_names` can have: +/// each `_`-separated suffix of the name, since segments may contain `_` themselves. +/// None for a name longer than any short name: the suffixes of a caller-supplied +/// name grow quadratically with its length. +pub fn short_name_last_segments(name: &str) -> Vec<String> { + if name.len() > MAX_PATH_LENGTH { + return vec![]; + } + let parts: Vec<&str> = name.split('_').collect(); + (0..parts.len()).map(|i| parts[i..].join("_")).collect() +} + /// Transform the path for hub scripts /// /// For short names (≤40 chars): `hs-{id}-{summary}` @@ -69,7 +152,11 @@ pub fn transform_hub_path(version_id: u64, summary: &str) -> String { /// Returns `(type_str, is_hub, is_hashed)`. /// Hashed names use an uppercase first character as the signal. pub fn parse_tool_prefix(name: &str) -> Result<(&str, bool, bool), String> { - let is_hashed = name.chars().next().map(|c| c.is_ascii_uppercase()).unwrap_or(false); + let is_hashed = name + .chars() + .next() + .map(|c| c.is_ascii_uppercase()) + .unwrap_or(false); let lower = name.to_ascii_lowercase(); let (type_str, is_hub) = if lower.starts_with("hs-") { ("script", true) @@ -376,6 +463,81 @@ mod tests { assert!(!hashed); } + const UNIVERSE: &[(&str, &str)] = &[ + ("script", "f/billing/send_email"), + ("script", "f/support/send_email"), + ("script", "f/q/billing_send_email"), + ("flow", "u/admin/onboard"), + ("script", "u/admin/onboard"), + ("script", "u/alice/report"), + ("script", "u/alice/listScripts"), + ("flow", "f/a_b/c"), + ("flow", "f/a/b_c"), + ("script", "f/x/c"), + ("script", "u/admin/b_c"), + ("script", "u/admin/s-legacy_looking"), + ]; + + #[test] + fn test_assign_tool_names() { + let reserved: HashSet<&str> = ["listScripts"].into_iter().collect(); + let names = assign_tool_names(UNIVERSE, &reserved); + assert_eq!( + names, + vec![ + "f_billing_send_email".to_string(), + "support_send_email".to_string(), + "q_billing_send_email".to_string(), + "f-u_admin_onboard".to_string(), + "s-u_admin_onboard".to_string(), + "report".to_string(), + "alice_listScripts".to_string(), + "f-f_a__b_c".to_string(), + "f-f_a_b__c".to_string(), + "x_c".to_string(), + "admin_b_c".to_string(), + "admin_s-legacy_looking".to_string(), + ] + ); + } + + /// A call resolves a short name from only the items whose last segment can end + /// that name, so naming that subset must give every item the name it was listed + /// under. + #[test] + fn test_short_names_resolve_from_last_segment_subset() { + let reserved: HashSet<&str> = ["listScripts"].into_iter().collect(); + let names = assign_tool_names(UNIVERSE, &reserved); + for (item, name) in UNIVERSE.iter().zip(&names) { + if is_encoded_tool_name(name) { + continue; + } + let segments = short_name_last_segments(name); + let subset: Vec<(&str, &str)> = UNIVERSE + .iter() + .filter(|(_, p)| segments.iter().any(|s| p.ends_with(&format!("/{s}")))) + .copied() + .collect(); + let subset_names = assign_tool_names(&subset, &reserved); + let resolved: Vec<_> = subset + .iter() + .zip(&subset_names) + .filter(|(_, n)| *n == name) + .map(|(i, _)| *i) + .collect(); + assert_eq!(resolved, vec![*item], "{name}"); + } + } + + #[test] + fn test_short_name_last_segments_bounded() { + assert_eq!( + short_name_last_segments("a_b_c"), + vec!["a_b_c".to_string(), "b_c".to_string(), "c".to_string()] + ); + assert!(short_name_last_segments(&"_".repeat(100_000)).is_empty()); + } + #[test] fn test_reverse_transform_short_names() { let (type_str, path, is_hub) = reverse_transform("s-u_admin_script").unwrap(); @@ -430,7 +592,10 @@ mod tests { #[test] fn test_extract_path_prefix_handles_hs_prefix() { // Hs- is 3 chars, not 2 — ensure the prefix is stripped correctly - let hashed = transform_hub_path(12345, "a]very long hub script summary that exceeds the limit"); + let hashed = transform_hub_path( + 12345, + "a]very long hub script summary that exceeds the limit", + ); let (_, is_hub, is_hashed) = parse_tool_prefix(&hashed).unwrap(); assert!(is_hub); assert!(is_hashed); diff --git a/backend/windmill-mcp/src/common/types.rs b/backend/windmill-mcp/src/common/types.rs index 92513b7a29..7d91e94255 100644 --- a/backend/windmill-mcp/src/common/types.rs +++ b/backend/windmill-mcp/src/common/types.rs @@ -22,6 +22,11 @@ pub struct WorkspaceId(pub String); #[derive(Clone, Debug)] pub struct MultiWorkspaceMcp; +/// Marker extension: the tools server lists a token's scripts and flows and no endpoint +/// tools. Set on `/api/mcp`, where the full toolset is what reaches the API. +#[derive(Clone, Debug)] +pub struct NoEndpointTools; + /// Raw bearer token wrapper for Axum extensions. In multi-workspace mode the /// runner needs the raw token to re-resolve auth for each requested workspace. #[derive(Clone, Debug)] @@ -145,7 +150,8 @@ pub struct ItemSchema { /// Trait for objects that can be converted to MCP tools pub trait ToolableItem { - /// Get the MCP-compatible tool name (path transformed with escaping/hashing) + /// Get the `s-`/`f-`/`hs-` encoded tool name (path transformed with escaping/hashing). + /// Scripts and flows are listed under `assign_tool_names` instead. fn get_transformed_path(&self) -> String; /// Get the original full path of this item (for display in tool title) fn get_full_path(&self) -> &str; diff --git a/backend/windmill-mcp/src/lib.rs b/backend/windmill-mcp/src/lib.rs index 33a20be2f0..c9daf25da9 100644 --- a/backend/windmill-mcp/src/lib.rs +++ b/backend/windmill-mcp/src/lib.rs @@ -81,8 +81,9 @@ pub mod oauth { /// guard validated for the request URL so the connect cannot rebind to an /// internal IP after the check (TOCTOU). The OAuth DCR/discovery/token /// requests target author-controlled URLs and carry secrets, so they must - /// go through this rather than the unpinned client. Empty `addrs` (IP literal - /// or ALLOW_PRIVATE_MCP_SERVER_URLS) leaves resolution untouched. + /// go through this rather than the unpinned client. Empty `addrs` (IP literal, + /// or a host ALLOW_PRIVATE_MCP_SERVER_URLS allows by name or by `true`) leaves + /// resolution untouched. pub fn no_redirect_http_client_pinned( target: &windmill_common::ssrf::ValidatedTarget, ) -> Result<reqwest::Client, reqwest::Error> { diff --git a/backend/windmill-mcp/src/server/backend.rs b/backend/windmill-mcp/src/server/backend.rs index 4801bfaa99..dc3a23bb51 100644 --- a/backend/windmill-mcp/src/server/backend.rs +++ b/backend/windmill-mcp/src/server/backend.rs @@ -4,6 +4,7 @@ //! (typically windmill-api) to provide the actual functionality for the MCP server. use async_trait::async_trait; +use rmcp::model::{CallToolResult, Tool}; use rmcp::ErrorData; use serde_json::Value; use std::collections::HashMap; @@ -16,6 +17,15 @@ use crate::server::endpoints::EndpointTool; /// Result type for backend operations using rmcp's ErrorData directly pub type BackendResult<T> = Result<T, ErrorData>; +/// What a v2 token may do: read, read and run deployed scripts and flows (`mcp:read_run`), +/// or everything its owner can. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SessionAccess { + ReadOnly, + ReadAndRun, + Full, +} + /// What the backend needs about the HTTP request a tool call arrived on, in order /// to hand a runnable the headers of the call that triggered it. pub struct McpRequest<'a> { @@ -40,6 +50,9 @@ pub enum PathFilter<'a> { /// `is_resource_allowed`: a `*` pattern matches everything, an empty list /// matches nothing. Patterns(&'a [String]), + /// Paths whose last segment is one of these — the candidates a short tool name + /// can resolve to. + LastSegment(&'a [String]), } /// Authentication context required by the MCP server @@ -106,6 +119,18 @@ pub trait McpBackend: Send + Sync + Clone + 'static { path_filter: Option<PathFilter<'_>>, ) -> BackendResult<Vec<FlowInfo>>; + /// Every path of `item_type` ("script" or "flow") the listing would include, with + /// no fetch cap: tool names are disambiguated against all of them, not only the + /// items that make it into the capped listing. + async fn list_runnable_paths( + &self, + auth: &Self::Auth, + workspace_id: &str, + item_type: &str, + favorites_only: bool, + path_filter: Option<PathFilter<'_>>, + ) -> BackendResult<Vec<String>>; + /// List resource types in workspace async fn list_resource_types( &self, @@ -219,4 +244,35 @@ pub trait McpBackend: Send + Sync + Clone + 'static { /// Get all available endpoint tools fn all_endpoint_tools(&self) -> Vec<EndpointTool>; + + // ───────────────────────────────────────────────────────────────── + // Workspace server (`SessionRunner`): the AI-session toolset + // ───────────────────────────────────────────────────────────────── + + /// The instructions a client reaching one workspace receives on connecting, built for + /// this user. + async fn session_instructions( + &self, + auth: &Self::Auth, + workspace_id: &str, + ) -> BackendResult<String>; + + /// The instructions for a client reaching several workspaces: what holds in all of them. + fn generic_session_instructions(&self) -> String; + + /// What `generic_session_instructions` leaves out, for one workspace. + async fn workspace_context(&self, auth: &Self::Auth, workspace_id: &str) -> String; + + /// The tools a token with this access is offered. + fn session_tools(&self, access: SessionAccess) -> Vec<Tool>; + + async fn call_session_tool( + &self, + auth: &Self::Auth, + workspace_id: &str, + name: &str, + args: Value, + access: SessionAccess, + origin: Option<&str>, + ) -> BackendResult<CallToolResult>; } diff --git a/backend/windmill-mcp/src/server/endpoints.rs b/backend/windmill-mcp/src/server/endpoints.rs index 5b08d211a5..7418e23206 100644 --- a/backend/windmill-mcp/src/server/endpoints.rs +++ b/backend/windmill-mcp/src/server/endpoints.rs @@ -113,9 +113,27 @@ pub fn endpoint_tool_to_mcp_tool_multi(tool: &EndpointTool) -> Tool { return mcp_tool; } + // Surface the requirement in the prose description too (the schema is + // authoritative, but some models/clients lean on the text). Kept terse — this + // repeats across every workspace-scoped tool in the list. + if let Some(desc) = mcp_tool.description.take() { + mcp_tool.description = Some(format!("{desc} Requires `workspace_id`.").into()); + } else { + mcp_tool.description = Some("Requires `workspace_id`.".into()); + } + with_workspace_id_arg(mcp_tool) +} + +/// The tool with a required `workspace_id` argument, which the caller strips before +/// dispatching. +pub fn with_workspace_id_arg(mut mcp_tool: Tool) -> Tool { let mut schema = (*mcp_tool.input_schema).clone(); - if let Some(props) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) { + if let Some(props) = schema + .entry("properties") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + { props.insert( "workspace_id".to_string(), serde_json::json!({ @@ -136,15 +154,6 @@ pub fn endpoint_tool_to_mcp_tool_multi(tool: &EndpointTool) -> Tool { } } - // Surface the requirement in the prose description too (the schema is - // authoritative, but some models/clients lean on the text). Kept terse — this - // repeats across every workspace-scoped tool in the list. - if let Some(desc) = mcp_tool.description.take() { - mcp_tool.description = Some(format!("{desc} Requires `workspace_id`.").into()); - } else { - mcp_tool.description = Some("Requires `workspace_id`.".into()); - } - mcp_tool.input_schema = Arc::new(schema); mcp_tool } diff --git a/backend/windmill-mcp/src/server/mod.rs b/backend/windmill-mcp/src/server/mod.rs index ad7c14d320..545812a411 100644 --- a/backend/windmill-mcp/src/server/mod.rs +++ b/backend/windmill-mcp/src/server/mod.rs @@ -9,16 +9,18 @@ pub mod backend; pub mod endpoints; mod list_changes; pub mod runner; +mod session; pub mod tools; // Re-export main types -pub use crate::common::types::{McpToken, MultiWorkspaceMcp, WorkspaceInfo}; -pub use backend::{BackendResult, McpAuth, McpBackend, McpRequest, PathFilter}; +pub use crate::common::types::{McpToken, MultiWorkspaceMcp, NoEndpointTools, WorkspaceInfo}; +pub use backend::{BackendResult, McpAuth, McpBackend, McpRequest, PathFilter, SessionAccess}; pub use endpoints::{ endpoint_tool_to_mcp_tool, endpoint_tool_to_mcp_tool_multi, is_endpoint_read_only, - list_workspaces_tool, non_empty_body_fields, EndpointTool, + list_workspaces_tool, non_empty_body_fields, with_workspace_id_arg, EndpointTool, }; -pub use runner::{has_endpoint_path_policy, Runner}; +pub use runner::{has_endpoint_path_policy, Runner, MAX_TOOL_RESULT_BYTES}; +pub use session::SessionRunner; pub use tools::create_tool_from_item; // Re-export rmcp types for convenience @@ -26,8 +28,8 @@ pub use rmcp::handler::server::ServerHandler; pub use rmcp::model::{ CallToolRequestParams, CallToolResult, ContentBlock, Implementation, InitializeRequestParams, InitializeResult, ListPromptsResult, ListResourceTemplatesResult, ListResourcesResult, - ListToolsResult, PaginatedRequestParams, ProtocolVersion, ServerCapabilities, ServerInfo, Tool, - ToolAnnotations, + ListToolsResult, MetaObject, PaginatedRequestParams, ProtocolVersion, ServerCapabilities, + ServerInfo, Tool, ToolAnnotations, }; pub use rmcp::service::{RequestContext, RoleServer}; pub use rmcp::transport::streamable_http_server::{ diff --git a/backend/windmill-mcp/src/server/runner.rs b/backend/windmill-mcp/src/server/runner.rs index 177989a82e..e2ce8b6c04 100644 --- a/backend/windmill-mcp/src/server/runner.rs +++ b/backend/windmill-mcp/src/server/runner.rs @@ -6,11 +6,13 @@ use crate::common::schema::extract_resource_types_from_schema; use crate::common::scope::parse_mcp_scopes; use crate::common::transform::{ - extract_hub_version_id_from_hashed, extract_path_prefix_from_hashed, parse_tool_prefix, - reverse_transform, reverse_transform_key, + assign_tool_names, extract_hub_version_id_from_hashed, extract_path_prefix_from_hashed, + is_encoded_tool_name, parse_tool_prefix, reverse_transform, reverse_transform_key, + short_name_last_segments, }; use crate::common::types::{ - McpToken, MultiWorkspaceMcp, ResourceInfo, SchemaType, ToolableItem, WorkspaceId, + McpToken, MultiWorkspaceMcp, NoEndpointTools, ResourceInfo, SchemaType, ToolableItem, + WorkspaceId, }; use crate::server::backend::{McpAuth, McpBackend, McpRequest, PathFilter}; use crate::server::endpoints::{ @@ -36,7 +38,7 @@ use tokio::sync::broadcast; /// Protocol revisions this server is willing to speak. `2026-07-28` is served /// statelessly with per-request metadata; the older revisions keep the /// `initialize` handshake, so both eras are answered on the same endpoint. -const SUPPORTED_PROTOCOL_VERSIONS: &[ProtocolVersion] = &[ +pub(super) const SUPPORTED_PROTOCOL_VERSIONS: &[ProtocolVersion] = &[ ProtocolVersion::V_2024_11_05, ProtocolVersion::V_2025_03_26, ProtocolVersion::V_2025_06_18, @@ -52,12 +54,12 @@ const SUPPORTED_PROTOCOL_VERSIONS: &[ProtocolVersion] = &[ /// covers only some of what the listing depends on (not resources, favorites or /// permissions), lags by up to a poll interval, and reaches only clients that hold a /// `subscriptions/listen` stream. -const LIST_TTL_MS: u64 = 0; +pub(super) const LIST_TTL_MS: u64 = 0; /// Every listing is filtered by the caller's token scopes and workspace /// membership, so no two callers necessarily see the same tools — a shared /// cache entry would leak one token's view to another. -const LIST_CACHE_SCOPE: CacheScope = CacheScope::Private; +pub(super) const LIST_CACHE_SCOPE: CacheScope = CacheScope::Private; /// How long a `subscriptions/listen` stream waits after a change before notifying, /// collecting the rest of the burst into the same notification. @@ -69,9 +71,9 @@ use http::request::Parts as HttpParts; /// Maximum size in bytes for tool result text content. /// Anthropic enforces a 25,000 token limit per tool result. /// At ~3.5 bytes/token, 87,500 bytes is a safe upper bound. -const MAX_TOOL_RESULT_BYTES: usize = 87_500; +pub const MAX_TOOL_RESULT_BYTES: usize = 87_500; -fn truncate_tool_result(text: String) -> String { +pub(super) fn truncate_tool_result(text: String) -> String { if text.len() <= MAX_TOOL_RESULT_BYTES { return text; } @@ -102,7 +104,7 @@ impl<B: McpBackend> Clone for Runner<B> { /// Whether the request targets one bound workspace or spans every workspace the /// token can access. -enum McpMode { +pub(super) enum McpMode { /// A single workspace, resolved from the URL path or the token's bound /// workspace. Tools operate against this workspace implicitly. Single(String), @@ -113,10 +115,12 @@ enum McpMode { } /// Everything a request carries besides its MCP payload. -struct McpContext<A> { - auth: A, - mode: McpMode, - headers: http::HeaderMap, +pub(super) struct McpContext<A> { + pub(super) auth: A, + pub(super) mode: McpMode, + pub(super) headers: http::HeaderMap, + /// See `McpScopeConfig::no_endpoints`. + pub(super) no_endpoints: bool, } impl<B: McpBackend> Runner<B> { @@ -127,7 +131,7 @@ impl<B: McpBackend> Runner<B> { /// Extract authentication, the workspace mode and the HTTP request itself /// from the request context - fn extract_context( + pub(super) fn extract_context( context: &RequestContext<RoleServer>, ) -> Result<McpContext<B::Auth>, ErrorData> { let http_parts = context.extensions.get::<HttpParts>().ok_or_else(|| { @@ -164,13 +168,31 @@ impl<B: McpBackend> Runner<B> { ErrorData::internal_error("WorkspaceId not found", None) }) .map(|w_id| w_id.0.clone())?; + // A token without a bound workspace reaches a workspace's own URL too. + let scope_config = parse_mcp_scopes(auth.scopes().unwrap_or(&[])) + .map_err(|e| ErrorData::internal_error(e, None))?; + if !scope_config.allows_workspace(&workspace_id) { + return Err(workspace_not_granted(&workspace_id)); + } McpMode::Single(workspace_id) }; - Ok(McpContext { auth: auth.clone(), mode, headers: http_parts.headers.clone() }) + Ok(McpContext { + auth: auth.clone(), + mode, + headers: http_parts.headers.clone(), + no_endpoints: http_parts.extensions.get::<NoEndpointTools>().is_some(), + }) } } +pub(super) fn workspace_not_granted(workspace_id: &str) -> ErrorData { + ErrorData::invalid_params( + format!("This token was not granted workspace '{workspace_id}'"), + None, + ) +} + /// How an endpoint tool interacts with the token's `mcp:scripts:` / `mcp:flows:` /// path scopes. Mirrored in /// `frontend/src/lib/components/mcp/endpointScopePolicy.ts` so the scope picker @@ -300,6 +322,9 @@ fn endpoint_tool_in_scope( scope_config: &crate::common::scope::McpScopeConfig, endpoint_tool: &EndpointTool, ) -> bool { + if scope_config.no_endpoints { + return false; + } let endpoint_allowed = !scope_config.granular || scope_config.is_allowed("endpoint", &endpoint_tool.name); match endpoint_path_policy(&endpoint_tool.name) { @@ -326,6 +351,15 @@ fn authorize_endpoint_call( args: &mut Value, read_only: bool, ) -> Result<(), ErrorData> { + if scope_config.no_endpoints { + return Err(ErrorData::invalid_params( + format!( + "Tool '{}' is not available to this token", + endpoint_tool.name + ), + None, + )); + } // A destination the caller omitted, or left empty because it had to fill in every // argument, means "leave the item where it is". Resolved to the path the item is // already at, so the endpoint receives a body naming its own path rather than one @@ -557,14 +591,16 @@ impl<B: McpBackend> ServerHandler for Runner<B> { _request: Option<PaginatedRequestParams>, context: RequestContext<RoleServer>, ) -> Result<ListToolsResult, ErrorData> { - let McpContext { auth, mode, .. } = Self::extract_context(&context)?; + let McpContext { auth, mode, no_endpoints, .. } = Self::extract_context(&context)?; // Parse MCP scopes to determine what to expose let scopes = auth.scopes().unwrap_or(&[]); - let scope_config = + let mut scope_config = parse_mcp_scopes(scopes).map_err(|e| ErrorData::internal_error(e, None))?; + scope_config.no_endpoints |= no_endpoints; - let read_only = auth.read_only(); + // v1 has no read-and-run access: such a token reads only. + let read_only = auth.read_only() || scope_config.read_run || scope_config.read_only; match mode { McpMode::Single(workspace_id) => { @@ -585,13 +621,14 @@ impl<B: McpBackend> ServerHandler for Runner<B> { request: CallToolRequestParams, context: RequestContext<RoleServer>, ) -> Result<CallToolResponse, ErrorData> { - let McpContext { auth, mode, headers } = Self::extract_context(&context)?; + let McpContext { auth, mode, headers, no_endpoints } = Self::extract_context(&context)?; // Parse MCP scopes for authorization let scopes = auth.scopes().unwrap_or(&[]); - let scope_config = + let mut scope_config = parse_mcp_scopes(scopes).map_err(|e| ErrorData::internal_error(e, None))?; - let read_only = auth.read_only(); + scope_config.no_endpoints |= no_endpoints; + let read_only = auth.read_only() || scope_config.read_run || scope_config.read_only; let args = request.arguments.map(Value::Object).unwrap_or(Value::Null); let mcp_request = McpRequest { headers: &headers, tool_name: request.name.as_ref() }; @@ -658,6 +695,51 @@ impl<B: McpBackend> ServerHandler for Runner<B> { } impl<B: McpBackend> Runner<B> { + /// Name every script and flow the token can see under the given path filters, as + /// `(item_type, path, tool_name)`. Listing and calling must both go through here + /// so that a listed name resolves to the item it was listed for. + async fn name_runnables( + &self, + auth: &B::Auth, + workspace_id: &str, + scope_config: &crate::common::scope::McpScopeConfig, + script_filter: Option<PathFilter<'_>>, + flow_filter: Option<PathFilter<'_>>, + ) -> Result<Vec<(&'static str, String, String)>, ErrorData> { + let favorites_only = scope_config.favorites; + let (scripts, flows) = tokio::try_join!( + self.backend.list_runnable_paths( + auth, + workspace_id, + "script", + favorites_only, + script_filter + ), + self.backend.list_runnable_paths( + auth, + workspace_id, + "flow", + favorites_only, + flow_filter + ), + )?; + let items: Vec<(&'static str, String)> = scripts + .into_iter() + .map(|p| ("script", p)) + .chain(flows.into_iter().map(|p| ("flow", p))) + .filter(|(t, p)| !scope_config.granular || scope_config.is_allowed(t, p)) + .collect(); + let endpoint_tools = self.backend.all_endpoint_tools(); + let reserved: HashSet<&str> = endpoint_tools.iter().map(|t| t.name.as_ref()).collect(); + let pairs: Vec<(&str, &str)> = items.iter().map(|(t, p)| (*t, p.as_str())).collect(); + let names = assign_tool_names(&pairs, &reserved); + Ok(items + .into_iter() + .zip(names) + .map(|((t, p), n)| (t, p, n)) + .collect()) + } + /// List tools for a single, bound workspace (URL-path or token-bound). async fn list_tools_single( &self, @@ -686,11 +768,12 @@ impl<B: McpBackend> Runner<B> { .granular .then(|| PathFilter::Patterns(scope_config.flows.as_slice())); - let (scripts, flows, resource_types, hub_scripts) = tokio::try_join!( + let (scripts, flows, named, resource_types, hub_scripts) = tokio::try_join!( self.backend .list_scripts(auth, workspace_id, favorites_only, script_filter), self.backend .list_flows(auth, workspace_id, favorites_only, flow_filter), + self.name_runnables(auth, workspace_id, scope_config, script_filter, flow_filter), self.backend.list_resource_types(auth, workspace_id), async { if let Some(ref apps) = scope_config.hub_apps { @@ -746,9 +829,22 @@ impl<B: McpBackend> Runner<B> { let resources_cache: HashMap<String, Vec<ResourceInfo>> = resource_results.into_iter().collect(); + let names: HashMap<(&str, &str), &str> = named + .iter() + .map(|(t, p, n)| ((*t, p.as_str()), n.as_str())) + .collect(); + // An item created between the two queries has no short name yet. + let name_of = |item: &dyn ToolableItem| { + names + .get(&(item.item_type(), item.get_full_path())) + .map(|n| n.to_string()) + .unwrap_or_else(|| item.get_transformed_path()) + }; + for script in &filtered_scripts { tools.push(create_tool_from_item( script, + name_of(script), self.backend.as_ref(), &resources_cache, &resource_types, @@ -758,6 +854,7 @@ impl<B: McpBackend> Runner<B> { for flow in &filtered_flows { tools.push(create_tool_from_item( flow, + name_of(flow), self.backend.as_ref(), &resources_cache, &resource_types, @@ -767,6 +864,7 @@ impl<B: McpBackend> Runner<B> { for hub_script in &hub_scripts { tools.push(create_tool_from_item( hub_script, + hub_script.get_transformed_path(), self.backend.as_ref(), &resources_cache, &resource_types, @@ -847,49 +945,71 @@ impl<B: McpBackend> Runner<B> { } // Resolve the tool name to (type, path, is_hub) - let (type_str, is_hub, is_hashed) = parse_tool_prefix(name.as_ref()).map_err(|e| { - ErrorData::internal_error(format!("Failed to parse tool name: {}", e), None) - })?; - - let (tool_type, path, is_hub) = if !is_hashed { - reverse_transform(name.as_ref()).map_err(|e| { - ErrorData::internal_error(format!("Failed to parse tool name: {}", e), None) - })? - } else if is_hub { - let version_id = extract_hub_version_id_from_hashed(name.as_ref()).map_err(|e| { - ErrorData::internal_error(format!("Failed to extract hub version_id: {}", e), None) - })?; - (type_str, version_id, true) + let (tool_type, path, is_hub) = if !is_encoded_tool_name(name.as_ref()) { + let segments = short_name_last_segments(name.as_ref()); + let filter = Some(PathFilter::LastSegment(segments.as_slice())); + let named = self + .name_runnables(auth, workspace_id, scope_config, filter, filter) + .await?; + let (tool_type, path, _) = named + .into_iter() + .find(|(_, _, n)| n == name.as_ref()) + .ok_or_else(|| { + ErrorData::internal_error(format!("No tool named '{}'", name), None) + })?; + (tool_type, path, false) } else { - let path_prefix = extract_path_prefix_from_hashed(name.as_ref()); - let path_filter = path_prefix.as_deref().map(PathFilter::Prefix); - let favorites_only = scope_config.favorites; - let matched_path = if type_str == "script" { - find_matching_path( - self.backend - .list_scripts(auth, workspace_id, favorites_only, path_filter) - .await - .map_err(|e| ErrorData::internal_error(e.message, None))?, - name.as_ref(), - ) - } else { - find_matching_path( - self.backend - .list_flows(auth, workspace_id, favorites_only, path_filter) - .await - .map_err(|e| ErrorData::internal_error(e.message, None))?, - name.as_ref(), - ) - }; - - let matched_path = matched_path.ok_or_else(|| { - ErrorData::internal_error( - format!("No {} found matching hashed tool name '{}'", type_str, name), - None, - ) + // The `s-`/`f-`/`hs-` encoding: what hub scripts and items whose short + // names all collide are listed under, and what clients listed before short + // names existed still call. + let (type_str, is_hub, is_hashed) = parse_tool_prefix(name.as_ref()).map_err(|e| { + ErrorData::internal_error(format!("Failed to parse tool name: {}", e), None) })?; - (type_str, matched_path, false) + if !is_hashed { + reverse_transform(name.as_ref()).map_err(|e| { + ErrorData::internal_error(format!("Failed to parse tool name: {}", e), None) + })? + } else if is_hub { + let version_id = + extract_hub_version_id_from_hashed(name.as_ref()).map_err(|e| { + ErrorData::internal_error( + format!("Failed to extract hub version_id: {}", e), + None, + ) + })?; + (type_str, version_id, true) + } else { + let path_prefix = extract_path_prefix_from_hashed(name.as_ref()); + let path_filter = path_prefix.as_deref().map(PathFilter::Prefix); + let favorites_only = scope_config.favorites; + let matched_path = if type_str == "script" { + find_matching_path( + self.backend + .list_scripts(auth, workspace_id, favorites_only, path_filter) + .await + .map_err(|e| ErrorData::internal_error(e.message, None))?, + name.as_ref(), + ) + } else { + find_matching_path( + self.backend + .list_flows(auth, workspace_id, favorites_only, path_filter) + .await + .map_err(|e| ErrorData::internal_error(e.message, None))?, + name.as_ref(), + ) + }; + + let matched_path = matched_path.ok_or_else(|| { + ErrorData::internal_error( + format!("No {} found matching hashed tool name '{}'", type_str, name), + None, + ) + })?; + + (type_str, matched_path, false) + } }; // Validate script/flow scope @@ -1004,11 +1124,12 @@ impl<B: McpBackend> Runner<B> { mut args: Value, ) -> Result<CallToolResult, ErrorData> { if name.as_ref() == "list_workspaces" { - let workspaces = self + let mut workspaces = self .backend .list_accessible_workspaces(base_auth) .await .map_err(|e| ErrorData::internal_error(e.message, None))?; + workspaces.retain(|w| scope_config.allows_workspace(&w.id)); return Ok(CallToolResult::success(vec![ContentBlock::text( serde_json::to_string_pretty(&workspaces).unwrap_or_else(|_| "[]".to_string()), )])); @@ -1056,6 +1177,9 @@ impl<B: McpBackend> Runner<B> { ) })? .to_string(); + if !scope_config.allows_workspace(&workspace_id) { + return Err(workspace_not_granted(&workspace_id)); + } let resolved = self .backend @@ -1197,6 +1321,17 @@ mod tests { ); } + #[test] + fn a_selection_without_endpoints_gets_none() { + // The scope withholds them on every URL, as the server does on `/api/mcp`. + let selection = cfg(&["mcp:favorites", "mcp:endpoints:*", "mcp:runnables_as_tools"]); + assert!(!endpoint_tool_in_scope(&selection, &ep("listJobs", "GET"))); + assert!( + authorize_endpoint_call(&selection, &ep("listJobs", "GET"), &mut json!({}), false) + .is_err() + ); + } + #[test] fn read_only_refuses_non_get_endpoint() { // Reaches the read-only check via mcp:all so scope isn't the blocker. diff --git a/backend/windmill-mcp/src/server/session.rs b/backend/windmill-mcp/src/server/session.rs new file mode 100644 index 0000000000..1130a01e84 --- /dev/null +++ b/backend/windmill-mcp/src/server/session.rs @@ -0,0 +1,338 @@ +//! The Windmill MCP server's toolset: an AI session's tools and guidance for an external +//! client. `/api/mcp` serves it to tokens with `mcp:all`, and the tools server (`Runner`) +//! to tokens scoped to a selection of scripts and flows. +//! +//! Every tool takes the workspace it acts in, whether the token reaches one workspace or +//! several, so a client's tool schemas do not change with the token it signed in with. + +use crate::common::scope::{parse_mcp_scopes, McpScopeConfig}; +use crate::common::types::WorkspaceInfo; +use crate::server::backend::{McpAuth, McpBackend, SessionAccess}; +use crate::server::endpoints::{list_workspaces_tool, with_workspace_id_arg}; +use crate::server::runner::{ + truncate_tool_result, workspace_not_granted, McpContext, McpMode, Runner, LIST_CACHE_SCOPE, + LIST_TTL_MS, SUPPORTED_PROTOCOL_VERSIONS, +}; +use rmcp::handler::server::ServerHandler; +use rmcp::model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, DiscoverResult, + Implementation, InitializeRequestParams, InitializeResult, ListToolsResult, + PaginatedRequestParams, ProtocolVersion, ServerCapabilities, ServerInfo, Tool, ToolAnnotations, +}; +use rmcp::service::{RequestContext, RoleServer}; +use rmcp::ErrorData; +use serde_json::Value; +use std::borrow::Cow; +use std::sync::Arc; + +/// `mcp:all`, and no scope from outside `mcp:` that confines the token to some routes +/// or paths (`variables:read:f/x/*`): the tools call the API without a route scope, so +/// that confinement would be lost here. The job tag fence is carried through instead. +fn admits(scopes: &[String], all: bool) -> bool { + all && scopes + .iter() + .all(|s| s.starts_with("mcp:") || s.starts_with("if_jobs:filter_tags:")) +} + +fn access<A: McpAuth>(auth: &A, scope: &McpScopeConfig) -> SessionAccess { + if auth.read_only() || scope.read_only { + SessionAccess::ReadOnly + } else if scope.read_run { + SessionAccess::ReadAndRun + } else { + SessionAccess::Full + } +} + +const GET_WORKSPACE_CONTEXT: &str = "get_workspace_context"; + +/// Workspaces named in the instructions before the rest are left to `list_workspaces`. +const MAX_LISTED_WORKSPACES: usize = 40; + +fn get_workspace_context_tool() -> Tool { + let schema = serde_json::json!({ "type": "object", "properties": {} }); + with_workspace_id_arg( + Tool::new( + Cow::Borrowed(GET_WORKSPACE_CONTEXT), + "Your username in a workspace, the folders you can write to there, and the \ + workspace's skills and instructions. Call it before your first action in a \ + workspace.", + Arc::new(schema.as_object().unwrap().clone()), + ) + .with_annotations(ToolAnnotations::new().read_only(true)), + ) +} + +/// A request's caller, and the workspaces its token reaches. +struct Caller<A> { + auth: A, + mode: McpMode, + scope: McpScopeConfig, + /// The scheme and host the client reached, for links to Windmill pages. + origin: Option<String>, +} + +/// `x-forwarded-*` first, since a proxy in front rewrites the host the request names. +fn request_origin(headers: &http::HeaderMap) -> Option<String> { + let header = |name: &str| { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.split(',').next()) + .map(str::trim) + .filter(|v| !v.is_empty()) + }; + let host = header("x-forwarded-host").or_else(|| header("host"))?; + let scheme = header("x-forwarded-proto").unwrap_or("http"); + Some(format!("{scheme}://{host}")) +} + +pub struct SessionRunner<B: McpBackend> { + backend: Arc<B>, +} + +impl<B: McpBackend> Clone for SessionRunner<B> { + fn clone(&self) -> Self { + Self { backend: self.backend.clone() } + } +} + +impl<B: McpBackend> SessionRunner<B> { + pub fn new(backend: B) -> Self { + Self { backend: Arc::new(backend) } + } + + /// This server writes drafts and deploys, so it takes the scope that already reaches + /// the create/update endpoint tools and nothing narrower: a token scoped to a few + /// scripts was never consented for authoring. + fn caller(context: &RequestContext<RoleServer>) -> Result<Caller<B::Auth>, ErrorData> { + let McpContext { auth, mode, headers, .. } = Runner::<B>::extract_context(context)?; + let scope = parse_mcp_scopes(auth.scopes().unwrap_or(&[])) + .map_err(|e| ErrorData::internal_error(e, None))?; + if !admits(auth.scopes().unwrap_or(&[]), scope.all) { + return Err(ErrorData::invalid_request( + "This server needs an `mcp:all` token with no other route or path scope", + None, + )); + } + Ok(Caller { auth, mode, scope, origin: request_origin(&headers) }) + } + + async fn workspaces(&self, caller: &Caller<B::Auth>) -> Result<Vec<WorkspaceInfo>, ErrorData> { + let mut workspaces = self + .backend + .list_accessible_workspaces(&caller.auth) + .await?; + match &caller.mode { + McpMode::Single(bound) => workspaces.retain(|w| &w.id == bound), + McpMode::Multi(_) => workspaces.retain(|w| caller.scope.allows_workspace(&w.id)), + } + Ok(workspaces) + } + + /// The caller as they are in `workspace_id`, if the token reaches it. + async fn auth_in( + &self, + caller: &Caller<B::Auth>, + workspace_id: &str, + ) -> Result<B::Auth, ErrorData> { + match &caller.mode { + McpMode::Single(bound) if bound == workspace_id => Ok(caller.auth.clone()), + McpMode::Multi(token) if caller.scope.allows_workspace(workspace_id) => { + self.backend + .resolve_workspace_auth(token, workspace_id) + .await + } + _ => Err(workspace_not_granted(workspace_id)), + } + } + + async fn info_for( + &self, + context: &RequestContext<RoleServer>, + ) -> Result<ServerInfo, ErrorData> { + let caller = Self::caller(context)?; + let workspaces = self.workspaces(&caller).await?; + let instructions = match workspaces.as_slice() { + [only] => { + let auth = self.auth_in(&caller, &only.id).await?; + format!( + "Every tool takes a `workspace_id`: pass `{}`, the workspace this connection \ + works in.\n\n{}", + only.id, + self.backend.session_instructions(&auth, &only.id).await? + ) + } + _ => { + let mut listed = workspaces + .iter() + .take(MAX_LISTED_WORKSPACES) + .map(|w| format!("`{}` ({})", w.id, w.name)) + .collect::<Vec<_>>() + .join(", "); + if workspaces.len() > MAX_LISTED_WORKSPACES { + listed.push_str(&format!( + " (+{} more)", + workspaces.len() - MAX_LISTED_WORKSPACES + )); + } + format!( + "This connection works across several Windmill workspaces: {listed}. Every \ + tool takes a `workspace_id` naming the one it acts in; list_workspaces lists \ + them. Before your first action in a workspace, call get_workspace_context \ + with its id: it gives your username there (the `<username>` in the paths \ + below), the folders you can write to, and the workspace's skills and \ + instructions. When the user has not said which workspace, ask rather than \ + guess, and name the workspace whenever you change something.\n\n{}", + self.backend.generic_session_instructions() + ) + } + }; + Ok(self.get_info().with_instructions(instructions)) + } +} + +impl<B: McpBackend> ServerHandler for SessionRunner<B> { + /// Without instructions: those are built per caller, in `initialize` and `discover`. + fn get_info(&self) -> ServerInfo { + let server_info = Implementation::new("windmill", env!("CARGO_PKG_VERSION")) + .with_title("Windmill") + .with_website_url("https://windmill.dev"); + InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(server_info) + } + + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + Cow::Borrowed(SUPPORTED_PROTOCOL_VERSIONS) + } + + async fn initialize( + &self, + request: InitializeRequestParams, + context: RequestContext<RoleServer>, + ) -> Result<InitializeResult, ErrorData> { + context.peer.set_peer_info(request.clone()); + let mut info = self.info_for(&context).await?; + if SUPPORTED_PROTOCOL_VERSIONS.contains(&request.protocol_version) { + info.protocol_version = request.protocol_version; + } + Ok(info) + } + + async fn discover( + &self, + context: RequestContext<RoleServer>, + ) -> Result<DiscoverResult, ErrorData> { + Ok(DiscoverResult::from_server_info( + SUPPORTED_PROTOCOL_VERSIONS.to_vec(), + self.info_for(&context).await?, + )) + } + + async fn list_tools( + &self, + _request: Option<PaginatedRequestParams>, + context: RequestContext<RoleServer>, + ) -> Result<ListToolsResult, ErrorData> { + let caller = Self::caller(&context)?; + let mut tools = vec![list_workspaces_tool()]; + // A bound token's one workspace is described in the instructions already. + if let McpMode::Multi(_) = caller.mode { + tools.push(get_workspace_context_tool()); + } + tools.extend( + self.backend + .session_tools(access(&caller.auth, &caller.scope)) + .into_iter() + .map(with_workspace_id_arg), + ); + // Without these a `2026-07-28` client rejects the whole listing. + Ok(ListToolsResult::with_all_items(tools) + .with_ttl_ms(LIST_TTL_MS) + .with_cache_scope(LIST_CACHE_SCOPE)) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext<RoleServer>, + ) -> Result<CallToolResponse, ErrorData> { + let caller = Self::caller(&context)?; + if request.name == "list_workspaces" { + let workspaces = self.workspaces(&caller).await?; + return Ok(CallToolResult::success(vec![ContentBlock::text( + serde_json::to_string_pretty(&workspaces).unwrap_or_default(), + )]) + .into()); + } + let mut args = request.arguments.unwrap_or_default(); + let workspace_id = + match args.remove("workspace_id") { + Some(Value::String(w)) if !w.is_empty() => w, + _ => return Err(ErrorData::invalid_params( + "Missing required `workspace_id` argument. Call list_workspaces to see the \ + workspaces you can act in.", + None, + )), + }; + let auth = self.auth_in(&caller, &workspace_id).await?; + if request.name == GET_WORKSPACE_CONTEXT { + let text = self.backend.workspace_context(&auth, &workspace_id).await; + return Ok(CallToolResult::success(vec![ContentBlock::text(text)]).into()); + } + let mut result = self + .backend + .call_session_tool( + &auth, + &workspace_id, + request.name.as_ref(), + Value::Object(args), + access(&auth, &caller.scope), + caller.origin.as_deref(), + ) + .await?; + for block in result.content.iter_mut() { + if let Some(text) = block.as_text() { + *block = ContentBlock::text(truncate_tool_result(text.text.clone())); + } + } + Ok(result.into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn origin_prefers_forwarded_headers() { + let mut headers = http::HeaderMap::new(); + headers.insert("host", "backend:8000".parse().unwrap()); + assert_eq!( + request_origin(&headers).as_deref(), + Some("http://backend:8000") + ); + headers.insert("x-forwarded-host", "wm.example.com, proxy".parse().unwrap()); + headers.insert("x-forwarded-proto", "https".parse().unwrap()); + assert_eq!( + request_origin(&headers).as_deref(), + Some("https://wm.example.com") + ); + } + + #[test] + fn only_an_unnarrowed_mcp_all_token_is_admitted() { + let admitted = |scopes: &[&str]| { + let scopes: Vec<String> = scopes.iter().map(|s| s.to_string()).collect(); + let all = parse_mcp_scopes(&scopes).is_ok_and(|c| c.all); + admits(&scopes, all) + }; + assert!(admitted(&["mcp:all"])); + assert!(admitted(&["mcp:all", "if_jobs:filter_tags:gpu"])); + assert!(admitted(&["mcp:all", "mcp:workspaces:dev"])); + assert!(!admitted(&["mcp:all", "variables:read:u/a/safe/*"])); + assert!(!admitted(&["mcp:all:f/x/*"])); + assert!(!admitted(&["mcp:favorites"])); + assert!(!admitted(&[])); + } +} diff --git a/backend/windmill-mcp/src/server/tools.rs b/backend/windmill-mcp/src/server/tools.rs index 98ea105db4..57a4bbab00 100644 --- a/backend/windmill-mcp/src/server/tools.rs +++ b/backend/windmill-mcp/src/server/tools.rs @@ -178,18 +178,18 @@ fn omission_hint(input_schema: &Map<String, Value>, item_type: &str) -> Option<S )) } -/// Create an MCP Tool from a ToolableItem +/// Create an MCP Tool named `path` from a ToolableItem /// /// The resources_cache should be pre-populated with all resource types /// that may be referenced by the item's schema. pub fn create_tool_from_item<T: ToolableItem, B: McpBackend>( item: &T, + path: String, backend: &B, resources_cache: &HashMap<String, Vec<ResourceInfo>>, resources_types: &[ResourceType], ) -> Tool { let is_hub = item.is_hub(); - let path = item.get_transformed_path(); let item_type = item.item_type(); let mut description = format!( "This is a {} named `{}` with the following description: `{}`.{}", diff --git a/backend/windmill-native-triggers/Cargo.toml b/backend/windmill-native-triggers/Cargo.toml index 126c22c7c0..1f14154b7e 100644 --- a/backend/windmill-native-triggers/Cargo.toml +++ b/backend/windmill-native-triggers/Cargo.toml @@ -7,6 +7,9 @@ edition.workspace = true [lib] name = "windmill_native_triggers" path = "src/lib.rs" +# src/ has no unit tests or doctests: remove both lines before adding one, or cargo test never runs it. +test = false +doctest = false [features] default = [] diff --git a/backend/windmill-native-triggers/src/github/external.rs b/backend/windmill-native-triggers/src/github/external.rs index 2c23e02bef..6e34154df3 100644 --- a/backend/windmill-native-triggers/src/github/external.rs +++ b/backend/windmill-native-triggers/src/github/external.rs @@ -33,7 +33,7 @@ impl External for GitHub { async fn create( &self, w_id: &str, - _oauth_data: &Self::OAuthData, + oauth_data: &Self::OAuthData, webhook_token: &str, data: &NativeTriggerData<Self::ServiceConfig>, db: &DB, @@ -69,7 +69,15 @@ impl External for GitHub { }); let response: CreateWebhookResponse = self - .http_client_request(&url, Method::POST, w_id, db, None, Some(&payload)) + .http_client_request( + &url, + Method::POST, + w_id, + &oauth_data.connection_path, + db, + None, + Some(&payload), + ) .await?; Ok(response) @@ -78,7 +86,7 @@ impl External for GitHub { async fn update( &self, w_id: &str, - _oauth_data: &Self::OAuthData, + oauth_data: &Self::OAuthData, external_id: &str, webhook_token: &str, data: &NativeTriggerData<Self::ServiceConfig>, @@ -114,7 +122,15 @@ impl External for GitHub { }); let _: serde_json::Value = self - .http_client_request(&url, Method::PATCH, w_id, db, None, Some(&payload)) + .http_client_request( + &url, + Method::PATCH, + w_id, + &oauth_data.connection_path, + db, + None, + Some(&payload), + ) .await?; // Return the resolved service_config @@ -125,7 +141,7 @@ impl External for GitHub { async fn get( &self, w_id: &str, - _oauth_data: &Self::OAuthData, + oauth_data: &Self::OAuthData, external_id: &str, db: &DB, _tx: &mut PgConnection, @@ -137,7 +153,14 @@ impl External for GitHub { }; let data = self - .get_webhook(w_id, &owner, &repo, external_id, db) + .get_webhook( + w_id, + &oauth_data.connection_path, + &owner, + &repo, + external_id, + db, + ) .await?; Ok(Some(data)) } @@ -145,7 +168,7 @@ impl External for GitHub { async fn delete( &self, w_id: &str, - _oauth_data: &Self::OAuthData, + oauth_data: &Self::OAuthData, external_id: &str, db: &DB, _tx: &mut PgConnection, @@ -165,7 +188,15 @@ impl External for GitHub { // Swallow 404 only (webhook may already be deleted on GitHub); propagate // other errors so callers don't think cleanup succeeded when it didn't. let result: std::result::Result<serde_json::Value, _> = self - .http_client_request::<_, ()>(&url, Method::DELETE, w_id, db, None, None) + .http_client_request::<_, ()>( + &url, + Method::DELETE, + w_id, + &oauth_data.connection_path, + db, + None, + None, + ) .await; match result { @@ -180,7 +211,7 @@ impl External for GitHub { db: &DB, workspace_id: &str, triggers: &[NativeTrigger], - _oauth_data: &Self::OAuthData, + oauth_data: &Self::OAuthData, synced: &mut Vec<crate::sync::TriggerSyncInfo>, errors: &mut Vec<crate::sync::SyncError>, ) { @@ -208,7 +239,15 @@ impl External for GitHub { ); let result: std::result::Result<GithubWebhookApiResponse, _> = self - .http_client_request::<_, ()>(&url, Method::GET, workspace_id, db, None, None) + .http_client_request::<_, ()>( + &url, + Method::GET, + workspace_id, + &oauth_data.connection_path, + db, + None, + None, + ) .await; match result { @@ -295,7 +334,7 @@ impl External for GitHub { fn error_hint(&self, status: StatusCode) -> Option<&'static str> { match status { StatusCode::UNAUTHORIZED => { - Some("reconnect the GitHub integration from Workspace settings > Integrations.") + Some("reconnect the GitHub account this uses with Reconnect next to it, or ask its owner or an admin to.") } StatusCode::FORBIDDEN => Some( "check that the OAuth app is authorized for the organization and that the \ @@ -363,6 +402,7 @@ impl GitHub { async fn get_webhook( &self, w_id: &str, + connection_path: &str, owner: &str, repo: &str, external_id: &str, @@ -374,7 +414,7 @@ impl GitHub { ); let response: GithubWebhookApiResponse = self - .http_client_request::<_, ()>(&url, Method::GET, w_id, db, None, None) + .http_client_request::<_, ()>(&url, Method::GET, w_id, connection_path, db, None, None) .await?; Ok(GithubTriggerData { diff --git a/backend/windmill-native-triggers/src/github/mod.rs b/backend/windmill-native-triggers/src/github/mod.rs index d01fa0020b..45d0ebf257 100644 --- a/backend/windmill-native-triggers/src/github/mod.rs +++ b/backend/windmill-native-triggers/src/github/mod.rs @@ -17,6 +17,7 @@ pub struct GithubOAuthData { pub access_token: String, pub refresh_token: Option<String>, pub token_expires_at: Option<chrono::DateTime<chrono::Utc>>, + pub connection_path: String, } /// User-provided configuration for a GitHub webhook trigger. diff --git a/backend/windmill-native-triggers/src/github/routes.rs b/backend/windmill-native-triggers/src/github/routes.rs index 68de93b6a9..8a20d72693 100644 --- a/backend/windmill-native-triggers/src/github/routes.rs +++ b/backend/windmill-native-triggers/src/github/routes.rs @@ -1,14 +1,15 @@ use std::sync::Arc; -use axum::{extract::Path, routing::get, Extension, Json, Router}; +use axum::{ + extract::{Path, Query}, + routing::get, + Extension, Json, Router, +}; use http::Method; use windmill_api_auth::ApiAuthed; -use windmill_common::{error::JsonResult, DB}; +use windmill_common::{db::UserDB, error::JsonResult, DB}; -use crate::{ - get_workspace_integration, map_external_error, require_native_integration_use, External, - ServiceName, -}; +use crate::{map_external_error, picker_connection, ConnectionQuery, External, ServiceName}; use super::{GitHub, GithubApiRepoResponse, GithubRepoEntry}; @@ -19,10 +20,19 @@ async fn list_repos( authed: ApiAuthed, Extension(handler): Extension<Arc<GitHub>>, Extension(db): Extension<DB>, + Extension(user_db): Extension<UserDB>, Path(workspace_id): Path<String>, + Query(query): Query<ConnectionQuery>, ) -> JsonResult<Vec<GithubRepoEntry>> { - require_native_integration_use(&authed)?; - get_workspace_integration(&db, &workspace_id, ServiceName::Github).await?; + let connection_path = picker_connection( + &authed, + &db, + user_db, + &workspace_id, + ServiceName::Github, + query.connection_path.as_deref(), + ) + .await?; let mut all_entries = Vec::new(); @@ -35,7 +45,15 @@ async fn list_repos( ); let repos: Vec<GithubApiRepoResponse> = handler - .http_client_request::<_, ()>(&url, Method::GET, &workspace_id, &db, None, None) + .http_client_request::<_, ()>( + &url, + Method::GET, + &workspace_id, + &connection_path, + &db, + None, + None, + ) .await .map_err(map_external_error)?; diff --git a/backend/windmill-native-triggers/src/google/external.rs b/backend/windmill-native-triggers/src/google/external.rs index 1c5a2dd9ec..a456a0a0bb 100644 --- a/backend/windmill-native-triggers/src/google/external.rs +++ b/backend/windmill-native-triggers/src/google/external.rs @@ -42,7 +42,7 @@ impl External for Google { async fn create( &self, w_id: &str, - _oauth_data: &Self::OAuthData, + oauth_data: &Self::OAuthData, webhook_token: &str, data: &NativeTriggerData<Self::ServiceConfig>, db: &DB, @@ -51,8 +51,16 @@ impl External for Google { // At creation time, channel_id also becomes the trigger's external_id // (see external_id_and_metadata_from_response). let channel_id = uuid::Uuid::new_v4().to_string(); - self.create_watch_channel(w_id, &channel_id, &channel_id, webhook_token, data, db) - .await + self.create_watch_channel( + w_id, + &oauth_data.connection_path, + &channel_id, + &channel_id, + webhook_token, + data, + db, + ) + .await } async fn update( @@ -72,7 +80,15 @@ impl External for Google { // external_id stays the stable routing key used in the webhook URL. let channel_id = uuid::Uuid::new_v4().to_string(); let resp = self - .create_watch_channel(w_id, external_id, &channel_id, webhook_token, data, db) + .create_watch_channel( + w_id, + &oauth_data.connection_path, + external_id, + &channel_id, + webhook_token, + data, + db, + ) .await?; self.service_config_from_create_response(data, &resp) @@ -86,7 +102,7 @@ impl External for Google { async fn delete( &self, w_id: &str, - _oauth_data: &Self::OAuthData, + oauth_data: &Self::OAuthData, external_id: &str, db: &DB, tx: &mut PgConnection, @@ -122,7 +138,15 @@ impl External for Google { // Stop the channel (ignore errors - channel may have already expired) let result: std::result::Result<serde_json::Value, _> = self - .http_client_request(&url, Method::POST, w_id, db, None, Some(&stop_request)) + .http_client_request( + &url, + Method::POST, + w_id, + &oauth_data.connection_path, + db, + None, + Some(&stop_request), + ) .await; if let Err(e) = result { @@ -203,7 +227,7 @@ impl External for Google { fn error_hint(&self, status: http::StatusCode) -> Option<&'static str> { match status { http::StatusCode::UNAUTHORIZED => { - Some("reconnect the Google integration from Workspace settings > Integrations.") + Some("reconnect the Google account this uses with Reconnect next to it, or ask its owner or an admin to.") } http::StatusCode::FORBIDDEN => Some( "the connected Google account is missing access to this resource or the scope the \ @@ -231,6 +255,7 @@ impl Google { async fn create_watch_channel( &self, w_id: &str, + connection_path: &str, external_id: &str, channel_id: &str, webhook_token: &str, @@ -263,12 +288,24 @@ impl Google { match data.service_config.trigger_type { GoogleTriggerType::Drive => { - self.create_drive_watch(w_id, &data.service_config, &watch_request, db) - .await + self.create_drive_watch( + w_id, + connection_path, + &data.service_config, + &watch_request, + db, + ) + .await } GoogleTriggerType::Calendar => { - self.create_calendar_watch(w_id, &data.service_config, &watch_request, db) - .await + self.create_calendar_watch( + w_id, + connection_path, + &data.service_config, + &watch_request, + db, + ) + .await } } } @@ -276,6 +313,7 @@ impl Google { async fn create_drive_watch( &self, w_id: &str, + connection_path: &str, config: &GoogleServiceConfig, watch_request: &WatchRequest, db: &DB, @@ -285,14 +323,30 @@ impl Google { // Specific file: use files.watch let url = format!("{}/files/{}/watch", endpoints::DRIVE_API_BASE, resource_id); - self.http_client_request(&url, Method::POST, w_id, db, None, Some(watch_request)) - .await + self.http_client_request( + &url, + Method::POST, + w_id, + connection_path, + db, + None, + Some(watch_request), + ) + .await } None => { // All changes: use changes.watch let token_url = format!("{}/changes/startPageToken", endpoints::DRIVE_API_BASE); let token_response: serde_json::Value = self - .http_client_request::<_, ()>(&token_url, Method::GET, w_id, db, None, None) + .http_client_request::<_, ()>( + &token_url, + Method::GET, + w_id, + connection_path, + db, + None, + None, + ) .await?; let start_page_token = token_response @@ -313,6 +367,7 @@ impl Google { &watch_url, Method::POST, w_id, + connection_path, db, None, Some(&watch_body), @@ -325,6 +380,7 @@ impl Google { async fn create_calendar_watch( &self, w_id: &str, + connection_path: &str, config: &GoogleServiceConfig, watch_request: &WatchRequest, db: &DB, @@ -339,8 +395,16 @@ impl Google { urlencoding::encode(calendar_id) ); - self.http_client_request(&url, Method::POST, w_id, db, None, Some(watch_request)) - .await + self.http_client_request( + &url, + Method::POST, + w_id, + connection_path, + db, + None, + Some(watch_request), + ) + .await } /// Renew an expiring Google watch channel. @@ -362,6 +426,12 @@ impl Google { .map(|v| serde_json::from_value(v.clone())) .transpose()? .ok_or_else(|| Error::InternalErr("Missing service config".to_string()))?; + let connection_path = trigger.connection_path.as_deref().ok_or_else(|| { + Error::BadRequest(format!( + "Cannot renew channel {}: the trigger has no Google connection", + trigger.external_id + )) + })?; let rotated = match crate::rotate_webhook_token( db, @@ -430,7 +500,15 @@ impl Google { } }; let result: std::result::Result<serde_json::Value, _> = self - .http_client_request(&url, Method::POST, w_id, db, None, Some(&stop_request)) + .http_client_request( + &url, + Method::POST, + w_id, + connection_path, + db, + None, + Some(&stop_request), + ) .await; if let Err(e) = result { tracing::warn!( @@ -444,11 +522,11 @@ impl Google { // Create new watch channel with a fresh channel ID let resp = match config.trigger_type { GoogleTriggerType::Drive => { - self.create_drive_watch(w_id, &config, &watch_request, db) + self.create_drive_watch(w_id, connection_path, &config, &watch_request, db) .await? } GoogleTriggerType::Calendar => { - self.create_calendar_watch(w_id, &config, &watch_request, db) + self.create_calendar_watch(w_id, connection_path, &config, &watch_request, db) .await? } }; diff --git a/backend/windmill-native-triggers/src/google/mod.rs b/backend/windmill-native-triggers/src/google/mod.rs index 5bd07e5dab..4e8e398cc7 100644 --- a/backend/windmill-native-triggers/src/google/mod.rs +++ b/backend/windmill-native-triggers/src/google/mod.rs @@ -134,6 +134,7 @@ pub struct GoogleOAuthData { pub refresh_token: Option<String>, /// When the access token expires pub token_expires_at: Option<chrono::DateTime<chrono::Utc>>, + pub connection_path: String, } /// Google API endpoints diff --git a/backend/windmill-native-triggers/src/google/routes.rs b/backend/windmill-native-triggers/src/google/routes.rs index 306a7e54e2..dec2010ab2 100644 --- a/backend/windmill-native-triggers/src/google/routes.rs +++ b/backend/windmill-native-triggers/src/google/routes.rs @@ -10,10 +10,9 @@ use serde::{Deserialize, Serialize}; use windmill_api_auth::ApiAuthed; use windmill_common::{error::JsonResult, DB}; -use crate::{ - get_workspace_integration, map_external_error, require_native_integration_use, External, - ServiceName, -}; +use windmill_common::db::UserDB; + +use crate::{map_external_error, picker_connection, ConnectionQuery, External, ServiceName}; use super::Google; @@ -85,16 +84,26 @@ pub struct DriveFilesQuery { pub page_token: Option<String>, #[serde(default)] pub shared_with_me: bool, + pub connection_path: Option<String>, } async fn list_calendars( authed: ApiAuthed, Extension(handler): Extension<Arc<Google>>, Extension(db): Extension<DB>, + Extension(user_db): Extension<UserDB>, Path(workspace_id): Path<String>, + Query(query): Query<ConnectionQuery>, ) -> JsonResult<Vec<GoogleCalendarEntry>> { - require_native_integration_use(&authed)?; - get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?; + let connection_path = picker_connection( + &authed, + &db, + user_db, + &workspace_id, + ServiceName::Google, + query.connection_path.as_deref(), + ) + .await?; let url = format!( "{}/users/me/calendarList", @@ -102,7 +111,15 @@ async fn list_calendars( ); let response: GoogleCalendarListResponse = handler - .http_client_request::<_, ()>(&url, Method::GET, &workspace_id, &db, None, None) + .http_client_request::<_, ()>( + &url, + Method::GET, + &workspace_id, + &connection_path, + &db, + None, + None, + ) .await .map_err(map_external_error)?; @@ -123,11 +140,19 @@ async fn list_drive_files( authed: ApiAuthed, Extension(handler): Extension<Arc<Google>>, Extension(db): Extension<DB>, + Extension(user_db): Extension<UserDB>, Path(workspace_id): Path<String>, Query(query): Query<DriveFilesQuery>, ) -> JsonResult<GoogleDriveFilesResponse> { - require_native_integration_use(&authed)?; - get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?; + let connection_path = picker_connection( + &authed, + &db, + user_db, + &workspace_id, + ServiceName::Google, + query.connection_path.as_deref(), + ) + .await?; let drive_query = if query.shared_with_me { "sharedWithMe = true and trashed = false".to_string() @@ -156,7 +181,15 @@ async fn list_drive_files( } let response: DriveApiResponse = handler - .http_client_request::<_, ()>(&url, Method::GET, &workspace_id, &db, None, None) + .http_client_request::<_, ()>( + &url, + Method::GET, + &workspace_id, + &connection_path, + &db, + None, + None, + ) .await .map_err(map_external_error)?; @@ -199,10 +232,19 @@ async fn list_shared_drives( authed: ApiAuthed, Extension(handler): Extension<Arc<Google>>, Extension(db): Extension<DB>, + Extension(user_db): Extension<UserDB>, Path(workspace_id): Path<String>, + Query(query): Query<ConnectionQuery>, ) -> JsonResult<Vec<SharedDriveEntry>> { - require_native_integration_use(&authed)?; - get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?; + let connection_path = picker_connection( + &authed, + &db, + user_db, + &workspace_id, + ServiceName::Google, + query.connection_path.as_deref(), + ) + .await?; let url = format!( "{}/drives?pageSize=100&fields=drives(id,name)", @@ -210,7 +252,15 @@ async fn list_shared_drives( ); let response: SharedDrivesApiResponse = handler - .http_client_request::<_, ()>(&url, Method::GET, &workspace_id, &db, None, None) + .http_client_request::<_, ()>( + &url, + Method::GET, + &workspace_id, + &connection_path, + &db, + None, + None, + ) .await .map_err(map_external_error)?; diff --git a/backend/windmill-native-triggers/src/handler.rs b/backend/windmill-native-triggers/src/handler.rs index 2c785a4e26..c920c48195 100644 --- a/backend/windmill-native-triggers/src/handler.rs +++ b/backend/windmill-native-triggers/src/handler.rs @@ -1,8 +1,9 @@ use crate::{ classify_read_failure, decrypt_oauth_data, delete_native_trigger, delete_token_by_hash, - get_native_trigger, list_native_triggers, lock::TriggerLock, map_external_error, - map_external_error_with, rotate_webhook_token, set_native_trigger_enabled, - store_native_trigger, sync::EXTERNAL_TRIGGER_MISSING_ERROR, update_native_trigger_error, + get_native_trigger, list_native_triggers, list_usable_connections, lock::TriggerLock, + lock_connection, map_external_error, map_external_error_with, resolve_usable_connection, + rotate_webhook_token, set_native_trigger_enabled, store_native_trigger, + sync::EXTERNAL_TRIGGER_MISSING_ERROR, update_native_trigger_error, update_native_trigger_if_runnable_unchanged, webhook_token_label, webhook_token_scopes, External, ExternalReadFailure, NativeTrigger, NativeTriggerConfig, NativeTriggerData, ServiceName, @@ -187,9 +188,21 @@ async fn create_native_trigger<T: External>( ) .await?; - let integration_service = service_name.integration_service(); - let oauth_data: T::OAuthData = - decrypt_oauth_data(&db, &workspace_id, integration_service).await?; + let connection_path = resolve_usable_connection( + &db, + &mut tx, + &workspace_id, + service_name.integration_service(), + data.connection_path.as_deref(), + ) + .await?; + let oauth_data: T::OAuthData = decrypt_oauth_data( + &db, + &workspace_id, + service_name.integration_service(), + Some(&connection_path), + ) + .await?; let resp = handler .create( @@ -232,6 +245,23 @@ async fn create_native_trigger<T: External>( webhook_token, }; + lock_connection(&mut tx, &workspace_id, &connection_path).await?; + // Through `db`: a member using the workspace connection cannot read its variable. + let still_connected = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM variable WHERE workspace_id = $1 AND path = $2)", + workspace_id, + connection_path, + ) + .fetch_one(&db) + .await? + .unwrap_or(false); + if !still_connected { + return Err(Error::BadRequest(format!( + "{connection_path} was disconnected while this trigger was being created. Its \ + registration on {service_name} may remain; connect an account and create it again." + ))); + } + store_native_trigger( &mut *tx, &workspace_id, @@ -241,6 +271,7 @@ async fn create_native_trigger<T: External>( service_config, data.summary.as_deref(), data.enabled, + &connection_path, ) .await?; @@ -282,10 +313,6 @@ async fn update_native_trigger_handler<T: External>( .await?; require_runnable_exists(&db, &workspace_id, &data.script_path, data.is_flow).await?; - let integration_service = service_name.integration_service(); - let oauth_data: T::OAuthData = - decrypt_oauth_data(&db, &workspace_id, integration_service).await?; - let lock = TriggerLock::acquire(&db, &workspace_id, service_name, &external_id).await?; let mut tx = user_db.clone().begin(&authed).await?; @@ -294,6 +321,21 @@ async fn update_native_trigger_handler<T: External>( .await? .ok_or_else(|| Error::NotFound(format!("Native trigger not found: {}", external_id)))?; + // Saving re-registers the webhook as the connection's account, so the editor must be allowed + // to act as it, not just to write the runnable. + let integration_service = service_name.integration_service(); + if let Some(path) = existing.connection_path.as_deref() { + resolve_usable_connection(&db, &mut tx, &workspace_id, integration_service, Some(path)) + .await?; + } + let oauth_data: T::OAuthData = decrypt_oauth_data( + &db, + &workspace_id, + integration_service, + existing.connection_path.as_deref(), + ) + .await?; + let runnable_changed = existing.script_path != data.script_path || existing.is_flow != data.is_flow; @@ -446,9 +488,38 @@ async fn get_native_trigger_handler<T: External>( ) .await?; - let integration_service = service_name.integration_service(); - let oauth_data: T::OAuthData = - decrypt_oauth_data(&db, &workspace_id, integration_service).await?; + // Reading the registration acts as the connection's account, like saving does. + let usable = match windmill_trigger.connection_path.as_deref() { + Some(path) => list_usable_connections( + &db, + &mut tx, + &workspace_id, + service_name.integration_service(), + ) + .await? + .iter() + .any(|c| c.path == path), + None => false, + }; + if !usable { + tx.commit().await?; + return Ok(Json(FullTriggerResponse { + windmill_data: windmill_trigger, + external_data: None, + external_error: Some(format!( + "You cannot use this trigger's {service_name} connection, so its registration was \ + not read." + )), + })); + } + + let oauth_data: T::OAuthData = decrypt_oauth_data( + &db, + &workspace_id, + service_name.integration_service(), + windmill_trigger.connection_path.as_deref(), + ) + .await?; let native_trigger = handler .get(&workspace_id, &oauth_data, &external_id, &db, &mut tx) @@ -554,23 +625,35 @@ async fn delete_native_trigger_handler<T: External>( ) .await?; - let integration_service = service_name.integration_service(); - let oauth_data: T::OAuthData = - decrypt_oauth_data(&db, &workspace_id, integration_service).await?; - - handler - .delete(&workspace_id, &oauth_data, &external_id, &db, &mut tx) - .await - .map_err(|e| { - map_external_error_with(e, |m| { - let end = if m.ends_with(['.', '!', '?']) { - "" - } else { - "." - }; - format!("{m}{end} The trigger was kept in Windmill, so it can still fire.") - }) - })?; + // A connection can be deleted from under its triggers. Without it the registration cannot be + // removed, but deleting the row still revokes the webhook token, so the service's deliveries + // stop starting jobs; refusing would leave a trigger nobody can remove. + match decrypt_oauth_data::<T::OAuthData>( + &db, + &workspace_id, + service_name.integration_service(), + existing.connection_path.as_deref(), + ) + .await + { + Ok(oauth_data) => handler + .delete(&workspace_id, &oauth_data, &external_id, &db, &mut tx) + .await + .map_err(|e| { + map_external_error_with(e, |m| { + let end = if m.ends_with(['.', '!', '?']) { + "" + } else { + "." + }; + format!("{m}{end} The trigger was kept in Windmill, so it can still fire.") + }) + })?, + Err(e @ (Error::NotFound(_) | Error::BadRequest(_))) => tracing::warn!( + "Deleting {service_name} trigger {external_id} without removing its registration: {e}" + ), + Err(e) => return Err(e), + } let deleted = delete_native_trigger(&mut *tx, &workspace_id, service_name, &external_id).await?; diff --git a/backend/windmill-native-triggers/src/lib.rs b/backend/windmill-native-triggers/src/lib.rs index 6398e8a6d9..c61a1ff335 100644 --- a/backend/windmill-native-triggers/src/lib.rs +++ b/backend/windmill-native-triggers/src/lib.rs @@ -45,6 +45,7 @@ use std::{collections::HashMap, fmt::Debug}; use strum::{EnumIter, IntoEnumIterator}; use tokio::task; use windmill_common::{ + db::UserDB, error::{to_anyhow, Error, Result}, triggers::TriggerKind, utils::HTTP_CLIENT, @@ -230,6 +231,9 @@ pub struct NativeTrigger { /// its initial value and `setenabled` is its only mutator afterwards, so saving a /// configuration can never silently re-enable a trigger someone paused. pub enabled: bool, + /// Path of the connection (the OAuth resource created by the connect flow) the trigger acts + /// through on the external service. + pub connection_path: Option<String>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -253,6 +257,11 @@ pub struct NativeTriggerData<C> { /// An update ignores it: `setenabled` is the only way to change an existing trigger's state. #[serde(default = "default_true")] pub enabled: bool, + /// Honoured on create only: the webhook is registered under this connection's account, so + /// switching it would leave the registration owned by an account the trigger no longer uses. + /// When omitted, the caller's only readable connection for the service is used. + #[serde(default)] + pub connection_path: Option<String>, } #[derive(Debug, Clone, FromRow, Serialize, Deserialize)] @@ -260,7 +269,6 @@ pub struct WorkspaceIntegration { pub workspace_id: String, pub service_name: ServiceName, pub oauth_data: serde_json::Value, - pub resource_path: Option<String>, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, pub created_by: String, @@ -399,12 +407,18 @@ pub trait External: Send + Sync + 'static { url: &str, method: Method, workspace_id: &str, + connection_path: &str, db: &DB, headers: Option<HashMap<String, String>>, body: Option<&B>, ) -> Result<T> { - let oauth_config: OAuthConfig = - decrypt_oauth_data(db, workspace_id, Self::SERVICE_NAME).await?; + let oauth_config: OAuthConfig = decrypt_oauth_data( + db, + workspace_id, + Self::SERVICE_NAME.integration_service(), + Some(connection_path), + ) + .await?; let result = make_http_request( url, @@ -446,14 +460,14 @@ pub trait External: Send + Sync + 'static { task::spawn({ let db_clone = db.clone(); let workspace_id_clone = workspace_id.to_string(); - let service_name = Self::SERVICE_NAME; + let account_id = refreshed_oauth_config.account_id; let new_access_token = refreshed_oauth_config.access_token.clone(); let new_refresh_token = refreshed_oauth_config.refresh_token.clone(); async move { update_oauth_token_resource( &db_clone, &workspace_id_clone, - service_name, + account_id, &new_access_token, new_refresh_token.as_deref(), ) @@ -594,6 +608,8 @@ pub struct OAuthConfig { pub refresh_token: Option<String>, pub client_id: String, pub client_secret: String, + pub connection_path: String, + pub account_id: i32, } pub async fn make_http_request<T: DeserializeOwned + Send, B: Serialize>( @@ -776,51 +792,50 @@ async fn get_instance_oauth_credentials( .await } +/// Assemble the credentials of one connection: its token from the variable and account at +/// `connection_path`, and the OAuth client from the workspace (or instance) configuration. +/// +/// Only an account made by the connect flow is accepted, since token refresh goes through this +/// service's OAuth client and any other account would be refreshed against the wrong one. pub async fn decrypt_oauth_data<T: DeserializeOwned>( db: &DB, workspace_id: &str, service_name: ServiceName, + connection_path: Option<&str>, ) -> Result<T> { - let integration = get_workspace_integration(db, workspace_id, service_name).await?; - let oauth_data = integration.oauth_data; - - let resource_path = integration.resource_path.as_deref().ok_or_else(|| { - Error::InternalErr(format!( - "No resource_path in {} integration config. Please reconnect the integration.", - service_name + let connection_path = connection_path.ok_or_else(|| { + Error::BadRequest(format!( + "This trigger has no {service_name} connection. Delete it and create it again with a connection." )) })?; + let integration = get_workspace_integration(db, workspace_id, service_name).await?; + let oauth_data = integration.oauth_data; + let mc = build_crypt(db, workspace_id).await?; - let var_row = sqlx::query!( - "SELECT value, account FROM variable WHERE workspace_id = $1 AND path = $2", + let row = sqlx::query!( + "SELECT v.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 + AND a.client = $3 AND a.is_workspace_integration = true", workspace_id, - resource_path, + connection_path, + service_name.as_str(), ) .fetch_optional(db) .await? .ok_or_else(|| { - Error::InternalErr(format!( - "Variable at {} not found for {} integration", - resource_path, service_name + Error::NotFound(format!( + "No {service_name} connection at {connection_path}. Connect an account there or pick another connection." )) })?; - let access_token = decrypt(&mc, var_row.value) + let access_token = decrypt(&mc, row.value) .map_err(|e| Error::InternalErr(format!("Failed to decrypt access token: {}", e)))?; - let refresh_token = if let Some(account_id) = var_row.account { - sqlx::query_scalar!( - "SELECT refresh_token FROM account WHERE workspace_id = $1 AND id = $2", - workspace_id, - account_id, - ) - .fetch_optional(db) - .await? - } else { - None - }; + let refresh_token = Some(row.refresh_token); let (client_id, client_secret) = if oauth_data .get("instance_shared") @@ -853,6 +868,8 @@ pub async fn decrypt_oauth_data<T: DeserializeOwned>( "refresh_token": refresh_token, "client_id": client_id, "client_secret": client_secret, + "connection_path": connection_path, + "account_id": row.account_id, }); serde_json::from_value(assembled) @@ -978,6 +995,8 @@ pub async fn refresh_oauth_tokens( .or_else(|| oauth_config.refresh_token.clone()), client_id: oauth_config.client_id.clone(), client_secret: oauth_config.client_secret.clone(), + connection_path: oauth_config.connection_path.clone(), + account_id: oauth_config.account_id, }) } @@ -994,61 +1013,48 @@ pub async fn refresh_oauth_tokens( }) } -async fn update_oauth_token_resource( +/// Write a refreshed token back to the account it was refreshed from, by that account's id, never +/// by service or path: several connections share a service, and a connection's path can be +/// reconnected or renamed while the refresh is in flight. A provider that rotates refresh tokens +/// invalidates the old one, so writing it onto another account would break that account's next +/// refresh. +/// +/// No authorization happens here: it overwrites the account's secrets as the server. Call it only +/// with tokens just refreshed from that account's own refresh token. +pub async fn update_oauth_token_resource( db: &DB, workspace_id: &str, - service_name: ServiceName, + account_id: i32, new_access_token: &str, new_refresh_token: Option<&str>, ) { let result = async { - let integration = get_workspace_integration(db, workspace_id, service_name).await?; - let resource_path = integration.resource_path.ok_or_else(|| { - Error::InternalErr(format!( - "No resource_path in {} integration config", - service_name - )) - })?; - let mc = build_crypt(db, workspace_id).await?; let encrypted_token = encrypt(&mc, new_access_token); sqlx::query!( - "UPDATE variable SET value = $1 WHERE workspace_id = $2 AND path = $3", + "UPDATE variable SET value = $1 WHERE workspace_id = $2 AND account = $3", encrypted_token, workspace_id, - resource_path, + account_id, ) .execute(db) .await?; - if let Some(refresh_token) = new_refresh_token { - sqlx::query!( - "UPDATE account SET - refresh_token = $1, - expires_at = now() + interval '1 hour', - refresh_error = NULL - WHERE workspace_id = $2 AND client = $3 AND is_workspace_integration = true", - refresh_token, - workspace_id, - service_name.as_str(), - ) - .execute(db) - .await?; - } else { - // Even without a new refresh token, update expires_at to prevent - // the background refresh from re-refreshing immediately - sqlx::query!( - "UPDATE account SET - expires_at = now() + interval '1 hour', - refresh_error = NULL - WHERE workspace_id = $1 AND client = $2 AND is_workspace_integration = true", - workspace_id, - service_name.as_str(), - ) - .execute(db) - .await?; - } + // Even without a new refresh token, expires_at moves so the background refresh does not + // re-refresh immediately. + sqlx::query!( + "UPDATE account SET + refresh_token = COALESCE($1, refresh_token), + expires_at = now() + interval '1 hour', + refresh_error = NULL + WHERE workspace_id = $2 AND id = $3", + new_refresh_token, + workspace_id, + account_id, + ) + .execute(db) + .await?; Ok::<(), Error>(()) } @@ -1056,8 +1062,8 @@ async fn update_oauth_token_resource( if let Err(e) = result { tracing::error!( - "Failed to update OAuth tokens for {} in workspace {}: {}", - service_name, + "Failed to update OAuth tokens of account {} in workspace {}: {}", + account_id, workspace_id, e ); @@ -1183,6 +1189,8 @@ pub async fn delete_token_by_hash<'c, E: sqlx::Executor<'c, Database = Postgres> Ok(deleted > 0) } +/// Callers must hold [`lock_connection`] on `connection_path` in the same transaction and check +/// the connection still exists, or a concurrent disconnect can leave this trigger stranded. pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres>, C: Serialize>( db: E, workspace_id: &str, @@ -1192,6 +1200,7 @@ pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres> service_config: C, summary: Option<&str>, enabled: bool, + connection_path: &str, ) -> Result<()> { use windmill_common::auth::hash_token; @@ -1211,12 +1220,13 @@ pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres> webhook_token_hash, service_config, summary, - enabled + enabled, + connection_path ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $9 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 ) ON CONFLICT (external_id, workspace_id, service_name) - DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, summary = $8, error = NULL, updated_at = NOW() + DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, summary = $8, connection_path = $10, error = NULL, updated_at = NOW() "#, external_id, workspace_id, @@ -1227,6 +1237,7 @@ pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres> sqlx::types::Json(service_config) as _, summary, enabled, + connection_path, ) .execute(db) .await?; @@ -1424,7 +1435,8 @@ pub async fn get_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres>>( created_at, updated_at, summary, - enabled + enabled, + connection_path FROM native_trigger WHERE @@ -1464,7 +1476,8 @@ pub async fn get_native_trigger_by_script<'c, E: sqlx::Executor<'c, Database = P created_at, updated_at, summary, - enabled + enabled, + connection_path FROM native_trigger WHERE @@ -1520,7 +1533,8 @@ pub async fn list_native_triggers<'c, E: sqlx::Executor<'c, Database = Postgres> nt.created_at, nt.updated_at, nt.summary, - nt.enabled + nt.enabled, + nt.connection_path FROM native_trigger nt WHERE @@ -1699,7 +1713,6 @@ pub async fn store_workspace_integration( workspace_id: &str, service_name: ServiceName, oauth_data: serde_json::Value, - resource_path: Option<&str>, ) -> Result<()> { sqlx::query!( r#" @@ -1707,23 +1720,20 @@ pub async fn store_workspace_integration( workspace_id, service_name, oauth_data, - resource_path, created_by, created_at, updated_at ) VALUES ( - $1, $2, $3, $4, $5, now(), now() + $1, $2, $3, $4, now(), now() ) ON CONFLICT (workspace_id, service_name) DO UPDATE SET oauth_data = $3, - resource_path = $4, updated_at = now() "#, workspace_id, service_name as ServiceName, oauth_data, - resource_path, authed.username, ) .execute(&mut *tx) @@ -1732,20 +1742,157 @@ pub async fn store_workspace_integration( Ok(()) } -/// Authorization gate for the integration *use* routes (calendar/drive/repo/event -/// pickers). A workspace admin configures the integration, but any member who can -/// create a native trigger needs the pickers to configure one. Operators are -/// read-only and cannot create triggers, so they must not be able to drive the -/// admin-configured integration's upstream API and enumerate its data. -pub fn require_native_integration_use(authed: &ApiAuthed) -> Result<()> { - if authed.is_operator { - return Err(Error::NotAuthorized( - "Operators cannot use workspace integrations".to_string(), - )); +/// Authorization gate for the integration *use* routes (connecting an account, and the +/// calendar/drive/repo/event pickers). They drive a connected account's upstream API and +/// enumerate its data, so they need both the right to manage triggers and the right to write +/// code: a role that only manages triggers (the default `operator`) must not browse a shared +/// account's Drive or repos. +pub async fn require_native_integration_use(db: &DB, w_id: &str, authed: &ApiAuthed) -> Result<()> { + let caps = windmill_common::roles::caller_capabilities(db, w_id, authed).await?; + if caps.code && caps.manage_triggers { + Ok(()) + } else { + Err(Error::PermissionDenied( + "Your role cannot use this workspace's native trigger integrations".to_string(), + )) } +} + +#[derive(Debug, Serialize)] +pub struct Connection { + pub path: String, + /// Username of whoever connected the account, when the caller can read its resource. + pub owner: Option<String>, + /// The workspace connection, which every member may use. + pub workspace: bool, +} + +/// The connections of a service the caller may use. `tx` must be a user transaction: using a +/// connection means acting as the account behind it, so it is gated on reading its token +/// variable, which the variable's row-level security decides. The one exception is the +/// workspace connection an admin shared, read through `db` since members cannot read its token. +pub async fn list_usable_connections( + db: &DB, + tx: &mut PgConnection, + workspace_id: &str, + service_name: ServiceName, +) -> Result<Vec<Connection>> { + let mut connections = sqlx::query_as!( + Connection, + r#"SELECT v.path, r.created_by AS "owner?", false AS "workspace!" + FROM variable v + JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account + LEFT JOIN resource r ON r.workspace_id = v.workspace_id AND r.path = v.path + WHERE v.workspace_id = $1 AND a.client = $2 AND a.is_workspace_integration = true + ORDER BY v.path"#, + workspace_id, + service_name.as_str(), + ) + .fetch_all(&mut *tx) + .await?; + + if let Some(shared) = workspace_connection(db, workspace_id, service_name).await? { + connections.retain(|c| c.path != shared.path); + connections.insert(0, shared); + } + Ok(connections) +} + +/// The connection an admin shared with the whole workspace, if it still exists. +pub async fn workspace_connection( + db: &DB, + workspace_id: &str, + service_name: ServiceName, +) -> Result<Option<Connection>> { + let connection = sqlx::query_as!( + Connection, + r#"SELECT v.path, r.created_by AS "owner?", true AS "workspace!" + FROM workspace_integrations wi + JOIN variable v ON v.workspace_id = wi.workspace_id AND v.path = wi.resource_path + JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account + LEFT JOIN resource r ON r.workspace_id = v.workspace_id AND r.path = v.path + WHERE wi.workspace_id = $1 AND wi.service_name = $2 + AND a.client = $3 AND a.is_workspace_integration = true"#, + workspace_id, + service_name as ServiceName, + service_name.as_str(), + ) + .fetch_optional(db) + .await?; + Ok(connection) +} + +/// Pick the connection a new trigger acts through: the one asked for if the caller may use it, +/// otherwise the shared account, otherwise the caller's only usable connection. Clients and +/// trigger specs that predate connections send none and expect the shared account, so it wins +/// over a lone personal one. `tx` must be a user transaction. +pub async fn resolve_usable_connection( + db: &DB, + tx: &mut PgConnection, + workspace_id: &str, + service_name: ServiceName, + requested: Option<&str>, +) -> Result<String> { + let usable = list_usable_connections(db, tx, workspace_id, service_name).await?; + match requested { + Some(path) if usable.iter().any(|c| c.path == path) => Ok(path.to_string()), + Some(path) => Err(Error::NotAuthorized(format!( + "No {service_name} connection you can use at {path}. Connect an account at that \ + path, or ask for read access to the connection there." + ))), + None if usable.first().is_some_and(|c| c.workspace) => Ok(usable[0].path.clone()), + None => match usable.as_slice() { + [only] => Ok(only.path.clone()), + [] => Err(Error::BadRequest(format!( + "You have no {service_name} connection. Connect your account first." + ))), + _ => Err(Error::BadRequest(format!( + "You can use several {service_name} connections; pick one with connection_path." + ))), + }, + } +} + +/// Serialize, until `tx` ends, the operations that must agree on whether a connection exists: +/// storing a trigger that uses it and disconnecting it. Whichever comes second waits and then +/// sees the other's outcome. +pub async fn lock_connection( + tx: &mut PgConnection, + workspace_id: &str, + connection_path: &str, +) -> Result<()> { + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(format!( + "native_connection:{workspace_id}:{connection_path}" + )) + .execute(&mut *tx) + .await?; Ok(()) } +#[derive(Debug, Deserialize)] +pub struct ConnectionQuery { + pub connection_path: Option<String>, +} + +/// Gate for the pickers that browse the service while configuring a trigger: returns the +/// connection to browse through, which the caller must be allowed to use. +pub async fn picker_connection( + authed: &ApiAuthed, + db: &DB, + user_db: UserDB, + workspace_id: &str, + service_name: ServiceName, + requested: Option<&str>, +) -> Result<String> { + require_native_integration_use(db, workspace_id, authed).await?; + let mut tx = user_db.begin(authed).await?; + let path = + resolve_usable_connection(db, &mut tx, workspace_id, service_name, requested).await?; + tx.commit().await?; + Ok(path) +} + pub async fn get_workspace_integration<'c, E: sqlx::Executor<'c, Database = Postgres>>( db: E, workspace_id: &str, @@ -1758,7 +1905,6 @@ pub async fn get_workspace_integration<'c, E: sqlx::Executor<'c, Database = Post workspace_id, service_name AS "service_name!: ServiceName", oauth_data, - resource_path, created_at, updated_at, created_by diff --git a/backend/windmill-native-triggers/src/nextcloud/external.rs b/backend/windmill-native-triggers/src/nextcloud/external.rs index 763ad6fbb6..4ae3798867 100644 --- a/backend/windmill-native-triggers/src/nextcloud/external.rs +++ b/backend/windmill-native-triggers/src/nextcloud/external.rs @@ -131,11 +131,25 @@ impl External for NextCloud { &url, Method::POST, w_id, + &oauth_data.connection_path, db, Some(headers), Some(&full_nextcloud_payload), ) - .await?; + .await + .map_err(|e| { + // Nextcloud lets only its admins register webhooks. + if http_error_status(&e) == Some(StatusCode::FORBIDDEN) { + Error::BadRequest( + "This Nextcloud account isn't a Nextcloud admin, so it can't register \ + webhooks. Use the shared account, or ask a workspace admin to share a \ + Nextcloud admin account." + .to_string(), + ) + } else { + e + } + })?; Ok(ocs_response.ocs.data) } @@ -167,6 +181,7 @@ impl External for NextCloud { &url, Method::POST, w_id, + &oauth_data.connection_path, db, Some(headers), Some(&full_nextcloud_payload), @@ -214,7 +229,15 @@ impl External for NextCloud { headers.insert("OCS-APIRequest".to_string(), "true".to_string()); let ocs_response: OcsResponse<NextCloudTriggerData> = self - .http_client_request::<_, ()>(&url, Method::GET, w_id, db, Some(headers), None) + .http_client_request::<_, ()>( + &url, + Method::GET, + w_id, + &oauth_data.connection_path, + db, + Some(headers), + None, + ) .await?; Ok(Some(ocs_response.ocs.data)) @@ -239,7 +262,15 @@ impl External for NextCloud { // A webhook already removed in Nextcloud is the outcome this call wants, so only 404 is // swallowed; anything else would leave a live webhook behind while Windmill forgets it. let _: serde_json::Value = self - .http_client_request::<_, ()>(&url, Method::DELETE, w_id, db, Some(headers), None) + .http_client_request::<_, ()>( + &url, + Method::DELETE, + w_id, + &oauth_data.connection_path, + db, + Some(headers), + None, + ) .await .or_else(|e| match http_error_status(&e) { Some(StatusCode::NOT_FOUND) => Ok(serde_json::Value::Null), @@ -320,13 +351,13 @@ impl External for NextCloud { fn error_hint(&self, status: StatusCode) -> Option<&'static str> { match status { StatusCode::UNAUTHORIZED => { - Some("reconnect the Nextcloud integration from Workspace settings > Integrations.") + Some("reconnect the Nextcloud account this uses with Reconnect next to it, or ask its owner or an admin to.") } StatusCode::FORBIDDEN => Some( "Nextcloud grants webhook management to administrators only, and a Windmill \ admin is a different thing. If the connected Nextcloud account is not an admin \ - there and holds no delegated admin rights for the Webhooks setting, reconnect \ - the integration from Workspace settings > Integrations with one that does.", + there and holds no delegated admin rights for the Webhooks setting, use one that \ + does, such as the workspace's shared account.", ), _ => None, } @@ -353,6 +384,7 @@ impl NextCloud { &url, Method::GET, w_id, + &oauth_data.connection_path, db, Some(headers), None, diff --git a/backend/windmill-native-triggers/src/nextcloud/mod.rs b/backend/windmill-native-triggers/src/nextcloud/mod.rs index fda8ad9f6e..7546211283 100644 --- a/backend/windmill-native-triggers/src/nextcloud/mod.rs +++ b/backend/windmill-native-triggers/src/nextcloud/mod.rs @@ -9,6 +9,7 @@ pub struct NextCloudOAuthData { pub access_token: String, pub refresh_token: Option<String>, pub token_expires_at: Option<chrono::DateTime<chrono::Utc>>, + pub connection_path: String, } #[derive(Debug, Serialize, Deserialize)] diff --git a/backend/windmill-native-triggers/src/nextcloud/routes.rs b/backend/windmill-native-triggers/src/nextcloud/routes.rs index a1c679602c..11e9f59227 100644 --- a/backend/windmill-native-triggers/src/nextcloud/routes.rs +++ b/backend/windmill-native-triggers/src/nextcloud/routes.rs @@ -1,8 +1,13 @@ use std::{collections::HashMap, sync::Arc}; -use axum::{extract::Path, routing::get, Extension, Json, Router}; +use axum::{ + extract::{Path, Query}, + routing::get, + Extension, Json, Router, +}; use http::Method; use windmill_common::{ + db::UserDB, error::{Error, JsonResult}, DB, }; @@ -12,16 +17,26 @@ use windmill_api_auth::ApiAuthed; use crate::{ get_workspace_integration, map_external_error, nextcloud::{NextCloudEventType, OcsResponse}, - require_native_integration_use, External, ServiceName, + picker_connection, ConnectionQuery, External, ServiceName, }; async fn list_available_events<T: External>( authed: ApiAuthed, Extension(handler): Extension<Arc<T>>, Extension(db): Extension<DB>, + Extension(user_db): Extension<UserDB>, Path(workspace_id): Path<String>, + Query(query): Query<ConnectionQuery>, ) -> JsonResult<Vec<NextCloudEventType>> { - require_native_integration_use(&authed)?; + let connection_path = picker_connection( + &authed, + &db, + user_db, + &workspace_id, + ServiceName::Nextcloud, + query.connection_path.as_deref(), + ) + .await?; let integration = get_workspace_integration(&db, &workspace_id, ServiceName::Nextcloud).await?; let base_url = integration @@ -43,6 +58,7 @@ async fn list_available_events<T: External>( &url, Method::GET, &workspace_id, + &connection_path, &db, Some(headers), None, diff --git a/backend/windmill-native-triggers/src/rename.rs b/backend/windmill-native-triggers/src/rename.rs index caae99028c..3c273bc57f 100644 --- a/backend/windmill-native-triggers/src/rename.rs +++ b/backend/windmill-native-triggers/src/rename.rs @@ -69,19 +69,8 @@ async fn reregister_service<T: External>( moved: &[&MovedNativeTrigger], handler: T, ) { - let oauth_data: T::OAuthData = - match decrypt_oauth_data(db, w_id, T::SERVICE_NAME.integration_service()).await { - Ok(oauth_data) => oauth_data, - Err(e) => { - for trigger in moved { - record_failure::<T>(db, w_id, &trigger.external_id, &e).await; - } - return; - } - }; - for trigger in moved { - if let Err(e) = reregister_one(db, authed, w_id, &handler, &oauth_data, trigger).await { + if let Err(e) = reregister_one(db, authed, w_id, &handler, trigger).await { record_failure::<T>(db, w_id, &trigger.external_id, &e).await; } } @@ -99,7 +88,6 @@ async fn reregister_one<T: External>( authed: &ApiAuthed, w_id: &str, handler: &T, - oauth_data: &T::OAuthData, moved: &MovedNativeTrigger, ) -> Result<()> { let external_id = moved.external_id.as_str(); @@ -136,8 +124,17 @@ async fn reregister_one<T: External>( service_config, summary: trigger.summary.clone(), enabled: trigger.enabled, + connection_path: trigger.connection_path.clone(), }; + let oauth_data: T::OAuthData = decrypt_oauth_data( + db, + w_id, + T::SERVICE_NAME.integration_service(), + trigger.connection_path.as_deref(), + ) + .await?; + // The token is scoped to the runnable path and only its hash is kept, so pointing the webhook // at the new path means minting a replacement rather than reusing the old one. Commit it // before handing it out: a service may call back the moment it accepts the new URL. @@ -157,7 +154,7 @@ async fn reregister_one<T: External>( let updated = handler .update( w_id, - oauth_data, + &oauth_data, external_id, &webhook_token, &data, diff --git a/backend/windmill-native-triggers/src/sync.rs b/backend/windmill-native-triggers/src/sync.rs index 4f75845429..074703f1b8 100644 --- a/backend/windmill-native-triggers/src/sync.rs +++ b/backend/windmill-native-triggers/src/sync.rs @@ -6,6 +6,9 @@ use serde::Serialize; use crate::ServiceName; +#[cfg(feature = "native_trigger")] +use itertools::Itertools; + #[cfg(feature = "native_trigger")] use crate::{ decrypt_oauth_data, list_native_triggers, update_native_trigger_error, @@ -238,38 +241,50 @@ pub async fn sync_workspace_triggers<T: External>( let mut synced = Vec::new(); let mut errors = Vec::new(); - // Use the integration service for OAuth lookup (e.g., GoogleDrive/GoogleCalendar -> Google) - let integration_service = T::SERVICE_NAME.integration_service(); + // Maintenance runs per connection: each one's triggers are registered under its account, and + // Nextcloud reconciles against that account's own list of webhooks. + let by_connection = windmill_triggers + .into_iter() + .into_group_map_by(|t| t.connection_path.clone()); - let oauth_data = { - match decrypt_oauth_data(db, workspace_id, integration_service).await { + for (connection_path, triggers) in by_connection { + let oauth_data = match decrypt_oauth_data( + db, + workspace_id, + T::SERVICE_NAME.integration_service(), + connection_path.as_deref(), + ) + .await + { Ok(oauth_data) => oauth_data, Err(e) => { tracing::error!( - "Failed to get workspace integration OAuth data for {}: {}", + "Failed to get the OAuth data of connection {:?} in {}: {}", + connection_path, workspace_id, e ); errors.push(SyncError { - resource_path: format!("workspace:{}", workspace_id), - error_message: format!("Failed to get workspace integration OAuth data: {}", e), + resource_path: connection_path + .unwrap_or_else(|| format!("workspace:{}", workspace_id)), + error_message: format!("Failed to get connection OAuth data: {}", e), error_type: "oauth_error".to_string(), }); - return Ok((Vec::new(), errors)); + continue; } - } - }; + }; - handler - .maintain_triggers( - db, - workspace_id, - &windmill_triggers, - &oauth_data, - &mut synced, - &mut errors, - ) - .await; + handler + .maintain_triggers( + db, + workspace_id, + &triggers, + &oauth_data, + &mut synced, + &mut errors, + ) + .await; + } tracing::info!( "Sync completed for {} in workspace '{}'. Updated: {}, Errors: {}", diff --git a/backend/windmill-native-triggers/src/workspace_integrations.rs b/backend/windmill-native-triggers/src/workspace_integrations.rs index eccfa56f19..f2e2e1cb23 100644 --- a/backend/windmill-native-triggers/src/workspace_integrations.rs +++ b/backend/windmill-native-triggers/src/workspace_integrations.rs @@ -2,7 +2,7 @@ use axum::Router; #[cfg(feature = "native_trigger")] use axum::{ - extract::Path, + extract::{Path, Query}, routing::{delete, get, post}, Extension, Json, }; @@ -30,14 +30,18 @@ use windmill_common::{ }; #[cfg(feature = "native_trigger")] -use windmill_api_auth::ApiAuthed; +use windmill_api_auth::{check_scopes, require_is_writer, ApiAuthed}; #[cfg(feature = "native_trigger")] use crate::{ - decrypt_oauth_data, delete_token_by_hash, delete_workspace_integration, nextcloud::OcsResponse, - resolve_endpoint, store_workspace_integration, ServiceName, + decrypt_oauth_data, delete_token_by_hash, delete_workspace_integration, + list_usable_connections, lock_connection, nextcloud::OcsResponse, + require_native_integration_use, resolve_endpoint, Connection, ServiceName, }; +#[cfg(feature = "native_trigger")] +use std::collections::HashMap; + #[cfg(feature = "native_trigger")] use windmill_oauth::{OClient, Url, OAUTH_HTTP_CLIENT}; @@ -57,24 +61,31 @@ type HmacSha256 = Hmac<Sha256>; const STATE_EXPIRATION_SECONDS: i64 = 600; // 10 minutes /// Generate a signed OAuth state that is cluster-safe. -/// The state contains: workspace_id, service_name, timestamp, and nonce. +/// The state contains: workspace_id, service_name, timestamp, nonce, the path the connection +/// will be saved at, and the user it was issued to. The path makes the callback independent of the +/// page the provider redirects to; the user stops anyone else from finishing the flow, which would +/// save their account at a path the issuer chose. /// It's signed with HMAC-SHA256 using the workspace key. #[cfg(feature = "native_trigger")] async fn generate_signed_state( db: &DB, workspace_id: &str, service_name: ServiceName, + connection_path: &str, + username: &str, ) -> Result<String> { use windmill_common::variables::get_workspace_key; let nonce = uuid::Uuid::new_v4().to_string(); let timestamp = chrono::Utc::now().timestamp(); let payload = format!( - "{}:{}:{}:{}", + "{}:{}:{}:{}:{}:{}", workspace_id, service_name.as_str(), timestamp, - nonce + nonce, + URL_SAFE_NO_PAD.encode(connection_path), + URL_SAFE_NO_PAD.encode(username) ); // Get workspace key for signing @@ -91,65 +102,63 @@ async fn generate_signed_state( Ok(format!("{}:{}", encoded_payload, encoded_signature)) } -/// Validate a signed OAuth state. -/// Returns true if the state is valid (correct signature and not expired). +/// Validate a signed OAuth state (correct signature, workspace, service and user, not expired) +/// and return the connection path it was issued for. #[cfg(feature = "native_trigger")] -async fn validate_signed_state(db: &DB, state: &str, workspace_id: &str) -> Result<bool> { +async fn validate_signed_state( + db: &DB, + state: &str, + workspace_id: &str, + service_name: ServiceName, + username: &str, +) -> Result<String> { use windmill_common::variables::get_workspace_key; - let parts: Vec<&str> = state.split(':').collect(); - if parts.len() != 2 { - return Ok(false); + let invalid = || Error::BadRequest("Invalid or expired state parameter".to_string()); + + let (encoded_payload, encoded_signature) = state.split_once(':').ok_or_else(invalid)?; + + let payload = URL_SAFE_NO_PAD + .decode(encoded_payload) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .ok_or_else(invalid)?; + + // Parse payload: workspace_id:service_name:timestamp:nonce:connection_path:username + let [state_workspace_id, state_service, timestamp, _nonce, encoded_path, encoded_user] = + payload + .split(':') + .collect::<Vec<_>>() + .try_into() + .map_err(|_| invalid())?; + + if state_workspace_id != workspace_id + || state_service != service_name.as_str() + || encoded_user != URL_SAFE_NO_PAD.encode(username) + { + return Err(invalid()); } - let encoded_payload = parts[0]; - let encoded_signature = parts[1]; - - // Decode payload - let payload_bytes = match URL_SAFE_NO_PAD.decode(encoded_payload) { - Ok(bytes) => bytes, - Err(_) => return Ok(false), - }; - let payload = match String::from_utf8(payload_bytes) { - Ok(s) => s, - Err(_) => return Ok(false), - }; - - // Parse payload: workspace_id:service_name:timestamp:nonce - let payload_parts: Vec<&str> = payload.split(':').collect(); - if payload_parts.len() != 4 { - return Ok(false); + let timestamp: i64 = timestamp.parse().map_err(|_| invalid())?; + if chrono::Utc::now().timestamp() - timestamp > STATE_EXPIRATION_SECONDS { + return Err(invalid()); } - let state_workspace_id = payload_parts[0]; - let timestamp: i64 = match payload_parts[2].parse() { - Ok(ts) => ts, - Err(_) => return Ok(false), - }; - - // Verify workspace_id matches - if state_workspace_id != workspace_id { - return Ok(false); - } - - // Check expiration - let now = chrono::Utc::now().timestamp(); - if now - timestamp > STATE_EXPIRATION_SECONDS { - return Ok(false); - } - - // Verify signature let key = get_workspace_key(workspace_id, db).await?; let mut mac = HmacSha256::new_from_slice(key.as_bytes()) .map_err(|e| Error::InternalErr(e.to_string()))?; mac.update(payload.as_bytes()); + let received_signature = URL_SAFE_NO_PAD + .decode(encoded_signature) + .map_err(|_| invalid())?; + mac.verify_slice(&received_signature) + .map_err(|_| invalid())?; - let received_signature = match URL_SAFE_NO_PAD.decode(encoded_signature) { - Ok(bytes) => bytes, - Err(_) => return Ok(false), - }; - - Ok(mac.verify_slice(&received_signature).is_ok()) + URL_SAFE_NO_PAD + .decode(encoded_path) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .ok_or_else(invalid) } #[cfg(feature = "native_trigger")] @@ -194,20 +203,114 @@ pub struct OAuthConfigResponse { pub redirect_uri: Option<String>, } +/// The OAuth client a connection is made with: the workspace's own when an admin configured one, +/// otherwise the instance's when the instance admin shares it. The flag says which. +#[cfg(feature = "native_trigger")] +async fn resolve_oauth_client( + db: &DB, + workspace_id: &str, + service_name: ServiceName, +) -> Result<(WorkspaceOAuthConfig, bool)> { + let workspace_config = + get_workspace_oauth_config::<WorkspaceOAuthConfig>(db, workspace_id, service_name) + .await + .ok() + .filter(|c| !c.instance_shared && !c.client_id.is_empty()); + match workspace_config { + Some(config) => Ok((config, false)), + None => Ok((get_instance_oauth_config(db, service_name).await?, true)), + } +} + +/// Connecting writes the connection's variable and resource at `path`, so a path-scoped token must +/// cover both, whatever the user behind it may write. +#[cfg(feature = "native_trigger")] +fn check_connection_scopes(authed: &ApiAuthed, path: &str) -> Result<()> { + check_scopes(authed, || format!("variables:write:{path}"))?; + check_scopes(authed, || format!("resources:write:{path}")) +} + +/// Reconnecting at a path swaps the account every trigger using it acts as, including the shared +/// account's, so only whoever connected it or an admin may replace what is there. Writing the +/// path is not enough: a folder writer would otherwise turn other members' triggers into their own. +#[cfg(feature = "native_trigger")] +async fn check_can_replace( + db: &DB, + authed: &ApiAuthed, + workspace_id: &str, + path: &str, +) -> Result<()> { + if authed.is_admin { + return Ok(()); + } + let existing = sqlx::query!( + r#"SELECT + (SELECT created_by FROM resource WHERE workspace_id = $1 AND path = $2) AS "owner?", + EXISTS(SELECT 1 FROM resource WHERE workspace_id = $1 AND path = $2) + OR EXISTS(SELECT 1 FROM variable WHERE workspace_id = $1 AND path = $2) AS "taken!""#, + workspace_id, + path, + ) + .fetch_one(db) + .await?; + if existing.taken && existing.owner.as_deref() != Some(authed.username.as_str()) { + return Err(Error::NotAuthorized(format!( + "{path} holds a connection someone else created. Connect at another path, or ask an \ + admin to reconnect it." + ))); + } + Ok(()) +} + +#[cfg(feature = "native_trigger")] +fn default_connection_path(authed: &ApiAuthed, service_name: ServiceName) -> String { + format!( + "u/{}/native_{}", + authed.username, + service_name.resource_type() + ) +} + #[cfg(feature = "native_trigger")] async fn generate_connect_url( authed: ApiAuthed, Extension(db): Extension<DB>, Path((workspace_id, service_name)): Path<(String, ServiceName)>, - Json(RedirectUri { redirect_uri }): Json<RedirectUri>, + Json(body): Json<ConnectUrlBody>, ) -> JsonResult<String> { - require_admin(authed.is_admin, &workspace_id)?; + require_native_integration_use(&db, &workspace_id, &authed).await?; - let oauth_config = - get_workspace_oauth_config_as_oauth_config(&db, &workspace_id, service_name).await?; + let (oauth_config, is_instance_shared) = + resolve_oauth_client(&db, &workspace_id, service_name).await?; + + let connection_path = body + .resource_path + .filter(|p| !p.is_empty()) + .unwrap_or_else(|| default_connection_path(&authed, service_name)); + check_connection_scopes(&authed, &connection_path)?; + check_can_replace(&db, &authed, &workspace_id, &connection_path).await?; + + // A GitHub OAuth app only redirects under its one registered callback, and the instance app's + // is the resource connect callback, so its connections land on a page below that one. + let redirect_uri = if is_instance_shared && service_name == ServiceName::Github { + let origin = Url::parse(&body.redirect_uri) + .map_err(|e| Error::BadRequest(format!("Invalid redirect URI: {e}")))? + .origin() + .ascii_serialization(); + format!("{origin}/oauth/callback/github/native_trigger") + } else { + body.redirect_uri + }; // Generate a signed state that is cluster-safe - let state = generate_signed_state(&db, &workspace_id, service_name).await?; + let state = generate_signed_state( + &db, + &workspace_id, + service_name, + &connection_path, + &authed.username, + ) + .await?; let auth_url = build_authorization_url(&oauth_config, service_name, &state, &redirect_uri); Ok(Json(auth_url)) } @@ -287,14 +390,26 @@ async fn fetch_nextcloud_user_id(base_url: &str, access_token: &str) -> anyhow:: Ok(ocs.ocs.data.id) } -/// Delete all native triggers for a workspace+service, including remote webhook cleanup. +/// Delete the native triggers of a workspace+service, including remote webhook cleanup. When `only` +/// gives `(external_ids, script_paths)`, just the triggers still at those paths: a trigger moved +/// since its path was checked is left alone. /// This is best-effort: errors during remote cleanup or token deletion are logged but ignored. #[cfg(feature = "native_trigger")] -async fn delete_triggers_for_service(db: &DB, workspace_id: &str, service_name: ServiceName) { +async fn delete_triggers_for_service( + db: &DB, + workspace_id: &str, + service_name: ServiceName, + only: Option<(&[String], &[String])>, +) { + let (external_ids, script_paths) = only.unzip(); let triggers = sqlx::query!( - "SELECT external_id, webhook_token_hash FROM native_trigger WHERE workspace_id = $1 AND service_name = $2", + "SELECT external_id, connection_path FROM native_trigger + WHERE workspace_id = $1 AND service_name = $2 AND ($3::text[] IS NULL + OR (external_id, script_path) IN (SELECT * FROM unnest($3::text[], $4::text[])))", workspace_id, - service_name as ServiceName + service_name as ServiceName, + external_ids, + script_paths, ) .fetch_all(db) .await; @@ -311,27 +426,33 @@ async fn delete_triggers_for_service(db: &DB, workspace_id: &str, service_name: return; } - // For Nextcloud: try to delete webhooks on the remote instance (best-effort) - if service_name == ServiceName::Nextcloud { - if let Ok(oauth_data) = - decrypt_oauth_data::<BasicOAuthData>(db, workspace_id, service_name).await - { - for trigger in &triggers { + // Nextcloud and GitHub: try to delete the webhooks remotely (best-effort). + // Google: skip remote cleanup (watch channels expire naturally). + if service_name != ServiceName::Google { + let mut credentials: HashMap<Option<String>, Option<BasicOAuthData>> = HashMap::new(); + for trigger in &triggers { + if !credentials.contains_key(&trigger.connection_path) { + let oauth_data = decrypt_oauth_data::<BasicOAuthData>( + db, + workspace_id, + service_name, + trigger.connection_path.as_deref(), + ) + .await + .ok(); + credentials.insert(trigger.connection_path.clone(), oauth_data); + } + let Some(Some(oauth_data)) = credentials.get(&trigger.connection_path) else { + continue; + }; + if service_name == ServiceName::Nextcloud { try_delete_nextcloud_webhook( &oauth_data.base_url, &oauth_data.access_token, &trigger.external_id, ) .await; - } - } - } - // For GitHub: try to delete webhooks on GitHub (best-effort) - if service_name == ServiceName::Github { - if let Ok(oauth_data) = - decrypt_oauth_data::<BasicOAuthData>(db, workspace_id, service_name).await - { - for trigger in &triggers { + } else { try_delete_github_webhook( db, workspace_id, @@ -342,15 +463,19 @@ async fn delete_triggers_for_service(db: &DB, workspace_id: &str, service_name: } } } - // For Google: skip remote cleanup (watch channels expire naturally) // Revoke the tokens the deleted rows actually named, not the ones listed above: a // re-registration running concurrently mints a replacement, and leaving that alive would let a // disconnected integration keep starting jobs. let deleted_token_hashes = sqlx::query_scalar!( - "DELETE FROM native_trigger WHERE workspace_id = $1 AND service_name = $2 RETURNING webhook_token_hash", + "DELETE FROM native_trigger + WHERE workspace_id = $1 AND service_name = $2 AND ($3::text[] IS NULL + OR (external_id, script_path) IN (SELECT * FROM unnest($3::text[], $4::text[]))) + RETURNING webhook_token_hash", workspace_id, - service_name as ServiceName + service_name as ServiceName, + external_ids, + script_paths, ) .fetch_all(db) .await @@ -378,13 +503,60 @@ async fn delete_integration( ) -> JsonResult<String> { require_admin(authed.is_admin, &workspace_id)?; - // Delete triggers first (needs OAuth data that cleanup_oauth_resource will remove) - delete_triggers_for_service(&db, &workspace_id, service_name).await; + // This deletes every trigger and connection of the service, so a path-scoped token must + // cover each of them, as for a single disconnect. + let script_paths = sqlx::query_scalar!( + "SELECT script_path FROM native_trigger WHERE workspace_id = $1 AND service_name = $2", + workspace_id, + service_name as ServiceName, + ) + .fetch_all(&db) + .await?; + for script_path in &script_paths { + check_scopes(&authed, || format!("native_triggers:write:{script_path}"))?; + } + let mut paths = sqlx::query_scalar!( + "SELECT v.path FROM variable v + JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account + WHERE v.workspace_id = $1 AND a.client = $2 AND a.is_workspace_integration = true", + workspace_id, + service_name.as_str(), + ) + .fetch_all(&db) + .await?; + for path in &paths { + check_connection_scopes(&authed, path)?; + } + + // Delete triggers first: removing their registrations needs the connections cleaned up below + delete_triggers_for_service(&db, &workspace_id, service_name, None).await; let mut tx = user_db.begin(&authed).await?; - // Clean up account+variable+resource - cleanup_oauth_resource(&mut *tx, &workspace_id, service_name).await; + // Same serialization as a single disconnect: a trigger created meanwhile either committed + // before these locks, and is caught below, or waits and then finds its connection gone. + paths.sort(); + for path in &paths { + lock_connection(&mut tx, &workspace_id, path).await?; + } + + cleanup_service_connections(&mut *tx, &workspace_id, service_name).await?; + + let created_meanwhile = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM native_trigger WHERE workspace_id = $1 AND service_name = $2)", + workspace_id, + service_name as ServiceName, + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false); + if created_meanwhile { + tx.rollback().await?; + return Err(Error::BadRequest(format!( + "A {service_name} trigger was created while removing the integration, so it was kept. \ + Remove it again." + ))); + } let deleted = delete_workspace_integration(&mut *tx, &workspace_id, service_name).await?; @@ -419,7 +591,6 @@ async fn delete_integration( struct WorkspaceIntegrations { service_name: ServiceName, oauth_data: Option<sqlx::types::Json<WorkspaceOAuthConfig>>, - resource_path: Option<String>, } #[cfg(feature = "native_trigger")] @@ -436,8 +607,7 @@ async fn list_integrations( r#" SELECT oauth_data as "oauth_data: sqlx::types::Json<WorkspaceOAuthConfig>", - service_name as "service_name!: ServiceName", - resource_path + service_name as "service_name!: ServiceName" FROM workspace_integrations WHERE @@ -450,23 +620,14 @@ async fn list_integrations( let key_value = integrations .into_iter() - .map(|integration| { - ( - integration.service_name, - (integration.oauth_data, integration.resource_path), - ) - }) + .map(|integration| (integration.service_name, integration.oauth_data)) .collect::<std::collections::HashMap<_, _>>(); use strum::IntoEnumIterator; let integrations = ServiceName::iter() .map(|service_name| { - let (oauth_data, resource_path) = key_value - .get(&service_name) - .cloned() - .map(|(od, rp)| (od, rp)) - .unwrap_or((None, None)); - WorkspaceIntegrations { service_name, oauth_data, resource_path } + let oauth_data = key_value.get(&service_name).cloned().flatten(); + WorkspaceIntegrations { service_name, oauth_data } }) .collect::<Vec<_>>(); @@ -475,12 +636,18 @@ async fn list_integrations( Ok(Json(integrations)) } +/// Whether members can connect an account for this service: the workspace has an OAuth client, +/// or the instance shares one. #[cfg(feature = "native_trigger")] async fn integration_exist( authed: ApiAuthed, + Extension(db): Extension<DB>, Extension(user_db): Extension<UserDB>, Path((workspace_id, service_name)): Path<(String, ServiceName)>, ) -> JsonResult<bool> { + if is_instance_sharing_enabled(&db, service_name).await? { + return Ok(Json(true)); + } let mut tx = user_db.begin(&authed).await?; let exists = sqlx::query_scalar!( r#" @@ -504,8 +671,10 @@ async fn integration_exist( #[cfg(feature = "native_trigger")] #[derive(Debug, Deserialize)] -struct RedirectUri { +struct ConnectUrlBody { redirect_uri: String, + /// Where to save the connection; defaults to `u/<caller>/native_<resource type>`. + resource_path: Option<String>, } #[cfg(feature = "native_trigger")] @@ -514,7 +683,6 @@ struct OAuthCallbackBody { redirect_uri: String, code: String, state: String, - resource_path: Option<String>, } #[cfg(feature = "native_trigger")] @@ -531,60 +699,34 @@ async fn oauth_callback( Path((workspace_id, service_name)): Path<(String, ServiceName)>, Json(body): Json<OAuthCallbackBody>, ) -> JsonResult<String> { - require_admin(authed.is_admin, &workspace_id)?; + require_native_integration_use(&db, &workspace_id, &authed).await?; - let state_was_valid = validate_signed_state(&db, &body.state, &workspace_id).await?; - - if !state_was_valid { - return Err(Error::BadRequest( - "Invalid or expired state parameter".to_string(), - )); - } - - // Check if this integration uses instance-shared credentials - let existing_oauth_data = sqlx::query_scalar!( - r#"SELECT oauth_data FROM workspace_integrations - WHERE workspace_id = $1 AND service_name = $2"#, - workspace_id, - service_name as ServiceName + let resource_path = validate_signed_state( + &db, + &body.state, + &workspace_id, + service_name, + &authed.username, ) - .fetch_optional(&db) - .await? - .flatten(); + .await?; + check_connection_scopes(&authed, &resource_path)?; + check_can_replace(&db, &authed, &workspace_id, &resource_path).await?; - let is_instance_shared = existing_oauth_data - .as_ref() - .and_then(|v| v.get("instance_shared")) - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - let oauth_config = if is_instance_shared { - get_instance_oauth_config(&db, service_name).await? - } else { - get_workspace_oauth_config::<WorkspaceOAuthConfig>(&db, &workspace_id, service_name).await? - }; + let (oauth_config, is_instance_shared) = + resolve_oauth_client(&db, &workspace_id, service_name).await?; let token_response = exchange_code_for_token(&oauth_config, service_name, &body.code, &body.redirect_uri) .await?; - let resource_path = body - .resource_path - .filter(|p| !p.is_empty()) - .unwrap_or_else(|| { - format!( - "u/{}/native_{}", - authed.username, - service_name.resource_type() - ) - }); - let expires_in = token_response.expires_in.unwrap_or(3600); + // A user transaction, so the variable and resource policies decide whether the caller may + // write a connection at this path. let mut tx = user_db.begin(&authed).await?; - // Clean up any previous account+variable+resource for this integration - cleanup_oauth_resource(&mut *tx, &workspace_id, service_name).await; + // Reconnecting at a path replaces the connection there; triggers using it keep working. + let replaced_account = cleanup_connection(&mut *tx, &workspace_id, &resource_path).await?; // 1. Create account record for token refresh let account_id = sqlx::query_scalar!( @@ -658,25 +800,21 @@ async fn oauth_callback( .await .map_err(|e| Error::InternalErr(format!("Failed to create resource: {}", e)))?; - // 4. Store config + resource_path in workspace_integrations (no tokens). - // For instance-shared integrations, store the flag instead of credentials. - let stored_data = if is_instance_shared { - json!({ - "instance_shared": true, - "base_url": "", - }) - } else { - to_value(&oauth_config).unwrap() - }; - store_workspace_integration( - &mut *tx, - &authed, - &workspace_id, - service_name, - stored_data, - Some(&resource_path), - ) - .await?; + // 4. The first connection made with the instance's client records that in the workspace. + // Token refresh reads the client from this row, so it must exist for every connection. + if is_instance_shared { + sqlx::query!( + "INSERT INTO workspace_integrations (workspace_id, service_name, oauth_data, created_by) + VALUES ($1, $2, $3, $4) + ON CONFLICT (workspace_id, service_name) DO NOTHING", + workspace_id, + service_name as ServiceName, + json!({ "instance_shared": true, "base_url": "" }), + authed.username, + ) + .execute(&mut *tx) + .await?; + } audit_log( &mut *tx, @@ -684,17 +822,15 @@ async fn oauth_callback( &format!("workspace_integrations.{}.connect", service_name), ActionKind::Create, &workspace_id, - Some(&format!("Connected {} integration via OAuth", service_name)), + Some(&resource_path), None, ) .await?; tx.commit().await?; + delete_unlinked_account(&db, &workspace_id, replaced_account).await; - Ok(Json(format!( - "{} integration connected successfully via OAuth", - service_name - ))) + Ok(Json(resource_path)) } /// Token response from OAuth token exchange @@ -806,7 +942,6 @@ pub async fn create_workspace_integration( &workspace_id, service_name, to_value(oauth_data).unwrap(), - None, ) .await?; @@ -815,16 +950,6 @@ pub async fn create_workspace_integration( Ok(()) } -#[cfg(feature = "native_trigger")] -#[inline] -async fn get_workspace_oauth_config_as_oauth_config( - db: &DB, - workspace_id: &str, - service_name: ServiceName, -) -> Result<WorkspaceOAuthConfig> { - get_workspace_oauth_config::<WorkspaceOAuthConfig>(db, workspace_id, service_name).await -} - #[cfg(feature = "native_trigger")] fn build_authorization_url( config: &WorkspaceOAuthConfig, @@ -855,73 +980,96 @@ fn build_authorization_url( format!("{}?{}", base_auth_url, query_string) } +/// Remove the connection at `path`: its variable and its resource. +/// +/// Returns the account the variable linked. The caller removes it with +/// [`delete_unlinked_account`] once `tx` is committed. #[cfg(feature = "native_trigger")] -pub async fn cleanup_oauth_resource( +pub async fn cleanup_connection( + tx: &mut sqlx::PgConnection, + workspace_id: &str, + path: &str, +) -> Result<Option<i32>> { + let account_id = sqlx::query_scalar!( + "DELETE FROM variable WHERE workspace_id = $1 AND path = $2 RETURNING account", + workspace_id, + path, + ) + .fetch_optional(&mut *tx) + .await? + .flatten(); + + sqlx::query!( + "DELETE FROM resource WHERE workspace_id = $1 AND path = $2", + workspace_id, + path, + ) + .execute(&mut *tx) + .await?; + + Ok(account_id) +} + +/// Delete a connection's account once no variable links it any more. Any variable can link any +/// account, so another one may still hold it; the check needs `db`, since the caller's own +/// transaction does not see variables it cannot read. +#[cfg(feature = "native_trigger")] +pub async fn delete_unlinked_account(db: &DB, workspace_id: &str, account_id: Option<i32>) { + let Some(account_id) = account_id else { + return; + }; + if let Err(e) = sqlx::query!( + "DELETE FROM account WHERE workspace_id = $1 AND id = $2 AND is_workspace_integration = true + AND NOT EXISTS (SELECT 1 FROM variable WHERE workspace_id = $1 AND account = $2)", + workspace_id, + account_id, + ) + .execute(db) + .await + { + tracing::error!("Failed to delete account {account_id} in {workspace_id}: {e}"); + } +} + +/// Remove every connection of a service in the workspace. +#[cfg(feature = "native_trigger")] +pub async fn cleanup_service_connections( tx: &mut sqlx::PgConnection, workspace_id: &str, service_name: ServiceName, -) { - // Look up the stored resource_path from workspace_integrations - let stored_resource_path: Option<String> = sqlx::query_scalar!( - r#"SELECT resource_path FROM workspace_integrations WHERE workspace_id = $1 AND service_name = $2"#, - workspace_id, - service_name as ServiceName, - ) - .fetch_optional(&mut *tx) - .await - .ok() - .flatten() - .flatten(); - - // Find and delete any existing account+variable+resource for this integration - let account_ids: Vec<i32> = sqlx::query_scalar!( +) -> Result<()> { + let account_ids = sqlx::query_scalar!( "DELETE FROM account WHERE workspace_id = $1 AND client = $2 AND is_workspace_integration = true RETURNING id", workspace_id, service_name.as_str(), ) .fetch_all(&mut *tx) - .await - .unwrap_or_default(); + .await?; - if !account_ids.is_empty() { - // Delete variables linked to these accounts - let _ = sqlx::query!( - "DELETE FROM variable WHERE workspace_id = $1 AND account = ANY($2)", - workspace_id, - &account_ids, - ) - .execute(&mut *tx) - .await; - } + let paths = sqlx::query_scalar!( + "DELETE FROM variable WHERE workspace_id = $1 AND account = ANY($2) RETURNING path", + workspace_id, + &account_ids, + ) + .fetch_all(&mut *tx) + .await?; - // Delete resource by exact stored path, or fall back to legacy pattern - let resource_type = service_name.resource_type(); - if let Some(ref path) = stored_resource_path { - let _ = sqlx::query!( - "DELETE FROM resource WHERE workspace_id = $1 AND path = $2", - workspace_id, - path, - ) - .execute(&mut *tx) - .await; - } else { - // Legacy fallback for integrations created before user-chosen paths - let _ = sqlx::query!( - "DELETE FROM resource WHERE workspace_id = $1 AND resource_type = $2 AND path LIKE 'u/%/native_%'", - workspace_id, - resource_type, - ) - .execute(&mut *tx) - .await; - } + sqlx::query!( + "DELETE FROM resource WHERE workspace_id = $1 AND path = ANY($2)", + workspace_id, + &paths, + ) + .execute(&mut *tx) + .await?; + + Ok(()) } /// Check if the instance admin has enabled sharing of OAuth credentials for a given service. -/// Currently only supported for Google (gworkspace). #[cfg(feature = "native_trigger")] async fn is_instance_sharing_enabled(db: &DB, service_name: ServiceName) -> Result<bool> { - // Only Google supports instance sharing for now - if service_name != ServiceName::Google { + // Each Nextcloud client belongs to one Nextcloud server, which is a workspace choice. + if service_name == ServiceName::Nextcloud { return Ok(false); } @@ -930,7 +1078,7 @@ async fn is_instance_sharing_enabled(db: &DB, service_name: ServiceName) -> Resu None => return Ok(false), }; - let key = service_name.resource_type(); // "gworkspace" + let key = service_name.resource_type(); let entry = match oauths_value.get(key) { Some(v) => v, None => return Ok(false), @@ -953,9 +1101,10 @@ async fn get_instance_oauth_config( service_name: ServiceName, ) -> Result<WorkspaceOAuthConfig> { if !is_instance_sharing_enabled(db, service_name).await? { - return Err(Error::BadRequest( - "Instance credential sharing is not enabled for this service".to_string(), - )); + return Err(Error::BadRequest(format!( + "No {service_name} OAuth app is configured for this workspace. A workspace admin can \ + configure one in the workspace settings, under native triggers." + ))); } let (client_id, client_secret) = @@ -985,36 +1134,213 @@ async fn check_instance_sharing_available( } #[cfg(feature = "native_trigger")] -async fn generate_instance_connect_url( +async fn list_connections( authed: ApiAuthed, Extension(db): Extension<DB>, Extension(user_db): Extension<UserDB>, Path((workspace_id, service_name)): Path<(String, ServiceName)>, - Json(RedirectUri { redirect_uri }): Json<RedirectUri>, +) -> JsonResult<Vec<Connection>> { + let mut tx = user_db.begin(&authed).await?; + let connections = list_usable_connections(&db, &mut tx, &workspace_id, service_name).await?; + tx.commit().await?; + Ok(Json(connections)) +} + +#[cfg(feature = "native_trigger")] +#[derive(Debug, Deserialize)] +struct ConnectionPathQuery { + path: String, +} + +#[cfg(feature = "native_trigger")] +#[derive(Debug, Deserialize)] +struct WorkspaceConnectionBody { + /// The connection to share with every member, or none to stop sharing. + path: Option<String>, +} + +/// Share one connection with the whole workspace: every member may then create and edit triggers +/// through it, without being able to read its token. +#[cfg(feature = "native_trigger")] +async fn set_workspace_connection( + authed: ApiAuthed, + Extension(db): Extension<DB>, + Path((workspace_id, service_name)): Path<(String, ServiceName)>, + Json(WorkspaceConnectionBody { path }): Json<WorkspaceConnectionBody>, ) -> JsonResult<String> { require_admin(authed.is_admin, &workspace_id)?; - let instance_config = get_instance_oauth_config(&db, service_name).await?; + if let Some(path) = path.as_deref() { + // Sharing lets every member act as the account, so a path-scoped token must cover it. + check_connection_scopes(&authed, path)?; + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 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 AND a.client = $3 + AND a.is_workspace_integration = true)", + workspace_id, + path, + service_name.as_str(), + ) + .fetch_one(&db) + .await? + .unwrap_or(false); + if !exists { + return Err(Error::BadRequest(format!( + "No {service_name} connection at {path}" + ))); + } + } - // Store a marker in workspace_integrations — NOT the actual credentials. - // The callback and token refresh will read credentials from global settings - // when they see instance_shared=true. - let mut tx = user_db.begin(&authed).await?; - crate::store_workspace_integration( - &mut tx, + let mut tx = db.begin().await?; + let updated = sqlx::query!( + "UPDATE workspace_integrations SET resource_path = $3, updated_at = now() + WHERE workspace_id = $1 AND service_name = $2", + workspace_id, + service_name as ServiceName, + path, + ) + .execute(&mut *tx) + .await? + .rows_affected(); + if updated == 0 { + return Err(Error::NotFound(format!( + "{service_name} is not set up in this workspace" + ))); + } + + audit_log( + &mut *tx, &authed, + &format!("workspace_integrations.{}.share", service_name), + ActionKind::Update, &workspace_id, - service_name, - json!({ "instance_shared": true, "base_url": "" }), + Some(path.as_deref().unwrap_or("none")), None, ) .await?; tx.commit().await?; - // Generate signed state and build authorization URL - let state = generate_signed_state(&db, &workspace_id, service_name).await?; - let auth_url = build_authorization_url(&instance_config, service_name, &state, &redirect_uri); - Ok(Json(auth_url)) + Ok(Json(match path { + Some(path) => format!("{path} is now the shared account"), + None => "No shared account".to_string(), + })) +} + +/// Disconnect one account: its triggers are deleted with it, since nothing else can act on +/// their registrations. +#[cfg(feature = "native_trigger")] +async fn delete_connection( + authed: ApiAuthed, + Extension(db): Extension<DB>, + Extension(user_db): Extension<UserDB>, + Path((workspace_id, service_name)): Path<(String, ServiceName)>, + Query(ConnectionPathQuery { path }): Query<ConnectionPathQuery>, +) -> JsonResult<String> { + check_connection_scopes(&authed, &path)?; + require_is_writer( + &authed, + &path, + &workspace_id, + db.clone(), + "SELECT extra_perms FROM variable WHERE path = $1 AND workspace_id = $2", + "variable", + ) + .await?; + + let mut tx = user_db.clone().begin(&authed).await?; + let usable = list_usable_connections(&db, &mut tx, &workspace_id, service_name).await?; + tx.commit().await?; + if !usable.iter().any(|c| c.path == path) { + return Err(Error::NotFound(format!( + "No {service_name} connection at {path}" + ))); + } + + // Disconnecting deletes these triggers, so a path-scoped token must cover each of them. Only + // the rows checked here, at the paths checked, are deleted: one created or moved meanwhile is + // never removed unchecked. + let triggers = sqlx::query!( + "SELECT external_id, script_path FROM native_trigger + WHERE workspace_id = $1 AND service_name = $2 AND connection_path = $3", + workspace_id, + service_name as ServiceName, + path, + ) + .fetch_all(&db) + .await?; + for trigger in &triggers { + check_scopes(&authed, || { + format!("native_triggers:write:{}", trigger.script_path) + })?; + } + let (external_ids, script_paths): (Vec<String>, Vec<String>) = triggers + .into_iter() + .map(|t| (t.external_id, t.script_path)) + .unzip(); + + // Before the connection goes: removing the registrations needs its token. + delete_triggers_for_service( + &db, + &workspace_id, + service_name, + Some((&external_ids, &script_paths)), + ) + .await; + + let mut tx = user_db.begin(&authed).await?; + lock_connection(&mut tx, &workspace_id, &path).await?; + let account = cleanup_connection(&mut *tx, &workspace_id, &path).await?; + + // A trigger moved or created meanwhile was skipped above and still acts through this + // connection, so removing the connection would strand it. `lock_connection` is what makes + // this check sound: a create stores its trigger under the same lock, so it either committed + // before this transaction took the lock, and is seen here, or it waits and finds the + // connection gone. + let still_used = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM native_trigger + WHERE workspace_id = $1 AND service_name = $2 AND connection_path = $3)", + workspace_id, + service_name as ServiceName, + path, + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false); + if still_used { + tx.rollback().await?; + return Err(Error::BadRequest(format!( + "A trigger using {path} changed while disconnecting, so the connection was kept. \ + Disconnect again to remove it." + ))); + } + + // A connection made later at this path must not inherit the sharing. + sqlx::query!( + "UPDATE workspace_integrations SET resource_path = NULL + WHERE workspace_id = $1 AND service_name = $2 AND resource_path = $3", + workspace_id, + service_name as ServiceName, + path, + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + &format!("workspace_integrations.{}.disconnect", service_name), + ActionKind::Delete, + &workspace_id, + Some(&path), + None, + ) + .await?; + + tx.commit().await?; + delete_unlinked_account(&db, &workspace_id, account).await; + + Ok(Json(format!("Disconnected {path}"))) } #[cfg(feature = "native_trigger")] @@ -1031,9 +1357,14 @@ pub fn workspaced_service() -> Router { "/{service_name}/instance_sharing_available", get(check_instance_sharing_available), ) + .route("/{service_name}/connections", get(list_connections)) .route( - "/{service_name}/generate_instance_connect_url", - post(generate_instance_connect_url), + "/{service_name}/connections/delete", + delete(delete_connection), + ) + .route( + "/{service_name}/workspace_connection", + post(set_workspace_connection), ) .route("/{service_name}/delete", delete(delete_integration)) .route("/{service_name}/callback", post(oauth_callback)); diff --git a/backend/windmill-oauth/Cargo.toml b/backend/windmill-oauth/Cargo.toml index 85499442b9..d1014da07d 100644 --- a/backend/windmill-oauth/Cargo.toml +++ b/backend/windmill-oauth/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_oauth" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] diff --git a/backend/windmill-oauth/src/lib.rs b/backend/windmill-oauth/src/lib.rs index 9768911813..3b32f0436f 100644 --- a/backend/windmill-oauth/src/lib.rs +++ b/backend/windmill-oauth/src/lib.rs @@ -20,12 +20,10 @@ use std::fmt::Debug; use anyhow::anyhow; use hmac::Mac; use serde::{de::DeserializeOwned, Deserialize, Serialize}; -use sqlx::{Postgres, Transaction}; use tower_cookies::{Cookie, Cookies}; use windmill_common::error::{self, to_anyhow, Error}; use windmill_common::more_serde::maybe_number_opt; use windmill_common::oauth2::*; -use windmill_common::utils::now_from_db; use windmill_common::BASE_URL; pub type DB = sqlx::Pool<sqlx::Postgres>; @@ -53,7 +51,8 @@ lazy_static::lazy_static! { pub static ref OAUTH_CLIENTS: arc_swap::ArcSwap<AllClients> = arc_swap::ArcSwap::from_pointee(AllClients { logins: HashMap::new(), connects: HashMap::new(), - slack: None + slack: None, + slack_signing_secret: None, }); } @@ -254,6 +253,13 @@ pub struct OAuthClient { pub tenant: Option<String>, #[serde(default = "default_grant_types")] pub grant_types: Vec<String>, + /// The Slack app's signing secret, which authenticates the requests Slack sends Windmill. + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "windmill_common::utils::empty_as_none" + )] + pub signing_secret: Option<String>, } impl std::fmt::Debug for OAuthClient { @@ -267,6 +273,10 @@ impl std::fmt::Debug for OAuthClient { .field("login_config", &self.login_config) .field("tenant", &self.tenant) .field("grant_types", &self.grant_types) + .field( + "signing_secret", + &self.signing_secret.as_ref().map(|_| "***"), + ) .finish() } } @@ -288,11 +298,23 @@ fn default_grant_types() -> Vec<String> { } /// Container for all OAuth clients (login, connect, and slack) -#[derive(Debug)] pub struct AllClients { pub logins: BasicClientsMap, pub connects: BasicClientsMap, pub slack: Option<OClient>, + /// The instance Slack app's signing secret, from its OAuth setting. + pub slack_signing_secret: Option<String>, +} + +impl std::fmt::Debug for AllClients { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AllClients") + .field("logins", &self.logins) + .field("connects", &self.connects) + .field("slack", &self.slack) + .field("slack_signing_secret", &self.slack_signing_secret.as_ref().map(|_| "***")) + .finish() + } } /// Slack token response from OAuth flow @@ -517,6 +539,7 @@ pub async fn build_client_credentials_oauth_client( .map(|e| e.grant_types.clone()) .unwrap_or_else(default_grant_types), tenant: instance_entry.as_ref().and_then(|e| e.tenant.clone()), + signing_secret: None, }; let base_url = (**BASE_URL.load()).clone(); @@ -890,61 +913,39 @@ pub struct OAuthAccountInfo { pub scopes: Option<Vec<String>>, } -/// Refresh an OAuth token and update the `account` row. -/// Fetches the account from DB, then delegates to `refresh_token_for_account`. -/// -/// Returns the freshly minted access token. Persisting it to the secret variable -/// backing the resource is the caller's responsibility (it must route through the -/// configured secret backend — see `store_oauth_token_value` in `windmill-store`). -pub async fn refresh_token<'c>( - mut tx: Transaction<'c, Postgres>, - w_id: &str, - id: i32, - db: &DB, - oauth_clients: &AllClients, - http_client: &reqwest::Client, - connect_configs_json: &str, -) -> error::Result<String> { - let account = sqlx::query_as!( - OAuthAccountInfo, - "SELECT client, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, scopes FROM account WHERE workspace_id = $1 AND id = $2", - w_id, - id, - ) - .fetch_optional(&mut *tx) - .await?; - let account = windmill_common::utils::not_found_if_none(account, "Account", &id.to_string())?; - - refresh_token_for_account( - tx, - w_id, - id, - db, - account, - oauth_clients, - http_client, - connect_configs_json, - ) - .await +/// A refresh the provider answered with a new access token. +pub struct RefreshedToken { + pub access_token: String, + /// The rotated refresh token, when the provider issued one. + pub refresh_token: Option<String>, + pub expires_in: i64, } -/// Refresh an OAuth token given pre-fetched account info (no additional SELECT). +pub enum RefreshError { + /// The provider refused the exchange: recorded on the account as `refresh_error`. + Provider(String), + Other(Error), +} + +impl From<Error> for RefreshError { + fn from(e: Error) -> Self { + RefreshError::Other(e) + } +} + +/// Exchange an account's refresh token (or client credentials) with its provider. /// -/// Exchanges the refresh token, updates the `account` row (`refresh_token`, -/// `expires_at`, `refresh_error`) and returns the new access token. It does NOT -/// persist the token to the secret variable — the caller must do that through the -/// configured secret backend (`store_oauth_token_value`), otherwise an external -/// secret backend would keep serving the stale connect-time token. -pub async fn refresh_token_for_account<'c>( - mut tx: Transaction<'c, Postgres>, +/// Holds no connection across the HTTP call and writes nothing: the caller records the +/// outcome on the `account` row and persists the access token through the secret backend. +pub async fn exchange_account_token( + db: &DB, w_id: &str, id: i32, - db: &DB, - account: OAuthAccountInfo, + account: &OAuthAccountInfo, oauth_clients: &AllClients, http_client: &reqwest::Client, connect_configs_json: &str, -) -> error::Result<String> { +) -> Result<RefreshedToken, RefreshError> { // Instance-configured client: required for authorization_code (the refresh // token exchange uses the instance app's credentials). For client_credentials // it is resolved inside `build_client_credentials_oauth_client` instead. @@ -1043,48 +1044,13 @@ pub async fn refresh_token_for_account<'c>( ) .await; - if let Err(token_err) = token { - sqlx::query!( - "UPDATE account SET refresh_error = $1 WHERE workspace_id = $2 AND id = $3", - token_err.alt(), - w_id, - id, - ) - .execute(&mut *tx) - .await?; - tx.commit().await?; - return Err(error::Error::BadRequest(format!( - "Error refreshing token: {}", - token_err.alt() - ))); - }; + let token = token.map_err(|e| RefreshError::Provider(e.alt()))?; - let token = token.unwrap(); - - let expires_at = now_from_db(&mut *tx).await? - + chrono::Duration::try_seconds( - token - .expires_in - .ok_or_else(|| Error::InternalErr("expires_in expected and not found".to_string()))? - .try_into() - .unwrap(), - ) - .unwrap_or_default(); - sqlx::query!( - "UPDATE account SET refresh_token = $1, expires_at = $2, refresh_error = NULL WHERE workspace_id = $3 AND id = $4", - token - .refresh_token - .map(|x| x.to_string()) - .unwrap_or(account.refresh_token), - expires_at, - w_id, - id, - ) - .execute(&mut *tx) - .await?; - tx.commit().await?; - - let token_str = token.access_token.to_string(); + let expires_in = token + .expires_in + .ok_or_else(|| Error::InternalErr("expires_in expected and not found".to_string()))? + .try_into() + .unwrap_or(i64::MAX); tracing::info!( grant_type = %account.grant_type, @@ -1094,7 +1060,11 @@ pub async fn refresh_token_for_account<'c>( "OAuth token refreshed successfully" ); - Ok(token_str) + Ok(RefreshedToken { + access_token: token.access_token.to_string(), + refresh_token: token.refresh_token.map(|t| t.to_string()), + expires_in, + }) } /// Generate OAuth redirect URL with CSRF protection diff --git a/backend/windmill-object-store/Cargo.toml b/backend/windmill-object-store/Cargo.toml index f80eb88881..9737440196 100644 --- a/backend/windmill-object-store/Cargo.toml +++ b/backend/windmill-object-store/Cargo.toml @@ -24,6 +24,8 @@ openidconnect = ["windmill-common/openidconnect"] [lib] name = "windmill_object_store" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [dependencies] windmill-common = { workspace = true, default-features = false } diff --git a/backend/windmill-object-store/src/fork_store.rs b/backend/windmill-object-store/src/fork_store.rs new file mode 100644 index 0000000000..a7d283a09e --- /dev/null +++ b/backend/windmill-object-store/src/fork_store.rs @@ -0,0 +1,260 @@ +//! An [`ObjectStore`] over one workspace storage that applies the workspace's +//! [`ForkStorageKeys`]: callers keep using logical keys. + +use std::sync::Arc; + +use futures::{stream::BoxStream, StreamExt, TryStreamExt}; +use object_store::path::Path; +use object_store::{ + GetOptions, GetResult, ListResult, MultipartUpload, ObjectMeta, ObjectStore, PutMultipartOpts, + PutOptions, PutPayload, PutResult, Result, +}; +use windmill_common::fork_storage::{fork_storage_keys, ForkStorageKeys}; + +use crate::{build_object_store_client, ObjectStoreResource}; + +/// The client for `w_id`'s storage `storage` (None = default). Every server-side access to a +/// workspace storage by logical key goes through this, so fork isolation holds on each path. +/// +/// It checks no authorization: the caller must already have checked that the requester may +/// use `w_id`'s storage, and its permission rules for each key it reads or writes. +pub async fn workspace_object_store( + db: &windmill_common::DB, + w_id: &str, + storage: Option<&str>, + resource: &ObjectStoreResource, +) -> windmill_common::error::Result<Arc<dyn ObjectStore>> { + Ok(workspace_fork_store(db, w_id, storage, resource).await?) +} + +/// [`workspace_object_store`] as its concrete type, under the same authorization contract. +pub async fn workspace_fork_store( + db: &windmill_common::DB, + w_id: &str, + storage: Option<&str>, + resource: &ObjectStoreResource, +) -> windmill_common::error::Result<Arc<ForkStore>> { + let inner = build_object_store_client(resource).await?; + let keys = fork_storage_keys(db, w_id, storage).await?; + Ok(Arc::new(ForkStore { inner, keys })) +} + +#[derive(Debug)] +pub struct ForkStore { + inner: Arc<dyn ObjectStore>, + keys: Arc<ForkStorageKeys>, +} + +impl ForkStore { + pub fn new(inner: Arc<dyn ObjectStore>, keys: Arc<ForkStorageKeys>) -> Self { + Self { inner, keys } + } + + pub fn keys(&self) -> &ForkStorageKeys { + &self.keys + } + + /// This store with reads confined to the workspace's own copies. + pub fn own_only(&self) -> Arc<dyn ObjectStore> { + Arc::new(ForkStore { inner: self.inner.clone(), keys: Arc::new(self.keys.own_only()) }) + } +} + +impl std::fmt::Display for ForkStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "ForkStore({})", self.inner) + } +} + +fn denied(location: &Path, e: windmill_common::error::Error) -> object_store::Error { + object_store::Error::PermissionDenied { path: location.to_string(), source: Box::new(e) } +} + +fn parse(key: String) -> Result<Path> { + Path::parse(key).map_err(|source| object_store::Error::InvalidPath { source }) +} + +impl ForkStore { + fn write_path(&self, location: &Path) -> Result<Path> { + parse( + self.keys + .write_key(location.as_ref()) + .map_err(|e| denied(location, e))?, + ) + } + + fn read_paths(&self, location: &Path) -> Result<Vec<Path>> { + self.keys + .read_keys(location.as_ref()) + .map_err(|e| denied(location, e))? + .into_iter() + .map(parse) + .collect() + } + + /// The physical path of the first of `location`'s read paths that exists (the last one + /// when none does). + pub async fn resolve_read(&self, location: &Path) -> Result<Path> { + parse(self.resolve_read_key(location.as_ref()).await?) + } + + /// [`Self::resolve_read`] on a raw key, for callers that forward the key themselves. + pub async fn resolve_read_key(&self, key: &str) -> Result<String> { + let location = Path::from(key); + let mut keys = self.keys.read_keys(key).map_err(|e| denied(&location, e))?; + let last = keys.pop().expect("read_keys returns at least one key"); + for candidate in keys { + match self.inner.head(&parse(candidate.clone())?).await { + Ok(_) => return Ok(candidate), + Err(object_store::Error::NotFound { .. }) => continue, + Err(e) => return Err(e), + } + } + Ok(last) + } +} + +#[async_trait::async_trait] +impl ObjectStore for ForkStore { + async fn put_opts( + &self, + location: &Path, + payload: PutPayload, + opts: PutOptions, + ) -> Result<PutResult> { + self.inner + .put_opts(&self.write_path(location)?, payload, opts) + .await + } + + async fn put_multipart_opts( + &self, + location: &Path, + opts: PutMultipartOpts, + ) -> Result<Box<dyn MultipartUpload>> { + self.inner + .put_multipart_opts(&self.write_path(location)?, opts) + .await + } + + async fn get_opts(&self, location: &Path, options: GetOptions) -> Result<GetResult> { + let paths = self.read_paths(location)?; + let last = paths.len() - 1; + for (i, path) in paths.into_iter().enumerate() { + match self.inner.get_opts(&path, options.clone()).await { + Err(object_store::Error::NotFound { .. }) if i < last => continue, + Ok(mut result) => { + result.meta.location = location.clone(); + return Ok(result); + } + Err(e) => return Err(e), + } + } + unreachable!("read_keys returns at least one key") + } + + async fn head(&self, location: &Path) -> Result<ObjectMeta> { + let paths = self.read_paths(location)?; + let last = paths.len() - 1; + for (i, path) in paths.into_iter().enumerate() { + match self.inner.head(&path).await { + Err(object_store::Error::NotFound { .. }) if i < last => continue, + Ok(meta) => return Ok(ObjectMeta { location: location.clone(), ..meta }), + Err(e) => return Err(e), + } + } + unreachable!("read_keys returns at least one key") + } + + /// Removes this workspace's copy only: an ancestor's object stays readable through it. + async fn delete(&self, location: &Path) -> Result<()> { + self.inner.delete(&self.write_path(location)?).await + } + + fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, Result<ObjectMeta>> { + let prefix = prefix.map(|p| p.as_ref().to_string()).unwrap_or_default(); + let mapping = match self.keys.list(&prefix) { + Ok(m) => m, + Err(e) => { + let err = denied(&Path::from(prefix), e); + return futures::stream::once(async move { Err(err) }).boxed(); + } + }; + let physical = Path::from(mapping.physical_prefix.clone()); + self.inner + .list(Some(&physical)) + .try_filter_map(move |meta| { + let logical = mapping.logical_key(meta.location.as_ref()); + futures::future::ready(Ok(logical + .and_then(|l| Path::parse(l).ok()) + .map(|location| ObjectMeta { location, ..meta }))) + }) + .boxed() + } + + fn list_with_offset( + &self, + prefix: Option<&Path>, + offset: &Path, + ) -> BoxStream<'static, Result<ObjectMeta>> { + let prefix = prefix.map(|p| p.as_ref().to_string()).unwrap_or_default(); + let mapping = match self.keys.list(&prefix) { + Ok(m) => m, + Err(e) => { + let err = denied(&Path::from(prefix), e); + return futures::stream::once(async move { Err(err) }).boxed(); + } + }; + let physical = Path::from(mapping.physical_prefix.clone()); + let physical_offset = match parse(mapping.physical_key(offset.as_ref())) { + Ok(p) => p, + Err(e) => return futures::stream::once(async move { Err(e) }).boxed(), + }; + self.inner + .list_with_offset(Some(&physical), &physical_offset) + .try_filter_map(move |meta| { + let logical = mapping.logical_key(meta.location.as_ref()); + futures::future::ready(Ok(logical + .and_then(|l| Path::parse(l).ok()) + .map(|location| ObjectMeta { location, ..meta }))) + }) + .boxed() + } + + async fn list_with_delimiter(&self, prefix: Option<&Path>) -> Result<ListResult> { + let prefix_str = prefix.map(|p| p.as_ref().to_string()).unwrap_or_default(); + let mapping = self + .keys + .list(&prefix_str) + .map_err(|e| denied(&Path::from(prefix_str.clone()), e))?; + let physical = Path::from(mapping.physical_prefix.clone()); + let listed = self.inner.list_with_delimiter(Some(&physical)).await?; + let logical = |p: &Path| { + mapping + .logical_key(p.as_ref()) + .and_then(|l| Path::parse(l).ok()) + }; + Ok(ListResult { + common_prefixes: listed.common_prefixes.iter().filter_map(logical).collect(), + objects: listed + .objects + .into_iter() + .filter_map(|meta| { + logical(&meta.location).map(|location| ObjectMeta { location, ..meta }) + }) + .collect(), + }) + } + + async fn copy(&self, from: &Path, to: &Path) -> Result<()> { + let from = self.resolve_read(from).await?; + self.inner.copy(&from, &self.write_path(to)?).await + } + + async fn copy_if_not_exists(&self, from: &Path, to: &Path) -> Result<()> { + let from = self.resolve_read(from).await?; + self.inner + .copy_if_not_exists(&from, &self.write_path(to)?) + .await + } +} diff --git a/backend/windmill-object-store/src/lib.rs b/backend/windmill-object-store/src/lib.rs index a9551ad835..4e40d6e313 100644 --- a/backend/windmill-object-store/src/lib.rs +++ b/backend/windmill-object-store/src/lib.rs @@ -59,6 +59,8 @@ use windmill_common::utils::rd_string; pub mod job_s3_helpers_ee; #[cfg(feature = "parquet")] pub mod job_s3_helpers_oss; +#[cfg(feature = "parquet")] +pub mod fork_store; // Re-export object_store types so consumers don't need a direct object_store dep #[cfg(feature = "parquet")] diff --git a/backend/windmill-operator/Cargo.toml b/backend/windmill-operator/Cargo.toml index f847e4ab25..97c6960a8a 100644 --- a/backend/windmill-operator/Cargo.toml +++ b/backend/windmill-operator/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_operator" path = "./src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] enterprise = ["windmill-common/enterprise"] diff --git a/backend/windmill-queue/Cargo.toml b/backend/windmill-queue/Cargo.toml index 6dc5d8ba42..de788f9f25 100644 --- a/backend/windmill-queue/Cargo.toml +++ b/backend/windmill-queue/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_queue" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 7c4cce6262..c51f057f16 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -54,6 +54,7 @@ use windmill_common::runnable_settings::{ }; use windmill_common::triggers::TriggerMetadata; use windmill_common::utils::{calculate_hash, configure_client, now_from_db, strip_json_nul}; +use windmill_common::variables::omit_untrusted_interpolation; use windmill_common::worker::Connection; use windmill_common::otel_oss::{ @@ -1641,6 +1642,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>( } let mut _skip_downstream_error_handlers = false; + let mut schedule_for_handlers = None; if completed_job.is_flow_step() { if let Some(parent_job) = monitor_parent { let r = sqlx::query_scalar!( @@ -1720,49 +1722,9 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>( // per-occurrence failure/recovery status across the whole retry // chain, so multi-count/exact handler policies work even though // each attempt is its own completed job. - #[cfg(all(feature = "enterprise", feature = "private"))] if !retry_pending { - if let Err(err) = crate::jobs_ee::apply_schedule_handlers( - db, - &schedule, - &script_path, - &completed_job.workspace_id, - success, - result, - job_id, - // Current occurrence's root: the terminal native-retry - // attempt's parent, else the job itself. - completed_job.parent_job.unwrap_or(job_id), - completed_job.started_at.unwrap_or(chrono::Utc::now()), - completed_job.priority, - ) - .warn_after_seconds(10) - .await - { - if !success { - tracing::error!("Could not apply schedule error handler: {}", err); - let base_url = windmill_common::BASE_URL.load(); - let w_id: &String = &completed_job.workspace_id; - if !matches!(err, Error::QuotaExceeded(_)) { - report_error_to_workspace_handler_or_critical_side_channel( - &completed_job, - db, - format!( - "Failed to push schedule error handler job to handle failed job ({base_url}/run/{}?workspace={w_id}): {}", - completed_job.id, - err - ), - ) - .warn_after_seconds(10) - .await; - } - } else { - tracing::error!("Could not apply schedule recovery handler: {}", err); - } - }; + schedule_for_handlers = Some((schedule, script_path)); } - #[cfg(not(all(feature = "enterprise", feature = "private")))] - let _ = retry_pending; } else { tracing::error!( "Schedule {schedule_path} in {} not found. Impossible to schedule again and apply schedule handlers", @@ -1824,6 +1786,52 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>( tx.commit().warn_after_seconds(10).await?; + // The handlers read and push through `db`, so they run after the commit: inside the + // transaction they would hold its connection while waiting for more from the same pool. + #[cfg(all(feature = "enterprise", feature = "private"))] + if let Some((schedule, script_path)) = schedule_for_handlers { + if let Err(err) = crate::jobs_ee::apply_schedule_handlers( + db, + &schedule, + script_path, + &completed_job.workspace_id, + success, + result, + job_id, + // Current occurrence's root: the terminal native-retry + // attempt's parent, else the job itself. + completed_job.parent_job.unwrap_or(job_id), + completed_job.started_at.unwrap_or(chrono::Utc::now()), + completed_job.priority, + ) + .warn_after_seconds(10) + .await + { + if !success { + tracing::error!("Could not apply schedule error handler: {}", err); + let base_url = windmill_common::BASE_URL.load(); + let w_id: &String = &completed_job.workspace_id; + if !matches!(err, Error::QuotaExceeded(_)) { + report_error_to_workspace_handler_or_critical_side_channel( + &completed_job, + db, + format!( + "Failed to push schedule error handler job to handle failed job ({base_url}/run/{}?workspace={w_id}): {}", + completed_job.id, + err + ), + ) + .warn_after_seconds(10) + .await; + } + } else { + tracing::error!("Could not apply schedule recovery handler: {}", err); + } + }; + } + #[cfg(not(all(feature = "enterprise", feature = "private")))] + let _ = schedule_for_handlers; + log_completed_job(completed_job, completed.duration_ms, success); // tracing::info!("completed job: {:?}", start.elapsed().as_micros()); Ok(( @@ -3681,10 +3689,16 @@ pub async fn push_success_handler<'a, 'c, T: Serialize + Send + Sync>( return Ok(uuid); } +/// A handler's args from the job's result, which the job's author controls while the handler +/// runs as its own identity. fn sanitize_result<T: Serialize + Send + Sync>(result: Json<&T>) -> HashMap<String, Box<RawValue>> { let as_str = serde_json::to_string(result.0).unwrap_or_else(|_| "{}".to_string()); - serde_json::from_str::<HashMap<String, Box<RawValue>>>(&as_str) - .unwrap_or_else(|_| [("error".to_string(), RawValue::from_string(as_str).unwrap())].into()) + let args = serde_json::from_str::<HashMap<String, Box<RawValue>>>(&as_str) + .unwrap_or_else(|_| [("error".to_string(), RawValue::from_string(as_str).unwrap())].into()); + omit_untrusted_interpolation( + args, + "The following resource has been omitted for security reasons: ", + ) } // #[derive(Serialize)] @@ -4661,7 +4675,9 @@ async fn clone_runnable(j: &mut PulledJob, db: &DB) -> error::Result<()> { // A script gets its new version from the worker, once the generated lock is known // to differ from the live version's: minting one here would deploy, and walk the // importers of, a version whose lock turns out byte-identical to its parent's. - JobKind::Dependencies => *base_hash, + // An app is relocked in place: a raw app's bundle is stored under its version id, + // so a copy minted here could never be published. + JobKind::Dependencies | JobKind::AppDependencies => *base_hash, JobKind::FlowDependencies => { sqlx::query_scalar!( "INSERT INTO flow_version @@ -4679,18 +4695,6 @@ async fn clone_runnable(j: &mut PulledJob, db: &DB) -> error::Result<()> { .fetch_one(db) .await? } - JobKind::AppDependencies => { - sqlx::query_scalar!( - "INSERT INTO app_version - (app_id, value, created_by, raw_app) - SELECT app_id, value, created_by, raw_app - FROM app_version WHERE id = $1 - RETURNING id", - *base_hash - ) - .fetch_one(db) - .await? - } _ => *base_hash, }; @@ -4788,6 +4792,9 @@ pub async fn pull( } } }; + let tag = + crate::tags::apply_dev_workspace_suffix(tag, &job.workspace_id, db) + .await?; sqlx::query!( "UPDATE v2_job_queue SET tag = $1, running = false WHERE id = $2", tag, @@ -5590,8 +5597,6 @@ pub async fn get_result_and_success_by_id_from_flow( }, }; - let mut completed = false; - if job_result.is_none() { job_result = match get_result_by_id_from_original_flow_inner(db, w_id, flow_id, node_id).await { @@ -5601,7 +5606,6 @@ pub async fn get_result_and_success_by_id_from_flow( _ => return Err(err), }, }; - completed = job_result.is_some(); } let job_result = not_found_if_none( @@ -5621,32 +5625,37 @@ pub async fn get_result_and_success_by_id_from_flow( .fetch_one(db) .await? } - JobResult::ListJob(_) => { - let query = format!( - r#"WITH modules AS ( - SELECT jsonb_array_elements(flow_status->'modules') AS module - FROM {} - WHERE id = $1 - ) - SELECT module->>'type' = 'Success' - FROM modules - WHERE module->>'id' = $2"#, - if completed { - "v2_job_completed" - } else { - "v2_job_status" + JobResult::ListJob(jobs) => { + // The step's status lives in the flow job that ran it, which is the root only for a + // top-level step: one nested in a branch is a module of that branch's job. An empty + // container has no job to start from, so only a top-level one resolves; a nested one + // falls back to the flow's final result. + let holder = match jobs.first() { + Some(job) => { + sqlx::query_scalar!("SELECT parent_job FROM v2_job WHERE id = $1", job) + .fetch_optional(db) + .await? + .flatten() } - ); - sqlx::query_scalar(&query) - .bind(flow_id) - .bind(node_id) - .fetch_optional(db) - .await? - .ok_or_else(|| { - error::Error::internal_err(format!( - "Could not get success from flow job status" - )) - })? + None => None, + } + .unwrap_or(*flow_id); + // That job can complete before the root does, so its status may be in either table. + sqlx::query_scalar( + r#"SELECT module->>'type' = 'Success' + FROM jsonb_array_elements(COALESCE( + (SELECT flow_status FROM v2_job_completed WHERE id = $1), + (SELECT flow_status FROM v2_job_status WHERE id = $1) + )->'modules') AS module + WHERE module->>'id' = $2"#, + ) + .bind(holder) + .bind(node_id) + .fetch_optional(db) + .await? + .ok_or_else(|| { + error::Error::internal_err(format!("Could not get success from flow job status")) + })? } }; @@ -6205,6 +6214,38 @@ pub async fn push<'c, 'd>( .await } +/// Reads a flow version through the pool unless the caller handed `push` its own transaction, +/// which may hold that version uncommitted. Opening a transaction just for this read would hold +/// its connection while `push` still writes the runnable settings through the pool. +async fn fetch_pushed_flow_version( + tx: &mut PushIsolationLevel<'_>, + db: &Pool<Postgres>, + version: i64, +) -> error::Result<Arc<FlowData>> { + // Do not use the lite version unless all workers are updated. + let lite = + !(*DISABLE_FLOW_SCRIPT || (!MIN_VERSION_IS_AT_LEAST_1_432.met().await && !*CLOUD_HOSTED)); + // Fallback to the original version if the lite version is not found. + // This also prevent a race condition where the flow is run just after deploy and + // the lite version is still being created. + macro_rules! fetch { + ($e:expr) => { + if lite { + match cache::flow::fetch_version_lite($e, version).await { + Ok(data) => Ok(data), + Err(_) => cache::flow::fetch_version($e, version).await, + } + } else { + cache::flow::fetch_version($e, version).await + } + }; + } + match tx { + PushIsolationLevel::Transaction(ntx) => fetch!(&mut **ntx), + PushIsolationLevel::Isolated(..) | PushIsolationLevel::IsolatedRoot(_) => fetch!(db), + } +} + // #[instrument(level = "trace", skip_all)] async fn push_inner<'c, 'd>( db: &Pool<Postgres>, @@ -6768,9 +6809,7 @@ async fn push_inner<'c, 'd>( restarted_from_val.branch_or_iteration_n, restarted_from_val.flow_version, restarted_from_val.nested.is_some(), - // RawFlow queues the request's (possibly edited) definition, not the - // stored one, so zombie reuse of the stored step is unsafe here. - false, + Some(&value), ) .await?; FlowStatus { @@ -7083,22 +7122,7 @@ async fn push_inner<'c, 'd>( labels, job_token_scopes, } => { - let mut ntx = tx.into_tx().await?; - // Do not use the lite version unless all workers are updated. - let data = if *DISABLE_FLOW_SCRIPT - || (!MIN_VERSION_IS_AT_LEAST_1_432.met().await && !*CLOUD_HOSTED) - { - cache::flow::fetch_version(&mut *ntx, version).await - } else { - // Fallback to the original version if the lite version is not found. - // This also prevent a race condition where the flow is run just after deploy and - // the lite version is still being created. - match cache::flow::fetch_version_lite(&mut *ntx, version).await { - Ok(data) => Ok(data), - Err(_) => cache::flow::fetch_version(&mut *ntx, version).await, - } - }?; - tx = PushIsolationLevel::Transaction(ntx); + let data = fetch_pushed_flow_version(&mut tx, db, version).await?; let mut value = data.value().clone(); refuse_step_scopes_on_outdated_workers(&value).await?; @@ -7181,7 +7205,7 @@ async fn push_inner<'c, 'd>( nested.is_some(), // RestartedFlow resolves and queues the completed job's stored definition, so the // step validated for reuse is the one that will run. - true, + None, ) .await?; @@ -7380,7 +7404,8 @@ async fn push_inner<'c, 'd>( // A dedicated worker runs every job it serves with its own unscoped worker token, so a // job with a restricted token runs on the regular workers of its language instead. - let tag = if dedicated_worker.is_some_and(|x| x) && job_token_scopes.is_none() { + let on_dedicated_worker = dedicated_worker.is_some_and(|x| x) && job_token_scopes.is_none(); + let tag = if on_dedicated_worker { let flow_prefix = if job_kind == JobKind::Flow || job_kind == JobKind::FlowDependencies { "flow/" } else { @@ -7461,6 +7486,24 @@ async fn push_inner<'c, 'd>( } }) }; + let tag = if on_dedicated_worker { + tag + } else { + crate::tags::apply_dev_workspace_suffix(tag, workspace_id, db).await? + }; + + // Before `into_tx`: these write through the pool on a cache miss, and a burst of pushes each + // holding its transaction would then wait on each other for a second connection. They stay + // off `tx` because they are cached as written, which a rollback would undo. + let runnable_settings_handle = windmill_common::runnable_settings::insert_rs( + RunnableSettings { + debouncing_settings: debouncing_settings.insert_cached(db).await?, + concurrency_settings: concurrency_settings.insert_cached(db).await?, + retry_settings: retry_settings.insert_cached(db).await?, + }, + db, + ) + .await?; let mut tx = tx.into_tx().await?; @@ -7641,16 +7684,6 @@ async fn push_inner<'c, 'd>( (job_kind, scheduled_for_o) }; - let runnable_settings_handle = windmill_common::runnable_settings::insert_rs( - RunnableSettings { - debouncing_settings: debouncing_settings.insert_cached(db).await?, - concurrency_settings: concurrency_settings.insert_cached(db).await?, - retry_settings: retry_settings.insert_cached(db).await?, - }, - db, - ) - .await?; - let (guarded_concurrent_limit, guarded_concurrency_time_window_s) = if windmill_common::runnable_settings::min_version_supports_runnable_settings_v0().await { (None, None) @@ -8381,6 +8414,29 @@ pub async fn fetch_restart_flow_version( cache::flow::fetch_version(db, version).await } +// A restart copies the steps before the restart step as completed, and the worker only gates the +// restart step on a copied approval when that gate opened in the original run. So a copied step +// whose gate never opened (timed out, disapproved) would let the restart skip its approval. +fn refuse_restart_past_unopened_suspend_gate( + flow_value: &FlowValue, + copied_modules: &[FlowStatusModule], +) -> Result<(), Error> { + for module in copied_modules { + let id = module.id(); + let suspend = flow_value + .modules + .iter() + .find(|m| m.id == id) + .and_then(|m| m.suspend.as_ref()); + if module.suspend_gate_never_opened(suspend) { + return Err(Error::BadRequest(format!( + "Step {id} was never approved. Restart the flow from step {id} to request the approval again." + ))); + } + } + Ok(()) +} + async fn restarted_flows_resolution( db: &Pool<Postgres>, workspace_id: &str, @@ -8391,12 +8447,12 @@ async fn restarted_flows_resolution( // A nested restart chain (RestartedFrom.nested) descends into the restart step's child to // re-run an inner step; zombie reuse would skip the whole container and ignore it. nested_restart: bool, - // Zombie reuse validates the restart step against the completed job's STORED definition and - // synthesizes Success from its recorded children. That is only sound when the run being queued - // uses that same definition (JobPayload::RestartedFlow). A JobPayload::RawFlow restart queues - // the editor's current, possibly EDITED, definition instead, so reuse would skip the edited - // step and reuse the old child result; disable it there. - allow_zombie_reuse: bool, + // The definition the restart queues when it is not the completed job's stored one + // (JobPayload::RawFlow queues the editor's, possibly EDITED, definition). Approval gates are + // read from it. Zombie reuse validates the restart step against the STORED definition and + // synthesizes Success from its recorded children, so it is disabled when this is set: reuse + // would skip the edited step and reuse the old child result. + queued_flow: Option<&FlowValue>, ) -> Result< ( Option<i64>, @@ -8457,6 +8513,8 @@ async fn restarted_flows_resolution( }; let flow_value = flow_data.value(); + let allow_zombie_reuse = queued_flow.is_none(); + let gate_flow = queued_flow.unwrap_or(flow_value); let flow_status = row .flow_status .as_ref() @@ -8477,6 +8535,7 @@ async fn restarted_flows_resolution( let module_id = &module_definition.id; if module_id == restart_step_id { + refuse_restart_past_unopened_suspend_gate(gate_flow, &truncated_modules)?; // Mark this and all following modules as WaitingForPriorSteps if branch_or_iteration_n.is_none() || branch_or_iteration_n.unwrap() == 0 { truncated_modules @@ -8528,6 +8587,7 @@ async fn restarted_flows_resolution( continue; }; if module.id() == restart_step_id { + refuse_restart_past_unopened_suspend_gate(gate_flow, &truncated_modules)?; // Reuse is only safe when there is a NEXT step to advance into (advancing past the // last module lands on the failure step) and the step's definition carries no // completion/arming semantics that reuse would skip (stop predicates, skip_if, @@ -8915,3 +8975,38 @@ mod render_tag_path_tests { RawValue::from_string(json.to_string()).unwrap() } } + +#[cfg(test)] +mod restart_past_suspend_gate_tests { + use super::refuse_restart_past_unopened_suspend_gate; + use serde_json::json; + use windmill_common::{flow_status::FlowStatusModule, flows::FlowValue}; + + #[test] + fn refuses_only_a_blocking_gate_that_never_opened() { + let flow = |suspend: serde_json::Value| -> FlowValue { + serde_json::from_value(json!({"modules": [ + {"id": "a", "value": {"type": "identity"}, "suspend": suspend}, + {"id": "b", "value": {"type": "identity"}}, + ]})) + .unwrap() + }; + let copied_a = |approvers: serde_json::Value| -> Vec<FlowStatusModule> { + vec![serde_json::from_value(json!({ + "type": "Success", "id": "a", "job": uuid::Uuid::new_v4(), "approvers": approvers + })) + .unwrap()] + }; + let blocking = flow(json!({"required_events": 1})); + let approved = json!([{"resume_id": 0, "approver": "admin"}]); + + assert!( + refuse_restart_past_unopened_suspend_gate(&blocking, &copied_a(json!([]))).is_err() + ); + assert!(refuse_restart_past_unopened_suspend_gate(&blocking, &copied_a(approved)).is_ok()); + let continues = flow(json!({"required_events": 1, "continue_on_disapprove_timeout": true})); + assert!( + refuse_restart_past_unopened_suspend_gate(&continues, &copied_a(json!([]))).is_ok() + ); + } +} diff --git a/backend/windmill-queue/src/schedule.rs b/backend/windmill-queue/src/schedule.rs index 81be3cb7e8..e404e07f05 100644 --- a/backend/windmill-queue/src/schedule.rs +++ b/backend/windmill-queue/src/schedule.rs @@ -42,7 +42,6 @@ use windmill_common::{ /// Helper to fetch metadata for a schedule's script or flow async fn get_schedule_metadata<'c>( tx: &mut sqlx::Transaction<'c, sqlx::Postgres>, - db: &DB, schedule: &Schedule, ) -> Result<( Option<String>, // tag @@ -78,7 +77,12 @@ async fn get_schedule_metadata<'c>( Ok(( flow_info.tag.clone(), None, - flow_info.on_behalf_of(&schedule.workspace_id, db).await?, + windmill_common::on_behalf_of_from_permissioned_as( + flow_info.on_behalf_of.as_deref(), + &schedule.workspace_id, + &mut **tx, + ) + .await?, None, Some(version), parsed_retry, @@ -106,7 +110,6 @@ async fn get_schedule_metadata<'c>( _job_token_scopes, ) = windmill_common::get_latest_hash_for_path( &mut **tx, - db, &schedule.workspace_id, &schedule.script_path, false, @@ -117,6 +120,10 @@ async fn get_schedule_metadata<'c>( } } +/// Read identity and runnable settings through `tx` rather than `db`: every caller holds `tx` +/// open on the same pool, so a second checkout per tick starves it when a burst of schedules +/// fires at once. `tx` may run as `windmill_user` (schedule and flow API); those tables carry +/// no RLS, so they resolve the same as on the pool. pub async fn push_scheduled_job<'c>( db: &DB, mut tx: Transaction<'c, Postgres>, @@ -175,15 +182,20 @@ pub async fn push_scheduled_job<'c>( // Scheduled events must be stored in the database in UTC let next = next.with_timezone(&chrono::Utc); let already_exists: bool = sqlx::query_scalar!( - // Query plan: - // - use of the `ix_v2_job_root_by_path` index; hence the `parent_job IS NULL` clause. - // - select from `v2_job` first, then join with `v2_job_queue` to avoid a full table scan - // on `scheduled_for = $3`. + // Must be driven by `v2_job_queue` (via `v2_job_queue_workspace_scheduled_for`), never + // by a `v2_job` path index, which walks the schedule's whole retained history (stats + // underestimate a single path's rows). The lateral reference forces the join order and + // `OFFSET 0` keeps the filters out of the subquery, so `v2_job` is only ever probed by + // primary key. "SELECT EXISTS ( - SELECT 1 FROM v2_job j JOIN v2_job_queue USING (id) - WHERE j.workspace_id = $1 AND trigger_kind = 'schedule' AND trigger = $2 AND runnable_path = $4 - AND parent_job IS NULL - AND scheduled_for = $3 + SELECT 1 FROM v2_job_queue q + CROSS JOIN LATERAL ( + SELECT trigger_kind, trigger, runnable_path, parent_job + FROM v2_job WHERE id = q.id OFFSET 0 + ) j + WHERE q.workspace_id = $1 AND q.scheduled_for = $3 + AND j.trigger_kind = 'schedule' AND j.trigger = $2 AND j.runnable_path = $4 + AND j.parent_job IS NULL )", &schedule.workspace_id, &schedule.path, @@ -314,7 +326,7 @@ pub async fn push_scheduled_job<'c>( // Get metadata from the scheduled script/flow for tag, timeout, etc. let (tag, timeout, on_behalf_of, hash, flow_version, retry) = - get_schedule_metadata(&mut tx, db, schedule).await?; + get_schedule_metadata(&mut tx, schedule).await?; ( JobPayload::SingleStepFlow { @@ -368,7 +380,12 @@ pub async fn push_scheduled_job<'c>( ) .warn_after_seconds_with_sql(1, "get_flow_version_info_from_version".to_string()) .await?; - let on_behalf_of = flow_info.on_behalf_of(&schedule.workspace_id, db).await?; + let on_behalf_of = windmill_common::on_behalf_of_from_permissioned_as( + flow_info.on_behalf_of.as_deref(), + &schedule.workspace_id, + &mut *tx, + ) + .await?; let FlowVersionInfo { version, tag, dedicated_worker, labels, job_token_scopes, .. } = flow_info; @@ -448,7 +465,6 @@ pub async fn push_scheduled_job<'c>( job_token_scopes, ) = windmill_common::get_latest_hash_for_path( &mut *tx, - db, &schedule.workspace_id, &schedule.script_path, false, @@ -456,17 +472,11 @@ pub async fn push_scheduled_job<'c>( .warn_after_seconds_with_sql(1, "get_latest_hash_for_path".to_string()) .await?; - // NB: read on the non-RLS pool (`db`), not `tx`. push_scheduled_job is - // also invoked with an RLS user_db transaction (api-schedule/api-flows), - // under which these lookups would resolve against the caller's row - // visibility rather than the full table. The dual-connection here is - // intentional and required for correctness. + let rs = + windmill_common::runnable_settings::from_handle(runnable_settings_handle, &mut *tx) + .await?; let (debouncing_settings, concurrency_settings) = - windmill_common::runnable_settings::prefetch_cached_from_handle( - runnable_settings_handle, - db, - ) - .await?; + windmill_common::runnable_settings::prefetch_cached_tx(&rs, &mut tx).await?; if schedule.retry.is_some() { let parsed_retry = serde_json::from_value::<Retry>(schedule.retry.clone().unwrap()) @@ -593,7 +603,7 @@ pub async fn push_scheduled_job<'c>( let resolved_email = windmill_common::users::get_email_from_permissioned_as( &permissioned_as, &schedule.workspace_id, - db, + &mut *tx, ) .await?; (resolved_email, permissioned_as, authed, false) @@ -616,7 +626,7 @@ pub async fn push_scheduled_job<'c>( }; if let Some(tag) = tag.as_deref().filter(|t| !t.is_empty()) { - let is_super_admin = windmill_common::auth::is_super_admin_email(db, &email).await?; + let is_super_admin = windmill_common::auth::is_super_admin_email(&mut *tx, &email).await?; crate::check_tag_available_for_push( db, &schedule.workspace_id, diff --git a/backend/windmill-queue/src/tags.rs b/backend/windmill-queue/src/tags.rs index 5f076bd3b2..b31e3d99c3 100644 --- a/backend/windmill-queue/src/tags.rs +++ b/backend/windmill-queue/src/tags.rs @@ -1,6 +1,10 @@ use sqlx::{Pool, Postgres}; +use windmill_common::error::Error; use windmill_common::worker::{ - DEFAULT_TAGS_PER_WORKSPACE, DEFAULT_TAGS_WORKSPACES, FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX, + dedicated_worker_tag, dev_workspace_tag_suffix, is_hashed_dedicated_worker_tag, + is_isolated_workspace, with_dev_workspace_suffix, DEFAULT_TAGS_PER_WORKSPACE, + DEFAULT_TAGS_WORKSPACES, DEV_WORKSPACE_TAG_SUFFIX, FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX, + MAX_TAG_LEN, WORKER_SERVES_DEV_WORKSPACES, }; use windmill_common::workspaces::WM_FORK_PREFIX; @@ -13,6 +17,22 @@ struct TagWorkspace { /// Whether the `-fork` suffix may apply: the workspace shares an ancestor's id, or carries the /// generated prefix itself. is_fork: bool, + /// Whether the workspace's jobs run on the workers serving dev workspaces: it has a parent, + /// or carries the generated prefix (a fork orphaned by its parent's deletion stays a fork). + /// `None` when the lookup failed. + is_dev: Option<bool>, + /// Whether a dev workspace is on the ancestor chain, the workspace itself included. A fork + /// orphaned by its parent's deletion counts as one, and so does an archived ancestor with a + /// parent: archiving a dev workspace clears its flag, so neither shows what the lineage was. + in_dev_lineage: bool, +} + +impl TagWorkspace { + /// Whether its jobs go to the workers serving dev workspaces, `None` when the lookup failed. + fn is_isolated(&self) -> Option<bool> { + self.is_dev + .map(|is_dev| is_isolated_workspace(is_dev, self.in_dev_lineage)) + } } lazy_static::lazy_static! { @@ -21,7 +41,7 @@ lazy_static::lazy_static! { // invalidates the mutated workspace AND its descendants; a parentless result is still cached // briefly so non-forks (the common case) do not hammer the DB. static ref FORK_PARENT_CACHE: quick_cache::sync::Cache<String, (TagWorkspace, std::time::Instant)> = - quick_cache::sync::Cache::new(500); + quick_cache::sync::Cache::new(5000); } /// Channel on which a lineage change is broadcast to every server and worker process. @@ -41,16 +61,20 @@ const CLEAR_ALL: &str = "*"; pub fn invalidate_fork_parent_cache(workspace_id: &str) { FORK_PARENT_CACHE.remove(workspace_id); windmill_common::workspaces::invalidate_root_workspace_cache(workspace_id); + windmill_common::fork_storage::invalidate_fork_storage_keys_cache(workspace_id); } -/// Apply a broadcast lineage change to this process's caches. +/// Apply a broadcast lineage change to this process's caches. The object storage key mapping is +/// among them: a replica keeping a recreated fork's old mapping would write the wrong keys. pub fn apply_fork_lineage_change(payload: &str) { if payload == CLEAR_ALL { FORK_PARENT_CACHE.clear(); windmill_common::workspaces::clear_root_workspace_cache(); + windmill_common::fork_storage::clear_fork_storage_keys_cache(); } else { FORK_PARENT_CACHE.remove(payload); windmill_common::workspaces::invalidate_root_workspace_cache(payload); + windmill_common::fork_storage::invalidate_fork_storage_keys_cache(payload); } } @@ -140,9 +164,154 @@ pub async fn per_workspace_tag(workspace_id: &str, db: &Pool<Postgres>) -> Optio }) } +/// `tag`, fully resolved, as a job of `workspace_id` is queued on it: suffixed with the dev +/// workspace tag suffix when isolation is on and the workspace is a dev workspace or a fork, so +/// only the worker groups serving dev workspaces pick it up. A tag no group serves leaves the job +/// queued; it never falls back to the unsuffixed tag. Dedicated worker tags are exempt: they +/// already name the workspace and an admin places each dedicated worker by hand. +pub async fn apply_dev_workspace_suffix( + tag: String, + workspace_id: &str, + db: &Pool<Postgres>, +) -> Result<String, Error> { + let Some(suffix) = dev_workspace_suffix_for(workspace_id, db).await? else { + return Ok(tag); + }; + if is_dedicated_worker_tag_of(workspace_id, &tag, db).await? { + return Ok(tag); + } + let suffixed = with_dev_workspace_suffix(&tag, &suffix); + if suffixed.len() > MAX_TAG_LEN { + return Err(Error::BadRequest(format!( + "tag {tag} of a dev workspace job would be {suffixed} once suffixed with {suffix}, \ + longer than the {MAX_TAG_LEN} characters a job tag holds" + ))); + } + Ok(suffixed) +} + +/// Whether `tag` is a dedicated worker tag of `workspace_id`. A hashed one keeps only a prefix of +/// the workspace id, which another workspace may share, so it counts only when a worker group's +/// dedicated config for this workspace produces it. +async fn is_dedicated_worker_tag_of( + workspace_id: &str, + tag: &str, + db: &Pool<Postgres>, +) -> Result<bool, Error> { + if tag.starts_with(&format!("{workspace_id}:")) { + return Ok(true); + } + if !is_hashed_dedicated_worker_tag(tag) { + return Ok(false); + } + let configs = sqlx::query_scalar!("SELECT config FROM config WHERE name LIKE 'worker\\_\\_%'") + .fetch_all(db) + .await?; + Ok(configs.into_iter().flatten().any(|config| { + let single = config.get("dedicated_worker").cloned().into_iter(); + let many = config + .get("dedicated_workers") + .and_then(|v| v.as_array().cloned()) + .unwrap_or_default(); + single.chain(many).any(|entry| { + entry + .as_str() + .and_then(|e| e.split_once(':')) + .is_some_and(|(ws, path)| { + ws == workspace_id && dedicated_worker_tag(ws, path) == tag + }) + }) + })) +} + +/// Gives `new_id` the lineage of `old_id` before a rename commits: the rename re-pushes the +/// workspace's schedules in its own transaction, where the new row is not yet visible to the +/// lookup, and a job queued then would keep a tag resolved without its parent for good. +pub async fn carry_lineage_over_rename( + old_id: &str, + new_id: &str, + db: &Pool<Postgres>, +) -> Result<(), Error> { + let old = lookup_tag_workspace(old_id, db).await; + if old.is_dev.is_none() { + return Err(Error::internal_err(format!( + "could not resolve the lineage of {old_id}" + ))); + } + let new = TagWorkspace { + id: if old.id == old_id { + new_id.to_string() + } else { + old.id + }, + ..old + }; + FORK_PARENT_CACHE.insert(new_id.to_string(), (new, std::time::Instant::now())); + Ok(()) +} + +/// The suffix to strip from a flow job's tag to compare it with the generic flow tags: that of a +/// dev workspace's flow, isolation on or off, so a flow started before isolation was turned off +/// still routes its steps by their own tags. +pub async fn dev_flow_tag_suffix( + workspace_id: &str, + flow_tag: &str, + db: &Pool<Postgres>, +) -> Result<Option<String>, Error> { + let suffix = DEV_WORKSPACE_TAG_SUFFIX.load(); + if !flow_tag.ends_with(suffix.as_str()) { + return Ok(None); + } + match lookup_tag_workspace(workspace_id, db).await.is_dev { + Some(true) => Ok(Some(suffix.to_string())), + Some(false) => Ok(None), + None => Err(Error::internal_err(format!( + "could not resolve whether {workspace_id} is a dev workspace" + ))), + } +} + +/// The suffix the jobs of `workspace_id` are queued with: `Some` while isolation is on and the +/// workspace is a dev workspace or a fork, unless it is a fork of a non-dev workspace and the +/// instance does not isolate those. A lineage that cannot be read is an error rather than +/// a guess, as either guess queues a job on the wrong pool for good. +pub async fn dev_workspace_suffix_for( + workspace_id: &str, + db: &Pool<Postgres>, +) -> Result<Option<String>, Error> { + let Some(suffix) = dev_workspace_tag_suffix() else { + return Ok(None); + }; + match lookup_tag_workspace(workspace_id, db).await.is_isolated() { + Some(true) => Ok(Some(suffix)), + Some(false) => Ok(None), + None => Err(Error::internal_err(format!( + "could not resolve whether {workspace_id} is a dev workspace, which decides the \ + workers its jobs run on" + ))), + } +} + +/// Refuses running code of `workspace_id` in this process, as the inline routes do, when its +/// jobs are isolated on dev workers and this process's group does not serve dev workspaces. +pub async fn check_inline_run_allowed( + workspace_id: &str, + db: &Pool<Postgres>, +) -> Result<(), Error> { + if dev_workspace_suffix_for(workspace_id, db).await?.is_some() + && !WORKER_SERVES_DEV_WORKSPACES.load(std::sync::atomic::Ordering::Relaxed) + { + return Err(Error::BadRequest(format!( + "{workspace_id} is a dev workspace or a fork, whose code runs only on worker groups \ + serving dev workspaces, and this worker's group does not: run it as a job instead" + ))); + } + Ok(()) +} + /// Backed by a short-TTL cache to avoid a DB round-trip per job push. A transient DB error resolves /// to the workspace's own id for this call but is NOT cached, so the next push retries instead of -/// misrouting for the whole TTL. +/// misrouting for the whole TTL. Whether the workspace is dev is then unknown. async fn lookup_tag_workspace(workspace_id: &str, db: &Pool<Postgres>) -> TagWorkspace { if let Some((resolved, cached_at)) = FORK_PARENT_CACHE.get(workspace_id) { if cached_at.elapsed().as_secs() < FORK_PARENT_CACHE_TTL_SECS { @@ -154,46 +323,65 @@ async fn lookup_tag_workspace(workspace_id: &str, db: &Pool<Postgres>) -> TagWor // itself under a generated fork id. The depth bound mirrors the other chain walkers as a // cycle-safety backstop. A `wm-fork-` workspace orphaned by `ON DELETE SET NULL` has no parent // left to borrow from, so it ends the walk on itself. - let resolved = match sqlx::query_scalar!( + let resolved = match sqlx::query!( r#" WITH RECURSIVE chain AS ( - SELECT id, parent_workspace_id, is_dev_workspace, 0 AS depth + SELECT id, parent_workspace_id, is_dev_workspace, deleted, 0 AS depth FROM workspace WHERE id = $1 UNION ALL - SELECT w.id, w.parent_workspace_id, w.is_dev_workspace, chain.depth + 1 + SELECT w.id, w.parent_workspace_id, w.is_dev_workspace, w.deleted, chain.depth + 1 FROM workspace w JOIN chain ON w.id = chain.parent_workspace_id WHERE chain.depth < 20 ) - SELECT id AS "id!" FROM chain - WHERE parent_workspace_id IS NULL - OR (is_dev_workspace AND id NOT LIKE 'wm-fork-%') - ORDER BY depth LIMIT 1 + SELECT + (SELECT id FROM chain + WHERE parent_workspace_id IS NULL + OR (is_dev_workspace AND id NOT LIKE 'wm-fork-%') + ORDER BY depth LIMIT 1) AS id, + (SELECT parent_workspace_id IS NOT NULL FROM chain WHERE depth = 0) AS has_parent, + (SELECT bool_or(is_dev_workspace + OR (parent_workspace_id IS NULL AND id LIKE 'wm-fork-%') + OR (deleted AND parent_workspace_id IS NOT NULL)) + FROM chain) AS in_dev_lineage "#, workspace_id ) - .fetch_optional(db) + .fetch_one(db) .await { - // No row means the workspace does not exist yet, or its chain is broken: both are "we - // cannot resolve this", so fall back to its own id for this call WITHOUT caching. Caching - // it would pin a fork to its own unserved id for the whole TTL — a rename resolves the new - // id before the row lands, and every job pushed until the TTL expires would queue. - Ok(None) => { - return TagWorkspace { - id: workspace_id.to_string(), - is_fork: workspace_id.starts_with(WM_FORK_PREFIX), + Ok(row) => match row.id { + Some(id) => TagWorkspace { + is_fork: id != workspace_id || workspace_id.starts_with(WM_FORK_PREFIX), + is_dev: Some( + row.has_parent == Some(true) || workspace_id.starts_with(WM_FORK_PREFIX), + ), + in_dev_lineage: row.in_dev_lineage != Some(false), + id, + }, + // No id means the workspace does not exist yet, or its chain is broken: both are "we + // cannot resolve this", so fall back to its own id for this call WITHOUT caching. + // Caching it would pin a fork to its own unserved id for the whole TTL — a rename + // resolves the new id before the row lands, and every job pushed until the TTL + // expires would queue. + None => { + return TagWorkspace { + id: workspace_id.to_string(), + is_fork: workspace_id.starts_with(WM_FORK_PREFIX), + is_dev: Some( + row.has_parent == Some(true) || workspace_id.starts_with(WM_FORK_PREFIX), + ), + in_dev_lineage: true, + } } - } - Ok(Some(id)) => { - let is_fork = id != workspace_id || workspace_id.starts_with(WM_FORK_PREFIX); - TagWorkspace { id, is_fork } - } + }, Err(e) => { tracing::warn!("failed to resolve tag workspace for {workspace_id}: {e:#}"); return TagWorkspace { id: workspace_id.to_string(), is_fork: workspace_id.starts_with(WM_FORK_PREFIX), + is_dev: None, + in_dev_lineage: true, }; } }; diff --git a/backend/windmill-queue/tests/concurrency_counter_test.rs b/backend/windmill-queue/tests/concurrency_counter_test.rs index b341c317bb..9c55617516 100644 --- a/backend/windmill-queue/tests/concurrency_counter_test.rs +++ b/backend/windmill-queue/tests/concurrency_counter_test.rs @@ -349,8 +349,10 @@ mod concurrency { /// `limit` and spawns N workers; all must reject. Reports elapsed. /// Useful for comparing legacy FOR-UPDATE-on-every-pull vs the lockless /// precheck — same setup, just toggle the function under test by checking - /// out the legacy file before running. + /// out the legacy file before running. `bail_path_no_admits_when_full` pins the + /// zero-admit behavior in CI. #[sqlx::test(migrations = "../migrations")] + #[ignore = "manual benchmark: cargo test --test concurrency_counter_test -- --ignored"] async fn bench_over_limit_storm(db: Pool<Postgres>) { let key = "bench-over-limit-storm".to_string(); let limit: i32 = 5; @@ -408,8 +410,10 @@ mod concurrency { /// fail with the offending iteration index. /// /// This is a single test rather than 500 cargo invocations so the cost - /// is one DB setup, not 500. Total ~30s. + /// is one DB setup, not 500. Total ~30s, so it is a manual soak: in CI + /// `stress_never_over_admits` and `stress_matrix` run the same storm. #[sqlx::test(migrations = "../migrations")] + #[ignore = "manual soak: cargo test --test concurrency_counter_test -- --ignored"] async fn long_haul_stress(db: Pool<Postgres>) { let limit: i32 = 2; let workers: usize = 100; diff --git a/backend/windmill-queue/tests/native_retry_test.rs b/backend/windmill-queue/tests/native_retry_test.rs index 4cf278cf83..949a3cf4f6 100644 --- a/backend/windmill-queue/tests/native_retry_test.rs +++ b/backend/windmill-queue/tests/native_retry_test.rs @@ -339,7 +339,7 @@ mod native_retry { // -> failure: handler child must NOT count as a recovery // f: root fails, only a WAC inline child (unmarked) succeeds // -> failure: WAC inline child must NOT count as a recovery - // d: the current occurrence (excluded by `j.id != $4`) + // d: the current occurrence (excluded with any later one by the created_at bound) let (a, b, c, e, f, d) = ( Uuid::new_v4(), Uuid::new_v4(), @@ -371,7 +371,8 @@ mod native_retry { )) FROM v2_job j JOIN v2_job_completed USING (id) WHERE j.workspace_id = $1 AND trigger_kind = 'schedule' AND trigger = $2 - AND parent_job IS NULL AND runnable_path = $3 AND j.id != $4 + AND parent_job IS NULL AND runnable_path = $3 + AND j.created_at < (SELECT created_at FROM v2_job WHERE id = $4) ORDER BY created_at DESC", ) .bind(WS) diff --git a/backend/windmill-queue/tests/schedule_push.rs b/backend/windmill-queue/tests/schedule_push.rs index ed50215473..e79d4ff976 100644 --- a/backend/windmill-queue/tests/schedule_push.rs +++ b/backend/windmill-queue/tests/schedule_push.rs @@ -222,6 +222,43 @@ mod schedule_push { Ok(()) } + // Callers hold `tx` on the pool they pass in, so a burst of schedules the size of the pool + // starves it if a push checks out a second connection. On a one-connection pool any such + // checkout hangs until the acquire timeout. + #[sqlx::test(migrations = "../migrations", fixtures("base", "schedule_push"))] + async fn test_push_holds_one_connection(db: Pool<Postgres>) -> anyhow::Result<()> { + let one = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(std::time::Duration::from_secs(5)) + .connect_with((*db.connect_options()).clone()) + .await?; + + let schedules = [ + make_schedule(|s| { + s.script_path = "f/system/tagged_script".to_string(); + s.path = "f/system/tagged_schedule".to_string(); + }), + make_schedule(|s| { + s.script_path = "f/system/obo_script".to_string(); + s.path = "f/system/obo_schedule".to_string(); + }), + make_schedule(|s| { + s.is_flow = true; + s.script_path = "f/system/obo_flow".to_string(); + s.path = "f/system/obo_flow_schedule".to_string(); + }), + ]; + for schedule in &schedules { + let tx = one.begin().await?; + let push = push_scheduled_job(&one, tx, schedule, None, None); + let tx = tokio::time::timeout(std::time::Duration::from_secs(15), push).await??; + tx.commit().await?; + } + + assert_eq!(count_queued_jobs(&db).await, 3); + Ok(()) + } + // ----------------------------------------------------------------------- // push_scheduled_job: with retry wraps in SingleStepFlow // ----------------------------------------------------------------------- diff --git a/backend/windmill-runtime-nativets/Cargo.toml b/backend/windmill-runtime-nativets/Cargo.toml index 22da97ab1d..054505e886 100644 --- a/backend/windmill-runtime-nativets/Cargo.toml +++ b/backend/windmill-runtime-nativets/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_runtime_nativets" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] diff --git a/backend/windmill-store/Cargo.toml b/backend/windmill-store/Cargo.toml index 31cd0fbe0f..fefb6467e5 100644 --- a/backend/windmill-store/Cargo.toml +++ b/backend/windmill-store/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_store" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] diff --git a/backend/windmill-store/src/oauth_refresh_oss.rs b/backend/windmill-store/src/oauth_refresh_oss.rs index 13e24dfd8b..3d55b05167 100644 --- a/backend/windmill-store/src/oauth_refresh_oss.rs +++ b/backend/windmill-store/src/oauth_refresh_oss.rs @@ -6,39 +6,221 @@ * LICENSE-AGPL for a copy of the license. */ +use std::future::Future; +use std::sync::Arc; + +use windmill_common::db::DB; +use windmill_common::error::{self, Error}; +use windmill_oauth::{OAuthAccountInfo, RefreshError, RefreshedToken}; + #[cfg(feature = "private")] pub use crate::oauth_refresh_ee::_refresh_token; -#[cfg(not(feature = "private"))] -use sqlx::{Postgres, Transaction}; -#[cfg(not(feature = "private"))] -use windmill_common::db::DB; -#[cfg(not(feature = "private"))] -use windmill_common::error; +/// The `account` row a refresh starts from. +pub struct RefreshAccount { + pub mcp_server_url: Option<String>, + pub is_workspace_integration: bool, + pub info: OAuthAccountInfo, +} -#[cfg(not(feature = "private"))] -pub async fn _refresh_token<'c>( - tx: Transaction<'c, Postgres>, - path: &str, - w_id: &str, - id: i32, +lazy_static::lazy_static! { + static ref REFRESH_LOCKS: dashmap::DashMap<(String, i32), Arc<tokio::sync::Mutex<()>>> = + dashmap::DashMap::new(); +} + +/// Refresh the token of account `id`, which backs the secret variable at `path`. +/// +/// No connection is held across `exchange`: it does its own config reads and the provider +/// call, and every write here is a single autocommit statement. Concurrent refreshers of one +/// account are serialized within a process, and across processes the account is only +/// committed if its refresh token is still the one exchanged, so a rotated refresh token is +/// never overwritten by a stale one. An account refreshed by someone else while we waited for +/// the lock is not refreshed again: without `force` any fresh account counts, with it only one +/// whose expiry moved since we asked. Callers must have authorized `w_id`/`path`/`id`. +pub(crate) async fn refresh_account_token<F, Fut>( db: &DB, -) -> error::Result<String> { - let token = windmill_oauth::refresh_token( - tx, + w_id: &str, + path: &str, + id: i32, + force: bool, + exchange: F, +) -> error::Result<String> +where + F: FnOnce(RefreshAccount) -> Fut, + Fut: Future<Output = Result<RefreshedToken, RefreshError>>, +{ + let seen_expiry = if force { + sqlx::query_scalar!( + "SELECT expires_at FROM account WHERE workspace_id = $1 AND id = $2", + w_id, + id + ) + .fetch_optional(db) + .await? + } else { + None + }; + + let lock = REFRESH_LOCKS + .entry((w_id.to_string(), id)) + .or_default() + .clone(); + let _guard = lock.lock().await; + + let row = sqlx::query!( + "SELECT mcp_server_url, is_workspace_integration, client, refresh_token, grant_type, + cc_client_id, cc_client_secret, cc_token_url, scopes, expires_at, + now() > expires_at AS \"expired!\" + FROM account WHERE workspace_id = $1 AND id = $2", w_id, id, + ) + .fetch_optional(db) + .await? + .ok_or_else(|| Error::NotFound(format!("Account {id} not found")))?; + + // A failed store moves `expires_at` too, into the past: only a fresh account counts. + let refreshed_meanwhile = !row.expired && (!force || Some(row.expires_at) != seen_expiry); + if refreshed_meanwhile { + return current_token(db, w_id, path).await; + } + + let used = row.refresh_token.clone(); + let account = RefreshAccount { + mcp_server_url: row.mcp_server_url, + is_workspace_integration: row.is_workspace_integration, + info: OAuthAccountInfo { + client: row.client, + refresh_token: row.refresh_token, + grant_type: row.grant_type, + cc_client_id: row.cc_client_id, + cc_client_secret: row.cc_client_secret, + cc_token_url: row.cc_token_url, + scopes: row.scopes, + }, + }; + + let token = match exchange(account).await { + Ok(token) => token, + Err(RefreshError::Other(e)) => return Err(e), + Err(RefreshError::Provider(msg)) => { + let recorded = sqlx::query!( + "UPDATE account SET refresh_error = $1 + WHERE workspace_id = $2 AND id = $3 AND refresh_token = $4", + msg, + w_id, + id, + used, + ) + .execute(db) + .await? + .rows_affected(); + // Another process rotated the refresh token first, so the one we sent was spent: + // its token is the one to serve, unless storing it failed too. + if recorded == 0 { + let expired = sqlx::query_scalar!( + "SELECT now() > expires_at AS \"expired!\" FROM account + WHERE workspace_id = $1 AND id = $2", + w_id, + id + ) + .fetch_optional(db) + .await?; + if expired == Some(false) { + return current_token(db, w_id, path).await; + } + } + return Err(Error::BadRequest(format!("Error refreshing token: {msg}"))); + } + }; + + // The lifetime runs from the exchange, not from the end of the stores below. + let expires_at = windmill_common::utils::now_from_db(db).await? + + chrono::Duration::try_seconds(token.expires_in).unwrap_or_default(); + + // Store at every secret variable of the account before marking it fresh, so no reader of + // any of them sees the account fresh with the old token. A failed store already marks the + // account for a retry; the rotated refresh token must still be saved, or the next refresh + // sends one the provider has revoked. + let mut paths = sqlx::query_scalar!( + "SELECT path FROM variable WHERE workspace_id = $1 AND account = $2 AND is_secret", + w_id, + id + ) + .fetch_all(db) + .await?; + if !paths.iter().any(|p| p == path) { + paths.push(path.to_string()); + } + let mut stored = Ok(()); + for p in &paths { + stored = + crate::secret_backend_ext::store_oauth_token_value(db, w_id, p, &token.access_token) + .await; + if stored.is_err() { + break; + } + } + sqlx::query!( + "UPDATE account SET refresh_token = COALESCE($1, refresh_token), + expires_at = CASE WHEN $2 THEN $3 ELSE expires_at END, + refresh_error = CASE WHEN $2 THEN NULL ELSE refresh_error END + WHERE workspace_id = $4 AND id = $5 AND refresh_token = $6", + token.refresh_token, + stored.is_ok(), + expires_at, + w_id, + id, + used, + ) + .execute(db) + .await?; + stored?; + + Ok(token.access_token) +} + +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", + w_id, + path + ) + .fetch_optional(db) + .await? + .ok_or_else(|| Error::NotFound(format!("Variable {path} not found")))?; + crate::secret_backend_ext::get_secret_value(db, w_id, path, &value).await +} + +/// The standard OAuth / client-credentials exchange. +pub(crate) async fn exchange_standard( + db: &DB, + w_id: &str, + id: i32, + account: &OAuthAccountInfo, +) -> Result<RefreshedToken, RefreshError> { + windmill_oauth::exchange_account_token( db, + w_id, + id, + account, &*windmill_oauth::OAUTH_CLIENTS.load(), &windmill_oauth::OAUTH_HTTP_CLIENT, include_str!("../../oauth_connect.json"), ) - .await?; + .await +} - // Persist the refreshed token through the configured secret backend so an - // external backend (Vault / Azure KV / AWS Secrets Manager) is updated too, - // not just the in-DB variable mirror. - crate::secret_backend_ext::store_oauth_token_value(db, w_id, path, &token).await?; - - Ok(token) +#[cfg(not(feature = "private"))] +pub async fn _refresh_token( + path: &str, + w_id: &str, + id: i32, + db: &DB, + force: bool, +) -> error::Result<String> { + refresh_account_token(db, w_id, path, id, force, |account| async move { + exchange_standard(db, w_id, id, &account.info).await + }) + .await } diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index eeea790009..a384351911 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -17,10 +17,11 @@ use windmill_api_auth::{ }; use windmill_common::db::DB; use windmill_common::per_minute_counter::PerMinuteCounter; +use windmill_common::scopes::ScopeDefinition; use windmill_common::ssrf::{ is_private_ip, private_git_host_allowed, private_git_host_hint, GitRemoteCaller, }; -use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult}; +use windmill_common::workspaces::{check_deploy_rules, operator_can_draft, RuleCheckResult}; use crate::secret_backend_ext::rename_vault_secret; use crate::var_resource_cache::{auth_identity, cache_resource, get_cached_resource}; @@ -236,6 +237,10 @@ pub struct CreateResource { pub labels: Option<Vec<String>>, #[serde(default)] pub ws_specific: Option<bool>, + /// `ai_agent` only: keep the identity `value.on_behalf_of` names, for a caller allowed to + /// (see `resolve_agent_on_behalf_of`). + #[serde(default)] + pub preserve_on_behalf_of: Option<bool>, } #[derive(Deserialize)] struct EditResource { @@ -245,6 +250,7 @@ struct EditResource { resource_type: Option<String>, labels: Option<Vec<String>>, ws_specific: Option<bool>, + preserve_on_behalf_of: Option<bool>, } #[derive(Deserialize)] @@ -261,6 +267,8 @@ pub struct ListResourceQuery { /// When true, append per-user draft-only rows; picker callers leave it off /// to stay deployed-only. See list synthesis in scripts.rs. pub include_draft_only: Option<bool>, + /// Only the `ai_agent` resources the authed user starred. + pub starred_only: Option<bool>, } #[derive(Serialize, FromRow)] @@ -437,6 +445,14 @@ async fn list_resources( ); } + if lq.starred_only.unwrap_or(false) { + sqlb.and_where( + "EXISTS(SELECT 1 FROM favorite WHERE favorite.workspace_id = resource.workspace_id \ + AND favorite.path = resource.path AND favorite.favorite_kind = 'agent' AND favorite.usr = ?)" + .bind(&authed.username), + ); + } + if let Some(label) = &lq.label { for l in label.split(',') { sqlb.and_where( @@ -464,7 +480,6 @@ async fn list_resources( // list (the resources page always passes `resource_type_exclude`); they're applied // per-row below against the draft JSON's `resource_type` instead. if lq.include_draft_only.unwrap_or(false) - && !authed.is_operator && offset == 0 && lq.path_start.is_none() && lq.path.is_none() @@ -472,6 +487,8 @@ async fn list_resources( && lq.value.is_none() && lq.broad_filter.is_none() && lq.label.is_none() + && !lq.starred_only.unwrap_or(false) + && operator_can_draft(&db, &w_id, &authed, UserDraftItemKind::Resource).await? { let rt_filter: Option<Vec<&str>> = lq .resource_type @@ -890,18 +907,61 @@ pub async fn transform_json_value( /// RLS resolves a reference as the token's user, not as the token: a token scoped to one /// resource would otherwise read, through that resource, every variable or resource its /// user can. Job tokens carry no scopes, so what a runnable resolves is unaffected. -/// `resource_path` is the resource being expanded: its own linked secrets -/// ([`is_owned_linked_var`]) are covered by the read of it. +/// `resource_path` is the resource being expanded: see [`check_resource_var_ref_scope`]. fn check_interpolation_scope( db_with_opt_authed: &DbWithOptAuthed<'_, ApiAuthed>, domain: &str, path: &str, resource_path: Option<&str>, ) -> Result<()> { - match db_with_opt_authed.authed() { - Some(_) if resource_path.is_some_and(|r| is_owned_linked_var(r, path)) => Ok(()), - Some(authed) => check_scopes(authed, || format!("{domain}:read:{path}")), - None => Ok(()), + match (db_with_opt_authed.authed(), resource_path) { + (Some(authed), Some(r)) if domain == "variables" => { + check_resource_var_ref_scope(authed, r, path) + } + (Some(authed), _) => check_scopes(authed, || format!("{domain}:read:{path}")), + (None, _) => Ok(()), + } +} + +/// The scope a token needs to resolve variable `var_path` referenced by resource +/// `resource_path`: `variables:read` on it, unless it is the resource's own linked secret +/// ([`is_owned_linked_var`]) or a wildcard pattern of the token's `resources:` scopes +/// matches it. An exact path, or a scope naming no path at all, grants no variable: RLS +/// lets the token resolve any variable its user can read. +pub fn check_resource_var_ref_scope( + authed: &ApiAuthed, + resource_path: &str, + var_path: &str, +) -> Result<()> { + if is_owned_linked_var(resource_path, var_path) { + return Ok(()); + } + let denied = match check_scopes(authed, || format!("variables:read:{var_path}")) { + Ok(()) => return Ok(()), + Err(e) => e, + }; + let required = ScopeDefinition::new("variables", "read", None, Some(vec![var_path.into()])); + let covered = authed.scopes.iter().flatten().any(|scope| { + let Ok(scope) = ScopeDefinition::from_scope_string(scope) else { + return false; + }; + if scope.domain != "resources" { + return false; + } + let patterns = scope + .resource + .into_iter() + .flatten() + .filter(|r| r.contains('*')) + .collect::<Vec<_>>(); + !patterns.is_empty() + && ScopeDefinition::new("variables", &scope.action, None, Some(patterns)) + .includes(&required) + }); + if covered { + Ok(()) + } else { + Err(denied) } } @@ -1242,7 +1302,20 @@ async fn create_resource( check_path_conflict(&mut tx, &w_id, &resource.path).await?; } - let res_value = resource.value.unwrap_or_default(); + let mut res_value = resource.value.unwrap_or_default(); + if resource.resource_type == "ai_agent" { + res_value = resolve_agent_raw_value( + &mut tx, + &authed, + &db, + &w_id, + &resource.path, + &res_value, + resource.preserve_on_behalf_of.unwrap_or(false), + true, + ) + .await?; + } let raw_json = sqlx::types::Json(res_value.as_ref()); if resource.path.starts_with("f/app_themes/") { @@ -2083,9 +2156,6 @@ async fn update_resource( if let Some(npath) = &ns.path { sqlb.set_str("path", npath); } - if let Some(nvalue) = &ns.value { - sqlb.set_str("value", nvalue.to_string()); - } if let Some(nrt) = &ns.resource_type { sqlb.set_str("resource_type", nrt); } @@ -2154,6 +2224,15 @@ async fn update_resource( .execute(&mut *tx) .await?; + sqlx::query!( + "UPDATE native_trigger SET connection_path = $1 WHERE workspace_id = $2 AND connection_path = $3", + npath, + w_id, + path + ) + .execute(&mut *tx) + .await?; + sqlx::query!( "UPDATE workspace_integrations SET resource_path = $1 WHERE workspace_id = $2 AND resource_path = $3", npath, @@ -2193,6 +2272,49 @@ async fn update_resource( } } + lock_resource_row(&mut tx, &w_id, path).await?; + let current = sqlx::query!( + "SELECT resource_type, value AS \"value: sqlx::types::Json<Box<RawValue>>\" + FROM resource WHERE workspace_id = $1 AND path = $2", + &w_id, + path + ) + .fetch_optional(&mut *tx) + .await?; + let resource_type = ns + .resource_type + .clone() + .or_else(|| current.as_ref().map(|c| c.resource_type.clone())); + // Retyping a resource into an agent makes its value an agent's, identity included, so it is + // resolved as a written one is. + let retyped = ns.resource_type.as_deref() == Some("ai_agent") + && current + .as_ref() + .is_some_and(|c| c.resource_type != "ai_agent"); + let nvalue = match &ns.value { + Some(v) => Some(v.clone()), + None if retyped => current.and_then(|c| c.value).map(|v| v.0), + None => None, + }; + if let Some(nvalue) = nvalue { + let nvalue = if resource_type.as_deref() == Some("ai_agent") { + resolve_agent_raw_value( + &mut tx, + &authed, + &db, + &w_id, + ns.path.as_deref().unwrap_or(path), + &nvalue, + ns.preserve_on_behalf_of.unwrap_or(false), + false, + ) + .await? + } else { + nvalue + }; + sqlb.set_str("value", nvalue.to_string()); + } + let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let npath_o: Option<String> = sqlx::query_scalar(&sql) .fetch_optional(&mut *tx) @@ -2362,6 +2484,110 @@ async fn update_resource_value( Ok(format!("value of resource {} updated", path)) } +/// Rewrites the identity an `ai_agent` runs as (`value.on_behalf_of`), resolved on every write of +/// it as a flow's `on_behalf_of` is on every deploy. It exists only while the agent's +/// `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. +async fn resolve_agent_on_behalf_of( + tx: &mut Transaction<'_, Postgres>, + authed: &ApiAuthed, + db: &DB, + w_id: &str, + path: &str, + value: &mut serde_json::Value, + preserve: bool, + created: bool, +) -> Result<()> { + let Some(obj) = value.as_object_mut() else { + return Ok(()); + }; + let mut requested = obj + .remove("on_behalf_of") + .and_then(|v| v.as_str().map(str::to_string)); + if obj.get("run_on_behalf_of").and_then(|v| v.as_bool()) != Some(true) { + return Ok(()); + } + let mut preserve = preserve; + let explicit = + requested.is_some() && preserve && windmill_common::can_preserve_on_behalf_of(authed); + if created && !explicit && windmill_common::can_preserve_on_behalf_of(authed) { + if let Some((_, folder_default)) = + windmill_common::folders::resolve_folder_default_on_behalf_of(db, w_id, path).await? + { + requested = Some(folder_default); + preserve = true; + } + } + let own = windmill_common::users::username_to_permissioned_as(&authed.username); + // Nothing requested is the writer's own. + let resolved = windmill_common::resolve_on_behalf_of( + None, + Some(requested.as_deref().unwrap_or(&own)), + preserve, + authed, + w_id, + db, + ) + .await?; + if let Some(resolved) = resolved.as_ref() { + obj.insert("on_behalf_of".to_string(), resolved.clone().into()); + } + if let Some(kept) = windmill_common::check_on_behalf_of_preservation( + resolved.as_deref(), + preserve, + authed, + &own, + ) { + audit_log( + &mut **tx, + authed, + "resources.on_behalf_of", + if created { + ActionKind::Create + } else { + ActionKind::Update + }, + w_id, + Some(path), + Some([("on_behalf_of", kept.as_str())].into()), + ) + .await?; + } + Ok(()) +} + +/// Held until the write commits: whether a value is resolved as an agent's depends on the type +/// read after this, and a concurrent retype into an agent must not land between the two. +async fn lock_resource_row( + tx: &mut Transaction<'_, Postgres>, + w_id: &str, + path: &str, +) -> Result<()> { + sqlx::query("SELECT 1 FROM resource WHERE workspace_id = $1 AND path = $2 FOR UPDATE") + .bind(w_id) + .bind(path) + .execute(&mut **tx) + .await?; + Ok(()) +} + +/// `resolve_agent_on_behalf_of` on a value as the request carried it. +async fn resolve_agent_raw_value( + tx: &mut Transaction<'_, Postgres>, + authed: &ApiAuthed, + db: &DB, + w_id: &str, + path: &str, + value: &RawValue, + preserve: bool, + created: bool, +) -> Result<Box<RawValue>> { + let mut parsed: serde_json::Value = serde_json::from_str(value.get())?; + resolve_agent_on_behalf_of(tx, authed, db, w_id, path, &mut parsed, preserve, created).await?; + Ok(serde_json::value::to_raw_value(&parsed)?) +} + /// Write a resource's value and run everything that has to follow it: a version row, the audit /// entry, deployment metadata, the webhook, and dependent CI tests. Shared with version restore /// so a restored value is indistinguishable downstream from any other edit. @@ -2390,6 +2616,21 @@ async fn set_resource_value( let mut tx = user_db.clone().begin(authed).await?; + let mut value = value; + if let Some(v) = value.as_mut() { + lock_resource_row(&mut tx, w_id, path).await?; + let resource_type = sqlx::query_scalar!( + "SELECT resource_type FROM resource WHERE workspace_id = $1 AND path = $2", + w_id, + path + ) + .fetch_optional(&mut *tx) + .await?; + if resource_type.as_deref() == Some("ai_agent") { + resolve_agent_on_behalf_of(&mut tx, authed, db, w_id, path, v, false, false).await?; + } + } + // `RETURNING resource_type` rather than a second lookup: the advisory below has to know the // type to leave `state` and `cache` alone, and this statement already runs. let updated = sqlx::query_scalar!( diff --git a/backend/windmill-store/src/secret_backend_ext.rs b/backend/windmill-store/src/secret_backend_ext.rs index d5272384c0..814a3174a9 100644 --- a/backend/windmill-store/src/secret_backend_ext.rs +++ b/backend/windmill-store/src/secret_backend_ext.rs @@ -77,14 +77,13 @@ pub async fn store_secret_value( /// here instead would leave the external store frozen at its connect-time token /// while reads (which resolve through the backend) keep serving the stale value. /// -/// The caller has already committed the `account` row as fresh (advanced -/// `expires_at`) by the time we get here. If persisting the token fails — most -/// likely a transient error talking to an external backend — that would leave -/// the account marked fresh while the served secret is stale, so the on-fetch -/// refresh gate (`now() > expires_at`) would skip refresh and keep serving the -/// stale token for the whole token lifetime. To avoid that we reset `expires_at` -/// to the past (and record `refresh_error`) on failure — looking the account up -/// via `variable.account` — so the very next fetch retries the refresh instead. +/// If persisting the token fails — most likely a transient error talking to an +/// external backend — the account must not end up marked fresh while the served +/// secret is stale, or the on-fetch refresh gate (`now() > expires_at`) would keep +/// serving the stale token for the whole token lifetime. So on failure we reset +/// `expires_at` to the past and record `refresh_error` — looking the account up via +/// `variable.account` — and the very next fetch retries the refresh. This also +/// covers another refresher of the account having marked it fresh meanwhile. /// /// Authorization contract: this performs NO access control. It writes the /// caller-supplied token into the secret variable at `path` and may mutate the diff --git a/backend/windmill-store/src/variables.rs b/backend/windmill-store/src/variables.rs index 151b75bd85..352a411214 100644 --- a/backend/windmill-store/src/variables.rs +++ b/backend/windmill-store/src/variables.rs @@ -11,7 +11,7 @@ use windmill_api_auth::{ ApiAuthed, }; use windmill_common::db::{Authable, DB}; -use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult}; +use windmill_common::workspaces::{check_deploy_rules, operator_can_draft, RuleCheckResult}; use crate::secret_backend_ext::{ delete_secret_from_backend, get_secret_value, is_external_stored_value, is_vault_stored_value, @@ -229,7 +229,6 @@ async fn list_variables( // Append the authed user's draft-only variables; see scripts.rs. if lq.include_draft_only.unwrap_or(false) - && !authed.is_operator && offset == 0 && lq.path_start.is_none() && lq.path.is_none() @@ -237,6 +236,7 @@ async fn list_variables( && lq.value.is_none() && lq.broad_filter.is_none() && lq.label.is_none() + && operator_can_draft(&db, &w_id, &authed, UserDraftItemKind::Variable).await? { let draft_only_rows = fetch_draft_only_list_rows(&db, &w_id, &authed.email, UserDraftItemKind::Variable) @@ -372,42 +372,42 @@ async fn get_variable( explain_variable_perm_error(&path, &w_id, &db, Some(&authed)).await?; unreachable!() } else { + drop(tx); explain_variable_perm_error(&path, &w_id, &db, Some(&authed)).await?; unreachable!() }; let decrypt_secret = q.decrypt_secret.unwrap_or(true); - let r = if variable.is_secret { - if decrypt_secret { - audit_log( - &mut *tx, - &authed, - "variables.decrypt_secret", - ActionKind::Execute, - &w_id, - Some(&variable.path), - None, - ) - .await?; - } + if variable.is_secret && decrypt_secret { + audit_log( + &mut *tx, + &authed, + "variables.decrypt_secret", + ActionKind::Execute, + &w_id, + Some(&variable.path), + None, + ) + .await?; + } + // Everything below reads the pool: holding this connection meanwhile would make each + // concurrent request wait on a second one. + tx.commit().await?; + let r = if variable.is_secret { let value = variable.value.unwrap_or_else(|| "".to_string()); ListableVariable { value: if variable.is_expired.unwrap_or(false) && variable.account.is_some() { #[cfg(feature = "oauth2")] { - let refresh_tx = db - .begin() - .await - .map_err(|e| Error::InternalErr(e.to_string()))?; Some( crate::oauth_refresh_oss::_refresh_token( - refresh_tx, &variable.path, &w_id, variable.account.unwrap(), &db, + false, ) .await?, ) @@ -415,7 +415,6 @@ async fn get_variable( #[cfg(not(feature = "oauth2"))] return Err(Error::internal_err("Require oauth2 feature".to_string())); } else if !value.is_empty() && decrypt_secret { - let _ = tx.commit().await; // Use secret backend for decryption (supports both DB and Vault) Some(get_secret_value(&db, &w_id, &variable.path, &value).await?) } else if q.include_encrypted.unwrap_or(false) { @@ -595,6 +594,49 @@ async fn create_variable( Query(AlreadyEncrypted { already_encrypted }): Query<AlreadyEncrypted>, Json(variable): Json<CreateVariable>, ) -> Result<(StatusCode, String)> { + let path = variable.path.clone(); + create_variable_with_account( + authed, + &db, + user_db, + &webhook, + w_id, + variable, + already_encrypted.unwrap_or(false), + None, + ) + .await?; + Ok((StatusCode::CREATED, format!("variable {path} created"))) +} + +/// An OAuth account to create with the variable that holds its access token. +pub struct NewOAuthAccount { + pub client: String, + pub expires_in: i64, + pub refresh_token: Option<String>, + pub grant_type: String, + pub cc_client_id: Option<String>, + pub cc_client_secret: Option<String>, + pub cc_token_url: Option<String>, + pub mcp_server_url: Option<String>, + pub scopes: Option<Vec<String>>, +} + +/// Creates a variable and, given `account`, the OAuth account it refreshes through, linked in the +/// same transaction. Reading a linked variable refreshes through its account and hands the reader +/// the fresh token, so this is the only way a variable gets a link: `variable.account` is ignored. +/// Returns the new account's id. +pub async fn create_variable_with_account( + authed: ApiAuthed, + db: &DB, + user_db: UserDB, + webhook: &WebhookShared, + w_id: String, + variable: CreateVariable, + already_encrypted: bool, + account: Option<NewOAuthAccount>, +) -> Result<Option<i32>> { + let db = db.clone(); check_scopes(&authed, || format!("variables:write:{}", variable.path))?; check_proper_path(&variable.path)?; if let RuleCheckResult::Blocked(msg) = check_deploy_rules( @@ -626,7 +668,7 @@ async fn create_variable( let authed = maybe_refresh_folders(&variable.path, &w_id, authed, &db).await; check_path_conflict(&db, &w_id, &variable.path).await?; - let value = if variable.is_secret && !already_encrypted.unwrap_or(false) { + let value = if variable.is_secret && !already_encrypted { // A restored draft sends the `$encrypted:` marker as-is; decrypt it back // (validating against the workspace key) before the secret backend re-stores it. let plain = if variable.value.starts_with(ENCRYPTED_DRAFT_PREFIX) { @@ -647,6 +689,27 @@ async fn create_variable( let mut tx = user_db.begin(&authed).await?; + let account_id = match account { + Some(a) => Some( + sqlx::query_scalar!( + "INSERT INTO account (workspace_id, client, expires_at, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, mcp_server_url, scopes) VALUES ($1, $2, now() + ($3 || ' seconds')::interval, $4, $5, $6, $7, $8, $9, $10) RETURNING id", + &w_id, + a.client, + a.expires_in.to_string(), + a.refresh_token, + a.grant_type, + a.cc_client_id, + a.cc_client_secret, + a.cc_token_url, + a.mcp_server_url, + a.scopes.as_deref() + ) + .fetch_one(&mut *tx) + .await?, + ), + None => None, + }; + sqlx::query!( "INSERT INTO variable (workspace_id, path, value, is_secret, description, account, is_oauth, expires_at, labels, edited_by) @@ -656,7 +719,7 @@ async fn create_variable( value, variable.is_secret, variable.description, - variable.account, + account_id, variable.is_oauth.unwrap_or(false), variable.expires_at, variable.labels.as_deref() as Option<&[String]>, @@ -706,10 +769,7 @@ async fn create_variable( WebhookMessage::CreateVariable { workspace: w_id, path: variable.path.clone() }, ); - Ok(( - StatusCode::CREATED, - format!("variable {} created", variable.path), - )) + Ok(account_id) } async fn encrypt_value( @@ -1060,7 +1120,6 @@ struct EditVariable { value: Option<String>, is_secret: Option<bool>, description: Option<String>, - account: Option<i32>, labels: Option<Vec<String>>, ws_specific: Option<bool>, } @@ -1157,11 +1216,6 @@ async fn update_variable( has_sql_updates = true; } - if let Some(account_id) = ns.account { - sqlb.set_str("account", account_id); - 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", @@ -1180,20 +1234,6 @@ async fn update_variable( has_sql_updates = true; } - // Get old account_id if we're updating the account field - let old_account_id = if ns.account.is_some() { - sqlx::query_scalar!( - "SELECT account FROM variable WHERE path = $1 AND workspace_id = $2", - &path, - &w_id - ) - .fetch_optional(&db) - .await? - .flatten() - } else { - None - }; - let mut tx: Transaction<'_, Postgres> = user_db.begin(&authed).await?; if let Some(npath) = ns.path.clone() { @@ -1256,6 +1296,15 @@ async fn update_variable( .execute(&mut *tx) .await?; + sqlx::query!( + "UPDATE native_trigger SET connection_path = $1 WHERE workspace_id = $2 AND connection_path = $3", + npath, + w_id, + path + ) + .execute(&mut *tx) + .await?; + sqlx::query!( "UPDATE workspace_integrations SET resource_path = $1 WHERE workspace_id = $2 AND resource_path = $3", npath, @@ -1367,32 +1416,6 @@ async fn update_variable( ) .await?; - // Clean up old account if it's no longer referenced and different from new account - if let Some(old_acc_id) = old_account_id { - if ns.account.is_some() && ns.account != Some(old_acc_id) { - // Check if old account is still referenced by other variables or resources - let account_still_used = sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM variable WHERE account = $1 AND workspace_id = $2)", - old_acc_id, - &w_id - ) - .fetch_one(&mut *tx) - .await? - .unwrap_or(true); - - if !account_still_used { - // Delete the orphaned account - sqlx::query!( - "DELETE FROM account WHERE id = $1 AND workspace_id = $2", - old_acc_id, - &w_id - ) - .execute(&mut *tx) - .await?; - } - } - } - tx.commit().await?; // Detect if this was a rename operation @@ -1542,17 +1565,12 @@ pub async fn get_value_internal<'a>( if variable.is_expired.unwrap_or(false) && variable.account.is_some() { #[cfg(feature = "oauth2")] { - let db = db_with_opt_authed.db(); - let refresh_tx = db - .begin() - .await - .map_err(|e| Error::InternalErr(e.to_string()))?; crate::oauth_refresh_oss::_refresh_token( - refresh_tx, &variable.path, &w_id, variable.account.unwrap(), - db, + db_with_opt_authed.db(), + false, ) .await? } diff --git a/backend/windmill-test-utils/Cargo.toml b/backend/windmill-test-utils/Cargo.toml index 3e409717bd..d8d938a1e8 100644 --- a/backend/windmill-test-utils/Cargo.toml +++ b/backend/windmill-test-utils/Cargo.toml @@ -7,6 +7,9 @@ edition.workspace = true [lib] name = "windmill_test_utils" path = "src/lib.rs" +# src/ has no unit tests or doctests: remove both lines before adding one, or cargo test never runs it. +test = false +doctest = false [features] default = [] diff --git a/backend/windmill-test-utils/src/lib.rs b/backend/windmill-test-utils/src/lib.rs index f05fff66f8..5f804cee75 100644 --- a/backend/windmill-test-utils/src/lib.rs +++ b/backend/windmill-test-utils/src/lib.rs @@ -71,6 +71,22 @@ fn next_worker_name() -> String { format!("{id}/worker-{thread_name}") } +/// The migration only creates audit partitions for the day it ran and the next three, and the +/// monitor that rolls new ones does not run in tests. A test database cloned from an older +/// template therefore has no partition for today, and a failed audit insert fails the request. +pub async fn ensure_today_audit_partition(db: &Pool<Postgres>) { + let today = chrono::Utc::now().date_naive(); + let next = today + chrono::Duration::days(1); + let partition = format!("audit_{}", today.format("%Y%m%d")); + sqlx::query(&format!( + "CREATE TABLE IF NOT EXISTS \"{partition}\" PARTITION OF audit_partitioned \ + FOR VALUES FROM ('{today}') TO ('{next}')" + )) + .execute(db) + .await + .expect("creating today's audit partition"); +} + pub struct ApiServer { pub addr: std::net::SocketAddr, #[allow(unused)] @@ -104,6 +120,7 @@ impl ApiServer { server_mode: bool, mcp_mode: bool, ) -> anyhow::Result<Self> { + ensure_today_audit_partition(&db).await; let (tx, rx) = tokio::sync::broadcast::channel::<()>(1); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") @@ -212,6 +229,7 @@ impl RunJob { } pub async fn push(self, db: &Pool<Postgres>) -> Uuid { + ensure_today_audit_partition(db).await; let RunJob { payload, args, scheduled_for_o, username, email, job_id, workspace_id } = self; let mut hm_args = std::collections::HashMap::new(); for (k, v) in args { @@ -260,6 +278,7 @@ impl RunJob { /// Push the job as a specific user (for testing permissions) pub async fn push_as(self, db: &Pool<Postgres>, username: &str, email: &str) -> Uuid { + ensure_today_audit_partition(db).await; let RunJob { payload, args, scheduled_for_o, job_id, workspace_id, .. } = self; let mut hm_args = std::collections::HashMap::new(); for (k, v) in args { diff --git a/backend/windmill-trigger-amqp/Cargo.toml b/backend/windmill-trigger-amqp/Cargo.toml index 4c5ce41941..ac22b7bdf5 100644 --- a/backend/windmill-trigger-amqp/Cargo.toml +++ b/backend/windmill-trigger-amqp/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_trigger_amqp" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] diff --git a/backend/windmill-trigger-azure/Cargo.toml b/backend/windmill-trigger-azure/Cargo.toml index 31fb9a9fc7..bac8c58841 100644 --- a/backend/windmill-trigger-azure/Cargo.toml +++ b/backend/windmill-trigger-azure/Cargo.toml @@ -7,6 +7,9 @@ edition.workspace = true [lib] name = "windmill_trigger_azure" path = "src/lib.rs" +# src/ has no unit tests or doctests: remove both lines before adding one, or cargo test never runs it. +test = false +doctest = false [features] default = [] diff --git a/backend/windmill-trigger-email/Cargo.toml b/backend/windmill-trigger-email/Cargo.toml index 0ed00fa2dd..d4d477f509 100644 --- a/backend/windmill-trigger-email/Cargo.toml +++ b/backend/windmill-trigger-email/Cargo.toml @@ -7,6 +7,9 @@ edition.workspace = true [lib] name = "windmill_trigger_email" path = "src/lib.rs" +# src/ has no unit tests or doctests: remove both lines before adding one, or cargo test never runs it. +test = false +doctest = false [features] default = [] diff --git a/backend/windmill-trigger-gcp/Cargo.toml b/backend/windmill-trigger-gcp/Cargo.toml index 1b106171c6..e649f970e1 100644 --- a/backend/windmill-trigger-gcp/Cargo.toml +++ b/backend/windmill-trigger-gcp/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_trigger_gcp" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] diff --git a/backend/windmill-trigger-http/Cargo.toml b/backend/windmill-trigger-http/Cargo.toml index d776a34a00..378b07a61e 100644 --- a/backend/windmill-trigger-http/Cargo.toml +++ b/backend/windmill-trigger-http/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_trigger_http" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] diff --git a/backend/windmill-trigger-kafka/Cargo.toml b/backend/windmill-trigger-kafka/Cargo.toml index 9887dfc04a..f449837031 100644 --- a/backend/windmill-trigger-kafka/Cargo.toml +++ b/backend/windmill-trigger-kafka/Cargo.toml @@ -7,6 +7,9 @@ edition.workspace = true [lib] name = "windmill_trigger_kafka" path = "src/lib.rs" +# src/ has no unit tests or doctests: remove both lines before adding one, or cargo test never runs it. +test = false +doctest = false [features] default = [] diff --git a/backend/windmill-trigger-mqtt/Cargo.toml b/backend/windmill-trigger-mqtt/Cargo.toml index 305880b176..383e32a693 100644 --- a/backend/windmill-trigger-mqtt/Cargo.toml +++ b/backend/windmill-trigger-mqtt/Cargo.toml @@ -7,6 +7,9 @@ edition.workspace = true [lib] name = "windmill_trigger_mqtt" path = "src/lib.rs" +# src/ has no unit tests or doctests: remove both lines before adding one, or cargo test never runs it. +test = false +doctest = false [features] default = [] diff --git a/backend/windmill-trigger-nats/Cargo.toml b/backend/windmill-trigger-nats/Cargo.toml index a0830097bb..1e75052eb7 100644 --- a/backend/windmill-trigger-nats/Cargo.toml +++ b/backend/windmill-trigger-nats/Cargo.toml @@ -7,6 +7,9 @@ edition.workspace = true [lib] name = "windmill_trigger_nats" path = "src/lib.rs" +# src/ has no unit tests or doctests: remove both lines before adding one, or cargo test never runs it. +test = false +doctest = false [features] default = [] diff --git a/backend/windmill-trigger-postgres/Cargo.toml b/backend/windmill-trigger-postgres/Cargo.toml index d932274f8e..9abef142dd 100644 --- a/backend/windmill-trigger-postgres/Cargo.toml +++ b/backend/windmill-trigger-postgres/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_trigger_postgres" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] diff --git a/backend/windmill-trigger-sqs/Cargo.toml b/backend/windmill-trigger-sqs/Cargo.toml index ac46dc5935..7896f5acd0 100644 --- a/backend/windmill-trigger-sqs/Cargo.toml +++ b/backend/windmill-trigger-sqs/Cargo.toml @@ -7,6 +7,9 @@ edition.workspace = true [lib] name = "windmill_trigger_sqs" path = "src/lib.rs" +# src/ has no unit tests or doctests: remove both lines before adding one, or cargo test never runs it. +test = false +doctest = false [features] default = [] diff --git a/backend/windmill-trigger-websocket/Cargo.toml b/backend/windmill-trigger-websocket/Cargo.toml index ccf8d2b120..4005985cd3 100644 --- a/backend/windmill-trigger-websocket/Cargo.toml +++ b/backend/windmill-trigger-websocket/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_trigger_websocket" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] diff --git a/backend/windmill-trigger-websocket/src/listener.rs b/backend/windmill-trigger-websocket/src/listener.rs index 91bbc4bf23..e28ed8903c 100644 --- a/backend/windmill-trigger-websocket/src/listener.rs +++ b/backend/windmill-trigger-websocket/src/listener.rs @@ -31,6 +31,12 @@ use windmill_trigger::Listener; const MAX_CONNECT_ATTEMPTS: u32 = 5; +/// Unit of the connect backoff: attempt `n` waits `2^n` of it. Tests shrink it to avoid +/// waiting out real seconds; nothing else writes it. +#[doc(hidden)] +pub static CONNECT_RETRY_BASE_DELAY_MS: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(1000); + /// Whether a failed WebSocket connect is worth retrying: network-level IO /// errors and HTTP 5xx/429 handshake responses are typically transient (edge /// proxies like Cloudflare return 502/520 sporadically), while other errors @@ -215,24 +221,27 @@ impl Listener for WebsocketTrigger { && attempt < MAX_CONNECT_ATTEMPTS && is_transient_connect_error(&err) => { - let delay_secs = 1u64 << attempt; + let delay = std::time::Duration::from_millis( + CONNECT_RETRY_BASE_DELAY_MS.load(std::sync::atomic::Ordering::Relaxed) + << attempt, + ); tracing::warn!( - "Transient error connecting to WebSocket for trigger {} (attempt {}/{}), retrying in {}s: {}", + "Transient error connecting to WebSocket for trigger {} (attempt {}/{}), retrying in {:?}: {}", listening_trigger.path, attempt, MAX_CONNECT_ATTEMPTS, - delay_secs, + delay, err ); update_rw_lock( err_message.clone(), Some(format!( - "Connection attempt {}/{} failed ({}), retrying in {}s...", - attempt, MAX_CONNECT_ATTEMPTS, err, delay_secs + "Connection attempt {}/{} failed ({}), retrying in {:?}...", + attempt, MAX_CONNECT_ATTEMPTS, err, delay )), ) .await; - tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await; + tokio::time::sleep(delay).await; } Err(err) => return Err(to_anyhow(err).into()), } diff --git a/backend/windmill-trigger-websocket/tests/connect_retry.rs b/backend/windmill-trigger-websocket/tests/connect_retry.rs index 2d21512692..f73ca9b953 100644 --- a/backend/windmill-trigger-websocket/tests/connect_retry.rs +++ b/backend/windmill-trigger-websocket/tests/connect_retry.rs @@ -20,7 +20,8 @@ use tokio::{ }; use windmill_trigger::{listener::ListeningTrigger, Listener}; use windmill_trigger_websocket::{ - WebsocketConfig, WebsocketTrigger, ALLOW_PRIVATE_WEBSOCKET_URLS_ENV, + listener::CONNECT_RETRY_BASE_DELAY_MS, WebsocketConfig, WebsocketTrigger, + ALLOW_PRIVATE_WEBSOCKET_URLS_ENV, }; /// Mock server: rejects the first `failures` upgrade requests with @@ -108,6 +109,7 @@ async fn get_consumer_result( #[tokio::test] async fn transient_502s_are_retried_until_the_upgrade_succeeds() { std::env::set_var(ALLOW_PRIVATE_WEBSOCKET_URLS_ENV, "true"); + CONNECT_RETRY_BASE_DELAY_MS.store(1, Ordering::Relaxed); let (addr, attempts) = mock_ws_server(2, "502 Bad Gateway").await; let lt = trigger(format!("ws://{addr}"), true); let err_message = Arc::new(RwLock::new(None)); diff --git a/backend/windmill-trigger/Cargo.toml b/backend/windmill-trigger/Cargo.toml index 8132f8b62a..cfa11084d3 100644 --- a/backend/windmill-trigger/Cargo.toml +++ b/backend/windmill-trigger/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_trigger" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index ba40ed2c48..fec3180801 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -695,12 +695,18 @@ async fn list_triggers<T: TriggerCrud>( // Best-effort: the editor's TriggerData shape overlaps T::Trigger but a per-kind // config can deviate, so drop a row on deserialize failure rather than fail the list. if query.include_draft_only.unwrap_or(false) - && !authed.is_operator && query.page.unwrap_or(0) == 0 && query.path.is_none() && query.is_flow.is_none() && query.path_start.is_none() && query.label.is_none() + && windmill_common::workspaces::operator_can_draft( + &db, + &workspace_id, + &authed, + T::user_draft_item_kind(), + ) + .await? { let draft_only_rows = fetch_draft_only_list_rows( &db, diff --git a/backend/windmill-types/Cargo.toml b/backend/windmill-types/Cargo.toml index 2472d6bf02..84fa209ddd 100644 --- a/backend/windmill-types/Cargo.toml +++ b/backend/windmill-types/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_types" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [dependencies] windmill-parser.workspace = true diff --git a/backend/windmill-types/src/flow_status.rs b/backend/windmill-types/src/flow_status.rs index 6dff8430b7..2fd5ef1184 100644 --- a/backend/windmill-types/src/flow_status.rs +++ b/backend/windmill-types/src/flow_status.rs @@ -4,7 +4,7 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::flows::FlowValue; +use crate::flows::{FlowValue, Suspend}; const MINUTES: Duration = Duration::from_secs(60); const HOURS: Duration = MINUTES.saturating_mul(60); @@ -409,6 +409,21 @@ pub enum JobResult { } impl FlowStatusModule { + /// Whether this completed step's suspend gate could block and never opened (it timed out or + /// was disapproved): `approvers` is written when the gate opens, and is the only record of it + /// that outlives the run, since its resume_job rows are deleted with its queue row. + pub fn suspend_gate_never_opened(&self, suspend: Option<&Suspend>) -> bool { + let Some(suspend) = suspend else { + return false; + }; + if suspend.required_events.unwrap_or(0) == 0 + || suspend.continue_on_disapprove_timeout.unwrap_or(false) + { + return false; + } + matches!(self, FlowStatusModule::Success { skipped: false, approvers, .. } if approvers.is_empty()) + } + pub fn job(&self) -> Option<Uuid> { match self { FlowStatusModule::WaitingForPriorSteps { .. } => None, diff --git a/backend/windmill-types/src/s3.rs b/backend/windmill-types/src/s3.rs index 3af4b7f99a..e6a219a370 100644 --- a/backend/windmill-types/src/s3.rs +++ b/backend/windmill-types/src/s3.rs @@ -9,6 +9,12 @@ use std::fmt; /// it as the primary one. pub const DEFAULT_STORAGE: &str = "_default_"; +/// The s3-proxy path segment under which a request names the storage's real bucket +/// (`s3_proxy/_wm_bucket_/{storage}/{bucket}/{key}`), for S3 clients that address +/// `s3://<bucket>/<key>`. Reserved like `DEFAULT_STORAGE`: a secondary storage of that name +/// would be shadowed by the bucket-addressed routes. +pub const BUCKET_ADDRESSED_SEGMENT: &str = "_wm_bucket_"; + #[derive(Debug, Deserialize, Serialize, Clone)] pub struct FilesystemSettings { pub root_path: String, diff --git a/backend/windmill-worker-volumes/Cargo.toml b/backend/windmill-worker-volumes/Cargo.toml index 08b9a554b2..d94764e7cd 100644 --- a/backend/windmill-worker-volumes/Cargo.toml +++ b/backend/windmill-worker-volumes/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_worker_volumes" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] enterprise = [] diff --git a/backend/windmill-worker/Cargo.toml b/backend/windmill-worker/Cargo.toml index 57b292f8f2..082166419e 100644 --- a/backend/windmill-worker/Cargo.toml +++ b/backend/windmill-worker/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true [lib] name = "windmill_worker" path = "src/lib.rs" +# src/ has no doctests: remove this line before adding one, or cargo test never runs it. +doctest = false [features] default = [] @@ -39,7 +41,7 @@ nu = ["dep:windmill-parser-nu"] java = ["dep:windmill-parser-java"] ruby = ["dep:windmill-parser-ruby"] rlang = ["dep:windmill-parser-r"] -duckdb = ["dep:libloading"] +duckdb = [] quickjs = ["windmill-jseval/quickjs", "windmill-queue/quickjs"] bedrock = ["windmill-ai/bedrock"] @@ -143,7 +145,6 @@ backon.workspace = true pep440_rs.workspace = true process-wrap.workspace = true async-once-cell.workspace = true -libloading = { workspace = true, optional = true } opentelemetry-proto = { workspace = true, optional = true } opentelemetry = { workspace = true, optional = true } diff --git a/backend/windmill-worker/loader.bun.js b/backend/windmill-worker/loader.bun.js index edfd3375ab..a36e8e136f 100644 --- a/backend/windmill-worker/loader.bun.js +++ b/backend/windmill-worker/loader.bun.js @@ -41,7 +41,10 @@ const p = { imp.path.startsWith("/f/")) && !imp.path.endsWith(".ts") ) { - code = code.replaceAll(imp.path, imp.path + ".ts"); + // Whole quoted specifiers only: `./util` is also a substring of `../util_ws`. + for (const q of ['"', "'"]) { + code = code.replaceAll(q + imp.path + q, q + imp.path + ".ts" + q); + } } } } @@ -55,6 +58,17 @@ const p = { return replaceRelativeImports(code); }); + // Module files (written by write_module_files next to main.ts) need the same `.ts` + // rewrite: an extensionless relative import misses the resolve filter below, and the + // lockfile builder then treats it as an external package named ".". + const filterLoadModule = new RegExp( + `^${cdir}\/(?!node_modules\/)(?!_wm_)(?!main\\.ts$).*\\.ts$` + ); + build.onLoad({ filter: filterLoadModule }, async (args) => { + const code = readFileSync(args.path, "utf8"); + return replaceRelativeImports(code); + }); + // A stalled fetch would otherwise hold the whole build for bun's own 5-minute // default, with nothing naming the script it was waiting on. const RELATIVE_IMPORT_FETCH_TIMEOUT_MS = 120000; @@ -109,18 +123,32 @@ const p = { // Check if the import resolves to a local module file (written by write_module_files). // Only check relative paths — absolute/bare specifiers should fall through to the // remote resolver, matching the Windows loader pattern. - if (args.path.startsWith(".")) { - const localPath = resolve(cdir, args.path); + // Only for local importers (main.ts or a module, both `.ts` in the job dir): a fetched + // workspace script (`.url`) resolves within its own workspace folder, where a module + // file of the same name would shadow its real neighbour. + const importer = args.importer ? resolve(args.importer) : ""; + if ( + args.path.startsWith(".") && + importer.endsWith(".ts") && + importer.startsWith(cdir + "/") + ) { + // From the importing module's own folder: a module may sit in a subfolder (`lib/a.ts`). + const localPath = resolve(dirname(importer), args.path); try { readFileSync(localPath); return { path: localPath }; } catch {} } + const importerRel = args.importer.replace(cdir + "/", ""); + // A module file stands in the job dir for a script in the entrypoint's folder, so its + // own relative imports resolve from that folder's workspace path, not from the job dir. const file_path = args.importer == "./main.ts" || args.importer == resolve("./main.ts") ? current_path - : args.importer.replace(cdir + "/", ""); + : importerRel.endsWith(".ts") + ? join(dirname(current_path), importerRel.replace(/\.ts$/, "")) + : importerRel; const isRelative = !args.path.startsWith("/"); const endExt = args.path.endsWith(".ts") ? "" : ".ts"; diff --git a/backend/windmill-worker/nsjail/download.ruby.config.proto b/backend/windmill-worker/nsjail/download.ruby.config.proto index 4d6b398fda..31eefae766 100644 --- a/backend/windmill-worker/nsjail/download.ruby.config.proto +++ b/backend/windmill-worker/nsjail/download.ruby.config.proto @@ -10,6 +10,10 @@ rlimit_cpu: 1000 rlimit_fsize: 1024 rlimit_nofile: 64 +# The script's env vars, proto-escaped. Set here rather than on the process that +# execs nsjail: the loader honours `LD_PRELOAD` before the jail exists. +{ENVARS} + envar: "HOME=/user" envar: "LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH" diff --git a/backend/windmill-worker/nsjail/install.bun.config.proto b/backend/windmill-worker/nsjail/install.bun.config.proto new file mode 100644 index 0000000000..86fc052102 --- /dev/null +++ b/backend/windmill-worker/nsjail/install.bun.config.proto @@ -0,0 +1,118 @@ +name: "bun install" + +mode: ONCE +hostname: "bun" +log_level: ERROR +time_limit: {TIMEOUT} + +# Bun/JavaScriptCore reserves large virtual ranges up front, and native builds +# size their pools to the host: an address-space cap breaks the install itself. +disable_rl: true + +cwd: "{INSTALL_DIR}" + +clone_newnet: false +clone_newuser: {CLONE_NEWUSER} + +skip_setsid: true +keep_caps: false +keep_env: true +mount_proc: true + +mount { + src: "/bin" + dst: "/bin" + is_bind: true +} + +mount { + src: "/lib" + dst: "/lib" + is_bind: true +} + +mount { + src: "/lib64" + dst: "/lib64" + is_bind: true + mandatory: false +} + +mount { + src: "/usr" + dst: "/usr" + is_bind: true +} + +mount { + src: "/etc" + dst: "/etc" + is_bind: true +} + +# Container runtimes bind exactly these 3 files as separate submounts over +# /etc; nsjail's ro remount of /etc is non-recursive so they stay writable. +# Load-bearing -- do not remove as redundant with the /etc bind above. +mount { + src: "/etc/resolv.conf" + dst: "/etc/resolv.conf" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hosts" + dst: "/etc/hosts" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hostname" + dst: "/etc/hostname" + is_bind: true + mandatory: false +} + +mount { + src: "/dev/null" + dst: "/dev/null" + is_bind: true + rw: true +} + +mount { + src: "/dev/urandom" + dst: "/dev/urandom" + is_bind: true +} + +mount { + src: "/dev/random" + dst: "/dev/random" + is_bind: true +} + +{TMP_MOUNT_BLOCK} + +# HOME keeps the worker's path, so the package manager finds the operator's +# ~/.npmrc / ~/.bunfig.toml, but is otherwise a private tmpfs. +{HOME_MOUNT_BLOCK} + +# The install dir (package.json, lockfile, registry config; node_modules is +# written there) and, for bun, the shared bun cache, writable like the other +# install jails' shared caches. A bun install dir inside the cache gets no bind +# of its own: that would be a second mount, and bun can only hardlink from its +# cache within one. +{INSTALL_MOUNTS} + +mount { + src: "{TRACING_PROXY_CA_CERT_PATH}" + dst: "{TRACING_PROXY_CA_CERT_PATH}" + is_bind: true + mandatory: false +} + +{CA_MOUNT} + +#{DEV} diff --git a/backend/windmill-worker/nsjail/probe.config.proto b/backend/windmill-worker/nsjail/probe.config.proto new file mode 100644 index 0000000000..2fdc5abdca --- /dev/null +++ b/backend/windmill-worker/nsjail/probe.config.proto @@ -0,0 +1,47 @@ +name: "nsjail startup probe" + +mode: ONCE +hostname: "probe" +log_level: WARNING +time_limit: 10 + +# Same namespaces and rlimit handling as the job profiles (nsjail's default rlimits, a cgroup +# namespace, the user namespace unless DISABLE_NUSER): a worker whose kernel or runtime refuses +# any of them cannot run jobs either. +clone_newnet: false +clone_newuser: {CLONE_NEWUSER} + +skip_setsid: true +keep_caps: false +mount_proc: true + +mount { + src: "/bin" + dst: "/bin" + is_bind: true +} +mount { + src: "/lib" + dst: "/lib" + is_bind: true +} +mount { + src: "/lib64" + dst: "/lib64" + is_bind: true + mandatory: false +} +mount { + src: "/usr" + dst: "/usr" + is_bind: true +} +mount { + dst: "/tmp" + fstype: "tmpfs" + rw: true +} + +exec_bin { + path: "/bin/true" +} diff --git a/backend/windmill-worker/nsjail/run.bash.config.proto b/backend/windmill-worker/nsjail/run.bash.config.proto index 899f7caa1e..b89d4a5d91 100644 --- a/backend/windmill-worker/nsjail/run.bash.config.proto +++ b/backend/windmill-worker/nsjail/run.bash.config.proto @@ -9,7 +9,7 @@ disable_rl: true cwd: "/tmp" -clone_newnet: false +clone_newnet: {CLONE_NEWNET} clone_newuser: {CLONE_NEWUSER} skip_setsid: true diff --git a/backend/windmill-worker/nsjail/run.bun.config.proto b/backend/windmill-worker/nsjail/run.bun.config.proto index 43e4464beb..98794a74bd 100644 --- a/backend/windmill-worker/nsjail/run.bun.config.proto +++ b/backend/windmill-worker/nsjail/run.bun.config.proto @@ -11,7 +11,7 @@ mount_proc: true cwd: "/tmp/{LANG}" -clone_newnet: false +clone_newnet: {CLONE_NEWNET} clone_newuser: {CLONE_NEWUSER} clone_newcgroup: false @@ -20,6 +20,9 @@ clone_newcgroup: false skip_setsid: true keep_caps: false keep_env: true +# The script's env vars, proto-escaped. Set here rather than on the process that +# execs nsjail: the loader honours `LD_PRELOAD` before the jail exists. +{ENVARS} mount { src: "/bin" diff --git a/backend/windmill-worker/nsjail/run.csharp.config.proto b/backend/windmill-worker/nsjail/run.csharp.config.proto index c624e55c43..cf2d4f7f0e 100644 --- a/backend/windmill-worker/nsjail/run.csharp.config.proto +++ b/backend/windmill-worker/nsjail/run.csharp.config.proto @@ -15,6 +15,9 @@ clone_newuser: {CLONE_NEWUSER} skip_setsid: true keep_caps: false keep_env: true +# The script's env vars, proto-escaped. Set here rather than on the process that +# execs nsjail: the loader honours `LD_PRELOAD` before the jail exists. +{ENVARS} mount_proc: true mount { diff --git a/backend/windmill-worker/nsjail/run.docker.config.proto b/backend/windmill-worker/nsjail/run.docker.config.proto index a8e327927e..8ba41e947d 100644 --- a/backend/windmill-worker/nsjail/run.docker.config.proto +++ b/backend/windmill-worker/nsjail/run.docker.config.proto @@ -9,7 +9,7 @@ disable_rl: true cwd: {WORKDIR} -clone_newnet: false +clone_newnet: {CLONE_NEWNET} clone_newuser: {CLONE_NEWUSER} skip_setsid: true diff --git a/backend/windmill-worker/nsjail/run.duckdb.config.proto b/backend/windmill-worker/nsjail/run.duckdb.config.proto new file mode 100644 index 0000000000..426f3d583d --- /dev/null +++ b/backend/windmill-worker/nsjail/run.duckdb.config.proto @@ -0,0 +1,145 @@ +name: "duckdb run" + +mode: ONCE +hostname: "duckdb" +log_level: ERROR +# The executor's own job timeout kills the jail; nsjail adds none of its own. +time_limit: 0 + +# DuckDB bounds its memory through memory_limit, and the worker bounds its disk use (see the +# work dir mount). rlimit_as would break it: jemalloc reserves address space well past what it +# ever touches. +disable_rl: true + +cwd: "/tmp" + +clone_newnet: false +clone_newuser: {CLONE_NEWUSER} + +skip_setsid: true +keep_caps: false +keep_env: true +mount_proc: true + +mount { + src: "/lib" + dst: "/lib" + is_bind: true +} + +mount { + src: "/lib64" + dst: "/lib64" + is_bind: true + mandatory: false +} + +mount { + src: "/usr" + dst: "/usr" + is_bind: true +} + +mount { + src: "/proc/self/fd" + dst: "/dev/fd" + is_symlink: true + mandatory: false +} + +mount { + src: "/dev/null" + dst: "/dev/null" + is_bind: true + rw: true +} + +mount { + src: "/dev/random" + dst: "/dev/random" + is_bind: true +} + +mount { + src: "/dev/urandom" + dst: "/dev/urandom" + is_bind: true +} + +# DuckDB sizes its thread pool from the cgroup's CPU quota. +mount { + src: "/sys/fs/cgroup" + dst: "/sys/fs/cgroup" + is_bind: true + mandatory: false +} + +mount { + src: "/etc" + dst: "/etc" + is_bind: true +} + +# Container runtimes bind exactly these 3 files as separate submounts over +# /etc; nsjail's ro remount of /etc is non-recursive so they stay writable. +# Load-bearing -- do not remove as redundant with the /etc bind above. +mount { + src: "/etc/resolv.conf" + dst: "/etc/resolv.conf" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hosts" + dst: "/etc/hosts" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hostname" + dst: "/etc/hostname" + is_bind: true + mandatory: false +} + +{TMP_MOUNT_BLOCK} + +# At its host path, so every path the worker wrote into the blocks (Postgres root +# certificates) resolves unchanged. Read-only: the one writable place on the host is the +# runner's work dir below, which the worker caps at DUCKDB_MAX_TEMP_DIRECTORY_SIZE. +mount { + src: "{JOB_DIR}" + dst: "{JOB_DIR}" + is_bind: true +} + +# The spill directory and the result file. +mount { + src: "{JOB_DIR}/duckdb" + dst: "{JOB_DIR}/duckdb" + is_bind: true + rw: true +} + +mount { + src: "{RUNNER_PATH}" + dst: "/tmp/windmill_duckdb_runner" + is_bind: true +} + +# Read-only: the worker installs into it before the jail starts, so a job can load what it +# named but can neither add to nor replace what the next job loads. +mount { + src: "{EXTENSION_DIR}" + dst: "/tmp/.duckdb/extensions" + is_bind: true + mandatory: false +} + +{BIGQUERY_CREDENTIALS_MOUNT} + +envar: "HOME=/tmp" + +#{DEV} diff --git a/backend/windmill-worker/nsjail/run.go.config.proto b/backend/windmill-worker/nsjail/run.go.config.proto index 1e6de06c9c..691707b22f 100644 --- a/backend/windmill-worker/nsjail/run.go.config.proto +++ b/backend/windmill-worker/nsjail/run.go.config.proto @@ -15,6 +15,9 @@ clone_newuser: {CLONE_NEWUSER} skip_setsid: true keep_caps: false keep_env: true +# The script's env vars, proto-escaped. Set here rather than on the process that +# execs nsjail: the loader honours `LD_PRELOAD` before the jail exists. +{ENVARS} mount { src: "/bin" diff --git a/backend/windmill-worker/nsjail/run.java.config.proto b/backend/windmill-worker/nsjail/run.java.config.proto index 9ae43f5b52..b53758c3a3 100644 --- a/backend/windmill-worker/nsjail/run.java.config.proto +++ b/backend/windmill-worker/nsjail/run.java.config.proto @@ -15,6 +15,9 @@ clone_newuser: {CLONE_NEWUSER} skip_setsid: true keep_caps: false keep_env: true +# The script's env vars, proto-escaped. Set here rather than on the process that +# execs nsjail: the loader honours `LD_PRELOAD` before the jail exists. +{ENVARS} mount_proc: true mount { diff --git a/backend/windmill-worker/nsjail/run.nu.config.proto b/backend/windmill-worker/nsjail/run.nu.config.proto index 6c517a5b39..91174f920c 100644 --- a/backend/windmill-worker/nsjail/run.nu.config.proto +++ b/backend/windmill-worker/nsjail/run.nu.config.proto @@ -15,6 +15,9 @@ clone_newuser: {CLONE_NEWUSER} skip_setsid: true keep_caps: false keep_env: true +# The script's env vars, proto-escaped. Set here rather than on the process that +# execs nsjail: the loader honours `LD_PRELOAD` before the jail exists. +{ENVARS} mount_proc: true mount { diff --git a/backend/windmill-worker/nsjail/run.php.config.proto b/backend/windmill-worker/nsjail/run.php.config.proto index 6910cbe1a0..3156d6a71c 100644 --- a/backend/windmill-worker/nsjail/run.php.config.proto +++ b/backend/windmill-worker/nsjail/run.php.config.proto @@ -15,6 +15,9 @@ clone_newuser: {CLONE_NEWUSER} skip_setsid: true keep_caps: false keep_env: true +# The script's env vars, proto-escaped. Set here rather than on the process that +# execs nsjail: the loader honours `LD_PRELOAD` before the jail exists. +{ENVARS} mount_proc: true mount { diff --git a/backend/windmill-worker/nsjail/run.python3.config.proto b/backend/windmill-worker/nsjail/run.python3.config.proto index 269ae1f0f8..6b18f6d7fa 100644 --- a/backend/windmill-worker/nsjail/run.python3.config.proto +++ b/backend/windmill-worker/nsjail/run.python3.config.proto @@ -12,7 +12,7 @@ rlimit_nofile: 10000 cwd: "/tmp" -clone_newnet: false +clone_newnet: {CLONE_NEWNET} clone_newuser: {CLONE_NEWUSER} skip_setsid: true @@ -62,6 +62,8 @@ mount { is_bind: true } +{MODULE_MOUNTS} + mount { src: "{JOB_DIR}/loader.py" dst: "/tmp/loader.py" diff --git a/backend/windmill-worker/nsjail/run.r.config.proto b/backend/windmill-worker/nsjail/run.r.config.proto index bc4d74bcba..7384a4eb3b 100644 --- a/backend/windmill-worker/nsjail/run.r.config.proto +++ b/backend/windmill-worker/nsjail/run.r.config.proto @@ -8,12 +8,15 @@ disable_rl: true cwd: "/tmp" -clone_newnet: false +clone_newnet: {CLONE_NEWNET} clone_newuser: {CLONE_NEWUSER} skip_setsid: true keep_caps: false keep_env: true +# The script's env vars, proto-escaped. Set here rather than on the process that +# execs nsjail: the loader honours `LD_PRELOAD` before the jail exists. +{ENVARS} # mount_proc: true mount { diff --git a/backend/windmill-worker/nsjail/run.ruby.config.proto b/backend/windmill-worker/nsjail/run.ruby.config.proto index 2527e785b6..f1a642e0b8 100644 --- a/backend/windmill-worker/nsjail/run.ruby.config.proto +++ b/backend/windmill-worker/nsjail/run.ruby.config.proto @@ -15,6 +15,9 @@ clone_newuser: {CLONE_NEWUSER} skip_setsid: true keep_caps: false keep_env: true +# The script's env vars, proto-escaped. Set here rather than on the process that +# execs nsjail: the loader honours `LD_PRELOAD` before the jail exists. +{ENVARS} # mount_proc: true mount { diff --git a/backend/windmill-worker/nsjail/run.rust.config.proto b/backend/windmill-worker/nsjail/run.rust.config.proto index 070a76e997..2b60847f98 100644 --- a/backend/windmill-worker/nsjail/run.rust.config.proto +++ b/backend/windmill-worker/nsjail/run.rust.config.proto @@ -15,6 +15,9 @@ clone_newuser: {CLONE_NEWUSER} skip_setsid: true keep_caps: false keep_env: true +# The script's env vars, proto-escaped. Set here rather than on the process that +# execs nsjail: the loader honours `LD_PRELOAD` before the jail exists. +{ENVARS} mount { src: "/bin" diff --git a/backend/windmill-worker/src/ai/tools.rs b/backend/windmill-worker/src/ai/tools.rs index 9ff1815ad4..01b90922ff 100644 --- a/backend/windmill-worker/src/ai/tools.rs +++ b/backend/windmill-worker/src/ai/tools.rs @@ -19,6 +19,7 @@ use std::{collections::HashMap, sync::Arc, time::Duration}; use uuid::Uuid; use windmill_ai::{ai_types::OpenAIToolCall, query_builder::StreamEventSink, types::*}; use windmill_common::jobs::JobPayload; +use windmill_common::variables::untrusted_arg_interpolates; #[cfg(feature = "mcp")] use windmill_mcp::McpClient; @@ -153,6 +154,8 @@ pub async fn execute_tool_calls( final_events_str, ) .await?; + } else if tool.module.is_some() && interpolating_args(tool_call) { + refuse_tool_call(&mut ctx, tool_call, &mut messages, final_events_str).await?; } else if tool.module.is_some() { let mut batch = vec![(tool_call, tool)]; while let Some(next_call) = calls.peek() { @@ -168,6 +171,9 @@ pub async fn execute_tool_calls( }) else { break; }; + if interpolating_args(next_call) { + break; + } batch.push((calls.next().unwrap(), next_tool)); } execute_windmill_tools(&mut ctx, &batch, actions, &mut messages, final_events_str) @@ -189,6 +195,45 @@ pub async fn execute_tool_calls( Ok((messages, final_content, used_structured_output_tool)) } +/// Whether the model wrote an argument the tool's job would interpolate with the agent's token, +/// which may be an identity it runs on behalf of. Only the step's own input transforms may name +/// variables and resources; the model's arguments are text from whoever talks to it. +fn interpolating_args(tool_call: &OpenAIToolCall) -> bool { + // Empty for a tool called without arguments, which runs as `{}`. + let args = &tool_call.function.arguments; + !args.is_empty() && untrusted_arg_interpolates(args) +} + +async fn refuse_tool_call( + ctx: &mut ToolExecutionContext<'_>, + tool_call: &OpenAIToolCall, + messages: &mut Vec<OpenAIMessage>, + final_events_str: &mut String, +) -> Result<(), Error> { + let error_msg = "Tool call refused: its arguments could not be read, or reference variables, \ + resources or reserved names ($var:, $jsonvar:, $res:, $encrypted:, $WM_)" + .to_string(); + messages.push(OpenAIMessage { + role: "tool".to_string(), + content: Some(OpenAIContent::Text(error_msg.clone())), + tool_call_id: Some(tool_call.id.clone()), + ..Default::default() + }); + if let Some(stream_event_processor) = ctx.stream_event_processor { + let event = StreamingEvent::ToolResult { + call_id: tool_call.id.clone(), + function_name: tool_call.function.name.clone(), + result: error_msg.clone(), + success: false, + }; + stream_event_processor.send(event, final_events_str).await?; + } + let (content, extras) = windmill_tool_row(tool_call, false, &error_msg); + let agent_job_id = ctx.job.id; + add_tool_message_to_chat(ctx, Some(agent_job_id), &content, false, Some(extras)).await; + Ok(()) +} + /// Execute an MCP tool call async fn execute_mcp_tool_call( ctx: &mut ToolExecutionContext<'_>, @@ -533,6 +578,9 @@ async fn enqueue_windmill_tool( &ctx.job.workspace_id, ctx.preserve_step_tags, job_payload.tag.as_deref(), + windmill_queue::tags::dev_flow_tag_suffix(&ctx.job.workspace_id, &ctx.job.tag, ctx.db) + .await? + .as_deref(), ); if tool_tag.as_deref().is_some_and(tag_reads_flow_expr) { append_logs( @@ -618,10 +666,8 @@ async fn enqueue_windmill_tool( // narrows that further. windmill_common::scopes::intersect_job_token_scopes( ctx.job.job_token_scopes.as_deref(), - windmill_common::scopes::step_job_token_scopes( - tool_module.job_token_scopes.as_deref(), - ) - .as_deref(), + windmill_common::scopes::step_job_token_scopes(tool_module.job_token_scopes.as_deref()) + .as_deref(), ) .as_deref(), ) @@ -1051,7 +1097,7 @@ async fn add_tool_message_to_chat( #[cfg(test)] mod tests { - use super::{extract_ai_agent_output, windmill_tool_row}; + use super::{extract_ai_agent_output, interpolating_args, windmill_tool_row}; use serde_json::value::RawValue; use windmill_ai::ai_types::{OpenAIFunction, OpenAIToolCall}; @@ -1088,6 +1134,21 @@ mod tests { ); } + #[test] + fn a_model_written_reference_is_caught_however_it_is_spelled() { + let call = |arguments: &str| OpenAIToolCall { + id: "call_1".to_string(), + function: OpenAIFunction { name: "t".to_string(), arguments: arguments.to_string() }, + r#type: "function".to_string(), + extra_content: None, + }; + assert!(interpolating_args(&call(r#"{"x":"$var:u/a/s"}"#))); + assert!(interpolating_args(&call( + r#"{"x":{"y":["\u0024res:u/a/r"]}}"# + ))); + assert!(!interpolating_args(&call(r#"{"x":"$5 and var: 3"}"#))); + } + #[test] fn extracts_only_the_output_of_an_agent_result() { let envelope = RawValue::from_string( diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index 1b889aea34..6456a85bdf 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -260,6 +260,49 @@ fn keep_authored_memory_id( } } +/// A step's own `model` and `reasoning_effort` inputs replace the provider's, which is how a step +/// linked to a saved agent picks them per run. An empty model keeps the provider's, since a run +/// needs one; an empty effort is no effort, which is how a model that reasons only on request is +/// turned off. A reasoning token is per model, so a different model without an effort of its own +/// runs at its default. +/// Only an input the step authored counts: the arguments of an agent run as a tool also carry +/// whatever the calling model sent, which must not choose the model it runs on. +fn override_provider_model( + provider: &mut ProviderWithResource, + local_args: &HashMap<String, Box<RawValue>>, + step_input_transforms: &HashMap<String, InputTransform>, +) { + let authored = |key: &str| { + step_input_transforms + .get(key) + .filter(|t| { + matches!( + t, + InputTransform::Javascript { .. } | InputTransform::Static { .. } + ) + }) + .map(|t| match t { + // A static null is not evaluated for a tool, which leaves the caller's argument + // in its place. + InputTransform::Static { value } if value.get() == "null" => String::new(), + _ => local_args + .get(key) + .and_then(|v| serde_json::from_str::<String>(v.get()).ok()) + .map(|s| s.trim().to_string()) + .unwrap_or_default(), + }) + }; + if let Some(model) = authored("model").filter(|m| !m.is_empty()) { + if model != provider.model { + provider.model = model; + provider.reasoning_effort = None; + } + } + if let Some(effort) = authored("reasoning_effort") { + provider.reasoning_effort = Some(effort).filter(|e| !e.is_empty()); + } +} + /// Reconciles the step's history inputs, the agent's memory policy and the run's memory id. A step /// holds one of two shapes: an older `auto` or `manual` memory, read as the editor that wrote it /// meant it, or the current setting plus the step's own history inputs. Also returns lines for the @@ -854,6 +897,7 @@ pub async fn handle_ai_agent_job( }; keep_authored_memory_id(&mut args, &step_input_transforms); + override_provider_model(&mut args.provider, &local_args, &step_input_transforms); // Nesting is capped at flow → agent → nested agent. When this job is itself a nested tool, // a linked resource's tool set may still contain AIAgent or AIDecision tools (the editor can't @@ -997,8 +1041,7 @@ pub async fn handle_ai_agent_job( Ok(Some(hub_script.schema)) } else { let hash = get_latest_hash_for_path( - db, - db, + &mut *db.acquire().await?, &job.workspace_id, path.as_str(), true, @@ -2940,6 +2983,70 @@ mod tests { assert!(bounded_prompt_cache_key(&long).len() <= 64); } + #[test] + fn step_model_and_effort_replace_the_providers() { + use serde_json::json; + let run = |step: serde_json::Value| { + let mut provider: ProviderWithResource = serde_json::from_value(json!({ + "kind": "openai", + "resource": {}, + "model": "saved-model", + "reasoning_effort": "high" + })) + .unwrap(); + let local_args: HashMap<String, Box<RawValue>> = serde_json::from_value(step).unwrap(); + let authored = local_args + .keys() + .map(|key| { + ( + key.clone(), + InputTransform::Javascript { expr: String::new() }, + ) + }) + .collect(); + override_provider_model(&mut provider, &local_args, &authored); + (provider.model, provider.reasoning_effort) + }; + let saved = ("saved-model".to_string(), Some("high".to_string())); + + // An argument the step did not author is the calling model's, on an agent run as a tool. + let mut provider: ProviderWithResource = serde_json::from_value( + json!({ "kind": "openai", "resource": {}, "model": "saved-model" }), + ) + .unwrap(); + let sent = serde_json::from_value(json!({ "model": "other" })).unwrap(); + override_provider_model(&mut provider, &sent, &HashMap::new()); + assert_eq!(provider.model, "saved-model"); + let null = HashMap::from([( + "model".to_string(), + InputTransform::Static { value: to_raw_value(&serde_json::Value::Null) }, + )]); + override_provider_model(&mut provider, &sent, &null); + assert_eq!(provider.model, "saved-model"); + + assert_eq!(run(json!({})), saved); + assert_eq!(run(json!({ "model": "" })), saved); + assert_eq!(run(json!({ "model": "saved-model" })), saved); + // An effort the step sets to nothing is no effort, not the saved one. + assert_eq!( + run(json!({ "reasoning_effort": null })), + ("saved-model".to_string(), None) + ); + assert_eq!( + run(json!({ "reasoning_effort": "low" })), + ("saved-model".to_string(), Some("low".to_string())) + ); + // The saved effort names a level of the saved model, so it does not follow to another one. + assert_eq!( + run(json!({ "model": "other" })), + ("other".to_string(), None) + ); + assert_eq!( + run(json!({ "model": "other", "reasoning_effort": "low" })), + ("other".to_string(), Some("low".to_string())) + ); + } + #[test] fn overlay_tool_inputs_binds_matching_flowmodule_tool_only() { fn js(expr: &str) -> InputTransform { diff --git a/backend/windmill-worker/src/ansible_executor.rs b/backend/windmill-worker/src/ansible_executor.rs index b577097c9d..0613032678 100644 --- a/backend/windmill-worker/src/ansible_executor.rs +++ b/backend/windmill-worker/src/ansible_executor.rs @@ -2098,7 +2098,7 @@ fi nsjail_cmd .current_dir(job_dir) .env_clear() - .envs(PROXY_ENVS.clone()) + .envs(crate::job_proxy_envs()) // inject PYTHONPATH here - for some reason I had to do it in nsjail conf .envs(reserved_variables) .env("PATH", PATH_ENV.as_str()) diff --git a/backend/windmill-worker/src/bash_executor.rs b/backend/windmill-worker/src/bash_executor.rs index 2da8f8d73f..7ec2f4fd33 100644 --- a/backend/windmill-worker/src/bash_executor.rs +++ b/backend/windmill-worker/src/bash_executor.rs @@ -39,15 +39,16 @@ const NSJAIL_CONFIG_RUN_BASH_CONTENT: &str = include_str!("../nsjail/run.bash.co use crate::handle_child::run_future_with_polling_update_job_poller; use crate::{ + check_no_network_jailed, common::{ build_args_map, build_command_with_isolation, get_reserved_variables, raw_to_string, read_file, read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, - get_proxy_envs_for_lang, + get_proxy_envs_for_lang, get_run_proxy_envs, handle_child::handle_child, - is_sandboxing_enabled, DISABLE_NUSER, HOME_ENV, NSJAIL_AVAILABLE, NSJAIL_PATH, PATH_ENV, - TRACING_PROXY_CA_CERT_PATH, + is_sandboxing_enabled, nsjail_clone_newnet, DISABLE_NUSER, HOME_ENV, NSJAIL_AVAILABLE, + NSJAIL_PATH, PATH_ENV, TRACING_PROXY_CA_CERT_PATH, }; use windmill_common::client::AuthedClient; use windmill_common::scripts::ScriptLang; @@ -94,6 +95,7 @@ pub async fn handle_bash_job( if let Some(image) = windmill_common::worker::BashAnnotations::sandbox_image(content) { return crate::docker_v2::handle_docker_v2_job( &image, + annotation.no_network, mem_peak, canceled_by, job, @@ -115,6 +117,13 @@ pub async fn handle_bash_job( // `ssh_target` resource instead of on this worker. The OSS build returns a // clear "enterprise feature" error from the stub. if let Some(ssh_path) = windmill_common::worker::BashAnnotations::ssh_target(content) { + if annotation.no_network { + return Err(Error::ExecutionErr( + "Script has the `no_network` annotation, which a remote `#ssh` host cannot \ + honor. Refusing to run it with network." + .to_string(), + )); + } return crate::ssh_executor_oss::handle_ssh_bash_job( &ssh_path, mem_peak, @@ -139,6 +148,19 @@ 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); + + // Use nsjail if globally enabled OR if script has #sandbox annotation + let nsjail = (is_sandboxing_enabled() || annotation.sandbox) && is_regular_job; + check_no_network_jailed(annotation.no_network, nsjail)?; + let mut logs1 = "\n\n--- BASH CODE EXECUTION ---\n".to_string(); if annotation.docker { logs1.push_str("docker mode\n"); @@ -146,6 +168,9 @@ pub async fn handle_bash_job( if annotation.sandbox { logs1.push_str("sandbox mode (nsjail)\n"); } + if annotation.no_network { + logs1.push_str("no network\n"); + } append_logs(&job.id, &job.workspace_id, logs1, &conn).await; write_file(job_dir, "main.sh", &format!("set -e\n{content}"))?; @@ -204,6 +229,7 @@ exit $exit_status let mut reserved_variables = get_reserved_variables(job, &client.token, conn, parent_runnable_path).await?; reserved_variables.insert("RUST_LOG".to_string(), "info".to_string()); + crate::strip_no_network_token(&mut reserved_variables, annotation.no_network); let args = build_args_map(job, client, conn).await?.map(Json); let job_args = if args.is_some() { @@ -237,18 +263,6 @@ exit $exit_status vec![] }; - // 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); - - // Use nsjail if globally enabled OR if script has #sandbox annotation - let nsjail = (is_sandboxing_enabled() || annotation.sandbox) && is_regular_job; let child = if nsjail { let nsjail_timeout = resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; @@ -258,6 +272,7 @@ exit $exit_status &NSJAIL_CONFIG_RUN_BASH_CONTENT .replace("{JOB_DIR}", job_dir) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace("{CLONE_NEWNET}", nsjail_clone_newnet(annotation.no_network)) .replace("{SHARED_MOUNT}", shared_mount) .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) @@ -281,12 +296,13 @@ exit $exit_status .env_clear() .envs(reserved_variables) .envs( - get_proxy_envs_for_lang( + get_run_proxy_envs( &ScriptLang::Bash, job.kind, &job.id, &job.workspace_id, conn, + annotation.no_network, ) .await?, ) diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index de33a2b9e6..0094328927 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -18,18 +18,21 @@ use windmill_parser_ts::{remove_pinned_import_specifiers, remove_pinned_imports} use windmill_queue::{append_logs, CanceledBy, MiniPulledJob, PrecomputedAgentInfo}; use crate::{ + check_no_network_jailed, common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, parse_npm_config, read_file, read_file_content, read_result, resolve_nsjail_timeout, - resolve_nsjail_tmp_mount_block, start_child_process, write_file_binary, MaybeLock, - OccupancyMetrics, StreamNotifier, DEV_CONF_NSJAIL, + resolve_nsjail_tmp_mount_block, resolve_nsjail_tmpfs_size_bytes, script_envars, + start_child_process, write_file_binary, MaybeLock, OccupancyMetrics, StreamNotifier, + DEV_CONF_NSJAIL, }, - get_proxy_envs_for_lang, + get_proxy_envs_for_lang, get_run_proxy_envs, handle_child::handle_child, - is_sandboxing_enabled, read_ee_registry_with_workspace_override, BUNFIG_INSTALL_SCOPES, - BUN_BUNDLE_CACHE_DIR, BUN_CACHE_DIR, BUN_INSTALL_MIN_RELEASE_AGE, BUN_NO_CACHE, BUN_PATH, - DISABLE_NUSER, HOME_ENV, NODE_BIN_PATH, NODE_PATH, NPMRC, NPM_CONFIG_REGISTRY, NPM_PATH, - NSJAIL_AVAILABLE, NSJAIL_PATH, PATH_ENV, PROXY_ENVS, TRACING_PROXY_CA_CERT_PATH, TZ_ENV, + is_sandboxing_enabled, nsjail_clone_newnet, read_ee_registry_with_workspace_override, + BUNFIG_INSTALL_SCOPES, BUN_BUNDLE_CACHE_DIR, BUN_CACHE_DIR, BUN_INSTALL_MIN_RELEASE_AGE, + BUN_NO_CACHE, BUN_PATH, DISABLE_BUN_INSTALL_SANDBOX, DISABLE_NUSER, HOME_ENV, NODE_BIN_PATH, + NODE_PATH, NPMRC, NPM_CONFIG_REGISTRY, NPM_PATH, NSJAIL_AVAILABLE, NSJAIL_PATH, PATH_ENV, + PROXY_ENVS, TRACING_PROXY_CA_CERT_PATH, TZ_ENV, }; use windmill_common::{ cache, @@ -38,7 +41,7 @@ use windmill_common::{ min_version::MIN_VERSION_SUPPORTS_BUN_LOCKFILE_V2, scripts::{id_to_codebase_info, CodebaseInfo, ScriptHash, ScriptLang}, utils::WarnAfterExt, - workspace_dependencies::WorkspaceDependenciesPrefetched, + workspace_dependencies::{clean_lock_from_annotations, WorkspaceDependenciesPrefetched}, }; use windmill_types::s3::BundleFormat; @@ -73,6 +76,7 @@ pub const RELATIVE_BUN_LOADER: &str = include_str!("../loader.bun.windows.js"); pub const RELATIVE_BUN_BUILDER: &str = include_str!("../loader_builder.bun.js"); const NSJAIL_CONFIG_RUN_BUN_CONTENT: &str = include_str!("../nsjail/run.bun.config.proto"); +const NSJAIL_CONFIG_INSTALL_BUN_CONTENT: &str = include_str!("../nsjail/install.bun.config.proto"); pub const BUN_LOCK_SPLIT: &str = "\n//bun.lock\n"; pub const BUN_LOCKB_SPLIT: &str = "\n//bun.lockb\n"; @@ -731,6 +735,474 @@ registry = {} Ok(()) } +/// An nsjail bind mount of `path` onto itself, skipped when the host path is missing. +/// Empty for a path that is not absolute or that the proto string cannot carry verbatim. +fn optional_bind_mount(path: &str, rw: bool) -> String { + if !path.starts_with('/') || path.contains(['"', '\\', '\n', '\r']) { + return String::new(); + } + format!( + "mount {{\n src: \"{path}\"\n dst: \"{path}\"\n is_bind: true\n rw: {rw}\n mandatory: false\n}}\n" + ) +} + +/// node-gyp's header cache under HOME. Kept across jailed installs like the package +/// caches: without it every native build downloads the headers again, which a worker +/// with no internet access cannot. +const NODE_GYP_CACHE: &str = ".cache/node-gyp"; + +/// The install jail's HOME: a private tmpfs at the worker's HOME path, with the +/// package manager's user config read-only and the caches kept there writable (npm's +/// `~/.npm` in npm mode, node-gyp's headers in both). +fn install_home_mount_block(home: &str, npm_mode: bool, tmpfs_size_bytes: &str) -> String { + let home = home.trim_end_matches('/'); + let mut block = String::new(); + // A HOME under /tmp is already private: the jail's /tmp is. + if home.starts_with('/') + && home != "/tmp" + && !home.starts_with("/tmp/") + && !home.contains(['"', '\\', '\n', '\r']) + { + block.push_str(&format!( + "mount {{\n dst: \"{home}\"\n fstype: \"tmpfs\"\n rw: true\n options: \"size={tmpfs_size_bytes}\"\n}}\n" + )); + } + block.push_str(&optional_bind_mount(&format!("{home}/.npmrc"), false)); + block.push_str(&optional_bind_mount(&format!("{home}/.bunfig.toml"), false)); + block.push_str(&optional_bind_mount( + &format!("{home}/{NODE_GYP_CACHE}"), + true, + )); + if npm_mode { + block.push_str(&optional_bind_mount(&format!("{home}/.npm"), true)); + } + block +} + +/// An `.npmrc` value as npm reads it: `${VAR}` from the install env, and a leading `~/` +/// as HOME. Without it, a `cafile=~/…` or `${HOME}/…` is never mounted and the jail's +/// private HOME hides the certificate. +fn expand_npmrc_value(value: &str, home: &str, envs: &HashMap<String, String>) -> String { + let mut out = String::new(); + let mut rest = value; + while let Some(start) = rest.find("${") { + let Some(len) = rest[start..].find('}') else { + break; + }; + out.push_str(&rest[..start]); + let name = rest[start + 2..start + len].trim_end_matches('?'); + out.push_str(envs.get(name).map(String::as_str).unwrap_or_default()); + rest = &rest[start + len + 1..]; + } + out.push_str(rest); + match out.strip_prefix("~/") { + Some(p) => format!("{}/{p}", home.trim_end_matches('/')), + None => out, + } +} + +/// TLS files the install is pointed at (CA bundles, registry client certificates) live +/// outside the jail's mounts: bind them in, read-only, so private registries keep working. +fn install_ca_mounts(job_dir: &str, home: &str, envs: &HashMap<String, String>) -> String { + const CA_ENVS: [&str; 4] = [ + "NODE_EXTRA_CA_CERTS", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "NPM_CONFIG_CAFILE", + ]; + // Also registry-scoped (`//host/:certfile=`), hence the match on the last segment. + const TLS_FILE_KEYS: [&str; 3] = ["cafile", "certfile", "keyfile"]; + let from_env = crate::worker::WHITELIST_ENVS + .iter() + .filter(|(k, _)| CA_ENVS.iter().any(|c| c.eq_ignore_ascii_case(k))) + .map(|(_, v)| v.trim().to_string()); + // `.npmrc` lines and bunfig's `[install] cafile = "…"` share the `key = value` shape. + let from_config = [ + format!("{job_dir}/{BUN_NPMRC_FILE}"), + format!("{home}/.npmrc"), + format!("{job_dir}/{BUN_CONFIG_FILE}"), + format!("{home}/.bunfig.toml"), + ] + .into_iter() + .filter_map(|f| fs::read_to_string(f).ok()) + .flat_map(|content| { + content + .lines() + .filter_map(|l| { + let (k, v) = l.split_once('=')?; + let key = k.trim().rsplit(':').next()?; + TLS_FILE_KEYS + .iter() + .any(|t| t.eq_ignore_ascii_case(key)) + .then(|| expand_npmrc_value(v.trim().trim_matches(['"', '\'']), home, envs)) + }) + .collect::<Vec<_>>() + }); + from_env + .chain(from_config) + .filter(|p| !p.is_empty()) + .unique() + .map(|p| optional_bind_mount(&p, false)) + .collect() +} + +/// The files `bun install` reads from, and writes to, the directory it installs in. +#[cfg(unix)] +const BUN_INSTALL_INPUTS: [&str; 3] = ["package.json", "bun.lock", "bun.lockb"]; +const BUN_INSTALL_OUTPUTS: [&str; 3] = ["node_modules", "bun.lock", "bun.lockb"]; +/// What the worker reads or mounts from the job dir after an install that ran in it. +const BUN_JOB_DIR_OUTPUTS: [&str; 5] = [ + "node_modules", + "package.json", + "bun.lock", + "bun.lockb", + "package-lock.json", +]; + +/// Whether an install left `name` at `path`, refusing anything but a real directory +/// (node_modules) or regular file there: the install jail could write it, and the worker +/// follows what it then reads, renames or mounts. +fn check_install_output(path: &str, name: &str) -> Result<bool> { + let expect_dir = name == "node_modules"; + match fs::symlink_metadata(path) { + Ok(m) if (expect_dir && m.is_dir()) || (!expect_dir && m.is_file()) => Ok(true), + Ok(_) => Err(error::Error::ExecutionErr(format!( + "dependency install left an unexpected {name}: expected a regular {}", + if expect_dir { "directory" } else { "file" } + ))), + Err(_) => Ok(false), + } +} + +/// Registry configuration bun reads from the install dir. It can hold an auth token, so +/// a staged install gets it bind-mounted read-only from the job dir instead of a copy in +/// the cache, which every later install jail mounts. +const BUN_REGISTRY_FILES: [&str; 2] = [BUN_CONFIG_FILE, BUN_NPMRC_FILE]; + +/// Open a directory without following a symlink at `path`. +#[cfg(unix)] +fn open_dir_nofollow(path: &str) -> std::io::Result<fs::File> { + use std::os::unix::fs::OpenOptionsExt; + fs::OpenOptions::new() + .read(true) + .custom_flags(nix::libc::O_DIRECTORY | nix::libc::O_NOFOLLOW) + .open(path) +} + +/// Delete entry `name` of the directory open as `dir`, off the async worker thread: a +/// staging dir can hold a whole node_modules. A symlink there is removed, never followed. +#[cfg(unix)] +fn remove_entry_detached(dir: Arc<fs::File>, name: String) { + use std::os::fd::AsRawFd; + let remove = move || { + let path = format!("/proc/self/fd/{}/{name}", dir.as_raw_fd()); + let _ = match fs::symlink_metadata(&path) { + Ok(m) if m.is_dir() => fs::remove_dir_all(&path), + Ok(_) => fs::remove_file(&path), + Err(e) => Err(e), + }; + }; + match tokio::runtime::Handle::try_current() { + Ok(rt) => { + rt.spawn_blocking(remove); + } + Err(_) => remove(), + } +} + +/// A directory for one jailed bun install inside the bun cache, removed when dropped. +/// +/// The jail binds the job dir and the cache as two mounts, and link(2) fails across +/// mounts, so bun would copy every file out of the cache instead of hardlinking it. +/// Installing here and renaming node_modules into the job dir keeps the hardlinks. +/// +/// Every install jail can write the cache, so any path through it may since have become a +/// symlink. The worker reaches this dir only through descriptors opened without following +/// links before the jail runs ([`Self::entry`]); `path` is for the jail config alone. +struct BunStagingDir { + #[cfg(unix)] + root: Arc<fs::File>, + #[cfg(unix)] + dir: fs::File, + #[cfg(unix)] + name: String, + path: String, +} + +impl BunStagingDir { + /// Entry `f` of the staging dir, resolved through its descriptor. + fn entry(&self, f: &str) -> String { + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + format!("/proc/self/fd/{}/{f}", self.dir.as_raw_fd()) + } + #[cfg(not(unix))] + format!("{}/{f}", self.path) + } +} + +#[cfg(unix)] +impl Drop for BunStagingDir { + fn drop(&mut self) { + remove_entry_detached(self.root.clone(), std::mem::take(&mut self.name)); + } +} + +/// How long a staging dir may outlive its install before the next one deletes it: a +/// worker killed mid-install leaves it behind. Well past the longest install. +#[cfg(unix)] +const BUN_STAGING_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(6 * 60 * 60); + +/// Whether a bun install needs nothing of the job dir beyond [`BUN_INSTALL_INPUTS`]: no +/// dependency by path, no workspaces or patches, and no root lifecycle script, which bun +/// runs next to the manifest and which may read the job's other files. +#[cfg(any(unix, test))] +fn bun_install_can_stage(package_json: &str, lock: Option<&str>) -> bool { + const DEP_SECTIONS: [&str; 6] = [ + "dependencies", + "devDependencies", + "optionalDependencies", + "peerDependencies", + "overrides", + "resolutions", + ]; + const ROOT_SCRIPTS: [&str; 6] = [ + "preinstall", + "install", + "postinstall", + "preprepare", + "prepare", + "postprepare", + ]; + const LOCAL_SPECS: [&str; 8] = [ + "file:", + "link:", + "workspace:", + "portal:", + "./", + "../", + "/", + "~/", + ]; + fn has_local_spec(v: &serde_json::Value) -> bool { + match v { + serde_json::Value::String(s) => LOCAL_SPECS.iter().any(|p| s.trim().starts_with(p)), + serde_json::Value::Object(m) => m.values().any(has_local_spec), + _ => false, + } + } + let Ok(serde_json::Value::Object(pkg)) = serde_json::from_str(package_json) else { + return false; + }; + let root_script = pkg + .get("scripts") + .and_then(|s| s.as_object()) + .is_some_and(|s| ROOT_SCRIPTS.iter().any(|k| s.contains_key(*k))); + if root_script + || pkg.contains_key("workspaces") + || pkg.contains_key("patchedDependencies") + || DEP_SECTIONS + .iter() + .any(|k| pkg.get(*k).is_some_and(has_local_spec)) + { + return false; + } + // Every text lockfile has a root `"workspaces"` entry, so only these mark local + // packages: as a spec (`"file:x"`) or in a resolution (`"x@file:x"`). + const LOCK_LOCAL_PROTOCOLS: [&str; 4] = ["file:", "link:", "workspace:", "portal:"]; + !lock.is_some_and(|l| { + l.contains("\"patchedDependencies\"") + || LOCK_LOCAL_PROTOCOLS + .iter() + .any(|p| l.contains(&format!("\"{p}")) || l.contains(&format!("@{p}"))) + }) +} + +/// None when node_modules could not be renamed from the cache into the job dir (another +/// filesystem, or another mount of the same one): moving it back would be a copy, which +/// is what installing in the job dir costs anyway. Also None when +/// [`bun_install_can_stage`] says the install needs the job dir. +fn bun_staging_dir(cache_dir: &str, job_dir: &str) -> Option<BunStagingDir> { + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + let package_json = fs::read_to_string(format!("{job_dir}/package.json")).ok()?; + let lock = fs::read_to_string(format!("{job_dir}/bun.lock")).ok(); + if !bun_install_can_stage(&package_json, lock.as_deref()) { + return None; + } + // `cache_dir` is the jail's mount point, which no jail can replace; what is under + // it can be anything. + let root_path = format!("{cache_dir}/.wm-staging"); + if fs::symlink_metadata(&root_path).is_ok_and(|m| !m.is_dir()) { + fs::remove_file(&root_path).ok()?; + } + let _ = fs::create_dir(&root_path); + let root = Arc::new(open_dir_nofollow(&root_path).ok()?); + let root_fd = format!("/proc/self/fd/{}", root.as_raw_fd()); + for e in fs::read_dir(&root_fd).ok()?.flatten() { + let stale = e + .metadata() + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.elapsed().ok()) + .is_some_and(|age| age > BUN_STAGING_MAX_AGE); + if stale { + remove_entry_detached(root.clone(), e.file_name().to_string_lossy().into_owned()); + } + } + let name = Uuid::new_v4().to_string(); + fs::create_dir(format!("{root_fd}/{name}")).ok()?; + let dir = open_dir_nofollow(&format!("{root_fd}/{name}")).ok()?; + let path = format!("{root_path}/{name}"); + let staging = BunStagingDir { root, dir, name, path }; + // A jail sharing the cache can list the new dir and plant a symlink in it before + // the worker writes there: every file is created new, never opened as found. + let create_new = |path: &str| { + fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + }; + // The move back is a rename, which is the only test that holds: two mounts of + // one filesystem share st_dev and still refuse it. + let probe = staging.entry(".rename-probe"); + let probe_dst = format!("{job_dir}/.wm-staging-probe-{}", Uuid::new_v4()); + create_new(&probe).ok()?; + let renamed = fs::rename(&probe, &probe_dst).is_ok(); + let _ = fs::remove_file(&probe_dst); + if !renamed { + return None; + } + for f in BUN_INSTALL_INPUTS { + let src = format!("{job_dir}/{f}"); + if fs::symlink_metadata(&src).is_ok_and(|m| m.is_file()) { + let mut out = create_new(&staging.entry(f)).ok()?; + std::io::copy(&mut fs::File::open(&src).ok()?, &mut out).ok()?; + } + } + Some(staging) + } + #[cfg(not(unix))] + { + let _ = (cache_dir, job_dir); + None + } +} + +/// A fetch npm 12 refuses by default but npm 11 made. Windmill still allows it, for now, when +/// the install's config does not set the key either way. +#[derive(Clone, Copy)] +enum NpmFetchFallback { + Git, + Remote, +} + +impl NpmFetchFallback { + fn key(self) -> &'static str { + match self { + Self::Git => "allow-git", + Self::Remote => "allow-remote", + } + } + + fn error_code(self) -> &'static str { + match self { + Self::Git => "EALLOWGIT", + Self::Remote => "EALLOWREMOTE", + } + } + + fn what(self) -> &'static str { + match self { + Self::Git => "git dependencies", + Self::Remote => "dependency tarballs hosted outside the configured registry", + } + } +} + +/// The job-dir folder npm writes its debug log to (`--logs-dir`). The fallback reads a refusal +/// from there, so the install's output keeps streaming to the job log instead of being held. +const NPM_LOGS_DIR: &str = ".wm-npm-logs"; +const NPM_LOG_READ_LIMIT: u64 = 16 * 1024 * 1024; +const NPM_REFUSED_SPECS_SHOWN: usize = 20; + +/// The fetch a failed `npm install` was refused, read from the debug log npm wrote to +/// `logs_dir`. Called once the install has exited, so no jailed process can still swap these +/// paths; a symlink is never followed. +fn npm_refused_fetch(logs_dir: &str) -> Option<(NpmFetchFallback, Vec<String>)> { + use std::io::{BufRead, Read}; + if !fs::symlink_metadata(logs_dir).ok()?.is_dir() { + return None; + } + let log = fs::read_dir(logs_dir) + .ok()? + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_ok_and(|t| t.is_file())) + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|x| x == "log")) + .max()?; + let log = fs::File::open(log).ok()?.take(NPM_LOG_READ_LIMIT); + parse_npm_refused_fetch(std::io::BufReader::new(log).lines().map_while(|l| l.ok())) +} + +/// npm debug log lines read `<n> error <message>`. +fn parse_npm_refused_fetch( + lines: impl Iterator<Item = String>, +) -> Option<(NpmFetchFallback, Vec<String>)> { + let mut fallback = None; + let mut refused = vec![]; + for line in lines { + let Some(msg) = line + .split_once(' ') + .and_then(|(_, m)| m.strip_prefix("error ")) + else { + continue; + }; + if let Some(code) = msg.strip_prefix("code ") { + fallback = fallback.or([NpmFetchFallback::Git, NpmFetchFallback::Remote] + .into_iter() + .find(|f| f.error_code() == code.trim())); + } else if let Some(spec) = msg.strip_prefix("Refusing to fetch ") { + if refused.len() < NPM_REFUSED_SPECS_SHOWN { + refused.push(spec.trim().to_string()); + } + } + } + fallback.map(|f| (f, refused)) +} + +/// Whether npm config sets `key` explicitly, from any source npm reads (global, user, project, +/// env, including files redirected by `npm_config_userconfig` / `globalconfig`): `npm config +/// ls` lists only those. A check that fails counts as set, so it never relaxes the policy. +async fn npm_config_sets(key: &str, job_dir: &str, envs: &HashMap<String, String>) -> bool { + let output = Command::new(&*NPM_PATH) + .current_dir(job_dir) + .env_clear() + .envs(envs) + // The config can set `json` or `long`: JSON output hides an explicit value from + // `npm_config_ls_sets`, and `long` also lists npm's defaults. + .args(["config", "ls", "--json=false", "--long=false"]) + .stdin(Stdio::null()) + .kill_on_drop(true) + .output(); + match tokio::time::timeout(std::time::Duration::from_secs(30), output).await { + Ok(Ok(o)) if o.status.success() => { + npm_config_ls_sets(&String::from_utf8_lossy(&o.stdout), key) + } + _ => true, + } +} + +/// An overridden value is listed commented out (`; key = "v" ; overridden by …`): still set. +fn npm_config_ls_sets(config_ls: &str, key: &str) -> bool { + let prefix = format!("{key} = "); + config_ls + .lines() + .any(|l| l.trim_start_matches("; ").starts_with(&prefix)) +} + pub async fn install_bun_lockfile( mem_peak: &mut i32, canceled_by: &mut Option<CanceledBy>, @@ -744,9 +1216,34 @@ pub async fn install_bun_lockfile( occupancy_metrics: &mut Option<&mut OccupancyMetrics>, quiet: bool, ) -> Result<()> { - let mut child_cmd = Command::new(if npm_mode { &*NPM_PATH } else { &*BUN_PATH }); - - let mut args = vec!["install", "--save-text-lockfile"]; + let package_manager = if npm_mode { &*NPM_PATH } else { &*BUN_PATH }; + // The install runs the lifecycle scripts of dependencies (npm >= 12: those its allowScripts + // policy covers; bun: its default trusted list and `trustedDependencies`), so when + // sandboxing is on it is confined under nsjail like the run step. The env is the same + // either way. + let sandboxed = is_sandboxing_enabled() && !*DISABLE_BUN_INSTALL_SANDBOX; + if is_sandboxing_enabled() && *DISABLE_BUN_INSTALL_SANDBOX { + static WARNED: std::sync::Once = std::sync::Once::new(); + WARNED.call_once(|| { + tracing::warn!( + "DISABLE_BUN_INSTALL_SANDBOX is set: Bun and npm dependency installs run \ + outside nsjail, with their lifecycle scripts unconfined" + ) + }); + } + let home = common_bun_proc_envs + .get("HOME") + .cloned() + .unwrap_or_else(|| HOME_ENV.clone()); + let npm_logs_dir = format!("{job_dir}/{NPM_LOGS_DIR}"); + let npm_logs_dir_flag = format!("--logs-dir={npm_logs_dir}"); + let mut args = vec!["install"]; + // A bun flag: npm 12 rejects unknown cli flags instead of warning. + if !npm_mode { + args.push("--save-text-lockfile"); + } else { + args.push(&npm_logs_dir_flag); + } let no_cache = !npm_mode && *BUN_NO_CACHE; @@ -754,19 +1251,7 @@ pub async fn install_bun_lockfile( args.push("--no-cache"); } - child_cmd - .current_dir(job_dir) - .env_clear() - .envs(PROXY_ENVS.clone()) - .envs(common_bun_proc_envs) - .envs(&*crate::worker::WHITELIST_ENVS) - .args(args) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - #[cfg(windows)] - child_cmd.env("SystemRoot", SYSTEM_ROOT.as_str()); - + let mut registry_env = None; let mut npm_logs = if npm_mode { "NPM mode\n".to_string() } else if no_cache { @@ -828,7 +1313,7 @@ pub async fn install_bun_lockfile( } )); - child_cmd.env("NPM_CONFIG_REGISTRY", custom_registry); + registry_env = Some(custom_registry.to_string()); write_file(job_dir, ".npmrc", content)?; true } else { @@ -849,47 +1334,221 @@ pub async fn install_bun_lockfile( gen_bunfig(job_dir, job_id, w_id, db).await?; } - let child_process = start_child_process(child_cmd, &*BUN_PATH, false).await?; - if let Some(db) = db { - let mut quiet_buf = String::new(); - let result = handle_child( - job_id, - db, - mem_peak, - canceled_by, - child_process, - false, - worker_name, - w_id, - "bun install", - None, - false, - occupancy_metrics, - if quiet { Some(&mut quiet_buf) } else { None }, - None, - ) - .warn_after_seconds_for(10, "bun install") - .await; - if quiet && result.is_err() { - // On failure, flush suppressed install output so the user can diagnose - append_logs( - job_id, - w_id, - format!("\n--- BUN INSTALL (failed) ---\n{quiet_buf}"), - db, - ) - .await; + let make_cmd = |extra_args: &[String]| { + let mut cmd = if sandboxed { + let mut cmd = Command::new(NSJAIL_PATH.as_str()); + cmd.args(["--config", "install.config.proto", "--", package_manager]); + cmd + } else { + Command::new(package_manager) + }; + cmd.current_dir(job_dir) + .env_clear() + .envs(PROXY_ENVS.clone()) + .envs(&common_bun_proc_envs) + .envs(&*crate::worker::WHITELIST_ENVS); + if let Some(registry) = registry_env.as_ref() { + cmd.env("NPM_CONFIG_REGISTRY", registry); } - result?; + cmd.args(&args) + .args(extra_args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(windows)] + cmd.env("SystemRoot", SYSTEM_ROOT.as_str()); + cmd + }; + let install_envs: HashMap<String, String> = make_cmd(&[]) + .as_std() + .get_envs() + .filter_map(|(k, v)| Some((k.to_str()?.to_string(), v?.to_str()?.to_string()))) + .collect(); + + let mut staging = None; + if sandboxed { + // Bind-mounted into the jail, so they must exist before nsjail builds the mount + // tree. + let _ = std::fs::create_dir_all(format!("{home}/{NODE_GYP_CACHE}")); + if npm_mode { + let _ = std::fs::create_dir_all(format!("{home}/.npm")); + } else { + std::fs::create_dir_all(&*BUN_CACHE_DIR)?; + staging = bun_staging_dir(&BUN_CACHE_DIR, job_dir); + } + let install_dir = staging.as_ref().map_or(job_dir, |s| s.path.as_str()); + let bind = |src: &str, dst: &str, rw: bool| { + format!( + "mount {{\n src: \"{src}\"\n dst: \"{dst}\"\n is_bind: true\n rw: {rw}\n}}\n" + ) + }; + // npm never reads the bun cache, and its install scripts get no write to it. + let mut install_mounts = if npm_mode { + String::new() + } else { + bind(&BUN_CACHE_DIR, &BUN_CACHE_DIR, true) + }; + match staging.as_ref() { + // After the cache bind: these land inside it. + Some(s) => { + for f in BUN_REGISTRY_FILES { + let src = format!("{job_dir}/{f}"); + if fs::symlink_metadata(&src).is_ok_and(|m| m.is_file()) { + install_mounts.push_str(&bind(&src, &format!("{}/{f}", s.path), false)); + } + } + } + None => install_mounts.push_str(&bind(job_dir, job_dir, true)), + } + let nsjail_timeout = match db { + Some(conn) => resolve_nsjail_timeout(conn, w_id, *job_id, None).await, + // no job to bound it: the caller waits for the install unbounded too + None => "0".to_string(), + }; + write_file( + job_dir, + "install.config.proto", + &NSJAIL_CONFIG_INSTALL_BUN_CONTENT + .replace("{TIMEOUT}", &nsjail_timeout) + .replace("{INSTALL_MOUNTS}", &install_mounts) + .replace("{INSTALL_DIR}", install_dir) + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace( + "{HOME_MOUNT_BLOCK}", + &install_home_mount_block( + &home, + npm_mode, + &resolve_nsjail_tmpfs_size_bytes().await, + ), + ) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) + .replace( + "{CA_MOUNT}", + &install_ca_mounts(job_dir, &home, &install_envs), + ) + .replace("#{DEV}", DEV_CONF_NSJAIL) + .replace( + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, + ), + )?; + } + + let child_executable = if sandboxed { + NSJAIL_PATH.as_str() } else { - let output = Box::into_pin(child_process.wait_with_output()).await?; - if !output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(error::Error::ExecutionErr(format!( - "bun install exited with non-zero status: {:?}\nstdout:\n{stdout}\nstderr:\n{stderr}", - output.status - ))); + package_manager.as_str() + }; + let mut fallback_flags: Vec<String> = vec![]; + let result = loop { + if npm_mode { + // Only this attempt's debug log may decide the fallback. + let _ = tokio::fs::remove_dir_all(&npm_logs_dir).await; + } + let child_process = + start_child_process(make_cmd(&fallback_flags), child_executable, false).await?; + let result = if let Some(db) = db { + let mut quiet_buf = String::new(); + let result = handle_child( + job_id, + db, + mem_peak, + canceled_by, + child_process, + // jailed when sandboxing is on, so mem sampling finds the real process + sandboxed, + worker_name, + w_id, + "bun install", + None, + false, + occupancy_metrics, + if quiet { Some(&mut quiet_buf) } else { None }, + None, + ) + .warn_after_seconds_for(10, "bun install") + .await; + if quiet && result.is_err() { + // On failure, flush suppressed install output so the user can diagnose + append_logs( + job_id, + w_id, + format!("\n--- BUN INSTALL (failed) ---\n{quiet_buf}"), + db, + ) + .await; + } + result.map(|_| ()) + } else { + let output = Box::into_pin(child_process.wait_with_output()).await?; + if output.status.success() { + Ok(()) + } else { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + Err(error::Error::ExecutionErr(format!( + "bun install exited with non-zero status: {:?}\nstdout:\n{stdout}\nstderr:\n{stderr}", + output.status + ))) + } + }; + + if npm_mode && result.is_err() { + if let Some((fallback, refused)) = npm_refused_fetch(&npm_logs_dir) { + let flag = format!("--{}=all", fallback.key()); + if !fallback_flags.contains(&flag) + && !npm_config_sets(fallback.key(), job_dir, &install_envs).await + { + let key = fallback.key(); + let msg = format!( + "\nWARNING: npm refused {}:\n {}\nRetrying with {key}=all, which Windmill \ + sets by default for now. This default will be removed in a future \ + release: to keep fetching them, set `{key}=all` in the npm config (the \ + npmrc instance or workspace setting on Enterprise Edition, or the \ + worker's ~/.npmrc); set `{key}=none` to stop now.\n", + fallback.what(), + refused.join("\n ") + ); + tracing::warn!(%job_id, w_id, "{}", msg.trim()); + if let Some(db) = db { + append_logs(job_id, w_id, msg, db).await; + } + fallback_flags.push(flag); + continue; + } + } + } + break result; + }; + if npm_mode { + let _ = tokio::fs::remove_dir_all(&npm_logs_dir).await; + } + result?; + + if let Some(staging) = staging { + // Leave the job dir as an install in place would have: what bun wrote or kept + // replaces the job dir's copy, what it removed is removed there too. + for f in BUN_INSTALL_OUTPUTS { + let src = staging.entry(f); + let dst = format!("{job_dir}/{f}"); + match fs::symlink_metadata(&dst) { + Ok(m) if m.is_dir() => tokio::fs::remove_dir_all(&dst).await?, + Ok(_) => tokio::fs::remove_file(&dst).await?, + Err(_) => {} + } + if fs::symlink_metadata(&src).is_ok() { + fs::rename(&src, &dst)?; + // Checked once out of the cache, where no other jail can swap it. + if let Err(e) = check_install_output(&dst, f) { + let _ = fs::remove_file(&dst); + return Err(e); + } + } + } + } else if sandboxed { + // The jail could write the job dir: what the worker reads or mounts from it next + // must still be what an install leaves there. + for f in BUN_JOB_DIR_OUTPUTS { + check_install_output(&format!("{job_dir}/{f}"), f)?; } } @@ -1542,6 +2201,26 @@ pub async fn compute_bundle_local_and_remote_path( input_src.push_str(&path); input_src.push_str(&hash.to_string()); } + // Modules are inlined too, and so is every deployed script they import: those are + // reached through the module files, never through the entrypoint's own imports. + let dir = script_path + .rsplit_once('/') + .map(|(dir, _)| dir) + .unwrap_or(""); + let mut module_entries = modules.into_iter().flatten().collect_vec(); + module_entries.sort_by(|a, b| a.0.cmp(b.0)); + for (key, module) in module_entries { + let Some(stem) = key.strip_suffix(".ts") else { + continue; + }; + let module_path = format!("{dir}/{stem}"); + for (path, hash) in + collect_transitive_import_versions(db, w_id, &module_path, &module.content).await + { + input_src.push_str(&path); + input_src.push_str(&hash.to_string()); + } + } }; // Keep temp-script-ref (preview) bundles in a distinct cache slot: their @@ -1646,6 +2325,36 @@ pub async fn handle_bun_job( .to_string(), )); } + let jailed = is_sandboxing_enabled() || annotation.sandbox; + // Native scripts take their network away inside the isolate instead. + let jail_no_network = annotation.no_network && !annotation.native; + check_no_network_jailed(jail_no_network, jailed)?; + // Unbundled, Bun's run-time loader fetches workspace imports; Node's are bundled on the host. + if jail_no_network + && codebase.is_none() + && !annotation.nodejs + && (annotation.nobundling || *DISABLE_BUNDLING) + { + let script_path = crate::common::use_flow_root_path(job.runnable_path()); + let script_dir = script_path + .rsplit_once('/') + .map(|(dir, _)| dir) + .unwrap_or(""); + let imports = std::iter::once((script_path.clone(), inner_content.as_str())) + .chain(modules.iter().flatten().map(|(key, module)| { + let stem = key.rsplit_once('.').map(|(stem, _)| stem).unwrap_or(key); + (format!("{script_dir}/{stem}"), module.content.as_str()) + })) + .flat_map(|(path, code)| { + windmill_parser_ts::parse_runtime_relative_imports(code, &path).unwrap_or_default() + }); + crate::check_no_network_imports( + imports, + &script_path, + modules.as_ref(), + "Bun without bundling (`//nobundling` or `DISABLE_BUNDLING`)", + )?; + } let (mut has_bundle_cache, cache_logs, local_path, remote_path) = if let (Some(lock), true) = ( maybe_lock.get_lock(), @@ -1772,7 +2481,17 @@ pub async fn handle_bun_job( )); } - write_file(job_dir, "package.json", package_json)?; + if annotation.npm { + // The stored lock leads with `// workspace-dependencies` header lines: bun + // reads package.json leniently, npm refuses it as invalid JSON. + write_file( + job_dir, + "package.json", + &clean_lock_from_annotations(package_json, ScriptLang::Bun), + )?; + } else { + write_file(job_dir, "package.json", package_json)?; + } let bun_lock = if annotation.npm { "" @@ -1896,6 +2615,9 @@ pub async fn handle_bun_job( if annotation.sandbox { init_logs.push_str("sandbox mode (nsjail)\n"); } + if jail_no_network { + init_logs.push_str("no network\n"); + } let write_wrapper_f = async { if !has_bundle_cache && annotation.native { @@ -2106,6 +2828,14 @@ try {{ if (step_id) {{ err["step_id"] = step_id; }} + let job_id = process.env.WM_JOB_ID; + if (job_id) {{ + err["job_id"] = job_id; + }} + let flow_path = process.env.WM_FLOW_PATH; + if (flow_path) {{ + err["flow_path"] = flow_path; + }} const extra = {{}}; Object.getOwnPropertyNames(e).forEach((key) => {{ if (['line', 'name', 'stack', 'column', 'message', 'sourceURL', 'originalLine', 'originalColumn'].includes(key)) {{ @@ -2172,6 +2902,14 @@ try {{ if (step_id) {{ err["step_id"] = step_id; }} + let job_id = process.env.WM_JOB_ID; + if (job_id) {{ + err["job_id"] = job_id; + }} + let flow_path = process.env.WM_FLOW_PATH; + if (flow_path) {{ + err["flow_path"] = flow_path; + }} const extra = {{}}; Object.getOwnPropertyNames(e).forEach((key) => {{ if (['line', 'name', 'stack', 'column', 'message', 'sourceURL', 'originalLine', 'originalColumn'].includes(key)) {{ @@ -2212,7 +2950,8 @@ try {{ && !annotation.nobundling && !*DISABLE_BUNDLING && !codebase.is_some() - && (maybe_lock.get_lock().is_some() || annotation.native); + // A `no_network` run step cannot fetch relative imports, so they resolve while bundling. + && (maybe_lock.get_lock().is_some() || annotation.native || jail_no_network); let write_loader_f = async { if build_cache { @@ -2238,7 +2977,13 @@ try {{ build_loader( job_dir, base_internal_url, - &client.token, + // Bun's run step reads this loader (Node's builds on the host), and could hand + // the token out through its result. + if jail_no_network && !annotation.nodejs { + "" + } else { + &client.token + }, &job.workspace_id, job.runnable_path(), if annotation.nodejs { @@ -2254,11 +2999,12 @@ try {{ } }; - let (reserved_variables, _, _) = tokio::try_join!( + let (mut reserved_variables, _, _) = tokio::try_join!( reserved_variables_args_out_f, write_wrapper_f, write_loader_f )?; + crate::strip_no_network_token(&mut reserved_variables, jail_no_network); // For WAC v2, write checkpoint.json before bun runs if is_wac_v2 { @@ -2471,38 +3217,9 @@ try {{ append_logs(&job.id, &job.workspace_id, init_logs, conn).await; //do not cache local dependencies - let child = if is_sandboxing_enabled() || annotation.sandbox { + let child = if jailed { let nsjail_timeout = resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; - let _ = write_file( - job_dir, - "run.config.proto", - &NSJAIL_CONFIG_RUN_BUN_CONTENT - .replace("{LANG}", if annotation.nodejs { "nodejs" } else { "bun" }) - .replace("{JOB_DIR}", job_dir) - .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) - .replace("{UIDGIDMAP}", if *DISABLE_NUSER { "" } else { - "uidmap {\n inside_id: \"1000\"\n outside_id: \"\"\n count: 1\n}\n\ngidmap {\n inside_id: \"1000\"\n outside_id: \"\"\n count: 1\n}" - }) - .replace( - "{SHARED_MOUNT}", - &shared_mount.replace( - "/tmp/shared", - if annotation.nodejs { - "/tmp/nodejs/shared" - } else { - "/tmp/bun/shared" - }, - ), - ) - .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) - .replace("#{DEV}", DEV_CONF_NSJAIL) - .replace( - "{TMP_MOUNT_BLOCK}", - &resolve_nsjail_tmp_mount_block(job_dir).await, - ) - .replace("{TIMEOUT}", &nsjail_timeout), - )?; let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str()); let args = if annotation.nodejs { @@ -2541,15 +3258,15 @@ try {{ nsjail_cmd .current_dir(job_dir) .env_clear() - .envs(envs) .envs(reserved_variables) .envs( - get_proxy_envs_for_lang( + get_run_proxy_envs( &ScriptLang::Bun, job.kind, &job.id, &job.workspace_id, conn, + jail_no_network, ) .await?, ) @@ -2558,6 +3275,37 @@ try {{ .args(args) .stdout(Stdio::piped()) .stderr(Stdio::piped()); + let _ = write_file( + job_dir, + "run.config.proto", + &NSJAIL_CONFIG_RUN_BUN_CONTENT + .replace("{LANG}", if annotation.nodejs { "nodejs" } else { "bun" }) + .replace("{JOB_DIR}", job_dir) + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace("{CLONE_NEWNET}", nsjail_clone_newnet(jail_no_network)) + .replace("{UIDGIDMAP}", if *DISABLE_NUSER { "" } else { + "uidmap {\n inside_id: \"1000\"\n outside_id: \"\"\n count: 1\n}\n\ngidmap {\n inside_id: \"1000\"\n outside_id: \"\"\n count: 1\n}" + }) + .replace( + "{SHARED_MOUNT}", + &shared_mount.replace( + "/tmp/shared", + if annotation.nodejs { + "/tmp/nodejs/shared" + } else { + "/tmp/bun/shared" + }, + ), + ) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) + .replace("#{DEV}", DEV_CONF_NSJAIL) + .replace( + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, + ) + .replace("{TIMEOUT}", &nsjail_timeout) + .replace("{ENVARS}", &script_envars(&envs, &nsjail_cmd, &[])), + )?; start_child_process(nsjail_cmd, NSJAIL_PATH.as_str(), false).await? } else { let cmd = if annotation.nodejs { @@ -3410,6 +4158,43 @@ pub async fn handle_wac_v2_output( job_ids: serde_json::Map::new(), }); + // Generate resume URLs for the inline approval buttons. The resume_id + // is derived from the step key so each waitForApproval() in the same + // workflow gets a unique resume_job record, and so URLs the workflow + // minted for this step ahead of time (getApprovalUrls) address the + // same one. + 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 approval_token = + windmill_common::variables::approval_token_from_key(&wkey, job.id)?; + + let (resume_url, cancel_url, approval_page_url) = { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + let mut mac = Hmac::<Sha256>::new_from_slice(wkey.as_bytes()) + .map_err(|e| error::Error::internal_err(format!("HMAC key error: {e}")))?; + mac.update(job.id.as_bytes()); + mac.update(resume_id.to_be_bytes().as_ref()); + let signature = hex::encode(mac.finalize().into_bytes()); + + let base_url = (**windmill_common::BASE_URL.load()).clone(); + let w_id = &job.workspace_id; + let job_id = &job.id; + + let resume = format!( + "{base_url}/api/w/{w_id}/jobs_u/resume/{job_id}/{resume_id}/{signature}" + ); + let cancel = format!( + "{base_url}/api/w/{w_id}/jobs_u/cancel/{job_id}/{resume_id}/{signature}" + ); + let approval_page = + format!("{base_url}/approve/{w_id}/{job_id}?token={approval_token}"); + (resume, cancel, approval_page) + }; + let mut tx = db.begin().await?; // Park first: the queue row is locked before the status row, the order every @@ -3484,44 +4269,6 @@ pub async fn handle_wac_v2_output( } } - // Generate resume URLs for the inline approval buttons. The resume_id - // is derived from the step key so each waitForApproval() in the same - // workflow gets a unique resume_job record, and so URLs the workflow - // minted for this step ahead of time (getApprovalUrls) address the - // same one. - let resume_id: u32 = windmill_common::wac::approval_resume_id(&key); - // Generate stateless approval token using shared utility - let approval_token = - windmill_common::variables::generate_approval_token(&job.workspace_id, job.id, db) - .await?; - - let (resume_url, cancel_url, approval_page_url) = { - use hmac::{Hmac, Mac}; - use sha2::Sha256; - use windmill_common::variables::get_workspace_key; - - let wkey = get_workspace_key(&job.workspace_id, db).await?; - let mut mac = Hmac::<Sha256>::new_from_slice(wkey.as_bytes()) - .map_err(|e| error::Error::internal_err(format!("HMAC key error: {e}")))?; - mac.update(job.id.as_bytes()); - mac.update(resume_id.to_be_bytes().as_ref()); - let signature = hex::encode(mac.finalize().into_bytes()); - - let base_url = (**windmill_common::BASE_URL.load()).clone(); - let w_id = &job.workspace_id; - let job_id = &job.id; - - let resume = format!( - "{base_url}/api/w/{w_id}/jobs_u/resume/{job_id}/{resume_id}/{signature}" - ); - let cancel = format!( - "{base_url}/api/w/{w_id}/jobs_u/cancel/{job_id}/{resume_id}/{signature}" - ); - let approval_page = - format!("{base_url}/approve/{w_id}/{job_id}?token={approval_token}"); - (resume, cancel, approval_page) - }; - // Store approval form metadata for the approval page endpoint let mut approval_meta = serde_json::json!({ "key": key, @@ -4440,6 +5187,69 @@ pub async fn start_worker( mod tests { use super::*; + #[test] + fn bun_install_stages_only_self_contained_manifests() { + let lock = r#"{"lockfileVersion": 1, "workspaces": {"": {"dependencies": {"zod": "^4"}}}}"#; + assert!(bun_install_can_stage( + r#"{"dependencies": {"zod": "^4"}}"#, + Some(lock) + )); + for manifest in [ + r#"{"dependencies": {"x": "file:./x"}}"#, + r#"{"dependencies": {"x": "../x"}}"#, + r#"{"overrides": {"y": {"x": "link:x"}}}"#, + r#"{"workspaces": ["packages/*"]}"#, + r#"{"patchedDependencies": {"zod@4.0.0": "patches/zod.patch"}}"#, + r#"{"scripts": {"postinstall": "test -f main.ts"}}"#, + "not json", + ] { + assert!(!bun_install_can_stage(manifest, None), "{manifest}"); + } + let local_lock = r#"{"packages": {"x": ["x@file:x", {}]}}"#; + assert!(!bun_install_can_stage( + r#"{"dependencies": {}}"#, + Some(local_lock) + )); + } + + // Install jails can write the bun cache, so the worker must never follow a path + // through it: neither a staging root nor a staging dir swapped for a symlink may + // lead its cleanup outside the cache. + #[cfg(target_os = "linux")] + #[test] + fn bun_staging_cleanup_does_not_follow_symlinks_out_of_the_cache() { + let tmp = tempfile::tempdir().unwrap(); + let (cache, job, outside) = ( + tmp.path().join("cache"), + tmp.path().join("job"), + tmp.path().join("outside"), + ); + for d in [&cache, &job, &outside.join("victim")] { + fs::create_dir_all(d).unwrap(); + } + fs::write(job.join("package.json"), r#"{"dependencies": {}}"#).unwrap(); + // Old enough for the stale sweep if it were reached through the symlink. + fs::File::open(outside.join("victim")) + .unwrap() + .set_modified(std::time::SystemTime::UNIX_EPOCH) + .unwrap(); + std::os::unix::fs::symlink(&outside, cache.join(".wm-staging")).unwrap(); + + let staging = + bun_staging_dir(cache.to_str().unwrap(), job.to_str().unwrap()).expect("staging dir"); + assert!(outside.join("victim").exists()); + assert!(fs::symlink_metadata(cache.join(".wm-staging")) + .unwrap() + .is_dir()); + + let staged = cache.join(".wm-staging").join(&staging.name); + fs::remove_dir_all(&staged).unwrap(); + std::os::unix::fs::symlink(&outside, &staged).unwrap(); + drop(staging); + assert!(outside.join("victim").exists()); + assert!(fs::symlink_metadata(&staged).is_err()); + } + #[test] fn test_bun_lockfile_version() { assert_eq!(bun_lockfile_version(EMPTY_V1_BUN_LOCK), Some(1)); @@ -4534,6 +5344,37 @@ YmluYXJ5LWNvbnRlbnQ="#; // base64 encoded "binary-content" assert!(!is_binary); } + // Samples are npm 12.2.0 output: a format change there silently turns the fetch fallback + // off, or makes it override an explicit `none`. + #[test] + fn npm_fetch_fallback_reads_npm_output() { + let log = r#"22 verbose exit 1 +23 error code EALLOWGIT +24 error Fetching packages of type "git" have been disabled +25 error Refusing to fetch "wm-git-test@git+file:///work/git/wm-git-test.git" +26 verbose exit 1"#; + let (fallback, refused) = parse_npm_refused_fetch(log.lines().map(str::to_string)) + .expect("EALLOWGIT is a fetch refusal"); + assert_eq!(fallback.key(), "allow-git"); + assert_eq!( + refused, + [r#""wm-git-test@git+file:///work/git/wm-git-test.git""#] + ); + assert!( + parse_npm_refused_fetch(["23 error code EJSONPARSE".to_string()].into_iter()).is_none() + ); + + let config_ls = r#"; "global" config from /usr/etc/npmrc +; allow-git = "none" ; overridden by project +; "project" config from /work/j/.npmrc +allow-remote = "all" +registry = "http://127.0.0.1:4999/" +; npm version = 12.2.0"#; + assert!(npm_config_ls_sets(config_ls, "allow-git")); + assert!(npm_config_ls_sets(config_ls, "allow-remote")); + assert!(!npm_config_ls_sets(config_ls, "allow-scripts")); + } + #[test] fn test_split_lockfile_multiline_package_json() { let lockfile = r#"{ diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index 50af562062..c0ae372e38 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -13,6 +13,7 @@ use sqlx::types::Json; use sqlx::{Pool, Postgres}; use tokio::process::Command; use tokio::{fs::File, io::AsyncReadExt}; +use windmill_common::variables::arg_needs_interpolation; use windmill_common::flows::Step; use windmill_common::global_settings::NSJAIL_TMP_BACKING_DISK; @@ -91,6 +92,16 @@ pub(crate) fn raw_to_string(x: &str) -> String { } } +/// Narrow a JSON integer to the Rust type bound for a SQL argument, erroring the way the +/// database would rather than letting an `as` cast wrap it into a different value. +pub(crate) fn narrow_sql_int<T, N>(n: N, sql_type: &str) -> anyhow::Result<T> +where + T: TryFrom<N>, + N: std::fmt::Display + Copy, +{ + T::try_from(n).map_err(|_| anyhow!("value \"{n}\" is out of range for type {sql_type}")) +} + pub async fn build_args_map<'a>( job: &'a MiniPulledJob, client: &AuthedClient, @@ -173,15 +184,11 @@ pub async fn write_file_binary(dir: &str, path: &str, content: &[u8]) -> error:: Ok(file) } -lazy_static::lazy_static! { - static ref RE_RES_VAR: Regex = Regex::new(r#"\$(?:var|jsonvar|res|encrypted)\:"#).unwrap(); -} - /// Returns true if any value in `vs` contains a `$var:`/`$jsonvar:`/`$res:`/`$encrypted:` /// reference that would need interpolation by `transform_json`. Cheap pre-check that /// callers can use to skip the DB roundtrip + clone path when nothing requires resolution. pub(crate) fn map_needs_resolution(vs: &HashMap<String, Box<RawValue>>) -> bool { - vs.values().any(|v| (*RE_RES_VAR).is_match(v.get())) + vs.values().any(|v| arg_needs_interpolation(v.get())) } pub async fn transform_json<'a>( @@ -194,7 +201,7 @@ pub async fn transform_json<'a>( let mut has_match = false; for (_, v) in vs { let inner_vs = v.get(); - if (*RE_RES_VAR).is_match(inner_vs) { + if arg_needs_interpolation(inner_vs) { has_match = true; break; } @@ -205,7 +212,7 @@ pub async fn transform_json<'a>( let mut r = HashMap::new(); for (k, v) in vs { let inner_vs = v.get(); - if (*RE_RES_VAR).is_match(inner_vs) { + if arg_needs_interpolation(inner_vs) { let value = serde_json::from_str(inner_vs).map_err(|e| { error::Error::internal_err(format!("Error while parsing inner arg: {e:#}")) })?; @@ -232,7 +239,7 @@ pub async fn transform_json_as_values<'a>( let mut r: HashMap<String, serde_json::Value> = HashMap::new(); for (k, v) in vs { let inner_vs = v.get(); - if (*RE_RES_VAR).is_match(inner_vs) { + if arg_needs_interpolation(inner_vs) { let value = serde_json::from_str(inner_vs).map_err(|e| { error::Error::internal_err(format!("Error while parsing inner arg: {e:#}")) })?; @@ -979,12 +986,26 @@ pub fn build_command_with_isolation(program: &str, args: &[&str]) -> Command { } } +/// Under `NSJAIL_GVISOR_COMPAT`, rewrites the config of an nsjail `cmd` for gVisor. A spawn of +/// nsjail that bypasses `start_child_process` must call this itself. +pub fn apply_nsjail_gvisor_compat(cmd: &Command) -> Result<(), Error> { + if *crate::NSJAIL_GVISOR_COMPAT && cmd.as_std().get_program() == crate::NSJAIL_PATH.as_str() { + crate::nsjail_compat::apply(cmd.as_std()).map_err(|e| { + Error::ExecutionErr(format!( + "NSJAIL_GVISOR_COMPAT could not prepare nsjail: {e}" + )) + })?; + } + Ok(()) +} + pub async fn start_child_process( cmd: Command, executable: &str, disable_process_group: bool, ) -> Result<Box<dyn TokioChildWrapper>, Error> { use process_wrap::tokio::*; + apply_nsjail_gvisor_compat(&cmd)?; let mut cmd = TokioCommandWrap::from(cmd); // On Windows, put the child in its own console process group so a CTRL_BREAK_EVENT @@ -1314,6 +1335,84 @@ pub fn render_nsjail_rlimit_as(env_override: Option<&str>, default_mb: u32) -> S } } +/// Quote a string as a protobuf-text-format string literal for safe inclusion in +/// an nsjail config. Every byte is emitted as a printable ASCII char or a valid +/// protobuf escape (`\"`, `\\`, `\n`/`\r`/`\t`, or 3-digit octal `\NNN` for +/// control/non-ASCII bytes), so a hostile value can neither break the literal nor +/// inject further directives. +/// +/// `{` and `}` are escaped too: profiles are rendered by substituting `{PLACEHOLDER}` +/// tokens one after another, so a value that still contained braces could smuggle in a +/// later placeholder's expansion — and `{ENVARS}` expands to unquoted directives. +pub(crate) fn proto_str(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for &b in s.as_bytes() { + match b { + b'"' => out.push_str("\\\""), + b'\\' => out.push_str("\\\\"), + b'\n' => out.push_str("\\n"), + b'\r' => out.push_str("\\r"), + b'\t' => out.push_str("\\t"), + b'{' | b'}' => out.push_str(&format!("\\{b:03o}")), + 0x20..=0x7e => out.push(b as char), + _ => out.push_str(&format!("\\{b:03o}")), + } + } + out.push('"'); + out +} + +/// Render env vars as nsjail `envar:` directives (one per line), the only way +/// caller-controlled env may reach a jailed child. Set on the process that execs +/// nsjail instead, `LD_PRELOAD`/`LD_LIBRARY_PATH`/`LD_AUDIT` would be honoured by +/// the loader for nsjail itself, before any namespace or chroot exists. +pub(crate) fn render_envars<'a>(env: impl IntoIterator<Item = (&'a str, &'a str)>) -> String { + env.into_iter() + .map(|(k, v)| format!("envar: {}", proto_str(&format!("{k}={v}")))) + .collect::<Vec<_>>() + .join("\n") +} + +/// The script's env vars as `envar:` directives for its nsjail run profile. +/// +/// The profile sets `keep_env`, so the child also inherits the launcher's env, and +/// an `envar:` overrides an inherited key. A key the launcher sets therefore keeps +/// the launcher's value, as it did when both were set on the launcher with the +/// script's env first, unless it is listed in `script_overrides` (see +/// [`script_overrides`]). +pub(crate) fn script_envars( + envs: &HashMap<String, String>, + launcher: &Command, + script_overrides: &[&str], +) -> String { + let launcher_keys: HashSet<&std::ffi::OsStr> = + launcher.as_std().get_envs().map(|(k, _)| k).collect(); + render_envars( + envs.iter() + .filter(|(k, _)| { + script_overrides.contains(&k.as_str()) + || !launcher_keys.contains(std::ffi::OsStr::new(k.as_str())) + }) + .map(|(k, v)| (k.as_str(), v.as_str())), + ) +} + +/// The keys an executor sets on its nsjail launcher before the script's env, so the +/// script's value wins, less those `reserved_variables` (applied after the script's env, +/// and carrying worker-group env vars) sets again, so the worker's value wins. +#[cfg(any(feature = "nu", feature = "java", feature = "ruby", feature = "rlang"))] +pub(crate) fn script_overrides<'a>( + set_before: &[&'a str], + reserved_variables: &HashMap<String, String>, +) -> Vec<&'a str> { + set_before + .iter() + .copied() + .filter(|k| !reserved_variables.contains_key(*k)) + .collect() +} + /// Default size (in bytes) of the `/tmp` tmpfs mount inside nsjail sandboxes, /// used when the `nsjail_tmpfs_size_mb` instance setting is unset. pub const DEFAULT_NSJAIL_TMPFS_SIZE_BYTES: u64 = 800_000_000; @@ -2074,15 +2173,18 @@ pub struct StreamNotifier { parent_job: uuid::Uuid, root_job: uuid::Uuid, flow_step_id: Option<String>, + flow_scope: Option<uuid::Uuid>, } // Helper struct to hold parent job information #[derive(Debug)] struct JobInfo { flow_step_id: Option<String>, + flow_scope: Option<Uuid>, step: Option<i32>, len: Option<i32>, is_branch_one: Option<bool>, + is_branch: Option<bool>, next_parent: Option<Uuid>, } @@ -2092,9 +2194,11 @@ async fn get_job_info(db: &DB, job_id: Uuid) -> error::Result<JobInfo> { JobInfo, r#"SELECT flow_step_id, + flow_innermost_root_job as flow_scope, (flow_status->'step')::integer as step, jsonb_array_length(flow_status->'modules') as len, runnable_path ~ '/branchone-\d+$' as is_branch_one, + runnable_path ~ '/branch(one|all)-\d+$' as is_branch, parent_job as next_parent FROM v2_job LEFT JOIN v2_job_status USING (id) @@ -2142,21 +2246,20 @@ async fn check_if_last_step( } // Iterative implementation to avoid stack overflow from async_recursion -// Checks if we're at last step in nested branches AND if any parent's flow_step_id matches early_return_id +// True when the job is the early-return step, or the last step of branches to one that end in it. +// Step ids are only unique within one flow, so an id match counts when the job's innermost root +// is the root flow: a step of a subflow reusing the id has the subflow as its innermost root. async fn check_if_early_return_or_last_in_early_return_parent( db: &DB, mut next_parent: Option<Uuid>, mut step_id: Option<String>, + mut flow_scope: Option<Uuid>, early_return_id: &str, root_job: Uuid, ) -> error::Result<bool> { let mut visited = HashSet::new(); loop { - if step_id.is_none() { - return Ok(false); - } - - let Some(parent_job) = next_parent else { + let (Some(step), Some(parent_job)) = (step_id.as_deref(), next_parent) else { return Ok(false); }; @@ -2165,25 +2268,49 @@ async fn check_if_early_return_or_last_in_early_return_parent( return Ok(false); } - // If the parent's flow_step_id matches early_return_id, we found it! - if step_id.as_deref() == Some(early_return_id) && parent_job == root_job { - return Ok(true); - } else { - let parent_info = get_job_info(db, parent_job).await?; - if let Some(step) = parent_info.step { - let step = Step::from_i32_and_len(step, parent_info.len.unwrap_or(0) as usize); - // we only continue if we are at the last step and the parent is a branch one - if step.is_last_step() && parent_info.is_branch_one.unwrap_or(false) { - next_parent = parent_info.next_parent; - step_id = parent_info.flow_step_id; - continue; - } - } + if step == early_return_id && flow_scope == Some(root_job) { + return only_branches_up_to_root(db, parent_job, root_job).await; + } + if parent_job == root_job { return Ok(false); } + + let parent_info = get_job_info(db, parent_job).await?; + if let Some(parent_step) = parent_info.step { + let parent_step = + Step::from_i32_and_len(parent_step, parent_info.len.unwrap_or(0) as usize); + // we only continue if we are at the last step and the parent is a branch one + if parent_step.is_last_step() && parent_info.is_branch_one.unwrap_or(false) { + next_parent = parent_info.next_parent; + step_id = parent_info.flow_step_id; + flow_scope = parent_info.flow_scope; + continue; + } + } + return Ok(false); } } +// A step in a loop shares the root flow's scope but runs once per iteration, so no single run +// holds the flow's early-return result: every container between it and the root must be a branch. +async fn only_branches_up_to_root(db: &DB, mut job: Uuid, root_job: Uuid) -> error::Result<bool> { + let mut visited = HashSet::new(); + while job != root_job { + if !visited.insert(job) { + return Ok(false); + } + let info = get_job_info(db, job).await?; + if !info.is_branch.unwrap_or(false) { + return Ok(false); + } + let Some(parent) = info.next_parent else { + return Ok(false); + }; + job = parent; + } + Ok(true) +} + impl StreamNotifier { pub fn new(conn: &Connection, job: &MiniPulledJob) -> Option<Self> { let root_job = get_root_job_id(job); @@ -2195,6 +2322,7 @@ impl StreamNotifier { job_id: job.id, root_job, flow_step_id: job.flow_step_id.clone(), + flow_scope: job.flow_innermost_root_job, }), Connection::Http(_) => { tracing::warn!( @@ -2214,6 +2342,7 @@ impl StreamNotifier { job_id: Uuid, root_job: Uuid, flow_step_id: Option<String>, + flow_scope: Option<Uuid>, ) -> Result<(), Error> { // Check if early_return is set at the flow level let early_return_node_id = sqlx::query_scalar!( @@ -2229,26 +2358,33 @@ impl StreamNotifier { .await? .flatten(); - let should_set_stream_job = if let Some(ref early_return_id) = early_return_node_id { + let is_early_return = if let Some(ref early_return_id) = early_return_node_id { check_if_early_return_or_last_in_early_return_parent( &db, Some(parent_job), flow_step_id, + flow_scope, early_return_id, root_job, ) .await? } else { - check_if_last_step(&db, Some(parent_job), root_job).await? + false }; - if should_set_stream_job { + // An early-return step inside a branch that is not taken never runs, so the flow's last + // step streams in its place. One that did run has a leaf job on the root, streamed or not, + // and the last step must not take the stream from it. + let unless_ran = early_return_node_id.filter(|_| !is_early_return); + + if is_early_return || check_if_last_step(&db, Some(parent_job), root_job).await? { sqlx::query!(r#" UPDATE v2_job_status SET flow_status = jsonb_set(flow_status, array['stream_job'], to_jsonb($1::UUID::TEXT)) - WHERE id = $2"#, + WHERE id = $2 AND NOT COALESCE(flow_leaf_jobs ? $3, false)"#, job_id, - root_job + root_job, + unless_ran ) .execute(&db) .await?; @@ -2263,6 +2399,7 @@ impl StreamNotifier { let job_id = self.job_id; let root_job = self.root_job; let flow_step_id = self.flow_step_id.clone(); + let flow_scope = self.flow_scope; tokio::spawn(async move { if let Err(err) = Self::update_flow_status_with_stream_job_inner( db, @@ -2270,6 +2407,7 @@ impl StreamNotifier { job_id, root_job, flow_step_id, + flow_scope, ) .await { @@ -2636,6 +2774,81 @@ mod tests { assert!(result.is_err()); } + + // A profile that loses `{ENVARS}` silently drops the script's env, since a + // `replace` on a missing token does nothing. It must also precede every fixed + // `envar:`, which overrides it the way it overrode the launcher's env. + #[test] + fn script_profiles_take_envars_before_their_fixed_ones() { + for (name, profile) in [ + ("go", include_str!("../nsjail/run.go.config.proto")), + ("rust", include_str!("../nsjail/run.rust.config.proto")), + ("bun", include_str!("../nsjail/run.bun.config.proto")), + ("nu", include_str!("../nsjail/run.nu.config.proto")), + ("php", include_str!("../nsjail/run.php.config.proto")), + ("csharp", include_str!("../nsjail/run.csharp.config.proto")), + ("java", include_str!("../nsjail/run.java.config.proto")), + ("ruby", include_str!("../nsjail/run.ruby.config.proto")), + ("r", include_str!("../nsjail/run.r.config.proto")), + ( + "gem install", + include_str!("../nsjail/download.ruby.config.proto"), + ), + ] { + // Line by line: a Windows checkout gives the profiles CRLF endings. + let lines = profile.lines().collect::<Vec<_>>(); + let at = lines + .iter() + .position(|l| *l == "{ENVARS}") + .unwrap_or_else(|| panic!("{name}: no {{ENVARS}}")); + assert!( + !lines[..at].iter().any(|l| l.starts_with("envar:")), + "{name}: a fixed envar precedes {{ENVARS}}" + ); + } + } + + #[test] + fn script_envars_reach_the_jail_not_the_launcher() { + let envs = HashMap::from([ + ("LD_PRELOAD".to_string(), "/tmp/evil.so".to_string()), + ("WM_TOKEN".to_string(), "forged".to_string()), + ("PATH".to_string(), "/custom/bin".to_string()), + ]); + let mut launcher = Command::new("nsjail"); + launcher + .env_clear() + .env("PATH", "/usr/bin") + .env("WM_TOKEN", "real"); + + let out = script_envars(&envs, &launcher, &["PATH"]); + let mut lines = out.lines().collect::<Vec<_>>(); + lines.sort(); + // A launcher key keeps the launcher's value unless the executor lets the + // script override it. + assert_eq!( + lines, + vec![ + "envar: \"LD_PRELOAD=/tmp/evil.so\"", + "envar: \"PATH=/custom/bin\"", + ] + ); + assert!(launcher + .as_std() + .get_envs() + .all(|(k, _)| k != std::ffi::OsStr::new("LD_PRELOAD"))); + + let hostile = HashMap::from([( + "X".to_string(), + "v\"\nclone_newuser: false {JOB_DIR}".to_string(), + )]); + let line = script_envars(&hostile, &launcher, &[]); + assert_eq!(line.lines().count(), 1, "{line}"); + assert!( + !line.contains('{') && !line.contains("\nclone_newuser"), + "{line}" + ); + } } lazy_static::lazy_static! { diff --git a/backend/windmill-worker/src/csharp_executor.rs b/backend/windmill-worker/src/csharp_executor.rs index 7d77f8703f..87213223e7 100644 --- a/backend/windmill-worker/src/csharp_executor.rs +++ b/backend/windmill-worker/src/csharp_executor.rs @@ -28,7 +28,7 @@ use crate::{ common::{ build_command_with_isolation, check_executor_binary_exists, create_args_and_out_file, get_reserved_variables, read_result, resolve_nsjail_timeout, - resolve_nsjail_tmp_mount_block, start_child_process, DEV_CONF_NSJAIL, + resolve_nsjail_tmp_mount_block, script_envars, start_child_process, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, handle_child::handle_child, @@ -729,28 +729,10 @@ pub async fn handle_csharp_job( let child = if is_sandboxing_enabled() { let nsjail_timeout = resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; - write_file( - job_dir, - "run.config.proto", - &NSJAIL_CONFIG_RUN_CSHARP_CONTENT - .replace("{JOB_DIR}", job_dir) - .replace("{CACHE_DIR}", &*CSHARP_CACHE_DIR) - .replace("{CACHE_HASH}", &hash) - .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) - .replace("{SHARED_MOUNT}", shared_mount) - .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) - .replace("#{DEV}", DEV_CONF_NSJAIL) - .replace( - "{TMP_MOUNT_BLOCK}", - &resolve_nsjail_tmp_mount_block(job_dir).await, - ) - .replace("{TIMEOUT}", &nsjail_timeout), - )?; let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str()); nsjail_cmd .current_dir(job_dir) .env_clear() - .envs(envs) .envs(reserved_variables) .envs( get_proxy_envs_for_lang( @@ -777,6 +759,24 @@ pub async fn handle_csharp_job( #[cfg(windows)] nsjail_cmd.env("SystemRoot", SYSTEM_ROOT.as_str()); + write_file( + job_dir, + "run.config.proto", + &NSJAIL_CONFIG_RUN_CSHARP_CONTENT + .replace("{JOB_DIR}", job_dir) + .replace("{CACHE_DIR}", &*CSHARP_CACHE_DIR) + .replace("{CACHE_HASH}", &hash) + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace("{SHARED_MOUNT}", shared_mount) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) + .replace("#{DEV}", DEV_CONF_NSJAIL) + .replace( + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, + ) + .replace("{TIMEOUT}", &nsjail_timeout) + .replace("{ENVARS}", &script_envars(&envs, &nsjail_cmd, &[])), + )?; start_child_process(nsjail_cmd, NSJAIL_PATH.as_str(), true).await? } else { #[cfg(unix)] diff --git a/backend/windmill-worker/src/dbt_executor.rs b/backend/windmill-worker/src/dbt_executor.rs index 90db1b9795..4db8201e9b 100644 --- a/backend/windmill-worker/src/dbt_executor.rs +++ b/backend/windmill-worker/src/dbt_executor.rs @@ -31,7 +31,7 @@ use windmill_parser_yaml::{ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::common::{ - render_nsjail_rlimit_as, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, + render_envars, render_nsjail_rlimit_as, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, }; use crate::common::{start_child_process, OccupancyMetrics}; use crate::dbt_engine::{provision_engine, ProvisionedEngine, DBT_CACHE_DIR}; @@ -1393,12 +1393,10 @@ pub(crate) async fn prepare_project( // invocation's. Neither may sit on the launcher. .replace( "{ENVARS}", - &jail_envars( + &render_envars( env.iter() - .map(|(k, v)| (k.clone(), v.clone())) - .chain(invocation_env.iter().map(|(k, v)| (k.clone(), v.clone()))) - .collect::<Vec<_>>() - .as_slice(), + .map(|(k, v)| (k.as_str(), v.as_str())) + .chain(invocation_env.iter().map(|(k, v)| (k.as_str(), v.as_str()))), ), ) .replace("{SHARED_MOUNT}", "") @@ -2345,7 +2343,12 @@ pub(crate) fn dbt_command(p: &PreparedProject, args: &[&str]) -> Command { } cmd.current_dir(&p.project_dir) .env_clear() - .envs(PROXY_ENVS.clone()) + // `deps` is a dependency install, which keeps the proxy whatever the group says. + .envs(if args == ["deps"] { + PROXY_ENVS.clone() + } else { + crate::job_proxy_envs() + }) .env("PATH", PATH_ENV.as_str()) .env("TZ", TZ_ENV.as_str()) .env("GIT_PATH", GIT_PATH.as_str()) @@ -2376,16 +2379,6 @@ pub(crate) fn dbt_command(p: &PreparedProject, args: &[&str]) -> Command { cmd } -/// The project's environment, as jail directives rather than launcher -/// environment. `keep_env` passes nsjail's own environment through, so these -/// are added on top of the Windmill-controlled ones the launcher carries. -fn jail_envars(env: &[(String, String)]) -> String { - env.iter() - .map(|(k, v)| format!("envar: \"{}={}\"", escape_textproto(k), escape_textproto(v))) - .collect::<Vec<_>>() - .join("\n") -} - const SANDBOX_PROFILE_NAME: &str = "dbt.nsjail.config.proto"; /// The written nsjail profile, kept OUTSIDE every path the jailed child can @@ -4065,6 +4058,7 @@ pub(crate) async fn run_captured( ) -> error::Result<Captured> { use tokio::io::AsyncReadExt; + crate::common::apply_nsjail_gvisor_compat(&cmd)?; let mut child = cmd .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -5862,7 +5856,7 @@ mod tests { }; } - let envars = jail_envars(&[("LD_PRELOAD".to_string(), hostile.to_string())]); + let envars = render_envars([("LD_PRELOAD", hostile)]); assert_eq!(envars.lines().count(), 1, "{envars}"); assert!(envars.starts_with("envar: \"LD_PRELOAD="), "{envars}"); } diff --git a/backend/windmill-worker/src/deno_executor.rs b/backend/windmill-worker/src/deno_executor.rs index 58388c3cfd..881923f299 100644 --- a/backend/windmill-worker/src/deno_executor.rs +++ b/backend/windmill-worker/src/deno_executor.rs @@ -387,6 +387,14 @@ try {{ if (step_id) {{ err["step_id"] = step_id; }} + let job_id = Deno.env.get("WM_JOB_ID"); + if (job_id) {{ + err["job_id"] = job_id; + }} + let flow_path = Deno.env.get("WM_FLOW_PATH"); + if (flow_path) {{ + err["flow_path"] = flow_path; + }} await Deno.writeTextFile("result.json", JSON.stringify(err)); Deno.exit(1); }} diff --git a/backend/windmill-worker/src/docker_v2.rs b/backend/windmill-worker/src/docker_v2.rs index 52d1060b67..1b02129696 100644 --- a/backend/windmill-worker/src/docker_v2.rs +++ b/backend/windmill-worker/src/docker_v2.rs @@ -32,13 +32,13 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ - build_args_map, get_reserved_variables, raw_to_string, read_and_check_file, - resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, + build_args_map, get_reserved_variables, proto_str, raw_to_string, read_and_check_file, + render_envars, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, - get_proxy_envs_for_lang, + get_run_proxy_envs, handle_child::handle_child, - DISABLE_NUSER, NSJAIL_AVAILABLE, NSJAIL_PATH, SANDBOX_IMAGE_CACHE_MAX_MB, + nsjail_clone_newnet, DISABLE_NUSER, NSJAIL_AVAILABLE, NSJAIL_PATH, SANDBOX_IMAGE_CACHE_MAX_MB, SANDBOX_IMAGE_DEFAULT_REGISTRY, SANDBOX_IMAGE_MAX_SIZE_MB, SANDBOX_IMAGE_PULL_POLICY, SANDBOX_REGISTRY_AUTH, }; @@ -163,36 +163,6 @@ struct OciConfig { working_dir: Option<String>, } -/// Quote a string as a protobuf-text-format string literal for safe inclusion in -/// the nsjail config. Image-controlled values (mount srcs/dsts, symlink targets, -/// WorkingDir) flow into the config, so they MUST be escaped — an unescaped `"` or -/// newline would otherwise let a hostile image config inject arbitrary nsjail -/// directives and break out of the sandbox. Every byte is emitted as a printable -/// ASCII char or a valid protobuf escape (`\"`, `\\`, `\n`/`\r`/`\t`, or 3-digit -/// octal `\NNN` for control/non-ASCII bytes), so the result always parses. -/// -/// `{` and `}` are escaped too: the profile is rendered by substituting `{PLACEHOLDER}` -/// tokens one after another, so a value that still contained braces could smuggle in a -/// later placeholder's expansion — and `{ENVARS}` expands to unquoted directives. -fn proto_str(s: &str) -> String { - let mut out = String::with_capacity(s.len() + 2); - out.push('"'); - for &b in s.as_bytes() { - match b { - b'"' => out.push_str("\\\""), - b'\\' => out.push_str("\\\\"), - b'\n' => out.push_str("\\n"), - b'\r' => out.push_str("\\r"), - b'\t' => out.push_str("\\t"), - b'{' | b'}' => out.push_str(&format!("\\{b:03o}")), - 0x20..=0x7e => out.push(b as char), - _ => out.push_str(&format!("\\{b:03o}")), - } - } - out.push('"'); - out -} - /// Why `./result.json` can't be collected, phrased for the job log. const UNVERIFIABLE_DST: &str = "it cannot be proven to resolve inside the container root (a `..` component, or a \ @@ -270,16 +240,6 @@ fn render_result_mount(job_dir: &str, dst: &str) -> String { ) } -/// Render container env vars as nsjail `envar:` directives (one per line). Each -/// `KEY=VALUE` is proto-escaped, so image-controlled keys/values can neither break -/// the config nor reach nsjail's own process environment. -fn render_envars(env: &[(String, String)]) -> String { - env.iter() - .map(|(k, v)| format!("envar: {}", proto_str(&format!("{k}={v}")))) - .collect::<Vec<_>>() - .join("\n") -} - /// Run `crane` with the optional per-job `DOCKER_CONFIG` auth dir. async fn crane(args: &[&str], auth_dir: Option<&str>) -> Result<std::process::Output, Error> { let mut cmd = Command::new(CRANE_PATH.as_str()); @@ -682,6 +642,7 @@ async fn generate_rootfs_mounts(rootfs: &str) -> Result<String, Error> { #[tracing::instrument(level = "trace", skip_all)] pub async fn handle_docker_v2_job( image: &str, + no_network: bool, mem_peak: &mut i32, canceled_by: &mut Option<CanceledBy>, job: &MiniPulledJob, @@ -711,7 +672,10 @@ pub async fn handle_docker_v2_job( append_logs( &job.id, &job.workspace_id, - format!("\n\n--- SANDBOXED CONTAINER (nsjail) ---\nextracting image {image}...\n"), + format!( + "\n\n--- SANDBOXED CONTAINER (nsjail) ---\n{}extracting image {image}...\n", + if no_network { "no network\n" } else { "" } + ), conn, ) .await; @@ -795,7 +759,7 @@ pub async fn handle_docker_v2_job( if !container_env.iter().any(|(k, _)| k == "HOME") { container_env.push(("HOME".to_string(), "/root".to_string())); } - let envars = render_envars(&container_env); + let envars = render_envars(container_env.iter().map(|(k, v)| (k.as_str(), v.as_str()))); // Render the nsjail profile: dynamic per-entry rootfs binds + image WorkingDir. let nsjail_timeout = resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; @@ -821,6 +785,7 @@ pub async fn handle_docker_v2_job( &NSJAIL_CONFIG_RUN_DOCKER_CONTENT .replace("{TIMEOUT}", &nsjail_timeout) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace("{CLONE_NEWNET}", nsjail_clone_newnet(no_network)) // proto_str-quoted: WorkingDir is image-controlled, must not break out // of the `cwd:` string and inject nsjail directives. .replace("{WORKDIR}", &proto_str(working_dir)) @@ -854,13 +819,15 @@ pub async fn handle_docker_v2_job( "BASE_INTERNAL_URL".to_string(), base_internal_url.to_string(), ); + crate::strip_no_network_token(&mut reserved_variables, no_network); - let proxy_envs = get_proxy_envs_for_lang( + let proxy_envs = get_run_proxy_envs( &ScriptLang::Bash, job.kind, &job.id, &job.workspace_id, conn, + no_network, ) .await?; @@ -926,9 +893,10 @@ pub async fn handle_docker_v2_job( #[cfg(test)] mod tests { use super::{ - digest_key, proto_str, ref_key, registry_qualified, render_envars, render_result_mount, - result_mount_dst, SHADOWED_DST, UNVERIFIABLE_DST, + digest_key, ref_key, registry_qualified, render_result_mount, result_mount_dst, + SHADOWED_DST, UNVERIFIABLE_DST, }; + use crate::common::{proto_str, render_envars}; #[tokio::test] async fn result_mount_dst_targets_working_dir() { @@ -1046,14 +1014,14 @@ mod tests { ("PATH".to_string(), "/usr/bin".to_string()), ("LD_PRELOAD".to_string(), "rootfs/evil.so".to_string()), ]; - let out = render_envars(&env); + let out = render_envars(env.iter().map(|(k, v)| (k.as_str(), v.as_str()))); assert_eq!( out, "envar: \"PATH=/usr/bin\"\nenvar: \"LD_PRELOAD=rootfs/evil.so\"" ); // A value trying to inject extra directives is escaped, not interpreted. let evil = vec![("X".to_string(), "v\"\nclone_newuser: false".to_string())]; - let line = render_envars(&evil); + let line = render_envars(evil.iter().map(|(k, v)| (k.as_str(), v.as_str()))); assert!(line.starts_with("envar: \"")); assert!(!line.contains("\nclone_newuser")); assert!(line.contains("\\n")); diff --git a/backend/windmill-worker/src/duckdb_executor.rs b/backend/windmill-worker/src/duckdb_executor.rs index 513d30d181..0db954d85f 100644 --- a/backend/windmill-worker/src/duckdb_executor.rs +++ b/backend/windmill-worker/src/duckdb_executor.rs @@ -1,10 +1,6 @@ -use std::cell::RefCell; use std::env; -use std::ffi::{c_char, c_uint, CStr, CString}; -use std::ptr::NonNull; use std::sync::{Arc, Mutex}; -use libloading::{Library, Symbol}; use serde::Serialize; use serde_json::value::RawValue; use serde_json::{json, Value}; @@ -14,7 +10,8 @@ use windmill_common::utils::sanitize_string_from_password; use windmill_common::worker::{get_memory, to_raw_value, Connection, SqlResultCollectionStrategy}; use windmill_common::workspaces::{ get_datatable_resource_from_db, get_ducklake_from_db_unchecked, - strip_fork_reserved_attach_args, DatatableAccess, DatatableConnectAs, DucklakeCatalogResourceType, + strip_fork_reserved_attach_args, DatatableAccess, DatatableConnectAs, + DucklakeCatalogResourceType, }; use windmill_common::PgDatabase; use windmill_object_store::S3_PROXY_LAST_ERRORS_CACHE; @@ -24,6 +21,7 @@ use windmill_types::s3::S3Object; use crate::agent_workers::{get_datatable_resource_from_agent_http, get_ducklake_from_agent_http}; use crate::common::{build_args_values, get_reserved_variables, OccupancyMetrics}; +use crate::duckdb_runner::{RequestMode, RunnerMode, RunnerRequest}; use crate::handle_child::run_future_with_polling_update_job_poller; #[cfg(feature = "mysql")] use crate::mysql_executor::MysqlDatabase; @@ -405,23 +403,19 @@ async fn compute_sync_alter_ddl( base_internal_url: String, w_id: String, job_dir: String, + runner: RunnerMode, ) -> Result<String> { - let n = probe_blocks.len(); - let (result, _) = tokio::task::spawn_blocking(move || { - run_duckdb_ffi_safe( - probe_blocks.iter().map(String::as_str), - n, - job_args, - &token, - &base_internal_url, - &w_id, - &job_dir, - SqlResultCollectionStrategy::LastStatementAllRows, - ) - }) + let (result, _) = run_duckdb( + runner, + probe_blocks, + job_args, + token, + base_internal_url, + w_id, + job_dir, + SqlResultCollectionStrategy::LastStatementAllRows, + ) .await - .map_err(|e| Error::from(to_anyhow(e))) - .and_then(|r| r) .map_err(|e| { Error::ExecutionErr(format!( "on_schema_change=sync: the schema pre-pass probe failed ({e}); refusing to write" @@ -1328,6 +1322,7 @@ pub async fn do_duckdb( let token = client.token.clone(); let hidden_passwords = Arc::new(Mutex::new(Vec::<String>::new())); + let runner = crate::duckdb_runner::runner_mode(); let result_f = async { let mut hidden_passwords = hidden_passwords.clone(); @@ -1522,10 +1517,6 @@ pub async fn do_duckdb( probe_blocks, ) .await?; - // The probe opens its own DuckDB connection, so it needs the same - // connection policy as the main query below. - #[cfg(feature = "private")] - let probe_blocks = crate::duckdb_isolation_ee::apply_duckdb_isolation(probe_blocks)?; let alter_ddl = compute_sync_alter_ddl( probe_blocks, job_args.clone(), @@ -1534,6 +1525,7 @@ pub async fn do_duckdb( client.base_internal_url.clone(), job.workspace_id.clone(), job_dir.to_string(), + runner, ) .await?; query = query.replace( @@ -1610,46 +1602,34 @@ pub async fn do_duckdb( ) .await?; - #[cfg(feature = "private")] - let query_block_list = - crate::duckdb_isolation_ee::apply_duckdb_isolation(query_block_list)?; let base_internal_url = client.base_internal_url.clone(); let w_id = job.workspace_id.clone(); let job_dir = job_dir.to_string(); if annotations.prepare { - let result = tokio::task::spawn_blocking(move || { - prepare_duckdb_ffi_safe( - query_block_list.iter().map(String::as_str), - &token, - &base_internal_url, - &w_id, - &job_dir, - ) - }) - .await - .map_err(|e| Error::from(to_anyhow(e))) - .and_then(|r| r)?; - - return Ok(result); + return prepare_duckdb( + runner, + query_block_list, + token.clone(), + base_internal_url, + w_id, + job_dir, + ) + .await; } - let result = tokio::task::spawn_blocking(move || { - run_duckdb_ffi_safe( - query_block_list.iter().map(String::as_str), - query_block_list.len(), - job_args, - &token, - &base_internal_url, - &w_id, - &job_dir, - collection_strategy, - ) - }) - .await - .map_err(|e| Error::from(to_anyhow(e))) - .and_then(|r| r); + let result = run_duckdb( + runner, + query_block_list, + job_args, + token.clone(), + base_internal_url, + w_id, + job_dir, + collection_strategy, + ) + .await; let (result, column_order) = match result { Ok(r) => r, @@ -1812,6 +1792,8 @@ pub async fn do_duckdb( return Err(Error::ExecutionRawError(to_raw_value(&json!({ "message": message, "name": "ExecutionErr", + "flow_path": reserved_variables.get("WM_FLOW_PATH").filter(|p| !p.is_empty()), + "job_id": job.id, "step_id": job.flow_step_id, "data_tests": data_tests, })))); @@ -1857,8 +1839,6 @@ pub async fn do_duckdb( match result { Ok(result) => Ok(result), Err(e) => { - #[cfg(feature = "private")] - let e = crate::duckdb_isolation_ee::explain_error(e); // Passwords might appear in the error message — and, for the // structured data-test failure, in sampled row data read from an // attached database — so every outgoing error is sanitized here. @@ -1903,111 +1883,6 @@ pub async fn do_duckdb( } } -thread_local! { - static DUCKDB_FFI_LIB_SINGLETON: RefCell<*const DuckDbFfiLib> = RefCell::new(std::ptr::null()); -} - -struct DuckDbFfiLib { - run_duckdb_ffi: Symbol< - 'static, - unsafe extern "C" fn( - query_block_list: *const *const c_char, - query_block_list_count: usize, - job_args: *const c_char, - token: *const c_char, - base_internal_url: *const c_char, - w_id: *const c_char, - memory_limit: *const c_char, - temp_directory: *const c_char, - max_result_size: usize, - column_order_ptr: *mut *mut c_char, - collect_last_only: bool, - collect_first_row_only: bool, - ) -> *mut c_char, - >, - prepare_duckdb_ffi: Option< - Symbol< - 'static, - unsafe extern "C" fn( - query_block_list: *const *const c_char, - query_block_list_count: usize, - token: *const c_char, - base_internal_url: *const c_char, - w_id: *const c_char, - memory_limit: *const c_char, - temp_directory: *const c_char, - ) -> *mut c_char, - >, - >, - free_cstr: Symbol<'static, unsafe extern "C" fn(string: *mut c_char) -> ()>, -} - -impl DuckDbFfiLib { - fn get_singleton() -> Result<&'static DuckDbFfiLib> { - DUCKDB_FFI_LIB_SINGLETON.with(|cell| unsafe { - let mut singleton = cell.borrow_mut(); - if singleton.is_null() { - let lib = DuckDbFfiLib::init()?; - let boxed_lib = Box::new(lib); - let lib_ptr = Box::leak(boxed_lib); - *singleton = lib_ptr as *const _; - Ok(NonNull::new_unchecked(*singleton as *mut DuckDbFfiLib).as_ref()) - } else { - Ok(&**singleton) - } - }) - } - fn init() -> Result<Self> { - let lib = unsafe { - Library::new(if cfg!(target_os = "macos") { - "libwindmill_duckdb_ffi_internal.dylib" - } else if cfg!(target_os = "windows") { - "windmill_duckdb_ffi_internal.dll" - } else { - "libwindmill_duckdb_ffi_internal.so" - }) - .map_err(|e| { - Error::InternalErr(format!( - "Could not init duckdb. Make sure you have the latest windmill_duckdb_ffi_lib.{} alongside your binary : https://github.com/windmill-labs/windmill/releases \n{}", - if cfg!(target_os = "macos") { "dylib" } - else if cfg!(target_os = "windows") { "dll" } - else { "so" }, - e.to_string() - )) - })? - }; - - let lib = Box::leak(Box::new(lib)); - - // Version mismatch should only be possible on Windows agent workers - // We check for it because FFI interface mismatch will cause undefined behavior / crashes - unsafe { - let expected_version: c_uint = 3; - let get_version: Symbol<'static, unsafe extern "C" fn() -> c_uint> = - lib.get(b"get_version") - .map_err(|e| return Error::ExecutionErr(format!("Could not find get_version in the duckdb ffi library. If you are not using docker, consider manually upgrading windmill_duckdb_ffi_lib. {}", e.to_string())))?; - let actual_version = get_version(); - if actual_version < expected_version { - return Err(Error::InternalErr( - format!("Incompatible duckdb ffi library version. Expected: {expected_version}, actual: {actual_version}. Please update to the latest windmill_duckdb_ffi_lib."), - )); - } else if actual_version > expected_version { - return Err(Error::InternalErr( - format!("Incompatible duckdb ffi library version. Expected: {expected_version}, actual: {actual_version}. Please upgrade your worker to the latest windmill version."), - )); - } - } - - let prepare_duckdb_ffi = unsafe { lib.get(b"prepare_duckdb_ffi").ok() }; - - Ok(DuckDbFfiLib { - run_duckdb_ffi: unsafe { lib.get(b"run_duckdb_ffi").map_err(to_anyhow)? }, - prepare_duckdb_ffi, - free_cstr: unsafe { lib.get(b"free_cstr").map_err(to_anyhow)? }, - }) - } -} - // 20% headroom for Rust runtime + DuckDB's untracked allocations. Mirrors // DuckDB's own default ratio, but applied to the worker's cgroup budget // instead of host RAM. @@ -2037,123 +1912,29 @@ fn cgroup_bytes_to_duckdb_memory_limit(bytes: i64) -> Option<String> { Some(format!("{}MiB", mib.max(64))) } -// Read backend/windmill-duckdb-ffi-internal/README_DEV.md for details about why we use FFI -// The FFI returns errors as `ERROR <json-encoded-message>` (the message is -// serde_json::to_string'd). Decode the JSON string back so multi-line errors -// render with real newlines, not escaped `\n` inside wrapping quotes; fall back -// to the raw slice if it is somehow not a JSON string. +// The runner reports an engine error as `ERROR <json-encoded-message>`. Decode the JSON string +// back so multi-line errors render with real newlines, not escaped `\n` inside wrapping quotes; +// fall back to the raw slice if it is somehow not a JSON string. fn decode_ffi_error(result_str: &str) -> String { let raw = result_str.strip_prefix("ERROR ").unwrap_or(result_str); - let msg = serde_json::from_str::<String>(raw).unwrap_or_else(|_| raw.to_string()); - // Mirrors the filter the FFI applies to the same variable (`ResourceLimits::from_ffi`); - // they live in separate crates, so the rule is stated twice and must stay one rule. - let configured = env::var("DUCKDB_MAX_TEMP_DIRECTORY_SIZE").is_ok_and(|v| !v.trim().is_empty()); - match spill_cap_hint(&msg, configured) { - Some(hint) => format!("{msg}\n{hint}"), - None => msg, - } + serde_json::from_str::<String>(raw).unwrap_or_else(|_| raw.to_string()) } -/// DuckDB's own wording when a query outgrows the spill cap. Asserted against real engine -/// output by `spilling_past_the_configured_cap_fails_the_query_and_cleans_up` in -/// `windmill-duckdb-ffi-internal`, so an engine bump that rewords it fails there rather than -/// silently dropping the hint. -const SPILL_CAP_ENGINE_MARKER: &str = "This limit was set by the 'max_temp_directory_size' setting"; - -/// Split out of [`decode_ffi_error`] so the branch is testable without mutating the process -/// environment. The engine emits the same text for a configured cap and for its own default, -/// so which limit was hit has to come from the worker's config, not from the message. -fn spill_cap_hint(msg: &str, configured: bool) -> Option<&'static str> { - if !msg.contains(SPILL_CAP_ENGINE_MARKER) { - return None; - } - Some(if configured { - SPILL_CAP_CONFIGURED - } else { - SPILL_CAP_DEFAULT - }) -} - -/// Both hints open by contradicting the engine, whose message ends by suggesting -/// `PRAGMA max_temp_directory_size='10GiB'`: every windmill connection refuses that, since the -/// cap is frozen along with the temp directory, so following it yields a second, -/// unrelated-looking permission error. True either way, because the lock is unconditional. -const SPILL_CAP_CONFIGURED: &str = "\nThe PRAGMA suggested above is refused: this connection's \ - temp directory and the cap on it are frozen, so a script cannot raise the limit. That limit \ - is the worker's DUCKDB_MAX_TEMP_DIRECTORY_SIZE. Spill less (fewer threads, a narrower scan, \ - aggregating before the join), or ask an operator to raise it."; - -/// Nothing configured a cap, so this is DuckDB's own default and the worker is genuinely low on -/// disk. Naming the env var here would send the reader after a knob nobody set. -const SPILL_CAP_DEFAULT: &str = "\nThe PRAGMA suggested above is refused: this connection's temp \ - directory and the cap on it are frozen, so a script cannot raise the limit. No cap is \ - configured on this worker, so that limit is DuckDB's default of 90% of the free space on \ - its temp volume: the worker is low on disk. Free space on it, or spill less (fewer threads, \ - a narrower scan, aggregating before the join)."; - -fn run_duckdb_ffi_safe<'a>( - query_block_list: impl Iterator<Item = &'a str>, +/// Turns what the runner returned into the job result. +fn finish_run( + result_str: String, + column_order: Option<String>, query_block_list_count: usize, - job_args: Vec<Arg>, - token: &str, - base_internal_url: &str, - w_id: &str, - job_dir: &str, collection_strategy: SqlResultCollectionStrategy, ) -> Result<(Box<RawValue>, Option<Vec<String>>)> { - let query_block_list = query_block_list - .map(|s| { - CString::new(s).map_err(|e| { - Error::ExecutionErr(format!("Failed CString conversion: {}", e.to_string())) - }) - }) - .collect::<Result<Vec<_>>>()?; - let query_block_list = query_block_list - .iter() - .map(|s| s.as_ptr()) - .collect::<Vec<_>>(); - let job_args = serde_json::to_string(&job_args).map_err(to_anyhow)?; - - let job_args = CString::new(job_args).map_err(to_anyhow)?; - let token = CString::new(token).map_err(to_anyhow)?; - let base_internal_url = CString::new(base_internal_url).map_err(to_anyhow)?; - let w_id = CString::new(w_id).map_err(to_anyhow)?; - let memory_limit = - CString::new(resolve_duckdb_memory_limit().unwrap_or_default()).map_err(to_anyhow)?; - let temp_directory = CString::new(job_dir).map_err(to_anyhow)?; - - let run_duckdb_ffi = &DuckDbFfiLib::get_singleton()?.run_duckdb_ffi; - let free_cstr = &DuckDbFfiLib::get_singleton()?.free_cstr; - let mut column_order: *mut c_char = std::ptr::null_mut(); - let result_str = unsafe { - let ptr = run_duckdb_ffi( - query_block_list.as_ptr(), - query_block_list_count, - job_args.as_ptr(), - token.as_ptr(), - base_internal_url.as_ptr(), - w_id.as_ptr(), - memory_limit.as_ptr(), - temp_directory.as_ptr(), - *MAX_SQL_RESULT_SIZE, - &mut column_order, - collection_strategy.collect_last_statement_only(query_block_list_count), - collection_strategy.collect_first_row_only(), - ); - let str = CStr::from_ptr(ptr).to_string_lossy().to_string(); - free_cstr(ptr); - str - }; - - let column_order = if column_order.is_null() - || !collection_strategy.collect_last_statement_only(query_block_list_count) - || collection_strategy.collect_scalar() - { - None - } else { - let str = unsafe { CStr::from_ptr(column_order).to_string_lossy().to_string() }; - unsafe { free_cstr(column_order) }; - Some(serde_json::from_str::<Vec<String>>(&str)?) + let column_order = match column_order { + Some(str) + if collection_strategy.collect_last_statement_only(query_block_list_count) + && !collection_strategy.collect_scalar() => + { + Some(serde_json::from_str::<Vec<String>>(&str)?) + } + _ => None, }; if result_str.starts_with("ERROR") { @@ -2171,55 +1952,7 @@ fn run_duckdb_ffi_safe<'a>( } } -fn prepare_duckdb_ffi_safe<'a>( - query_block_list: impl Iterator<Item = &'a str>, - token: &str, - base_internal_url: &str, - w_id: &str, - job_dir: &str, -) -> Result<Box<RawValue>> { - let query_block_list = query_block_list - .map(|s| { - CString::new(s).map_err(|e| { - Error::ExecutionErr(format!("Failed CString conversion: {}", e.to_string())) - }) - }) - .collect::<Result<Vec<_>>>()?; - let query_block_list = query_block_list - .iter() - .map(|s| s.as_ptr()) - .collect::<Vec<_>>(); - - let token = CString::new(token).map_err(to_anyhow)?; - let base_internal_url = CString::new(base_internal_url).map_err(to_anyhow)?; - let w_id = CString::new(w_id).map_err(to_anyhow)?; - let memory_limit = - CString::new(resolve_duckdb_memory_limit().unwrap_or_default()).map_err(to_anyhow)?; - let temp_directory = CString::new(job_dir).map_err(to_anyhow)?; - - let lib = DuckDbFfiLib::get_singleton()?; - let prepare_fn = lib.prepare_duckdb_ffi.as_ref().ok_or_else(|| { - Error::InternalErr( - "prepare_duckdb_ffi not available in duckdb ffi library. Please update to the latest windmill_duckdb_ffi_lib.".to_string(), - ) - })?; - let free_cstr = &lib.free_cstr; - - let result_str = unsafe { - let ptr = prepare_fn( - query_block_list.as_ptr(), - query_block_list.len(), - token.as_ptr(), - base_internal_url.as_ptr(), - w_id.as_ptr(), - memory_limit.as_ptr(), - temp_directory.as_ptr(), - ); - let str = CStr::from_ptr(ptr).to_string_lossy().to_string(); - free_cstr(ptr); - str - }; - +fn finish_prepare(result_str: String) -> Result<Box<RawValue>> { if result_str.starts_with("ERROR") { Err(Error::ExecutionErr(decode_ffi_error(&result_str))) } else { @@ -2227,6 +1960,70 @@ fn prepare_duckdb_ffi_safe<'a>( } } +async fn run_duckdb( + runner: RunnerMode, + query_block_list: Vec<String>, + job_args: Vec<Arg>, + token: String, + base_internal_url: String, + w_id: String, + job_dir: String, + collection_strategy: SqlResultCollectionStrategy, +) -> Result<(Box<RawValue>, Option<Vec<String>>)> { + let n = query_block_list.len(); + let job_args = serde_json::value::to_raw_value(&job_args).map_err(to_anyhow)?; + let memory_limit = resolve_duckdb_memory_limit().unwrap_or_default(); + let out = crate::duckdb_runner::run( + runner, + &RunnerRequest { + mode: RequestMode::Run, + blocks: &query_block_list, + job_args: Some(&job_args), + token: &token, + base_internal_url: &base_internal_url, + w_id: &w_id, + memory_limit: &memory_limit, + temp_directory: &job_dir, + max_result_size: *MAX_SQL_RESULT_SIZE, + collect_last_only: collection_strategy.collect_last_statement_only(n), + collect_first_row_only: collection_strategy.collect_first_row_only(), + }, + &job_dir, + ) + .await?; + finish_run(out.result, out.column_order, n, collection_strategy) +} + +async fn prepare_duckdb( + runner: RunnerMode, + query_block_list: Vec<String>, + token: String, + base_internal_url: String, + w_id: String, + job_dir: String, +) -> Result<Box<RawValue>> { + let memory_limit = resolve_duckdb_memory_limit().unwrap_or_default(); + let out = crate::duckdb_runner::run( + runner, + &RunnerRequest { + mode: RequestMode::Prepare, + blocks: &query_block_list, + job_args: None, + token: &token, + base_internal_url: &base_internal_url, + w_id: &w_id, + memory_limit: &memory_limit, + temp_directory: &job_dir, + max_result_size: 0, + collect_last_only: false, + collect_first_row_only: false, + }, + &job_dir, + ) + .await?; + finish_prepare(out.result) +} + struct ParsedAttachDbResource<'a> { resource_path: &'a str, name: &'a str, @@ -3057,33 +2854,6 @@ mod tests { assert_eq!(decode_ffi_error("ERROR not json"), "not json"); } - /// The engine says the same thing whether the cap came from the worker or is its own - /// default, so the attribution has to come from the config — naming the env var on a - /// worker that never set it would send the reader after the wrong problem. - #[test] - fn spill_cap_hint_attributes_the_limit_to_whoever_set_it() { - let capped = "Out of Memory Error: failed to offload data block of size 256.0 KiB \ - (18.9 MiB/19.0 MiB used).\nThis limit was set by the 'max_temp_directory_size' \ - setting.\nYou can adjust this setting, by using (for example) PRAGMA \ - max_temp_directory_size='10GiB'"; - - let configured = spill_cap_hint(capped, true).expect("the cap error must be recognised"); - assert!(configured.contains("DUCKDB_MAX_TEMP_DIRECTORY_SIZE")); - - let default = spill_cap_hint(capped, false).expect("the cap error must be recognised"); - assert!( - !default.contains("DUCKDB_MAX_TEMP_DIRECTORY_SIZE") && default.contains("low on disk"), - "with nothing configured the hint must point at the disk, not at an unset variable" - ); - - // Both must contradict the PRAGMA the engine suggests, which the lock always refuses. - assert!([configured, default] - .iter() - .all(|h| h.contains("PRAGMA suggested above is refused"))); - - assert!(spill_cap_hint("Binder Error: column \"x\" not found", true).is_none()); - } - fn test_ancestor(wid: &str) -> windmill_common::workspaces::DucklakeAncestorAttach { windmill_common::workspaces::DucklakeAncestorAttach { workspace_id: wid.to_string(), diff --git a/backend/windmill-worker/src/duckdb_runner.rs b/backend/windmill-worker/src/duckdb_runner.rs new file mode 100644 index 0000000000..56b3c1f5cc --- /dev/null +++ b/backend/windmill-worker/src/duckdb_runner.rs @@ -0,0 +1,645 @@ +//! DuckDB jobs run in `windmill_duckdb_runner`, a process of their own, under the same job +//! isolation as every other language: inside nsjail when the worker jails jobs, under +//! `unshare` when it isolates that way, and as a plain child process otherwise. An engine +//! crash or a cancellation then ends the runner, never the worker. +//! +//! The runner hands back the string the engine call produced, so result collection and error +//! decoding stay in `duckdb_executor`. + +use std::{ + collections::HashSet, + path::{Path, PathBuf}, + process::Stdio, + sync::{LazyLock, Mutex}, + time::Duration, +}; + +use serde::Serialize; +use tokio::{ + io::{AsyncRead, AsyncReadExt, AsyncWriteExt}, + process::Command, +}; +use windmill_common::error::{Error, Result}; + +use crate::{ + common::{build_command_with_isolation, resolve_nsjail_tmp_mount_block, DEV_CONF_NSJAIL}, + is_sandboxing_enabled, is_unshare_enabled, DISABLE_NUSER, HOME_ENV, NSJAIL_PATH, PATH_ENV, +}; + +const NSJAIL_CONFIG_RUN_DUCKDB_CONTENT: &str = include_str!("../nsjail/run.duckdb.config.proto"); + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum RunnerMode { + /// Inside nsjail: the jail confines the file system. + Jailed, + /// Under `unshare`, as other languages are on such a worker: its own PID namespace and a + /// cleared environment, with the worker user's view of the file system. + Unshare, + /// A plain child process, on a worker that does not isolate jobs. + Plain, +} + +/// Decided once per job, so every connection a job opens runs the same way. +pub(crate) fn runner_mode() -> RunnerMode { + // Not gated on `NSJAIL_AVAILABLE`: a sandboxed worker whose nsjail cannot jail must fail + // the job, as every other jailed executor does, not run it outside the jail. + if cfg!(unix) && is_sandboxing_enabled() { + RunnerMode::Jailed + } else if is_unshare_enabled() { + // `build_command_with_isolation` logs and runs unisolated when `unshare` is missing, + // as it does for every other language. + RunnerMode::Unshare + } else { + RunnerMode::Plain + } +} + +#[derive(Serialize, Clone, Copy)] +#[serde(rename_all = "snake_case")] +pub(crate) enum RequestMode { + Run, + Prepare, + Install, +} + +#[derive(Serialize, Clone, Copy)] +pub(crate) struct RunnerRequest<'a> { + pub mode: RequestMode, + pub blocks: &'a [String], + #[serde(skip_serializing_if = "Option::is_none")] + pub job_args: Option<&'a serde_json::value::RawValue>, + pub token: &'a str, + pub base_internal_url: &'a str, + pub w_id: &'a str, + pub memory_limit: &'a str, + pub temp_directory: &'a str, + pub max_result_size: usize, + pub collect_last_only: bool, + pub collect_first_row_only: bool, +} + +/// The runner's answer: the result string (`ERROR <json>` on failure) and the column order. +pub(crate) struct RunnerOutput { + pub result: String, + pub column_order: Option<String>, +} + +fn runner_path() -> Result<String> { + if let Ok(p) = std::env::var("DUCKDB_RUNNER_PATH") { + return Ok(p); + } + let exe = std::env::current_exe() + .map_err(|e| Error::InternalErr(format!("Could not locate the worker binary: {e}")))?; + let name = format!("windmill_duckdb_runner{}", std::env::consts::EXE_SUFFIX); + let path = exe.with_file_name(&name); + // Cargo runs test binaries from `target/<profile>/deps`, one level below where the + // worker binary and `build_dev.sh`'s runner sit. + let in_target_dir = exe + .parent() + .filter(|d| d.ends_with("deps")) + .and_then(|d| d.parent()) + .map(|d| d.join(&name)); + let path = match in_target_dir { + Some(p) if !path.exists() && p.exists() => p, + _ => path, + }; + if !path.exists() { + return Err(Error::InternalErr(format!( + "DuckDB jobs need {}, alongside the worker binary. Build it with \ + windmill-duckdb-ffi-internal/build_dev.sh, or point DUCKDB_RUNNER_PATH at it.", + path.display() + ))); + } + Ok(path.to_string_lossy().into_owned()) +} + +/// The jail's own extension cache, separate from the worker user's `~/.duckdb` and mounted +/// read-only. Only [`install_missing_extensions`] writes to it, so a job can neither add to +/// nor replace what the next job loads from it. +fn extension_dir() -> String { + format!("{}/.duckdb/windmill_jail_extensions", HOME_ENV.as_str()) +} + +/// Extensions a job may load inside the jail, where the cache is read-only: the two +/// `setup_duckdb_connection` installs on every connection, the two the platform reaches for +/// without naming them (Parquet data files, the JSON type), and every extension a block +/// installs by name. A `FORCE INSTALL` stays forced: it is the one +/// statement here that must run every time. +fn needed_extensions(blocks: &[String]) -> Vec<String> { + let mut out: Vec<String> = ["httpfs", "azure", "parquet", "json"] + .iter() + .map(|s| format!("INSTALL {s};")) + .collect(); + for block in blocks { + let stmt = block.trim().trim_end_matches(';').trim(); + let mut toks = stmt.split_whitespace().peekable(); + let force = toks.peek().is_some_and(|t| t.eq_ignore_ascii_case("FORCE")); + if force { + toks.next(); + } + if !toks + .next() + .is_some_and(|t| t.eq_ignore_ascii_case("INSTALL")) + { + continue; + } + let Some(name) = toks.next() else { continue }; + if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + continue; + } + let name = name.to_ascii_lowercase(); + let force = if force { "FORCE " } else { "" }; + let stmt = match (toks.next(), toks.next(), toks.next()) { + (None, _, _) => format!("{force}INSTALL {name};"), + (Some(f), Some(r), None) + if f.eq_ignore_ascii_case("FROM") && r.eq_ignore_ascii_case("community") => + { + format!("{force}INSTALL {name} FROM community;") + } + _ => continue, + }; + if !out.contains(&stmt) { + out.push(stmt); + } + } + out +} + +static INSTALLED: LazyLock<Mutex<HashSet<String>>> = LazyLock::new(Default::default); + +/// Installs, ahead of the job and with the worker's proxy, what the job will load: into the +/// jail's cache for a jailed run, or into the runner's default one for a run that does not get +/// the proxy and so could not download them itself. The runner only runs `INSTALL` here, and +/// only of statements built above, so no script SQL and no extension code runs unjailed. Once +/// per extension per cache per worker process. +async fn install_missing_extensions( + blocks: &[String], + job_dir: &str, + jail_cache: bool, +) -> Result<()> { + let key = |s: &String| format!("{jail_cache}:{s}"); + let missing: Vec<String> = { + let installed = INSTALLED.lock().unwrap(); + needed_extensions(blocks) + .into_iter() + .filter(|s| s.starts_with("FORCE ") || !installed.contains(&key(s))) + .collect() + }; + if missing.is_empty() { + return Ok(()); + } + let mut blocks = Vec::with_capacity(missing.len() + 1); + if jail_cache { + let ext_dir = extension_dir(); + tokio::fs::create_dir_all(&ext_dir).await?; + blocks.push(format!( + "SET extension_directory='{}';", + ext_dir.replace('\'', "''") + )); + } + blocks.extend(missing.iter().cloned()); + let out = spawn_runner( + RunnerMode::Plain, + &RunnerRequest { + mode: RequestMode::Install, + blocks: &blocks, + job_args: None, + token: "", + base_internal_url: "", + w_id: "", + memory_limit: "", + temp_directory: "", + max_result_size: 0, + collect_last_only: false, + collect_first_row_only: false, + }, + job_dir, + ) + .await?; + if let Some(raw) = out.result.strip_prefix("ERROR ") { + let msg = serde_json::from_str::<String>(raw).unwrap_or_else(|_| raw.to_string()); + return Err(Error::ExecutionErr(format!( + "Installing DuckDB extensions ahead of the job failed: {msg}" + ))); + } + INSTALLED + .lock() + .unwrap() + .extend(missing.iter().filter(|s| !s.starts_with("FORCE ")).map(key)); + Ok(()) +} + +/// `FORCE INSTALL` rewrites the cache, which is read-only inside the jail. The worker has +/// already honored it outside, so inside it becomes a plain `INSTALL`, which finds the +/// extension present and does nothing. Same block count, so result collection is unchanged. +fn unforce_installs(blocks: &[String]) -> Vec<String> { + blocks + .iter() + .map(|b| { + let t = b.trim_start(); + match t.split_once(char::is_whitespace) { + Some((first, rest)) + if first.eq_ignore_ascii_case("FORCE") + && rest + .trim_start() + .get(..7) + .is_some_and(|k| k.eq_ignore_ascii_case("INSTALL")) => + { + rest.trim_start().to_string() + } + _ => b.clone(), + } + }) + .collect() +} + +/// The only place a jailed runner can write on the host: its spill directory and its result +/// file. The rest of the job dir is mounted read-only. +fn work_dir(job_dir: &str) -> String { + format!("{job_dir}/duckdb") +} + +/// DuckDB's spill cap, in DuckDB's spelling. The cloud runs one worker per pod on nodes with no +/// per-pod ephemeral-storage limit, so it gets one even when nothing configured it. +fn max_temp_directory_size() -> Option<String> { + match std::env::var("DUCKDB_MAX_TEMP_DIRECTORY_SIZE") { + Ok(v) if !v.trim().is_empty() => Some(v.trim().to_string()), + _ if *windmill_common::worker::CLOUD_HOSTED => Some("6GiB".to_string()), + _ => None, + } +} + +/// Parses a DuckDB size (`6GiB`, `500 MB`, `1024`): 1024-based for `KiB`..`TiB`, 1000-based +/// for `KB`..`TB`. `None` for anything else, which DuckDB then refuses on its own. +fn parse_duckdb_size(s: &str) -> Option<u64> { + let s = s.trim(); + let split = s + .find(|c: char| !(c.is_ascii_digit() || c == '.')) + .unwrap_or(s.len()); + let (num, unit) = s.split_at(split); + let num: f64 = num.parse().ok()?; + let mult: f64 = match unit.trim().to_ascii_lowercase().as_str() { + "" | "b" | "byte" | "bytes" => 1.0, + "kb" => 1e3, + "mb" => 1e6, + "gb" => 1e9, + "tb" => 1e12, + "kib" => 1024.0, + "mib" => 1024.0 * 1024.0, + "gib" => 1024.0 * 1024.0 * 1024.0, + "tib" => 1024.0 * 1024.0 * 1024.0 * 1024.0, + _ => return None, + }; + Some((num * mult) as u64) +} + +pub(crate) async fn run( + mode: RunnerMode, + req: &RunnerRequest<'_>, + job_dir: &str, +) -> Result<RunnerOutput> { + let spill_dir = format!("{}/spill", work_dir(job_dir)); + tokio::fs::create_dir_all(&spill_dir).await?; + let req = RunnerRequest { temp_directory: &spill_dir, ..*req }; + if mode != RunnerMode::Jailed && crate::passes_proxy_to_jobs() { + return spawn_runner(mode, &req, job_dir).await; + } + install_missing_extensions(req.blocks, job_dir, mode == RunnerMode::Jailed).await?; + let blocks = unforce_installs(req.blocks); + spawn_runner(mode, &RunnerRequest { blocks: &blocks, ..req }, job_dir).await +} + +/// Disk the directory actually uses (allocated blocks, so sparse files count for what they +/// hold), without following symlinks: the jailed script controls what is in there. +fn disk_usage(dir: &Path) -> u64 { + let mut total = 0; + let mut stack = vec![dir.to_path_buf()]; + while let Some(d) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&d) else { + continue; + }; + for entry in entries.flatten() { + let Ok(meta) = entry.path().symlink_metadata() else { + continue; + }; + #[cfg(unix)] + { + total += std::os::unix::fs::MetadataExt::blocks(&meta) * 512; + } + #[cfg(not(unix))] + { + total += meta.len(); + } + if meta.is_dir() { + stack.push(entry.path()); + } + } + } + total +} + +/// Resolves once the runner's work dir holds more than `cap`. Inside the jail that dir is the +/// only place the script can write to on the host, and the script can raise DuckDB's own +/// `max_temp_directory_size`, so this is what makes the cap hold. +async fn exceeds(dir: PathBuf, cap: u64) -> u64 { + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + let d = dir.clone(); + let used = tokio::task::spawn_blocking(move || disk_usage(&d)) + .await + .unwrap_or(0); + if used > cap { + return used; + } + } +} + +/// Kills the runner's process group when the job future is dropped (cancellation, timeout) +/// before the runner exits. Killing the spawned process alone is not enough: `unshare --fork` +/// leaves its child running. +struct KillGroupOnDrop(Option<i32>); + +impl Drop for KillGroupOnDrop { + fn drop(&mut self) { + #[cfg(unix)] + if let Some(pgid) = self.0 { + let _ = nix::sys::signal::killpg( + nix::unistd::Pid::from_raw(pgid), + nix::sys::signal::Signal::SIGKILL, + ); + } + } +} + +async fn spawn_runner( + mode: RunnerMode, + req: &RunnerRequest<'_>, + job_dir: &str, +) -> Result<RunnerOutput> { + let runner = runner_path()?; + let call_id = uuid::Uuid::new_v4(); + let work_dir = work_dir(job_dir); + tokio::fs::create_dir_all(&work_dir).await?; + let result_path = format!("{work_dir}/result_{call_id}.out"); + let spill_cap = max_temp_directory_size(); + + let mut cmd = match mode { + RunnerMode::Jailed => { + let ext_dir = extension_dir(); + tokio::fs::create_dir_all(&ext_dir).await?; + let bigquery_mount = match std::env::var("GOOGLE_APPLICATION_CREDENTIALS") { + Ok(p) if !p.is_empty() => format!( + "mount {{\n src: \"{p}\"\n dst: \"{p}\"\n is_bind: true\n mandatory: false\n}}" + ), + _ => String::new(), + }; + let config_name = format!("run.duckdb.{call_id}.config.proto"); + tokio::fs::write( + format!("{job_dir}/{config_name}"), + NSJAIL_CONFIG_RUN_DUCKDB_CONTENT + .replace("{JOB_DIR}", job_dir) + .replace("{RUNNER_PATH}", &runner) + .replace("{EXTENSION_DIR}", &ext_dir) + .replace("{BIGQUERY_CREDENTIALS_MOUNT}", &bigquery_mount) + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace("#{DEV}", DEV_CONF_NSJAIL) + // Rooted at the work dir rather than the job dir: with a disk-backed /tmp + // the jail's /tmp is a host directory, and inside the work dir the disk cap + // covers it. A RAM-backed /tmp is bounded by its tmpfs size. + .replace( + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(&work_dir).await, + ), + ) + .await?; + let mut cmd = Command::new(NSJAIL_PATH.as_str()); + cmd.args([ + "--config", + &config_name, + "--", + "/tmp/windmill_duckdb_runner", + ]); + cmd + } + RunnerMode::Unshare => build_command_with_isolation(&runner, &[]), + RunnerMode::Plain => Command::new(&runner), + }; + + cmd.current_dir(job_dir) + .env_clear() + .env("PATH", PATH_ENV.as_str()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + #[cfg(unix)] + cmd.process_group(0); + // The jail sets its own HOME, over the read-only extension cache. + if mode != RunnerMode::Jailed { + cmd.env("HOME", HOME_ENV.as_str()); + } + // Windows networking (Winsock) needs SystemRoot, so without these every extension install + // and S3 proxy request fails. Empty off Windows. + cmd.envs(crate::WIN_ENVS.iter().cloned()); + #[cfg(windows)] + cmd.env("windir", crate::SYSTEM_ROOT.as_str()); + // DuckDB's default TimeZone follows TZ, as it did in the worker's own environment. + if !crate::TZ_ENV.is_empty() { + cmd.env("TZ", crate::TZ_ENV.as_str()); + } + if let Some(cap) = &spill_cap { + cmd.env("DUCKDB_MAX_TEMP_DIRECTORY_SIZE", cap); + } + if let Ok(v) = std::env::var("GOOGLE_APPLICATION_CREDENTIALS") { + cmd.env("GOOGLE_APPLICATION_CREDENTIALS", v); + } + // An `Install` request only fetches extensions, a dependency install that keeps the proxy. + if matches!(req.mode, RequestMode::Install) || crate::passes_proxy_to_jobs() { + for var in [ + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + "all_proxy", + ] { + if let Ok(v) = std::env::var(var) { + cmd.env(var, v); + } + } + } + + let mut payload = serde_json::to_value(req).map_err(|e| Error::InternalErr(e.to_string()))?; + payload["result_path"] = serde_json::Value::String(result_path.clone()); + let payload = serde_json::to_vec(&payload).map_err(|e| Error::InternalErr(e.to_string()))?; + + // The engine's memory lives outside the worker, so a native OOM can take the job alone, as + // long as the kernel picks the runner first. Set before exec so nsjail or unshare, and the + // runner they start, inherit it: written after spawn it races that, and the jail's /proc is + // read-only. Only raw syscalls here, between fork and exec. + #[cfg(target_os = "linux")] + { + let adj = windmill_common::worker::JOB_OOM_SCORE_ADJ.to_string(); + unsafe { + cmd.pre_exec(move || { + let fd = nix::libc::open(c"/proc/self/oom_score_adj".as_ptr(), nix::libc::O_WRONLY); + if fd >= 0 { + nix::libc::write(fd, adj.as_ptr().cast(), adj.len()); + nix::libc::close(fd); + } + Ok(()) + }); + } + } + crate::common::apply_nsjail_gvisor_compat(&cmd)?; + let mut child = cmd.spawn().map_err(|e| { + Error::InternalErr(format!("Could not start the DuckDB runner ({mode:?}): {e}")) + })?; + let mut guard = KillGroupOnDrop(child.id().map(|p| p as i32)); + let mut stdin = child.stdin.take().expect("stdin is piped"); + let write = async move { + stdin.write_all(&payload).await?; + stdin.shutdown().await + }; + let stdout = child.stdout.take().expect("stdout is piped"); + let stderr = child.stderr.take().expect("stderr is piped"); + let cap_bytes = spill_cap.as_deref().and_then(parse_duckdb_size); + let watchdog = async { + match cap_bytes { + Some(cap) if mode == RunnerMode::Jailed => exceeds(PathBuf::from(&work_dir), cap).await, + _ => std::future::pending().await, + } + }; + let (write, status, stdout, stderr) = tokio::select! { + r = async { tokio::join!(write, child.wait(), output_tail(stdout), output_tail(stderr)) } => r, + // Returning drops the child, and with it `kill_on_drop` and `guard` end the runner. + used = watchdog => { + return Err(Error::ExecutionErr(format!( + "DuckDB: this job wrote {} MB to disk, past this worker's limit of {} \ + (DUCKDB_MAX_TEMP_DIRECTORY_SIZE), and was stopped. Raising \ + max_temp_directory_size from the script does not lift it. Spill less (fewer \ + threads, a narrower scan, aggregating before the join), or write large outputs \ + to S3 rather than local files.", + used / 1_000_000, + spill_cap.as_deref().unwrap_or_default() + ))); + } + }; + let status = status?; + guard.0 = None; + let read = read_result(&result_path).await; + let _ = tokio::fs::remove_file(&result_path).await; + + if !status.success() || read.is_err() { + #[cfg(unix)] + let signal = std::os::unix::process::ExitStatusExt::signal(&status); + #[cfg(not(unix))] + let signal: Option<i32> = None; + let how = match signal { + Some(sig) => format!("was killed by signal {sig}"), + None => format!("exited with {status}"), + }; + return Err(Error::ExecutionErr(format!( + "The DuckDB engine process {how}. The worker is unaffected.{}{}{}", + write + .err() + .map(|e| format!("\nrequest: {e}")) + .unwrap_or_default(), + if stderr.trim().is_empty() { + String::new() + } else { + format!("\nstderr: {}", stderr.trim()) + }, + if stdout.trim().is_empty() { + String::new() + } else { + format!("\nstdout: {}", stdout.trim()) + }, + ))); + } + let mut column_order = read?; + let header_end = column_order.find('\n').unwrap_or(column_order.len()); + let result = column_order.split_off((header_end + 1).min(column_order.len())); + column_order.truncate(header_end); + Ok(RunnerOutput { column_order: (!column_order.is_empty()).then_some(column_order), result }) +} + +/// The result file sits in a directory the jailed script can write to, so a symlink planted there +/// must not make the worker read some other host file into the job result. +async fn read_result(path: &str) -> std::io::Result<String> { + let mut opts = tokio::fs::OpenOptions::new(); + opts.read(true); + #[cfg(unix)] + opts.custom_flags(nix::libc::O_NOFOLLOW); + let mut s = String::new(); + opts.open(path).await?.read_to_string(&mut s).await?; + Ok(s) +} + +/// Drains a runner pipe keeping only its last few KB. A script controls how much the engine +/// prints (`CALL enable_logging(storage='stdout')`), so buffering it whole would let a jailed +/// job grow the worker's memory without bound. +async fn output_tail(mut r: impl AsyncRead + Unpin) -> String { + const KEEP: usize = 4096; + let mut buf = vec![0u8; 16 * 1024]; + let mut tail: Vec<u8> = Vec::with_capacity(2 * KEEP); + loop { + match r.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + tail.extend_from_slice(&buf[..n]); + if tail.len() > KEEP { + tail.drain(..tail.len() - KEEP); + } + } + } + } + String::from_utf8_lossy(&tail).into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn duckdb_sizes_parse_in_duckdb_units() { + assert_eq!(parse_duckdb_size("6GiB"), Some(6 * 1024 * 1024 * 1024)); + assert_eq!(parse_duckdb_size("500 MB"), Some(500_000_000)); + assert_eq!(parse_duckdb_size("1.5kib"), Some(1536)); + assert_eq!(parse_duckdb_size("1024"), Some(1024)); + // Kubernetes' spelling is not DuckDB's: the engine refuses it, so no cap is derived. + assert_eq!(parse_duckdb_size("6Gi"), None); + assert_eq!(parse_duckdb_size(""), None); + } + + fn strs(v: &[&str]) -> Vec<String> { + v.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn force_installs_run_outside_and_become_plain_inside() { + let blocks = strs(&[ + "force install httpfs;", + "INSTALL bigquery FROM community;", + "INSTALL x FROM 'http://evil';", + "INSTALL '/tmp/x.so';", + "SELECT 'FORCE INSTALL';", + ]); + assert_eq!( + needed_extensions(&blocks)[4..], + strs(&["FORCE INSTALL httpfs;", "INSTALL bigquery FROM community;"]) + ); + assert_eq!( + unforce_installs(&blocks), + strs(&[ + "install httpfs;", + "INSTALL bigquery FROM community;", + "INSTALL x FROM 'http://evil';", + "INSTALL '/tmp/x.so';", + "SELECT 'FORCE INSTALL';", + ]) + ); + } +} diff --git a/backend/windmill-worker/src/go_executor.rs b/backend/windmill-worker/src/go_executor.rs index 9fe6d4a616..fd26484f23 100644 --- a/backend/windmill-worker/src/go_executor.rs +++ b/backend/windmill-worker/src/go_executor.rs @@ -22,8 +22,8 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ build_command_with_isolation, capitalize, create_args_and_out_file, get_reserved_variables, - read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, - OccupancyMetrics, DEV_CONF_NSJAIL, + read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, script_envars, + start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, handle_child::handle_child, is_sandboxing_enabled, read_ee_registry, GoBuildLimits, DISABLE_NUSER, GOPRIVATE, GOPROXY, @@ -600,26 +600,10 @@ pub async fn handle_go_job( let child = if is_sandboxing_enabled() { let nsjail_timeout = resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; - let _ = write_file( - job_dir, - "run.config.proto", - &NSJAIL_CONFIG_RUN_GO_CONTENT - .replace("{JOB_DIR}", job_dir) - .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) - .replace("{SHARED_MOUNT}", shared_mount) - .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) - .replace("#{DEV}", DEV_CONF_NSJAIL) - .replace( - "{TMP_MOUNT_BLOCK}", - &resolve_nsjail_tmp_mount_block(job_dir).await, - ) - .replace("{TIMEOUT}", &nsjail_timeout), - )?; let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str()); nsjail_cmd .current_dir(job_dir) .env_clear() - .envs(envs) .envs(reserved_variables) .envs( get_proxy_envs_for_lang( @@ -637,6 +621,22 @@ pub async fn handle_go_job( .args(vec!["--config", "run.config.proto", "--", "/tmp/go/main"]) .stdout(Stdio::piped()) .stderr(Stdio::piped()); + let _ = write_file( + job_dir, + "run.config.proto", + &NSJAIL_CONFIG_RUN_GO_CONTENT + .replace("{JOB_DIR}", job_dir) + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace("{SHARED_MOUNT}", shared_mount) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) + .replace("#{DEV}", DEV_CONF_NSJAIL) + .replace( + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, + ) + .replace("{TIMEOUT}", &nsjail_timeout) + .replace("{ENVARS}", &script_envars(&envs, &nsjail_cmd, &[])), + )?; start_child_process(nsjail_cmd, NSJAIL_PATH.as_str(), false).await? } else { #[cfg(unix)] diff --git a/backend/windmill-worker/src/java_executor.rs b/backend/windmill-worker/src/java_executor.rs index 2e47fced18..c4678cfe83 100644 --- a/backend/windmill-worker/src/java_executor.rs +++ b/backend/windmill-worker/src/java_executor.rs @@ -28,7 +28,7 @@ use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, read_result, resolve_job_timeout, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, - start_child_process, OccupancyMetrics, + script_envars, script_overrides, start_child_process, OccupancyMetrics, }, handle_child, is_sandboxing_enabled, read_ee_registry_bool_with_workspace_override, read_ee_registry_with_workspace_override, @@ -252,7 +252,7 @@ pub async fn resolve<'a>( // Configure proxies { - let jps = parse_proxy()?; + let jps = parse_proxy(PROXY_ENVS.clone())?; if let Some(val) = jps.https_host { cmd.arg(&format!("-Dhttps.proxyHost={}", val)); } @@ -417,7 +417,7 @@ async fn install<'a>( .envs(PROXY_ENVS.clone()); // Configure proxies { - let jps = parse_proxy()?; + let jps = parse_proxy(PROXY_ENVS.clone())?; if let Some(val) = jps.https_host { cmd.arg(&format!("-Dhttps.proxyHost={}", val)); } @@ -678,9 +678,13 @@ async fn compile<'a>( .current_dir(job_dir.to_owned()) .env("PATH", PATH_ENV.as_str()) .env("HOME", &*JAVA_HOME_DIR) - .env("BASE_INTERNAL_URL", base_internal_url) - .envs(envs) - .envs(reserved_variables) + .env("BASE_INTERNAL_URL", base_internal_url); + // javac runs outside the jail even when sandboxing is on: script env + // (`LD_PRELOAD`, `JAVA_TOOL_OPTIONS`) would load code into it on the host. + if !is_sandboxing_enabled() { + cmd.envs(envs); + } + cmd.envs(reserved_variables) .envs(PROXY_ENVS.clone()) .args(&[ "-classpath", @@ -777,29 +781,13 @@ async fn run<'a>( let nsjail_timeout = resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; - write_file( - job_dir, - "run.config.proto", - &NSJAIL_CONFIG_RUN_JAVA_CONTENT - .replace("{JOB_DIR}", job_dir) - .replace("{CACHE_DIR}", &*JAVA_CACHE_DIR) - .replace("{SHARED_MOUNT}", &shared_mount) - // .replace("{CACHED_TARGET}", &shared_mount) - .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) - .replace( - "{TMP_MOUNT_BLOCK}", - &resolve_nsjail_tmp_mount_block(job_dir).await, - ) - .replace("{TIMEOUT}", &nsjail_timeout), - )?; let mut cmd = Command::new(NSJAIL_PATH.as_str()); cmd.env_clear() - .current_dir(job_dir) + .current_dir(*job_dir) .env("PATH", PATH_ENV.as_str()) .env("HOME", &*JAVA_HOME_DIR) .env("BASE_INTERNAL_URL", base_internal_url) - .envs(envs) - .envs(reserved_variables) + .envs(&reserved_variables) .envs(crate::get_otel_context_envs(&job.id)) .args(vec![ "--config", @@ -815,7 +803,7 @@ async fn run<'a>( } // Configure proxies { - let jps = parse_proxy()?; + let jps = parse_proxy(crate::job_proxy_envs())?; if let Some(val) = jps.https_host { cmd.arg(&format!("-Dhttps.proxyHost={}", val)); } @@ -835,6 +823,32 @@ async fn run<'a>( cmd.args(vec!["-classpath", &classpath, "net.script.App"]); cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + write_file( + job_dir, + "run.config.proto", + &NSJAIL_CONFIG_RUN_JAVA_CONTENT + .replace("{JOB_DIR}", job_dir) + .replace("{CACHE_DIR}", &*JAVA_CACHE_DIR) + .replace("{SHARED_MOUNT}", &shared_mount) + // .replace("{CACHED_TARGET}", &shared_mount) + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace( + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, + ) + .replace("{TIMEOUT}", &nsjail_timeout) + .replace( + "{ENVARS}", + &script_envars( + &envs, + &cmd, + &script_overrides( + &["PATH", "HOME", "BASE_INTERNAL_URL"], + &reserved_variables, + ), + ), + ), + )?; start_child_process(cmd, NSJAIL_PATH.as_str(), false).await? } else { append_logs( @@ -868,7 +882,7 @@ async fn run<'a>( } // Configure proxies { - let jps = parse_proxy()?; + let jps = parse_proxy(crate::job_proxy_envs())?; if let Some(val) = jps.https_host { cmd.arg(&format!("-Dhttps.proxyHost={}", val)); } @@ -929,9 +943,9 @@ struct JavaProxySettings { https_port: Option<String>, no_proxy: Option<String>, } -fn parse_proxy() -> anyhow::Result<JavaProxySettings> { +fn parse_proxy(proxy_envs: Vec<(&'static str, String)>) -> anyhow::Result<JavaProxySettings> { let mut jps = JavaProxySettings::default(); - for (ident, mut val) in PROXY_ENVS.clone() { + for (ident, mut val) in proxy_envs { match ident { "HTTPS_PROXY" => { if !val.contains("://") { diff --git a/backend/windmill-worker/src/lib.rs b/backend/windmill-worker/src/lib.rs index aa1d9ab209..1945d16346 100644 --- a/backend/windmill-worker/src/lib.rs +++ b/backend/windmill-worker/src/lib.rs @@ -45,8 +45,8 @@ mod deno_executor; mod docker_v2; #[cfg(feature = "duckdb")] mod duckdb_executor; -#[cfg(all(feature = "duckdb", feature = "private"))] -mod duckdb_isolation_ee; +#[cfg(feature = "duckdb")] +mod duckdb_runner; mod global_cache; mod go_executor; mod graphql_executor; @@ -62,6 +62,7 @@ pub mod memory_ee; pub mod memory_oss; #[cfg(feature = "mysql")] mod mysql_executor; +mod nsjail_compat; #[cfg(feature = "nu")] mod nu_executor; #[cfg(feature = "oracledb")] diff --git a/backend/windmill-worker/src/mssql_executor.rs b/backend/windmill-worker/src/mssql_executor.rs index 02d0da1164..1d3a323e34 100644 --- a/backend/windmill-worker/src/mssql_executor.rs +++ b/backend/windmill-worker/src/mssql_executor.rs @@ -26,7 +26,7 @@ use windmill_queue::MiniPulledJob; use windmill_queue::{append_logs, CanceledBy}; use crate::common::{ - build_args_values, get_reserved_variables, s3_mode_args_to_worker_data, + build_args_values, get_reserved_variables, narrow_sql_int, s3_mode_args_to_worker_data, s3_stream_and_upload_with_logs, OccupancyMetrics, }; use crate::handle_child::run_future_with_polling_update_job_poller; @@ -398,13 +398,13 @@ fn json_value_to_sql<'a>( query.bind(b.to_owned()); } Value::Number(n) if n.is_u64() && arg_t == "tinyint" => { - query.bind(n.as_u64().unwrap().to_owned() as u8) + query.bind(narrow_sql_int::<u8, _>(n.as_u64().unwrap(), "tinyint")?) } Value::Number(n) if n.is_i64() && arg_t == "smallint" => { - query.bind(n.as_i64().unwrap().to_owned() as i16) + query.bind(narrow_sql_int::<i16, _>(n.as_i64().unwrap(), "smallint")?) } Value::Number(n) if n.is_i64() && arg_t == "int" => { - query.bind(n.as_i64().unwrap().to_owned() as i32) + query.bind(narrow_sql_int::<i32, _>(n.as_i64().unwrap(), "int")?) } Value::Number(n) if n.is_i64() && arg_t == "bigint" => { query.bind(n.as_i64().unwrap().to_owned() as i64) diff --git a/backend/windmill-worker/src/nsjail_compat.rs b/backend/windmill-worker/src/nsjail_compat.rs new file mode 100644 index 0000000000..3069dcee52 --- /dev/null +++ b/backend/windmill-worker/src/nsjail_compat.rs @@ -0,0 +1,49 @@ +//! `NSJAIL_GVISOR_COMPAT=true`: run nsjail inside gVisor (runsc). Each config is rewritten +//! right before nsjail starts, with `gvisor_compat: true` (an option of the nsjail the images +//! build, from `docker/nsjail-patches/`) and no cgroup namespace, which gVisor refuses. + +use std::ffi::OsStr; +use std::io; +use std::path::PathBuf; +use std::process::Command; + +pub(crate) fn compat_config(config: &str) -> String { + let mut out = config + .lines() + .filter(|l| { + let l = l.trim_start(); + !l.starts_with("clone_newcgroup") && !l.starts_with("gvisor_compat") + }) + .collect::<Vec<_>>() + .join("\n"); + out.push_str("\nclone_newcgroup: false\ngvisor_compat: true\n"); + out +} + +/// Rewrites the config `cmd` (an nsjail command line) passes with `--config`. +pub(crate) fn apply(cmd: &Command) -> io::Result<()> { + let args = cmd.get_args().collect::<Vec<&OsStr>>(); + let Some(config) = args.windows(2).find(|w| w[0] == "--config").map(|w| w[1]) else { + return Ok(()); + }; + let path = match cmd.get_current_dir() { + Some(dir) => dir.join(config), + None => PathBuf::from(config), + }; + let rewritten = compat_config(&std::fs::read_to_string(&path)?); + std::fs::write(&path, rewritten) +} + +#[cfg(test)] +mod tests { + // nsjail rejects a config that sets a scalar field twice, which would fail every jailed job. + #[test] + fn compat_config_sets_each_field_once() { + let bun = include_str!("../nsjail/run.bun.config.proto"); + assert!(bun.contains("clone_newcgroup: false")); + let twice = super::compat_config(&super::compat_config(bun)); + assert_eq!(twice.matches("clone_newcgroup").count(), 1); + assert_eq!(twice.matches("gvisor_compat").count(), 1); + assert!(twice.ends_with("\nclone_newcgroup: false\ngvisor_compat: true\n")); + } +} diff --git a/backend/windmill-worker/src/nu_executor.rs b/backend/windmill-worker/src/nu_executor.rs index 8aa0946666..b57225f606 100644 --- a/backend/windmill-worker/src/nu_executor.rs +++ b/backend/windmill-worker/src/nu_executor.rs @@ -14,8 +14,8 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, - read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, - OccupancyMetrics, DEV_CONF_NSJAIL, + read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, script_envars, script_overrides, + start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, handle_child, is_sandboxing_enabled, DISABLE_NUSER, NSJAIL_PATH, PATH_ENV, TRACING_PROXY_CA_CERT_PATH, @@ -248,30 +248,13 @@ async fn run<'a>( let nsjail_timeout = resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; - write_file( - job_dir, - "run.config.proto", - &NSJAIL_CONFIG_RUN_NU_CONTENT - .replace("{JOB_DIR}", job_dir) - .replace("{NU_PATH}", &NU_PATH) - .replace("{SHARED_MOUNT}", &shared_mount) - .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) - .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) - .replace("#{DEV}", DEV_CONF_NSJAIL) - .replace( - "{TMP_MOUNT_BLOCK}", - &resolve_nsjail_tmp_mount_block(job_dir).await, - ) - .replace("{TIMEOUT}", &nsjail_timeout), - )?; let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str()); nsjail_cmd .env_clear() - .current_dir(job_dir) + .current_dir(*job_dir) .env("PATH", PATH_ENV.as_str()) .env("BASE_INTERNAL_URL", base_internal_url) - .envs(envs) - .envs(reserved_variables) + .envs(&reserved_variables) .envs( get_proxy_envs_for_lang( &ScriptLang::Nu, @@ -293,6 +276,26 @@ async fn run<'a>( .stdout(Stdio::piped()) .stderr(Stdio::piped()); + write_file( + job_dir, + "run.config.proto", + &NSJAIL_CONFIG_RUN_NU_CONTENT + .replace("{JOB_DIR}", job_dir) + .replace("{NU_PATH}", &NU_PATH) + .replace("{SHARED_MOUNT}", &shared_mount) + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) + .replace("#{DEV}", DEV_CONF_NSJAIL) + .replace( + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, + ) + .replace("{TIMEOUT}", &nsjail_timeout) + .replace( + "{ENVARS}", + &script_envars(&envs, &nsjail_cmd, &script_overrides(&["PATH", "BASE_INTERNAL_URL"], &reserved_variables)), + ), + )?; start_child_process(nsjail_cmd, NSJAIL_PATH.as_str(), false).await? } else { append_logs( diff --git a/backend/windmill-worker/src/pg_executor.rs b/backend/windmill-worker/src/pg_executor.rs index 12a3665c27..f70e3dd0ca 100644 --- a/backend/windmill-worker/src/pg_executor.rs +++ b/backend/windmill-worker/src/pg_executor.rs @@ -38,7 +38,7 @@ use windmill_queue::{CanceledBy, MiniPulledJob}; use crate::agent_workers::get_datatable_resource_from_agent_http; use crate::common::{ - build_args_values, get_reserved_variables, s3_mode_args_to_worker_data, + build_args_values, get_reserved_variables, narrow_sql_int, s3_mode_args_to_worker_data, s3_stream_and_upload_with_logs, sizeof_val, OccupancyMetrics, S3ModeWorkerData, }; use crate::handle_child::run_future_with_polling_update_job_poller; @@ -1768,23 +1768,30 @@ fn parse_datetime_utc(s: &str) -> Result<chrono::DateTime<Utc>, chrono::ParseErr fn map_as_single_type<T>( vec: Option<&Vec<Value>>, f: impl Fn(&Value) -> Option<T>, +) -> anyhow::Result<Option<Vec<Option<T>>>> { + try_map_as_single_type(vec, |v| Ok(f(v))) +} + +/// Like `map_as_single_type`, but `f` can reject an element of the right type +/// (e.g. an out-of-range integer) with its own error. +fn try_map_as_single_type<T>( + vec: Option<&Vec<Value>>, + f: impl Fn(&Value) -> anyhow::Result<Option<T>>, ) -> anyhow::Result<Option<Vec<Option<T>>>> { if let Some(vec) = vec { - Ok(Some( - vec.into_iter() - .map(|v| { - // first option is if the value is of the right type (if none, will stop the collection and throw error) - // second option is if the value is null - // allow nulls in arrays - if matches!(v, Value::Null) { - Some(None) - } else { - f(v).map(Some) - } - }) - .collect::<Option<Vec<Option<T>>>>() - .ok_or_else(|| anyhow::anyhow!("Mixed types in array"))?, - )) + let mut out = Vec::with_capacity(vec.len()); + for v in vec { + // allow nulls in arrays + if matches!(v, Value::Null) { + out.push(None); + } else { + match f(v)? { + Some(x) => out.push(Some(x)), + None => return Err(anyhow::anyhow!("Mixed types in array")), + } + } + } + Ok(Some(out)) } else { Ok(None) } @@ -1819,26 +1826,23 @@ fn convert_vec_val( Type::BOOL_ARRAY, )), "char" | "character" => Ok(( - Box::new(map_as_single_type(vec, |v| { - v.as_i64() - .map(|x| x as i8) - .or_else(|| v.as_str().and_then(|s| s.parse::<i8>().ok())) + Box::new(try_map_as_single_type(vec, |v| match v.as_i64() { + Some(x) => narrow_sql_int::<i8, _>(x, "\"char\"").map(Some), + None => Ok(v.as_str().and_then(|s| s.parse::<i8>().ok())), })?), Type::CHAR_ARRAY, )), "smallint" | "smallserial" | "int2" | "serial2" => Ok(( - Box::new(map_as_single_type(vec, |v| { - v.as_i64() - .map(|x| x as i16) - .or_else(|| v.as_str().and_then(|s| s.parse::<i16>().ok())) + Box::new(try_map_as_single_type(vec, |v| match v.as_i64() { + Some(x) => narrow_sql_int::<i16, _>(x, "smallint").map(Some), + None => Ok(v.as_str().and_then(|s| s.parse::<i16>().ok())), })?), Type::INT2_ARRAY, )), "int" | "integer" | "int4" | "serial" => Ok(( - Box::new(map_as_single_type(vec, |v| { - v.as_i64() - .map(|x| x as i32) - .or_else(|| v.as_str().and_then(|s| s.parse::<i32>().ok())) + Box::new(try_map_as_single_type(vec, |v| match v.as_i64() { + Some(x) => narrow_sql_int::<i32, _>(x, "integer").map(Some), + None => Ok(v.as_str().and_then(|s| s.parse::<i32>().ok())), })?), Type::INT4_ARRAY, )), @@ -1859,18 +1863,18 @@ fn convert_vec_val( Type::NUMERIC_ARRAY, )), "oid" => Ok(( - Box::new(map_as_single_type(vec, |v| { - v.as_u64() - .map(|x| x as u32) - .or_else(|| v.as_str().and_then(|s| s.parse::<u32>().ok())) + Box::new(try_map_as_single_type(vec, |v| match v.as_u64() { + Some(x) => narrow_sql_int::<u32, _>(x, "oid").map(Some), + None => Ok(v.as_str().and_then(|s| s.parse::<u32>().ok())), })?), Type::OID_ARRAY, )), "bigint" | "bigserial" | "int8" | "serial8" => Ok(( - Box::new(map_as_single_type(vec, |v| { - v.as_i64() - .or_else(|| v.as_u64().map(|x| x as i64)) - .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok())) + Box::new(try_map_as_single_type(vec, |v| match v.as_u64() { + Some(x) => narrow_sql_int::<i64, _>(x, "bigint").map(Some), + None => Ok(v + .as_i64() + .or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))), })?), Type::INT8_ARRAY, )), @@ -2074,7 +2078,8 @@ fn convert_val( Ok((Box::new(n.to_string()), t)) } Value::Number(n) if arg_t == "char" && n.is_i64() => { - Ok((Box::new(n.as_i64().unwrap() as i8), Type::CHAR)) + let n = narrow_sql_int::<i8, _>(n.as_i64().unwrap(), "\"char\"")?; + Ok((Box::new(n), Type::CHAR)) } Value::Number(n) if (arg_t == "smallint" @@ -2083,13 +2088,15 @@ fn convert_val( || arg_t == "serial2") && n.is_i64() => { - Ok((Box::new(n.as_i64().unwrap() as i16), Type::INT2)) + let n = narrow_sql_int::<i16, _>(n.as_i64().unwrap(), "smallint")?; + Ok((Box::new(n), Type::INT2)) } Value::Number(n) if (arg_t == "int" || arg_t == "integer" || arg_t == "int4" || arg_t == "serial") && n.is_i64() => { - Ok((Box::new(n.as_i64().unwrap() as i32), Type::INT4)) + let n = narrow_sql_int::<i32, _>(n.as_i64().unwrap(), "integer")?; + Ok((Box::new(n), Type::INT4)) } Value::Number(n) if (arg_t == "real" || arg_t == "float4") && n.as_f64().is_some() => { Ok((Box::new(n.as_f64().unwrap() as f32), Type::FLOAT4)) @@ -2109,7 +2116,8 @@ fn convert_val( Type::NUMERIC, )), Value::Number(n) if arg_t == "oid" && n.is_u64() => { - Ok((Box::new(n.as_u64().unwrap() as u32), Type::OID)) + let n = narrow_sql_int::<u32, _>(n.as_u64().unwrap(), "oid")?; + Ok((Box::new(n), Type::OID)) } Value::Number(n) if (arg_t == "bigint" @@ -2118,7 +2126,8 @@ fn convert_val( || arg_t == "serial8") && n.is_u64() => { - Ok((Box::new(n.as_u64().unwrap() as i64), Type::INT8)) + let n = narrow_sql_int::<i64, _>(n.as_u64().unwrap(), "bigint")?; + Ok((Box::new(n), Type::INT8)) } Value::Number(n) if n.is_i64() => Ok((Box::new(n.as_i64().unwrap()), Type::INT8)), Value::Number(n) => Ok((Box::new(n.as_f64().unwrap()), Type::FLOAT8)), @@ -3313,6 +3322,47 @@ mod tests { assert_convert_val_consistent("Number(0)/oid", json!(0), "oid", typ_for("oid"), Type::OID); } + #[test] + fn convert_val_rejects_out_of_range_integers() { + let convert = |value: Value, arg_t: &str| { + convert_val(&value, &arg_t.to_string(), &typ_for(arg_t), false) + .map(|_| ()) + .map_err(|e| e.to_string()) + }; + for (value, arg_t, sql_type) in [ + (json!(200), "char", "\"char\""), + (json!(70000), "smallint", "smallint"), + (json!(-40000), "int2", "smallint"), + (json!(4294967297u64), "int4", "integer"), + (json!(4294967296u64), "oid", "oid"), + (json!(u64::MAX), "bigint", "bigint"), + (json!([1, 70000]), "smallint[]", "smallint"), + (json!([u64::MAX]), "int8[]", "bigint"), + ] { + let err = convert(value.clone(), arg_t).expect_err(&format!("{value}/{arg_t}")); + let n = value.as_array().map_or(&value, |a| a.last().unwrap()); + assert!( + err.contains(&format!( + "value \"{n}\" is out of range for type {sql_type}" + )), + "{value}/{arg_t}: {err}" + ); + } + for (value, arg_t) in [ + (json!(127), "char"), + (json!(-128), "char"), + (json!(32767), "smallint"), + (json!(-32768), "smallint"), + (json!(2147483647), "int"), + (json!(-2147483648i64), "int"), + (json!(4294967295u64), "oid"), + (json!(i64::MAX), "bigint"), + (json!([null, -32768, 32767]), "smallint[]"), + ] { + convert(value.clone(), arg_t).unwrap_or_else(|e| panic!("{value}/{arg_t}: {e}")); + } + } + #[test] fn convert_val_float_number_against_every_arg_t() { let cases: &[(&str, Type)] = &[ diff --git a/backend/windmill-worker/src/php_executor.rs b/backend/windmill-worker/src/php_executor.rs index 8378455ef4..fad3a7ce56 100644 --- a/backend/windmill-worker/src/php_executor.rs +++ b/backend/windmill-worker/src/php_executor.rs @@ -2,7 +2,7 @@ use convert_case::{Case, Casing}; use itertools::Itertools; use regex::Regex; use serde_json::value::RawValue; -use std::{collections::HashMap, process::Stdio}; +use std::{collections::HashMap, path::Path, process::Stdio}; use tokio::{fs::File, io::AsyncReadExt, process::Command}; use uuid::Uuid; use windmill_common::{ @@ -20,9 +20,9 @@ use windmill_queue::{append_logs, CanceledBy}; use crate::common::DEV_CONF_NSJAIL; use crate::{ common::{ - build_command_with_isolation, check_executor_binary_exists, create_args_and_out_file, - get_reserved_variables, read_result, resolve_nsjail_timeout, - resolve_nsjail_tmp_mount_block, start_child_process, MaybeLock, OccupancyMetrics, + build_command_with_isolation, create_args_and_out_file, get_reserved_variables, + read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, script_envars, + start_child_process, MaybeLock, OccupancyMetrics, }, handle_child::handle_child, is_sandboxing_enabled, COMPOSER_CACHE_DIR, COMPOSER_PATH, DISABLE_NUSER, NSJAIL_PATH, PATH_ENV, @@ -43,6 +43,22 @@ lazy_static::lazy_static! { const COMPOSER_LOCK_SPLIT: &str = "\nLOCK\n"; +fn ensure_php_tool_installed(tool: &str, path: &str) -> Result<()> { + // A bare name is resolved through PATH when the process starts, not here. + let path_ref = Path::new(path); + if !path_ref.is_absolute() || path_ref.exists() { + return Ok(()); + } + Err(error::Error::ExecutionErr(format!( + "{tool} is not installed on this worker (looked for it at {path}). PHP and Composer are no \ + longer included in the standard windmill and windmill-ee images: they ship only in the \ + full images. To run PHP scripts, switch the workers that run PHP jobs to \ + ghcr.io/windmill-labs/windmill-full (Community Edition) or \ + ghcr.io/windmill-labs/windmill-ee-full (Enterprise Edition), or install PHP and Composer \ + on them and point PHP_PATH and COMPOSER_PATH at them." + ))) +} + static PHP_PARSER_SLOT: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(1); pub(crate) async fn parse_php_signature( @@ -122,7 +138,8 @@ pub async fn composer_install( lock: Option<String>, occupancy_metrics: &mut OccupancyMetrics, ) -> Result<String> { - check_executor_binary_exists("php", PHP_PATH.as_str(), "php")?; + ensure_php_tool_installed("PHP", PHP_PATH.as_str())?; + ensure_php_tool_installed("Composer", COMPOSER_PATH.as_str())?; // When no lock is provided (previews), try to reuse a previously resolved // lockfile from the DB so we can hit the same vendor cache as deployed scripts. @@ -414,7 +431,7 @@ pub async fn handle_php_job( shared_mount: &str, occupancy_metrics: &mut OccupancyMetrics, ) -> error::Result<Box<RawValue>> { - check_executor_binary_exists("php", PHP_PATH.as_str(), "php")?; + ensure_php_tool_installed("PHP", PHP_PATH.as_str())?; let (composer_json, composer_lock) = match &maybe_lock { MaybeLock::Resolved { lock } if !lock.is_empty() => split_reqs_and_lock(lock)?, @@ -512,6 +529,14 @@ try {{ if ($step_id) {{ $err["step_id"] = $step_id; }} + $job_id = getenv('WM_JOB_ID'); + if ($job_id) {{ + $err["job_id"] = $job_id; + }} + $flow_path = getenv('WM_FLOW_PATH'); + if ($flow_path) {{ + $err["flow_path"] = $flow_path; + }} file_put_contents("result.json", json_encode($err)); exit(1); }} @@ -541,19 +566,6 @@ try {{ let child = if is_sandboxing_enabled() { let nsjail_timeout = resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; - let _ = write_file( - job_dir, - "run.config.proto", - &NSJAIL_CONFIG_RUN_PHP_CONTENT - .replace("{JOB_DIR}", job_dir) - .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) - .replace("{SHARED_MOUNT}", shared_mount) - .replace( - "{TMP_MOUNT_BLOCK}", - &resolve_nsjail_tmp_mount_block(job_dir).await, - ) - .replace("{TIMEOUT}", &nsjail_timeout), - )?; let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str()); let args = vec![ @@ -566,7 +578,6 @@ try {{ nsjail_cmd .current_dir(job_dir) .env_clear() - .envs(envs) .envs(reserved_variables) .envs(crate::get_otel_context_envs(&job.id)) .env("COMPOSER_HOME", &*COMPOSER_CACHE_DIR) @@ -574,6 +585,20 @@ try {{ .args(args) .stdout(Stdio::piped()) .stderr(Stdio::piped()); + let _ = write_file( + job_dir, + "run.config.proto", + &NSJAIL_CONFIG_RUN_PHP_CONTENT + .replace("{JOB_DIR}", job_dir) + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace("{SHARED_MOUNT}", shared_mount) + .replace( + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, + ) + .replace("{TIMEOUT}", &nsjail_timeout) + .replace("{ENVARS}", &script_envars(&envs, &nsjail_cmd, &[])), + )?; start_child_process(nsjail_cmd, NSJAIL_PATH.as_str(), false).await? } else { let script_path = format!("{job_dir}/wrapper.php"); diff --git a/backend/windmill-worker/src/pwsh_executor.rs b/backend/windmill-worker/src/pwsh_executor.rs index c089930b42..f1baf9d5aa 100644 --- a/backend/windmill-worker/src/pwsh_executor.rs +++ b/backend/windmill-worker/src/pwsh_executor.rs @@ -36,7 +36,7 @@ use crate::{ handle_child::handle_child, is_sandboxing_enabled, read_ee_registry_with_workspace_override, DISABLE_NUSER, HOME_ENV, NSJAIL_PATH, PATH_ENV, POWERSHELL_CACHE_DIR, POWERSHELL_PATH, POWERSHELL_REPO_PAT, - POWERSHELL_REPO_URL, PROXY_ENVS, TZ_ENV, + POWERSHELL_REPO_URL, TZ_ENV, }; fn val_to_pwsh_param(v: serde_json::Value) -> String { @@ -728,7 +728,7 @@ $env:PSModulePath = \"{};$PSModulePathBackup\"", let mut cmd = Command::new(NSJAIL_PATH.as_str()); cmd.current_dir(job_dir) .env_clear() - .envs(PROXY_ENVS.clone()) + .envs(crate::job_proxy_envs()) .envs(reserved_variables) .env("TZ", TZ_ENV.as_str()) .env("PATH", PATH_ENV.as_str()) diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 4ab3d50df8..aea9bd4af6 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -117,7 +117,7 @@ async fn handle_piptar_uploads(mut rx: tokio::sync::mpsc::UnboundedReceiver<Pipt const NSJAIL_CONFIG_DOWNLOAD_PY_CONTENT: &str = include_str!("../nsjail/download.py.config.proto"); const NSJAIL_CONFIG_RUN_PYTHON3_CONTENT: &str = include_str!("../nsjail/run.python3.config.proto"); const NSJAIL_CONFIG_LOCK_PY_CONTENT: &str = include_str!("../nsjail/lock.py.config.proto"); -pub const RELATIVE_PYTHON_LOADER: &str = include_str!("../loader.py"); +const RELATIVE_PYTHON_LOADER: &str = include_str!("../loader.py"); /// Every file exchanged with a job is UTF-8 by construction, so the interpreter /// must agree. A job env carries no locale: Linux then picks UTF-8 on its own @@ -129,7 +129,9 @@ pub const PYTHON_UTF8_ENVS: [(&str, &str); 1] = [("PYTHONUTF8", "1")]; /// Python dict literal. Preview jobs pass a path -> temp-hash map so relative /// imports resolve from not-yet-deployed local content; deployed runs pass /// `None` which renders an empty dict (deployed resolution unchanged). -fn render_relative_python_loader(temp_script_refs: &Option<HashMap<String, String>>) -> String { +pub(crate) fn render_relative_python_loader( + temp_script_refs: &Option<HashMap<String, String>>, +) -> String { let temp_refs_py = temp_script_refs .as_ref() .and_then(|m| serde_json::to_string(m).ok()) @@ -149,15 +151,16 @@ use crate::global_cache::pull_from_tar; use windmill_object_store::get_cache_object_store; use crate::{ + check_no_network_jailed, common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, read_file, read_result, render_nsjail_rlimit_as, resolve_nsjail_timeout, - resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, StreamNotifier, - DEV_CONF_NSJAIL, + proto_str, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, + StreamNotifier, DEV_CONF_NSJAIL, }, - get_proxy_envs_for_lang, + get_proxy_envs_for_lang, get_run_proxy_envs, handle_child::handle_child, - is_sandboxing_enabled, read_ee_registry, + is_sandboxing_enabled, nsjail_clone_newnet, read_ee_registry, worker_utils::ping_job_status, PyV, DISABLE_NUSER, HOME_ENV, INDEX_CERT, NATIVE_CERT, NSJAIL_AVAILABLE, NSJAIL_PATH, NSJAIL_PY_RLIMIT_AS_MB, PATH_ENV, PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, PROXY_ENVS, @@ -775,6 +778,8 @@ pub async fn handle_python_job( .to_string(), )); } + let jailed = is_sandboxing_enabled() || annotations.sandbox; + check_no_network_jailed(annotations.no_network, jailed)?; // Preview jobs may carry _TEMP_SCRIPT_REFS so relative imports resolve from // not-yet-deployed local content uploaded to raw_script_temp. Gated on @@ -791,7 +796,21 @@ pub async fn handle_python_job( None }; - let (py_version, mut additional_python_paths) = handle_python_deps( + let module_sources = windmill_parser_py_imports::module_sources(&script_path, modules.as_ref()); + if annotations.no_network { + let imports = std::iter::once((script_path.as_str(), inner_content.as_str())) + .chain( + module_sources + .iter() + .flatten() + .map(|(path, code)| (path.as_str(), code.as_str())), + ) + .flat_map(|(path, code)| { + windmill_parser_py_imports::parse_relative_imports(code, path).unwrap_or_default() + }); + crate::check_no_network_imports(imports, &script_path, modules.as_ref(), "Python")?; + } + let (py_version, mut additional_python_paths) = handle_python_deps_with_modules( job_dir, requirements_o, inner_content, @@ -807,6 +826,7 @@ pub async fn handle_python_job( precomputed_agent_info, annotations.clone(), &temp_script_refs, + module_sources.as_ref(), ) .await?; @@ -837,6 +857,9 @@ pub async fn handle_python_job( if annotations.sandbox { logs.push_str("sandbox mode (nsjail)\n"); } + if annotations.no_network { + logs.push_str("no network\n"); + } append_logs(&job.id, &job.workspace_id, logs, conn).await; } let ( @@ -980,6 +1003,12 @@ except BaseException as e: flow_node_id = os.environ.get('WM_FLOW_STEP_ID') if flow_node_id: err['step_id'] = flow_node_id + job_id = os.environ.get('WM_JOB_ID') + if job_id: + err['job_id'] = job_id + flow_path = os.environ.get('WM_FLOW_PATH') + if flow_path: + err['flow_path'] = flow_path err_json = json.dumps(err, separators=(',', ':'), default=str).replace('\n', '') f.write(err_json) sys.exit(1) @@ -1050,6 +1079,12 @@ except BaseException as e: flow_node_id = os.environ.get('WM_FLOW_STEP_ID') if flow_node_id: err['step_id'] = flow_node_id + job_id = os.environ.get('WM_JOB_ID') + if job_id: + err['job_id'] = job_id + flow_path = os.environ.get('WM_FLOW_PATH') + if flow_path: + err['flow_path'] = flow_path err_json = json.dumps(err, separators=(',', ':'), default=str).replace('\n', '') f.write(err_json) sys.exit(1) @@ -1078,6 +1113,7 @@ except BaseException as e: let mut reserved_variables = get_reserved_variables(job, &client.token, conn, parent_runnable_path).await?; + crate::strip_no_network_token(&mut reserved_variables, annotations.no_network); // Add /tmp/windmill/cache/python_x_y_z/global-site-packages to PYTHONPATH. // Usefull if certain wheels needs to be preinstalled before execution. @@ -1110,7 +1146,7 @@ except BaseException as e: #[cfg(windows)] let additional_python_paths_folders = additional_python_paths_folders.replace(":", ";"); - if is_sandboxing_enabled() || annotations.sandbox { + if jailed { let shared_deps = additional_python_paths .into_iter() .map(|pp| { @@ -1126,6 +1162,7 @@ mount {{ ) }) .join("\n"); + let module_mounts = python_module_mounts(job_dir, &dirs, modules.as_ref()); let nsjail_timeout = resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; let _ = write_file( @@ -1139,8 +1176,13 @@ mount {{ .replace("{JOB_DIR}", job_dir) .replace("{PY_INSTALL_DIR}", &*PY_INSTALL_DIR) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace( + "{CLONE_NEWNET}", + nsjail_clone_newnet(annotations.no_network), + ) .replace("{SHARED_MOUNT}", shared_mount) .replace("{SHARED_DEPENDENCIES}", shared_deps.as_str()) + .replace("{MODULE_MOUNTS}", &module_mounts) .replace("{GLOBAL_SITE_PACKAGES}", &global_site_packages_path) .replace("{MAIN}", format!("{dirs}/{last}").as_str()) .replace( @@ -1165,7 +1207,7 @@ mount {{ job.id ); - let child = if is_sandboxing_enabled() || annotations.sandbox { + let child = if jailed { let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str()); nsjail_cmd .current_dir(job_dir) @@ -1173,12 +1215,13 @@ mount {{ // inject PYTHONPATH here - for some reason I had to do it in nsjail conf .envs(reserved_variables) .envs( - get_proxy_envs_for_lang( + get_run_proxy_envs( &ScriptLang::Python3, job.kind, &job.id, &job.workspace_id, conn, + annotations.no_network, ) .await?, ) @@ -1300,6 +1343,28 @@ mount {{ Ok(result) } +/// nsjail mounts for a job's module files. They are written next to the main script, and the +/// jail sees only what it mounts: without these, a module import falls through to the +/// loader's API fetch. Keys come from the request, hence the containment check and quoting. +fn python_module_mounts( + job_dir: &str, + dirs: &str, + modules: Option<&HashMap<String, windmill_common::scripts::ScriptModule>>, +) -> String { + modules + .into_iter() + .flat_map(|m| m.keys()) + .filter(|rel| crate::worker::is_contained_relative_path(rel)) + .map(|rel| { + format!( + "mount {{\n src: {}\n dst: {}\n is_bind: true\n mandatory: false\n}}\n", + proto_str(&format!("{job_dir}/{dirs}/{rel}")), + proto_str(&format!("/tmp/{dirs}/{rel}")), + ) + }) + .join("\n") +} + /// Generate Python code to spread preprocessor args from `kwargs` into `pre_args`. /// The `indent` parameter controls the join separator for multi-arg spreads. fn python_preprocessor_spread(sig: &windmill_parser::MainArgSignature, indent: &str) -> String { @@ -1939,6 +2004,7 @@ async fn replace_pip_secret( } } +#[cfg(feature = "private")] pub(crate) async fn handle_python_deps( job_dir: &str, requirements_o: Option<&String>, @@ -1955,6 +2021,45 @@ pub(crate) async fn handle_python_deps( precomputed_agent_info: Option<PrecomputedAgentInfo>, annotations: PythonAnnotations, temp_script_refs: &Option<HashMap<String, String>>, +) -> error::Result<(PyV, Vec<String>)> { + handle_python_deps_with_modules( + job_dir, + requirements_o, + inner_content, + w_id, + script_path, + job_id, + conn, + worker_name, + worker_dir, + mem_peak, + canceled_by, + occupancy_metrics, + precomputed_agent_info, + annotations, + temp_script_refs, + None, + ) + .await +} + +async fn handle_python_deps_with_modules( + job_dir: &str, + requirements_o: Option<&String>, + inner_content: &str, + w_id: &str, + script_path: &str, + job_id: &Uuid, + conn: &Connection, + worker_name: &str, + worker_dir: &str, + mem_peak: &mut i32, + canceled_by: &mut Option<CanceledBy>, + occupancy_metrics: &mut Option<&mut OccupancyMetrics>, + precomputed_agent_info: Option<PrecomputedAgentInfo>, + annotations: PythonAnnotations, + temp_script_refs: &Option<HashMap<String, String>>, + module_sources: Option<&HashMap<String, String>>, ) -> error::Result<(PyV, Vec<String>)> { create_dependencies_dir(job_dir).await; @@ -1984,6 +2089,7 @@ pub(crate) async fn handle_python_deps( &mut locked_v, &None, temp_script_refs, + module_sources, )) .await?; @@ -3673,6 +3779,25 @@ pub async fn start_worker( #[cfg(test)] mod tests { + #[test] + fn module_files_are_mounted_next_to_the_main_script() { + let module = |content: &str| windmill_common::scripts::ScriptModule { + content: content.to_string(), + language: windmill_common::scripts::ScriptLang::Python3, + lock: None, + }; + let modules = HashMap::from([ + ("lib/util.py".to_string(), module("")), + ("../escape.py".to_string(), module("")), + ("q\"uote.py".to_string(), module("")), + ]); + let mounts = super::python_module_mounts("/jobs/1", "u/admin", Some(&modules)); + assert!(mounts.contains("src: \"/jobs/1/u/admin/lib/util.py\""), "{mounts}"); + assert!(mounts.contains("dst: \"/tmp/u/admin/lib/util.py\""), "{mounts}"); + assert!(!mounts.contains("escape"), "{mounts}"); + assert!(mounts.contains("q\\\"uote.py"), "{mounts}"); + } + use super::*; #[test] diff --git a/backend/windmill-worker/src/r_executor.rs b/backend/windmill-worker/src/r_executor.rs index 9b29ae35b6..4220feba91 100644 --- a/backend/windmill-worker/src/r_executor.rs +++ b/backend/windmill-worker/src/r_executor.rs @@ -18,14 +18,15 @@ use windmill_parser_r::{parse_r_requirements, parse_r_signature}; use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ + check_no_network_jailed, common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, - read_result, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, - DEV_CONF_NSJAIL, + read_result, resolve_nsjail_tmp_mount_block, script_envars, script_overrides, + start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, - get_proxy_envs_for_lang, + get_proxy_envs_for_lang, get_run_proxy_envs, handle_child::{self}, - is_sandboxing_enabled, + is_sandboxing_enabled, nsjail_clone_newnet, universal_pkg_installer::{ par_install_language_dependencies_seq, DependencyGraph, InstallDeps, RequiredDependency, }, @@ -86,6 +87,8 @@ pub async fn handle_r_job<'a>( .to_string(), )); } + let jailed = !cfg!(windows) && (is_sandboxing_enabled() || annotation.sandbox); + check_no_network_jailed(annotation.no_network, jailed)?; // --- Prepare --- { @@ -120,7 +123,13 @@ pub async fn handle_r_job<'a>( }; // --- Execute --- { - run(&mut args, lib_path.as_deref(), annotation.sandbox).await?; + run( + &mut args, + lib_path.as_deref(), + jailed, + annotation.no_network, + ) + .await?; } // --- Retrieve results --- { @@ -593,51 +602,44 @@ async fn run<'a>( .. }: &mut JobHandlerInput<'a>, lib_path: Option<&str>, - sandbox: bool, + nsjail: bool, + no_network: bool, ) -> Result<(), Error> { - let reserved_variables = + let mut reserved_variables = get_reserved_variables(job, &client.token, conn, parent_runnable_path.clone()).await?; + crate::strip_no_network_token(&mut reserved_variables, no_network); - let nsjail = !cfg!(windows) && (is_sandboxing_enabled() || sandbox); let child = if nsjail { append_logs( &job.id, &job.workspace_id, - "\n--- R CODE EXECUTION (nsjail) ---\n".to_string(), + format!( + "\n--- R CODE EXECUTION (nsjail) ---\n{}", + if no_network { "no network\n" } else { "" } + ), conn, ) .await; - write_file( - job_dir, - "run.config.proto", - &NSJAIL_CONFIG_RUN_R_CONTENT - .replace("{JOB_DIR}", job_dir) - .replace("{SHARED_MOUNT}", &shared_mount) - .replace("{R_CACHE_DIR}", &*R_CACHE_DIR) - .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) - .replace("#{DEV}", DEV_CONF_NSJAIL) - .replace( - "{TMP_MOUNT_BLOCK}", - &resolve_nsjail_tmp_mount_block(job_dir).await, - ) - .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()), - )?; let mut cmd = Command::new(NSJAIL_PATH.as_str()); cmd.env_clear() - .current_dir(job_dir) + .current_dir(*job_dir) .env("PATH", PATH_ENV.as_str()) .env("BASE_INTERNAL_URL", base_internal_url) - .envs(envs) - .envs(reserved_variables) - .envs(R_PROXY_ENVS.clone()) + .envs(&reserved_variables) + .envs(if no_network || !crate::passes_proxy_to_jobs() { + vec![] + } else { + R_PROXY_ENVS.clone() + }) .envs( - get_proxy_envs_for_lang( + get_run_proxy_envs( &ScriptLang::Rlang, job.kind, &job.id, &job.workspace_id, conn, + no_network, ) .await?, ); @@ -653,6 +655,30 @@ async fn run<'a>( ]); cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + write_file( + job_dir, + "run.config.proto", + &NSJAIL_CONFIG_RUN_R_CONTENT + .replace("{JOB_DIR}", job_dir) + .replace("{SHARED_MOUNT}", &shared_mount) + .replace("{R_CACHE_DIR}", &*R_CACHE_DIR) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) + .replace("#{DEV}", DEV_CONF_NSJAIL) + .replace( + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, + ) + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace("{CLONE_NEWNET}", nsjail_clone_newnet(no_network)) + .replace( + "{ENVARS}", + &script_envars( + &envs, + &cmd, + &script_overrides(&["PATH", "BASE_INTERNAL_URL"], &reserved_variables), + ), + ), + )?; start_child_process(cmd, NSJAIL_PATH.as_str(), false).await? } else { append_logs( @@ -677,7 +703,11 @@ async fn run<'a>( .env("PATH", PATH_ENV.as_str()) .env("BASE_INTERNAL_URL", base_internal_url) .envs(reserved_variables) - .envs(R_PROXY_ENVS.clone()) + .envs(if crate::passes_proxy_to_jobs() { + R_PROXY_ENVS.clone() + } else { + vec![] + }) .envs( get_proxy_envs_for_lang( &ScriptLang::Rlang, diff --git a/backend/windmill-worker/src/ruby_executor.rs b/backend/windmill-worker/src/ruby_executor.rs index 7871c050c6..c5828f192a 100644 --- a/backend/windmill-worker/src/ruby_executor.rs +++ b/backend/windmill-worker/src/ruby_executor.rs @@ -23,8 +23,8 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, - read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, - OccupancyMetrics, DEV_CONF_NSJAIL, + read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, script_envars, + script_overrides, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, handle_child::{self}, @@ -629,21 +629,11 @@ async fn install<'a>( // true, // crate::common::InstallStrategy::Single(Arc::new(move |dependency| { move |dependency| { - let mut cmd = if jailed { + let nsjail_proto = jailed.then(|| format!("{}.download.config.proto", Uuid::new_v4())); + let mut cmd = if let Some(nsjail_proto) = &nsjail_proto { std::fs::create_dir_all(&dependency.path)?; - let nsjail_proto = format!("{}.download.config.proto", Uuid::new_v4()); - let _ = write_file( - &job_dir, - &nsjail_proto, - &NSJAIL_CONFIG_DOWNLOAD_RUBY_CONTENT - .replace("{TARGET}", &dependency.path) - .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) - .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) - .replace("{TMP_MOUNT_BLOCK}", &nsjail_tmp_mount_block) - .replace("#{DEV}", DEV_CONF_NSJAIL), // .replace("{BUILD}", &build_dir), - )?; let mut cmd = Command::new(NSJAIL_PATH.as_str()); - cmd.args(vec!["--config", &nsjail_proto, "--", GEM_PATH.as_str()]); + cmd.args(vec!["--config", nsjail_proto, "--", GEM_PATH.as_str()]); cmd } else { Command::new(if cfg!(windows) { @@ -699,10 +689,36 @@ async fn install<'a>( "--source", source.as_str(), ]) - .envs(envs.clone()) - .envs(reserved_variables.clone()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); + if !jailed { + cmd.envs(&envs); + } + cmd.envs(&reserved_variables); + if let Some(nsjail_proto) = &nsjail_proto { + let set_before = ["PATH", "HOME"] + .into_iter() + .chain(RUBY_PROXY_ENVS.iter().map(|(k, _)| k.as_str())) + .collect::<Vec<_>>(); + let _ = write_file( + &job_dir, + nsjail_proto, + &NSJAIL_CONFIG_DOWNLOAD_RUBY_CONTENT + .replace("{TARGET}", &dependency.path) + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) + .replace("{TMP_MOUNT_BLOCK}", &nsjail_tmp_mount_block) + .replace("#{DEV}", DEV_CONF_NSJAIL) + .replace( + "{ENVARS}", + &script_envars( + &envs, + &cmd, + &script_overrides(&set_before, &reserved_variables), + ), + ), + )?; + } if !verbose { // Make it output minimal info @@ -802,31 +818,18 @@ mount {{ let nsjail_timeout = resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; - write_file( - job_dir, - "run.config.proto", - &NSJAIL_CONFIG_RUN_RUBY_CONTENT - .replace("{JOB_DIR}", job_dir) - .replace("{SHARED_MOUNT}", &shared_mount) - .replace("{SHARED_DEPENDENCIES}", &shared_deps) - .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) - .replace("#{DEV}", DEV_CONF_NSJAIL) - .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) - .replace( - "{TMP_MOUNT_BLOCK}", - &resolve_nsjail_tmp_mount_block(job_dir).await, - ) - .replace("{TIMEOUT}", &nsjail_timeout), - )?; let mut cmd = Command::new(NSJAIL_PATH.as_str()); cmd.env_clear() - .current_dir(job_dir) + .current_dir(*job_dir) .env("PATH", PATH_ENV.as_str()) .env("BASE_INTERNAL_URL", base_internal_url) .env("RUBYLIB", rubylib.as_str()) - .envs(envs) - .envs(reserved_variables) - .envs(RUBY_PROXY_ENVS.clone()) + .envs(&reserved_variables) + .envs(if crate::passes_proxy_to_jobs() { + RUBY_PROXY_ENVS.clone() + } else { + vec![] + }) .envs( get_proxy_envs_for_lang( &ScriptLang::Ruby, @@ -846,6 +849,33 @@ mount {{ ]); cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + write_file( + job_dir, + "run.config.proto", + &NSJAIL_CONFIG_RUN_RUBY_CONTENT + .replace("{JOB_DIR}", job_dir) + .replace("{SHARED_MOUNT}", &shared_mount) + .replace("{SHARED_DEPENDENCIES}", &shared_deps) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) + .replace("#{DEV}", DEV_CONF_NSJAIL) + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace( + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, + ) + .replace("{TIMEOUT}", &nsjail_timeout) + .replace( + "{ENVARS}", + &script_envars( + &envs, + &cmd, + &script_overrides( + &["PATH", "BASE_INTERNAL_URL", "RUBYLIB"], + &reserved_variables, + ), + ), + ), + )?; start_child_process(cmd, NSJAIL_PATH.as_str(), false).await? } else { append_logs( @@ -874,7 +904,11 @@ mount {{ .env("RUBYLIB", rubylib.as_str()) .env("BASE_INTERNAL_URL", base_internal_url) .envs(reserved_variables) - .envs(RUBY_PROXY_ENVS.clone()) + .envs(if crate::passes_proxy_to_jobs() { + RUBY_PROXY_ENVS.clone() + } else { + vec![] + }) .envs( get_proxy_envs_for_lang( &ScriptLang::Ruby, diff --git a/backend/windmill-worker/src/rust_executor.rs b/backend/windmill-worker/src/rust_executor.rs index dff7d58c2d..fabea34499 100644 --- a/backend/windmill-worker/src/rust_executor.rs +++ b/backend/windmill-worker/src/rust_executor.rs @@ -23,7 +23,8 @@ use crate::{ common::{ build_command_with_isolation, check_executor_binary_exists, create_args_and_out_file, get_reserved_variables, read_result, resolve_nsjail_timeout, - resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, + resolve_nsjail_tmp_mount_block, script_envars, start_child_process, OccupancyMetrics, + DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, handle_child::handle_child, @@ -757,28 +758,10 @@ pub async fn handle_rust_job( let child = if is_sandboxing_enabled() { let nsjail_timeout = resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; - let _ = write_file( - job_dir, - "run.config.proto", - &NSJAIL_CONFIG_RUN_RUST_CONTENT - .replace("{JOB_DIR}", job_dir) - .replace("{CACHE_DIR}", &*RUST_CACHE_DIR) - .replace("{CACHE_HASH}", &hash) - .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) - .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) - .replace("#{DEV}", DEV_CONF_NSJAIL) - .replace("{SHARED_MOUNT}", shared_mount) - .replace( - "{TMP_MOUNT_BLOCK}", - &resolve_nsjail_tmp_mount_block(job_dir).await, - ) - .replace("{TIMEOUT}", &nsjail_timeout), - )?; let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str()); nsjail_cmd .current_dir(job_dir) .env_clear() - .envs(envs) .envs(reserved_variables) .envs( get_proxy_envs_for_lang( @@ -796,6 +779,24 @@ pub async fn handle_rust_job( .args(vec!["--config", "run.config.proto", "--", "/tmp/main"]) .stdout(Stdio::piped()) .stderr(Stdio::piped()); + let _ = write_file( + job_dir, + "run.config.proto", + &NSJAIL_CONFIG_RUN_RUST_CONTENT + .replace("{JOB_DIR}", job_dir) + .replace("{CACHE_DIR}", &*RUST_CACHE_DIR) + .replace("{CACHE_HASH}", &hash) + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) + .replace("#{DEV}", DEV_CONF_NSJAIL) + .replace("{SHARED_MOUNT}", shared_mount) + .replace( + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, + ) + .replace("{TIMEOUT}", &nsjail_timeout) + .replace("{ENVARS}", &script_envars(&envs, &nsjail_cmd, &[])), + )?; start_child_process(nsjail_cmd, NSJAIL_PATH.as_str(), false).await? } else { let compiled_executable_name = &format!("{job_dir}/{RUST_BIN_NAME}"); diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index a55f06a191..aa742bfdf5 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -261,6 +261,7 @@ const NUM_SECS_PING: u64 = 5; const NUM_SECS_READINGS: u64 = 60; const INCLUDE_DEPS_PY_SH_CONTENT: &str = include_str!("../nsjail/download_deps.py.sh"); +const NSJAIL_CONFIG_PROBE_CONTENT: &str = include_str!("../nsjail/probe.config.proto"); const WORKER_SHELL_NAP_TIME_DURATION: u64 = 15; const WORKER_SHELL_INITIAL_NAP_TIME_DURATION: u64 = 5; @@ -335,6 +336,14 @@ pub struct PowershellRepo { pub pat: String, } +/// Runs nsjail in a form gVisor (runsc) accepts, see `nsjail_compat`. +pub static NSJAIL_GVISOR_COMPAT: std::sync::LazyLock<bool> = std::sync::LazyLock::new(|| { + std::env::var("NSJAIL_GVISOR_COMPAT") + .ok() + .and_then(|x| x.parse::<bool>().ok()) + .unwrap_or(false) +}); + lazy_static::lazy_static! { static ref SLEEP_QUEUE_BASE: u64 = std::env::var("SLEEP_QUEUE") @@ -358,6 +367,15 @@ lazy_static::lazy_static! { .and_then(|x| x.parse::<bool>().ok()) .unwrap_or(false); + /// Runs Bun scripts' dependency installs (bun and `//npm`) outside nsjail even when + /// sandboxing is on, for a worker whose installs need host files the install jail + /// does not mount (keys for private git dependencies, a package cache outside HOME). + /// The run step stays jailed; the installs' lifecycle scripts see the worker again. + pub static ref DISABLE_BUN_INSTALL_SANDBOX: bool = std::env::var("DISABLE_BUN_INSTALL_SANDBOX") + .ok() + .and_then(|x| x.parse::<bool>().ok()) + .unwrap_or(false); + /// Per-language override for the nsjail `rlimit_as` (virtual address space) cap. /// Value is in MiB, or `unlimited`/`none`/`inf`/`0` to uncap. Unset keeps the /// historical default baked into the proto. See `render_nsjail_rlimit_as`. @@ -537,29 +555,41 @@ lazy_static::lazy_static! { pub static ref NSJAIL_AVAILABLE: Option<String> = { let nsjail_path = NSJAIL_PATH.as_str(); - let test_result = std::process::Command::new(nsjail_path) - .arg("--help") - .output(); - - match test_result { + match probe_nsjail(nsjail_path) { Ok(output) if output.status.success() => { - tracing::info!("nsjail available at: {}", nsjail_path); + tracing::info!( + "nsjail available at: {}{}", + nsjail_path, + if *NSJAIL_GVISOR_COMPAT { " (NSJAIL_GVISOR_COMPAT)" } else { "" } + ); Some(nsjail_path.to_string()) }, Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr); - tracing::error!( - "nsjail test failed (exit code: {}). stderr: '{}'. path: '{}'. \ - Nsjail sandboxing will NOT be available. \ - nsjail should be included in all standard windmill images. \ - If job_isolation is set to 'nsjail_sandboxing' in Instance Settings, jobs will fail.", + let msg = format!( + "nsjail could not run a test jail (exit code: {}). stderr: '{}'. path: '{}'. \ + Nsjail sandboxing will NOT be available: the worker reports its job isolation \ + without nsjail, and jobs that require nsjail fail. Under gVisor (runsc), see \ + NSJAIL_GVISOR_COMPAT.", output.status, - stderr.trim(), + String::from_utf8_lossy(&output.stderr).trim(), nsjail_path ); + // Most workers never jail anything, and most containers cannot. + if *DISABLE_NSJAIL { + tracing::info!("{msg}"); + } else { + tracing::error!("{msg}"); + } + None + }, + Err(NsjailProbeError::Setup(e)) => { + tracing::error!( + "Could not prepare the nsjail test jail: {}. Nsjail sandboxing will NOT be available.", + e + ); None }, - Err(e) => { + Err(NsjailProbeError::Start(e)) => { if e.kind() == std::io::ErrorKind::NotFound { tracing::error!( "nsjail not found at '{}'. Nsjail sandboxing will NOT be available. \ @@ -1012,6 +1042,44 @@ pub(crate) fn artifact_cache_name( format!("mod-{}", windmill_common::utils::calculate_hash(&keyed)) } +/// Runs `/bin/true` in a jail built like a job's, through the same spawn path, so a worker +/// whose runtime lets nsjail start but not jail (a container without the privileges, gVisor) +/// does not report nsjail isolation. +enum NsjailProbeError { + /// The probe could not be prepared: nothing is known about nsjail. + Setup(std::io::Error), + /// nsjail itself could not be executed. + Start(std::io::Error), +} + +fn probe_nsjail(nsjail_path: &str) -> Result<std::process::Output, NsjailProbeError> { + // A fresh private directory: the worker runs what this config says, as itself. + let dir = std::env::temp_dir().join(format!("windmill-nsjail-probe-{}", uuid::Uuid::new_v4())); + #[cfg_attr(not(unix), allow(unused_mut))] + let mut builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); + builder.create(&dir).map_err(NsjailProbeError::Setup)?; + let output = (|| { + std::fs::write( + dir.join("probe.config.proto"), + NSJAIL_CONFIG_PROBE_CONTENT.replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()), + ) + .map_err(NsjailProbeError::Setup)?; + let mut cmd = std::process::Command::new(nsjail_path); + cmd.current_dir(&dir) + .env_clear() + .env("PATH", PATH_ENV.as_str()) + .args(["--config", "probe.config.proto"]); + if *NSJAIL_GVISOR_COMPAT { + crate::nsjail_compat::apply(&cmd).map_err(NsjailProbeError::Setup)?; + } + cmd.output().map_err(NsjailProbeError::Start) + })(); + let _ = std::fs::remove_dir_all(&dir); + output +} + pub fn is_sandboxing_enabled() -> bool { if !*DISABLE_NSJAIL { return true; @@ -1022,6 +1090,204 @@ pub fn is_sandboxing_enabled() -> bool { } } +/// A `no_network` job loses its network only through the fresh network namespace its +/// nsjail run step is given, so a run step that would not be jailed is refused rather +/// than run with the worker's network. `jailed` is the executor's own decision to jail. +pub fn check_no_network_jailed( + no_network: bool, + jailed: bool, +) -> windmill_common::error::Result<()> { + no_network_refusal(no_network, jailed, NSJAIL_AVAILABLE.is_some()) +} + +fn no_network_refusal( + no_network: bool, + jailed: bool, + nsjail_available: bool, +) -> windmill_common::error::Result<()> { + if !no_network || (jailed && nsjail_available) { + return Ok(()); + } + let reason = if jailed { + "nsjail is not available on this worker" + } else { + "this job would not run in an nsjail sandbox: add the `sandbox` annotation, or enable \ + nsjail sandboxing for the instance" + }; + Err(windmill_common::error::Error::ExecutionErr(format!( + "Script has the `no_network` annotation, which removes the network from its nsjail \ + sandbox, but {reason}. Refusing to run it with network." + ))) +} + +/// Whether a script asks for a run step without network, which only nsjail provides. A +/// dedicated worker runs its script outside nsjail, so it leaves such a script to the +/// regular executor. +pub fn runs_no_network_in_jail(language: Option<ScriptLang>, content: &str) -> bool { + match language { + Some(ScriptLang::Python3) => { + windmill_common::worker::PythonAnnotations::parse(content).no_network + } + Some(ScriptLang::Bun) | Some(ScriptLang::Bunnative) => { + let annotation = windmill_common::worker::TypeScriptAnnotations::parse(content); + annotation.no_network && !annotation.native + } + _ => false, + } +} + +/// Refuses a `no_network` job that imports another workspace script its run step would fetch +/// from the API, which it cannot reach. Without this the fetch fails inside the script, where +/// Python's loader falls back to an empty namespace module and the error names something else. +/// `imports` are workspace paths; the job's own module files (keyed relative to +/// `script_path`'s folder) are on disk and need no fetch. +pub fn check_no_network_imports( + imports: impl IntoIterator<Item = String>, + script_path: &str, + modules: Option<&HashMap<String, ScriptModule>>, + fetched_by: &str, +) -> windmill_common::error::Result<()> { + let dir = script_path + .rsplit_once('/') + .map(|(dir, _)| dir) + .unwrap_or(""); + let local: Vec<String> = modules + .into_iter() + .flat_map(|m| m.keys()) + .map(|key| { + let stem = key.rsplit_once('.').map(|(stem, _)| stem).unwrap_or(key); + if dir.is_empty() { + stem.to_string() + } else { + format!("{dir}/{stem}") + } + }) + .collect(); + let fetched = imports.into_iter().find(|import| { + !local + .iter() + .any(|l| l == import || l.starts_with(&format!("{import}/"))) + }); + match fetched { + None => Ok(()), + Some(import) => Err(windmill_common::error::Error::ExecutionErr(format!( + "Script has the `no_network` annotation but imports the workspace script \ + `{import}`, which {fetched_by} fetches from the Windmill API while the script runs, \ + and the script has no network. Remove `no_network`, or copy that code into the \ + script." + ))), + } +} + +/// Drops `WM_TOKEN` from a `no_network` run step's env: it is useless without network, and +/// the script could still hand it out through its result. +pub fn strip_no_network_token( + reserved_variables: &mut std::collections::HashMap<String, String>, + no_network: bool, +) { + if no_network { + reserved_variables.remove("WM_TOKEN"); + } +} + +/// `clone_newnet` for a run step's nsjail profile. +pub fn nsjail_clone_newnet(no_network: bool) -> &'static str { + if no_network { + "true" + } else { + "false" + } +} + +/// Whether job runs get the worker's proxy settings. Dependency installs always do: they read +/// [`PROXY_ENVS`] directly, so a group that turns the proxy off for jobs still installs through it. +pub fn passes_proxy_to_jobs() -> bool { + !WORKER_CONFIG.load().disable_job_proxy +} + +/// [`PROXY_ENVS`] as a job run sees them; see [`passes_proxy_to_jobs`]. +pub fn job_proxy_envs() -> Vec<(&'static str, String)> { + if passes_proxy_to_jobs() { + PROXY_ENVS.clone() + } else { + vec![] + } +} + +/// [`get_proxy_envs_for_lang`] for a run step. A `no_network` one reaches no proxy, the +/// OTel tracing proxy included, so it gets only the trace context. +pub async fn get_run_proxy_envs( + lang: &ScriptLang, + job_kind: JobKind, + job_id: &uuid::Uuid, + w_id: &str, + conn: &Connection, + no_network: bool, +) -> anyhow::Result<Vec<(&'static str, String)>> { + if no_network { + return Ok(get_otel_context_envs(job_id)); + } + get_proxy_envs_for_lang(lang, job_kind, job_id, w_id, conn).await +} + +#[cfg(test)] +mod no_network_tests { + use super::{check_no_network_imports, no_network_refusal, runs_no_network_in_jail}; + use windmill_common::scripts::ScriptLang; + + #[test] + fn no_network_runs_only_in_an_available_jail() { + assert!(no_network_refusal(false, false, false).is_ok()); + assert!(no_network_refusal(true, true, true).is_ok()); + assert!(no_network_refusal(true, false, true).is_err()); + assert!(no_network_refusal(true, true, false).is_err()); + assert!(no_network_refusal(true, false, false).is_err()); + } + + #[test] + fn no_network_refuses_only_imports_it_would_fetch() { + let modules = std::collections::HashMap::from([( + "lib/util.py".to_string(), + windmill_common::scripts::ScriptModule { + content: String::new(), + lock: None, + language: ScriptLang::Python3, + }, + )]); + let check = |imports: &[&str]| { + check_no_network_imports( + imports.iter().map(|s| s.to_string()), + "u/admin/main", + Some(&modules), + "Python", + ) + }; + assert!(check(&["u/admin/lib/util", "u/admin/lib"]).is_ok()); + let err = check(&["u/admin/lib/util", "u/admin/helper"]).unwrap_err(); + assert!(err.to_string().contains("`u/admin/helper`"), "{err}"); + } + + #[test] + fn dedicated_workers_leave_jailed_no_network_scripts() { + let py = Some(ScriptLang::Python3); + let bun = Some(ScriptLang::Bun); + assert!(runs_no_network_in_jail( + py, + "# no_network\ndef main(): pass" + )); + assert!(!runs_no_network_in_jail(py, "def main(): pass")); + assert!(runs_no_network_in_jail( + bun, + "// no_network\nexport function main() {}" + )); + // Native scripts take their network away inside the isolate. + assert!(!runs_no_network_in_jail( + bun, + "//native\n//no_network\nexport function main() {}" + )); + } +} + /// Returns true if unshare PID isolation should be used (when not using nsjail). /// ENABLE_UNSHARE_PID forces unshare regardless of the global setting. /// FAVOR_UNSHARE_PID uses unshare only when the global setting is not set. @@ -1121,12 +1387,12 @@ pub async fn get_proxy_envs_for_lang( { envs = get_otel_tracing_proxy_envs(job_id, w_id, conn).await?; } else { - envs = PROXY_ENVS.clone(); + envs = job_proxy_envs(); } #[cfg(not(all(feature = "private", feature = "enterprise")))] { let _ = (lang, job_kind, w_id, conn); - envs = PROXY_ENVS.clone(); + envs = job_proxy_envs(); } envs.extend(get_otel_context_envs(job_id)); Ok(envs) @@ -1148,7 +1414,7 @@ async fn get_otel_tracing_proxy_envs( This job's HTTP requests will not be traced."; tracing::warn!("{}", reason); append_logs(job_id, w_id, format!("\n[warning] {reason}\n"), conn).await; - return Ok(PROXY_ENVS.clone()); + return Ok(job_proxy_envs()); } }; let proxy_url = format!("http://127.0.0.1:{}", port); @@ -5189,8 +5455,8 @@ pub fn build_envs( hm }; - for (k, v) in PROXY_ENVS.iter() { - envs.insert(k.to_string(), v.clone()); + for (k, v) in job_proxy_envs() { + envs.insert(k.to_string(), v); } Ok(envs) @@ -5779,7 +6045,7 @@ async fn record_declared_warehouse_write( /// True when `path` contains only `Normal`/`CurDir` components, i.e. it cannot /// escape the directory it is joined onto (no `..`, no absolute root, no Windows /// drive prefix). -fn is_contained_relative_path(path: &str) -> bool { +pub(crate) fn is_contained_relative_path(path: &str) -> bool { use std::path::Component; std::path::Path::new(path) .components() @@ -7245,6 +7511,33 @@ pub async fn parse_sig_of_lang( }) } +/// Inline runs happen outside any worker's job loop, so they get no job dir from it. Executors +/// write there (DuckDB's spill and result files), so each run needs its own: an empty one +/// resolves to `/`, shared by every concurrent run and unwritable to a non-root server. +/// Removed on drop: no job loop owns it, and the run's future is dropped mid-await when the +/// HTTP client disconnects. +struct InlineJobDir(String); + +impl Drop for InlineJobDir { + fn drop(&mut self) { + let dir = std::mem::take(&mut self.0); + match tokio::runtime::Handle::try_current() { + Ok(handle) => { + handle.spawn_blocking(move || std::fs::remove_dir_all(dir)); + } + Err(_) => { + let _ = std::fs::remove_dir_all(dir); + } + } + } +} + +async fn create_inline_job_dir() -> error::Result<InlineJobDir> { + let job_dir = format!("{}/inline/{}", *WINDMILL_DIR, Uuid::new_v4()); + tokio::fs::create_dir_all(&job_dir).await?; + Ok(InlineJobDir(job_dir)) +} + pub fn init_worker_internal_server_inline_utils( killpill_rx: tokio::sync::broadcast::Receiver<()>, base_internal_url: String, @@ -7275,13 +7568,14 @@ pub fn init_worker_internal_server_inline_utils( let mut occupancy_metrics = OccupancyMetrics::new(Instant::now()); let mut has_stream: bool = false; let mut killpill_rx = params.killpill_rx; + let job_dir = create_inline_job_dir().await?; run_language_executor( &job, ¶ms.conn, ¶ms.client, None, - ¶ms.job_dir, + &job_dir.0, ¶ms.worker_dir, &mut mem_peak, &mut canceled_by, @@ -7336,13 +7630,14 @@ pub fn init_worker_internal_server_inline_utils( let mut occupancy_metrics = OccupancyMetrics::new(Instant::now()); let mut has_stream: bool = false; let mut killpill_rx = params.killpill_rx; + let job_dir = create_inline_job_dir().await?; run_language_executor( &job, ¶ms.conn, ¶ms.client, None, - ¶ms.job_dir, + &job_dir.0, ¶ms.worker_dir, &mut mem_peak, &mut canceled_by, diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index a257ca51fa..e0c4026804 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -110,8 +110,8 @@ lazy_static::lazy_static! { /// Helper function to write itered data to separate table /// Returns None if data was written to separate table, Some(itered) if it should be stored in JSONB -async fn write_itered_to_db( - db: &DB, +async fn write_itered_to_db<'c>( + db: impl sqlx::PgExecutor<'c>, job_id: Uuid, itered: &Vec<Box<RawValue>>, ) -> error::Result<Option<Vec<Box<RawValue>>>> { @@ -328,6 +328,25 @@ fn get_stop_after_if_data(stop_after_if: Option<&StopAfterIf>) -> (bool, Option< return (false, None, false); } +/// Takes the caller's executor: under the status-update tx a pool read would wait on a second +/// connection, which a burst of completions holding every one never frees. +async fn fetch_flow_args<'c>( + db: impl sqlx::PgExecutor<'c>, + flow: Uuid, +) -> Result<HashMap<String, Box<RawValue>>, sqlx::Error> { + let args = sqlx::query_scalar!( + r#" + SELECT args AS "args: Json<HashMap<String, Box<RawValue>>>" + FROM v2_job + WHERE id = $1 + "#, + flow + ) + .fetch_one(db) + .await; + args.map(|flow_args| flow_args.map(|flow_args| flow_args.0).unwrap_or_default()) +} + async fn get_id_ctx_for_expr<'c>( expr: &str, flow: uuid::Uuid, @@ -633,22 +652,7 @@ pub async fn update_flow_status_after_job_completion_internal( } }; - let args = Arc::pin(Lazy::new(async move { - let args = sqlx::query_scalar!( - r#" - SELECT args AS "args: Json<HashMap<String, Box<RawValue>>>" - FROM v2_job - WHERE id = $1 - "#, - flow - ) - .fetch_one(db) - .await; - let args = - args.map(|flow_args| flow_args.map(|flow_args| flow_args.0).unwrap_or_default()); - - args - })); + let args = Arc::pin(Lazy::new(fetch_flow_args(db, flow))); let from_result_to_args = |args: &Result<HashMap<String, Box<RawValue>>, sqlx::Error>| { let args = args @@ -992,7 +996,7 @@ pub async fn update_flow_status_after_job_completion_internal( .as_ref() .and_then(|x| x.stop_after_all_iters_if.as_ref()) { - let args = from_result_to_args(args.as_ref().await.get_ref())?; + let args = from_result_to_args(&fetch_flow_args(&mut *tx, flow).await)?; // Isolate the reads in a savepoint on the same connection: the // caller below swallows our error and keeps using `tx`, so a DB // read failure must not leave the outer transaction aborted (that @@ -1227,7 +1231,7 @@ pub async fn update_flow_status_after_job_completion_internal( .as_ref() .and_then(|x| x.stop_after_all_iters_if.as_ref()) { - let args = from_result_to_args(args.as_ref().await.get_ref())?; + let args = from_result_to_args(&fetch_flow_args(&mut *tx, flow).await)?; // See the matching savepoint comment above: isolate the reads so a // DB read failure (whose error the caller swallows) cannot abort @@ -1329,7 +1333,7 @@ pub async fn update_flow_status_after_job_completion_internal( .unwrap_or_default(); tracing::info!("update flow status on retry: {retry:#?} "); - let args = from_result_to_args(args.as_ref().await.get_ref())?; + let args = from_result_to_args(&fetch_flow_args(&mut *tx, flow).await)?; evaluate_retry( &retry, @@ -1631,7 +1635,17 @@ pub async fn update_flow_status_after_job_completion_internal( Some(t) => { let no_args = HashMap::new(); let args = PushArgs::from(fetched_args.as_ref().unwrap_or(&no_args)); - resolve_push_tag(t, &args, &flow_job.workspace_id, db).await + match resolve_push_tag(t, &args, &flow_job.workspace_id, db).await { + Some(t) => Some( + windmill_queue::tags::apply_dev_workspace_suffix( + t, + &flow_job.workspace_id, + db, + ) + .await?, + ), + None => None, + } } None => None, }; @@ -2210,7 +2224,8 @@ pub async fn update_flow_status_after_job_completion_internal( None }, has_triggered_error_handler: has_triggered_error_handler || is_failure_step, - recovered_by_child: failure_step_recovers || (recovered_by_child && !success), + recovered_by_child: failure_step_recovers + || (recovered_by_child && !success), }, )); } @@ -3284,8 +3299,17 @@ pub(crate) fn resolve_flow_step_tag( workspace_id: &str, preserve_step_tags: bool, step_tag: Option<&str>, + // The suffix of a dev workspace's flow tag, if any: its flows run on the generic tag suffixed, + // and their steps are suffixed when pushed. + dev_suffix: Option<&str>, ) -> Option<String> { - if is_preprocessor_step || flow_tag == "flow" || flow_tag == format!("flow-{}", workspace_id) { + let generic_flow_tag = dev_suffix + .and_then(|s| flow_tag.strip_suffix(s)) + .unwrap_or(flow_tag); + if is_preprocessor_step + || generic_flow_tag == "flow" + || generic_flow_tag == format!("flow-{}", workspace_id) + { step_tag.map(str::to_string) } else if preserve_step_tags && step_tag.is_some_and(|t| !t.is_empty()) { step_tag.map(str::to_string) @@ -3363,7 +3387,14 @@ mod tag_resolution_tests { // Parent flow on a custom tag, step declares its own tag, preserve disabled: // the step inherits the flow tag (historical behavior). assert_eq!( - resolve_flow_step_tag(false, "worker-group-A", "w1", false, Some("worker-group-B")), + resolve_flow_step_tag( + false, + "worker-group-A", + "w1", + false, + Some("worker-group-B"), + None + ), Some("worker-group-A".to_string()) ); } @@ -3373,7 +3404,14 @@ mod tag_resolution_tests { // The exact customer scenario: a sub-flow tagged worker-group-B run as a step of a // flow tagged worker-group-A now runs on worker-group-B when preserve_step_tags is on. assert_eq!( - resolve_flow_step_tag(false, "worker-group-A", "w1", true, Some("worker-group-B")), + resolve_flow_step_tag( + false, + "worker-group-A", + "w1", + true, + Some("worker-group-B"), + None + ), Some("worker-group-B".to_string()) ); } @@ -3381,12 +3419,12 @@ mod tag_resolution_tests { #[test] fn untagged_step_inherits_flow_tag_even_when_preserve_enabled() { assert_eq!( - resolve_flow_step_tag(false, "worker-group-A", "w1", true, None), + resolve_flow_step_tag(false, "worker-group-A", "w1", true, None, None), Some("worker-group-A".to_string()) ); // An empty tag counts as "no tag" and still inherits. assert_eq!( - resolve_flow_step_tag(false, "worker-group-A", "w1", true, Some("")), + resolve_flow_step_tag(false, "worker-group-A", "w1", true, Some(""), None), Some("worker-group-A".to_string()) ); } @@ -3395,16 +3433,38 @@ mod tag_resolution_tests { fn generic_flow_tag_always_uses_step_tag() { for flow_tag in ["flow", "flow-w1"] { assert_eq!( - resolve_flow_step_tag(false, flow_tag, "w1", false, Some("worker-group-B")), + resolve_flow_step_tag(false, flow_tag, "w1", false, Some("worker-group-B"), None), Some("worker-group-B".to_string()) ); } } + #[test] + fn dev_workspace_flow_on_suffixed_generic_tag_uses_step_tag() { + for flow_tag in ["flow-dev", "flow-w1-dev"] { + assert_eq!( + resolve_flow_step_tag(false, flow_tag, "w1", false, Some("gpu"), Some("-dev")), + Some("gpu".to_string()) + ); + } + // In a root workspace `flow-dev` is a custom tag the steps inherit. + assert_eq!( + resolve_flow_step_tag(false, "flow-dev", "w1", false, Some("gpu"), None), + Some("flow-dev".to_string()) + ); + } + #[test] fn preprocessor_step_uses_step_tag() { assert_eq!( - resolve_flow_step_tag(true, "worker-group-A", "w1", false, Some("worker-group-B")), + resolve_flow_step_tag( + true, + "worker-group-A", + "w1", + false, + Some("worker-group-B"), + None + ), Some("worker-group-B".to_string()) ); } @@ -4654,6 +4714,9 @@ async fn push_next_flow_job( &flow_job.workspace_id, flow.preserve_step_tags, payload_tag.tag.as_deref(), + windmill_queue::tags::dev_flow_tag_suffix(&flow_job.workspace_id, &flow_job.tag, db) + .await? + .as_deref(), ); // A step whose inputs failed to evaluate, or whose `$flow_expr[...]` tag failed to resolve, @@ -4721,7 +4784,8 @@ async fn push_next_flow_job( { // A step with its own on-behalf-of carries a cached dispatch address, up to one // notify poll stale; accepted, see `get_email_from_permissioned_as`. - let is_super_admin = windmill_common::auth::is_super_admin_email(db, email).await?; + let is_super_admin = + windmill_common::auth::is_super_admin_email(&mut *tx, email).await?; check_tag_written_as_available_for_push( db, &flow_job.workspace_id, @@ -4794,10 +4858,8 @@ async fn push_next_flow_job( // narrows that further. windmill_common::scopes::intersect_job_token_scopes( flow_job.job_token_scopes.as_deref(), - windmill_common::scopes::step_job_token_scopes( - module.job_token_scopes.as_deref(), - ) - .as_deref(), + windmill_common::scopes::step_job_token_scopes(module.job_token_scopes.as_deref()) + .as_deref(), ) .as_deref(), ) @@ -4989,7 +5051,7 @@ async fn push_next_flow_job( // Conditionally write itered to separate table if all workers support it // Returns None if written to table, Some(itered) if should be in JSONB - let itered_for_status = write_itered_to_db(db, flow_job.id, &itered).await?; + let itered_for_status = write_itered_to_db(&mut *tx, flow_job.id, &itered).await?; FlowStatusModule::InProgress { job: uuid, diff --git a/backend/windmill-worker/src/worker_lockfiles.rs b/backend/windmill-worker/src/worker_lockfiles.rs index 6189d39b8d..4c50e63a8d 100644 --- a/backend/windmill-worker/src/worker_lockfiles.rs +++ b/backend/windmill-worker/src/worker_lockfiles.rs @@ -1,5 +1,5 @@ use std::borrow::Cow; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::fs::{create_dir_all, remove_dir_all}; #[cfg(feature = "python")] @@ -29,12 +29,14 @@ use windmill_common::workspace_dependencies::{ RawWorkspaceDependencies, WorkspaceDependenciesPrefetched, }; use windmill_dep_map::scoped_dependency_map::{DependencyDependent, ScopedDependencyMap}; -use windmill_dep_map::trigger_dependents::trigger_dependents_to_recompute_dependencies; +use windmill_dep_map::trigger_dependents::{ + trigger_dependents_to_recompute_dependencies, RELOCK_ALL_APP_COMPONENTS, +}; #[cfg(feature = "python")] use windmill_parser_yaml::AnsibleRequirements; use windmill_common::{ - apps::AppScriptId, + apps::{raw_app_runnables_map, AppScriptId, RAW_APP_RUNNABLES}, cache::{self, RawData}, error, flows::{add_virtual_items_if_necessary, FlowValue}, @@ -42,6 +44,7 @@ use windmill_common::{ DB, }; use windmill_dep_map::lock_hash::record_lock_hashes; +use windmill_dep_map::raw_app_runnables::RawAppRunnables; pub use windmill_dep_map::{ extract_referenced_paths, extract_relative_imports, process_relative_imports, refresh_dependency_map, @@ -546,8 +549,14 @@ pub async fn handle_dependency_job( ); tracing::info!(workspace_id = %w_id, job_id = %job.id, "{log_msg}"); append_logs(&job.id, w_id, log_msg, &db.into()).await; - requeue_relock(db, job, script_path, deployment_message, parent_path) - .await?; + requeue_relock( + db, + job, + script_dependent(script_path), + deployment_message, + parent_path, + ) + .await?; return Ok(to_raw_value_owned( json!({ "status": "Lock generation superseded by a newer version", "lock": content }), )); @@ -706,9 +715,14 @@ pub async fn handle_dependency_job( { Ok(RelockOutcome::Deployed(_)) => *deployment_tallied = false, Ok(RelockOutcome::Superseded(_)) => { - if let Err(e) = - requeue_relock(db, job, script_path, deployment_message, parent_path) - .await + if let Err(e) = requeue_relock( + db, + job, + script_dependent(script_path), + deployment_message, + parent_path, + ) + .await { tracing::error!(%e, "error queueing a relock of {script_path}") } @@ -830,14 +844,15 @@ async fn commit_relock( Ok(RelockOutcome::Deployed(ScriptHash(new_hash))) } -/// Queues another relative-import relock of `script_path`, through the same push the fan-out +/// Queues another relative-import relock of `dependent`, through the same push the fan-out /// uses. The version live now was deployed while a lock was generated for its predecessor; -/// when that deploy was a sibling relock it queued nothing for this path, and the result just -/// discarded may have been the one generated against the current imports. +/// when that deploy was a sibling relock it queued nothing for this path, and a deploy of an +/// edited app carries the locks of the version it was edited from, so the result just +/// discarded may have been the only one generated against the current imports. async fn requeue_relock( db: &DB, job: &MiniPulledJob, - script_path: &str, + dependent: DependencyDependent, deployment_message: Option<String>, parent_path: Option<String>, ) -> error::Result<()> { @@ -849,11 +864,7 @@ async fn requeue_relock( .unwrap_or_default(); trigger_dependents_to_recompute_dependencies( &job.workspace_id, - vec![DependencyDependent { - importer_path: script_path.to_string(), - importer_kind: "script".to_string(), - importer_node_ids: None, - }], + vec![dependent], deployment_message, parent_path, &job.permissioned_as_email, @@ -865,6 +876,55 @@ async fn requeue_relock( .await } +fn script_dependent(script_path: &str) -> DependencyDependent { + DependencyDependent { + importer_path: script_path.to_string(), + importer_kind: "script".to_string(), + importer_node_ids: None, + } +} + +/// The version an app is served from, which is the one a relative-import relock rewrites. +async fn published_app_version(db: &DB, w_id: &str, app_path: &str) -> error::Result<Option<i64>> { + Ok(sqlx::query_scalar!( + "SELECT versions[array_upper(versions, 1)] FROM app WHERE path = $1 AND workspace_id = $2", + app_path, + w_id + ) + .fetch_optional(db) + .await? + .flatten()) +} + +/// The dependency-map node id of every inline script of an app version's `value`. +fn app_component_ids(value: &Value) -> error::Result<Vec<String>> { + let mut ids = vec![]; + windmill_common::apps::traverse_app_inline_scripts(value, None, &mut |_, id| { + ids.push(id.unwrap_or_default()); + Ok(()) + })?; + ids.sort(); + ids.dedup(); + Ok(ids) +} + +/// A raw app's runnables are recorded in the dependency map under their name in `runnables`. +/// Versions deployed before that recorded every runnable under the empty node id, so a relock +/// asking for that id relocks each of them. +fn expand_legacy_raw_app_components( + components_to_relock: Option<Vec<String>>, + value: &Value, +) -> Option<Vec<String>> { + let mut components = components_to_relock?; + if let Some(i) = components.iter().position(String::is_empty) { + components.swap_remove(i); + if let Some(Value::Object(runnables)) = value.get(RAW_APP_RUNNABLES) { + components.extend(runnables.keys().cloned()); + } + } + Some(components) +} + fn remove_ansi_codes(s: &str) -> String { lazy_static::lazy_static! { static ref ANSI_REGEX: regex::Regex = regex::Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]").unwrap(); @@ -2093,8 +2153,10 @@ async fn insert_app_script( code: String, lock: Option<String>, language: Option<ScriptLang>, + modules: Option<BTreeMap<String, ScriptModule>>, ) -> Result<AppScriptId> { let code_sha256 = format!("{:x}", sha2::Sha256::digest(&code)); + let modules = modules.map(|m| serde_json::to_value(m)).transpose()?; let hash = { let mut hasher = sha2::Sha256::new(); hasher.update(app.to_le_bytes()); @@ -2106,14 +2168,18 @@ async fn insert_app_script( relative_imports_bytes(db, Some(&code), &format!("{path}/app"), language).await?, ); } + // Only when present, so a runnable without modules keeps the id it already had. + if let Some(modules) = modules.as_ref() { + hasher.update(modules.to_string()); + } format!("{:x}", hasher.finalize()) }; // Insert the app script if it doesn't exist. sqlx::query_scalar!( r#" - INSERT INTO app_script (app, hash, lock, code, code_sha256) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO app_script (app, hash, lock, code, code_sha256, modules) + VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (hash) DO UPDATE SET app = EXCLUDED.app -- trivial update to return the id RETURNING id "#, @@ -2121,7 +2187,8 @@ async fn insert_app_script( hash, lock, code, - code_sha256 + code_sha256, + modules ) .fetch_one(db) .await @@ -2299,6 +2366,7 @@ async fn reduce_app( path: &str, value: &mut Value, app: i64, + raw_app_runnables: &RawAppRunnables, ) -> Result<()> { match value { Value::Object(object) => { @@ -2319,26 +2387,20 @@ async fn reduce_app( Value::String(s) => Some(s), _ => None, }); - let id = insert_app_script( - db, - path, - app, - code, - lock, - language.map(|v| from_value(v).ok()).flatten(), - ) - .await?; + let language: Option<ScriptLang> = language.and_then(|v| from_value(v).ok()); + let modules = language.and_then(|l| raw_app_runnables.modules_of(path, &code, l)); + let id = insert_app_script(db, path, app, code, lock, language, modules).await?; // insert the `id` into the `script` object: script.insert("id".to_string(), json!(id.0)); } else { for (_, value) in object { - Box::pin(reduce_app(db, path, value, app)).await?; + Box::pin(reduce_app(db, path, value, app, raw_app_runnables)).await?; } } } Value::Array(array) => { for value in array { - Box::pin(reduce_app(db, path, value, app)).await?; + Box::pin(reduce_app(db, path, value, app, raw_app_runnables)).await?; } } _ => {} @@ -2383,6 +2445,7 @@ async fn lock_modules_app( raw_workspace_dependencies_o: &Option<RawWorkspaceDependencies>, triggered_by_relative_import: bool, temp_script_refs: &Option<HashMap<String, String>>, + raw_app_runnables: &RawAppRunnables, ) -> Result<Value> { match value { Value::Object(mut m) => { @@ -2417,15 +2480,18 @@ async fn lock_modules_app( .to_string(); let mut logs = "".to_string(); - let referenced_paths = extract_referenced_paths( - &content, - &format!("{job_path}/app"), - Some(language), - ); + let referenced_paths = + raw_app_runnables.referenced_paths(job_path, &content, language); + // The lock covers what the imported runnables import, and nothing + // records whether one of them changed, so it is never reused. + let modules = + raw_app_runnables.modules_of(job_path, &content, language); + let modules = modules.map(|m| m.into_iter().collect::<HashMap<_, _>>()); if let Some((l, id)) = locks_to_reload .as_ref() .zip(container_id.as_ref()) + .filter(|_| modules.is_none()) // TODO: Remove fallback .and_then(|e| { if *WMDEBUG_NO_COMPONENTS_TO_RELOCK { @@ -2447,9 +2513,9 @@ async fn lock_modules_app( .await?; return Ok(Value::Object(m.clone())); } - } else if v - .get("lock") - .is_some_and(|x| !x.as_str().unwrap().trim().is_empty()) + } else if modules.is_none() + && v.get("lock") + .is_some_and(|x| !x.as_str().unwrap().trim().is_empty()) { if skip_creating_new_lock(&language, &content) && (MIN_VERSION_SUPPORTS_DEBOUNCING_V2.met().await @@ -2486,7 +2552,7 @@ async fn lock_modules_app( base_internal_url, token, &format!("{}/app", job.runnable_path()), - None, + modules.as_ref(), occupancy_metrics, &None, existing_lock, @@ -2549,6 +2615,36 @@ async fn lock_modules_app( } } for (a, b) in m.clone().into_iter() { + if let Some(runnables) = raw_app_runnables_map(&a, &b, container_id.as_ref()) { + let mut locked = serde_json::Map::new(); + for (id, runnable) in runnables { + let runnable = lock_modules_app( + runnable.clone(), + job, + mem_peak, + canceled_by, + job_dir, + db, + worker_name, + worker_dir, + job_path, + base_internal_url, + token, + occupancy_metrics, + locks_to_reload, + Some(id.clone()), + dependency_map, + raw_workspace_dependencies_o, + triggered_by_relative_import, + temp_script_refs, + raw_app_runnables, + ) + .await?; + locked.insert(id.clone(), runnable); + } + m.insert(a, Value::Object(locked)); + continue; + } m.insert( a.clone(), lock_modules_app( @@ -2573,6 +2669,7 @@ async fn lock_modules_app( raw_workspace_dependencies_o, triggered_by_relative_import, temp_script_refs, + raw_app_runnables, ) .await?, ); @@ -2602,6 +2699,7 @@ async fn lock_modules_app( raw_workspace_dependencies_o, triggered_by_relative_import, temp_script_refs, + raw_app_runnables, ) .await?, ); @@ -2661,30 +2759,28 @@ pub async fn handle_app_dependency_job( .and_then(|x| x.get("temp_script_refs")) .and_then(|v| serde_json::from_str(v.get()).ok()); - // The version captured at job creation can be stale (the app may have been - // redeployed since). Relock the current latest instead, mirroring the flow - // dependency handler. - let id = if triggered_by_relative_import { - let latest_version = sqlx::query_scalar!( - "SELECT id FROM app_version WHERE app_id = (SELECT id FROM app WHERE path = $1 AND workspace_id = $2) ORDER BY created_at DESC LIMIT 1", - job_path, - job.workspace_id - ) - .fetch_optional(db) - .await?; - match latest_version { - Some(latest_version) if latest_version != id => { + // A relative-import relock rewrites the published version in place and deploys no new one, + // and that version was tallied by the deploy that published it. + if triggered_by_relative_import { + *deployment_tallied = true; + } + + // The version captured at job creation can be stale (the app may have been redeployed + // since): relock the one published now. + let (id, version_switched) = if triggered_by_relative_import { + match published_app_version(db, &job.workspace_id, &job_path).await? { + Some(published) if published != id => { tracing::info!( - "App version changed since dependency job was queued ({} -> {}), using latest", + "App version changed since dependency job was queued ({} -> {}), using the published one", id, - latest_version + published ); - latest_version + (published, true) } - _ => id, + _ => (id, false), } } else { - id + (id, false) }; sqlx::query!( @@ -2716,6 +2812,27 @@ pub async fn handle_app_dependency_job( // TODO: Use transaction for entire segment? if let Some((app_id, value, is_raw_app)) = record { + // The components to relock were picked in the version the job was queued for, and the + // one published since may name them differently and carry its editor's stale locks. + // Every selection that can outlive its version is resolved here, against the value + // this job writes. + let relock_all = version_switched + || components_to_relock + .as_ref() + .is_some_and(|c| c.iter().any(|c| c == RELOCK_ALL_APP_COMPONENTS)); + let components_to_relock = if relock_all { + Some(app_component_ids(&value)?) + } else if is_raw_app { + expand_legacy_raw_app_components(components_to_relock, &value) + } else { + components_to_relock + }; + let read_value = value.clone(); + let raw_app_runnables = if is_raw_app { + RawAppRunnables::from_value(&job_path, &value) + } else { + RawAppRunnables::default() + }; let value = lock_modules_app( value, &job, @@ -2733,8 +2850,11 @@ pub async fn handle_app_dependency_job( None, &mut dependency_map, &raw_workspace_dependencies_o, - triggered_by_relative_import, + // A relock of every component regenerates every lock: the imported lock hashes a + // skip compares are stamped even by a deploy that kept its editor's stale lock. + triggered_by_relative_import && !relock_all, &temp_script_refs, + &raw_app_runnables, ) .await?; @@ -2747,20 +2867,11 @@ pub async fn handle_app_dependency_job( // Compute a lite version of the app value (w/ `inlineScript.{lock,code}`). let mut value_lite = value.clone(); - reduce_app(db, &job_path, &mut value_lite, app_id).await?; + reduce_app(db, &job_path, &mut value_lite, app_id, &raw_app_runnables).await?; if let Value::Object(object) = &mut value_lite { object.insert("version".to_string(), json!(id)); object.remove("files"); } - sqlx::query!( - "INSERT INTO app_version_lite (id, value) VALUES ($1, $2) - ON CONFLICT (id) DO UPDATE SET value = EXCLUDED.value", - id, - sqlx::types::Json(to_raw_value(&value_lite)) as sqlx::types::Json<Box<RawValue>>, - ) - .execute(db) - .await?; - // Re-check cancelation to ensure we don't accidentially override an app. if sqlx::query_scalar!( "SELECT canceled_by IS NOT NULL AS \"canceled!\" FROM v2_job_queue WHERE id = $1", @@ -2777,27 +2888,63 @@ pub async fn handle_app_dependency_job( return Ok(()); } - sqlx::query!("UPDATE app_version SET value = $1 WHERE id = $2", value, id,) - .execute(db) - .await?; + let (deployment_message, parent_path) = + get_deployment_msg_and_parent_path_from_args(job.args.clone()); - // Re-publish the relocked version as latest for cache invalidation, but - // only if it is still the latest: the guard makes this a single atomic, - // never-demoting statement. Without it, a concurrent deploy that landed a - // newer version (e.g. same git-sync push) would be reverted, pointing a - // raw app's bundle_secret at a version with no bundle (404 / white screen). - if triggered_by_relative_import { - sqlx::query!( - "UPDATE app SET versions = array_append(versions, $1::bigint) WHERE path = $2 AND workspace_id = $3 AND versions[array_upper(versions, 1)] = $1::bigint", - id, + let mut tx = db.begin().await?; + // Only the published version is relocked: a raw app's bundle is stored under the + // version id it was deployed with, so serving any other version 404s its bundle. + // The row lock orders this against a deploy appending a newer version. + let superseded = triggered_by_relative_import + && sqlx::query_scalar!( + "SELECT versions[array_upper(versions, 1)] FROM app WHERE path = $1 AND workspace_id = $2 FOR NO KEY UPDATE", &job_path, &job.workspace_id ) - .execute(db) - .await?; + .fetch_optional(&mut *tx) + .await? + .flatten() + != Some(id); + // Every relock of the app, and the dependency job of the deploy that published it, + // write this one row: overwriting a write made since it was read would drop its locks. + let current = sqlx::query_scalar!( + "SELECT value FROM app_version WHERE id = $1 FOR NO KEY UPDATE", + id + ) + .fetch_optional(&mut *tx) + .await?; + if superseded || current.as_ref() != Some(&read_value) { + drop(tx); + let log_msg = format!( + "\nApp version {id} was rewritten or superseded while its locks were generated; discarding them and queueing a relock of the published version" + ); + tracing::info!(workspace_id = %job.workspace_id, job_id = %job.id, "{log_msg}"); + append_logs(&job.id, &job.workspace_id, log_msg, &db.into()).await; + tally_unfinished_dependency_deploy(db, &job, deployment_tallied).await; + if current.is_some() { + // The version published when the queued relock runs may name its components + // differently than this one, and carry its editor's stale locks. + let dependent = DependencyDependent { + importer_path: job_path, + importer_kind: "app".to_string(), + importer_node_ids: Some(vec![RELOCK_ALL_APP_COMPONENTS.to_string()]), + }; + requeue_relock(db, &job, dependent, deployment_message, parent_path).await?; + } + return Ok(()); } - let (deployment_message, parent_path) = - get_deployment_msg_and_parent_path_from_args(job.args.clone()); + sqlx::query!("UPDATE app_version SET value = $1 WHERE id = $2", value, id,) + .execute(&mut *tx) + .await?; + sqlx::query!( + "INSERT INTO app_version_lite (id, value) VALUES ($1, $2) + ON CONFLICT (id) DO UPDATE SET value = EXCLUDED.value", + id, + sqlx::types::Json(to_raw_value(&value_lite)) as sqlx::types::Json<Box<RawValue>>, + ) + .execute(&mut *tx) + .await?; + tx.commit().await?; let deployed_object = if is_raw_app { DeployedObject::RawApp { path: job_path, version: id, parent_path: parent_path.clone() } @@ -3311,6 +3458,7 @@ async fn capture_dependency_job( &mut locked_v, raw_workspace_dependencies_o, temp_script_refs, + windmill_parser_py_imports::module_sources(script_path, modules).as_ref(), ) .await? .0 @@ -3436,6 +3584,11 @@ async fn capture_dependency_job( // TODO: move inside gen_bun_lockfile if !wd_exist { write_file(job_dir, "main.ts", job_raw_code)?; + // The lock has to cover what the modules import, and the loader resolves a + // relative import against these files before asking for a deployed script. + if let Some(modules) = modules { + crate::worker::write_module_files(job_dir, modules, None).await?; + } } if let Some(lock) = gen_bun_lockfile( mem_peak, @@ -3457,10 +3610,9 @@ async fn capture_dependency_job( ) .await? { - // Nothing here writes the module files, so a bundle built now resolves a - // multi-file script's relative imports remotely rather than from its - // modules; caching that under a key naming them would serve the wrong - // code to every run. Leave it to the first run instead. + // A runnable with modules bundles on its first run instead: a multi-file + // script's per-module lock pass also lands here, once per module, and would + // prebundle each module as if it were an entrypoint no run ever asks for. if !wd_exist && modules.map_or(true, |m| m.is_empty()) { crate::bun_executor::prebundle_bun_script( job_raw_code, diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index c47248c43a..6be8a4523e 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.833.0"; +export const VERSION = "v1.847.0"; export async function login(email: string, password: string): Promise<string> { return await windmill.UserService.login({ diff --git a/chat-sdk/README.md b/chat-sdk/README.md index 006d5407d8..2106d35bee 100644 --- a/chat-sdk/README.md +++ b/chat-sdk/README.md @@ -148,6 +148,45 @@ const chat = useWindmillChat({ flowPath: 'f/support/assistant' }) - **`wmill app dev`**: there is no viewer session on the dev server, so pass `baseUrl`, `workspace` and `token` explicitly during development. +### A saved agent, through an agent runnable + +To chat with a saved AI agent (an `ai_agent` resource), give the app an **agent +runnable**: a path runnable of type `agent` naming the agent, picked under "AI agents" +in the editor, or in `raw_app.yaml`: + +```yaml +runnables: + chat: + type: agent + path: f/support/agent +``` + +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. +Name the runnable and pass the app's generated `wmill.ts`, which the chat goes through: + +```tsx +import { useWindmillChat } from '@windmill-labs/chat-sdk/react' +import * as wmill from './wmill' + +const chat = useWindmillChat({ wmill, runnable: 'chat' }) +``` + +`runnable` is checked against the runnables `wmill.ts` declares, and names the local +history, so two agent runnables in one app keep separate conversations. Going through +`wmill.ts` is what makes the same code work in a deployed app and under `wmill app dev`. + +`createChat` and the assistant-ui runtime take the same options; the AI SDK transport +does not. The runnable takes `user_message` and `memory_id`, the conversation id, which +the agent's memory is keyed by, so it remembers earlier turns (called from `wmill.ts` +without `memory_id`, it is a single run with no conversation). The agent needs managed +memory: one whose memory is off takes `previous_messages` instead, the history you keep. A signed-in viewer's turns +are filed under them, so nobody else can continue their conversations; an anonymous +visitor's are kept apart per app. The chat makes no request of its own: history is +`'local'` (the app's own storage), `stop()` stops following without cancelling the turn, +a message sent while it still runs is refused with `TurnRunningError`, and attachments +are refused. + ## Any framework `createChat` returns a store: `subscribe` calls the listener immediately and on every @@ -178,7 +217,7 @@ await chat.sendMessage('Hello') | Option | | |---|---| -| `flowPath` | Path of the deployed flow, e.g. `f/support/assistant`. Required. | +| `flowPath` | Path of the deployed flow, e.g. `f/support/assistant`. Required unless `follow` is set. | | `baseUrl` | The Windmill origin. Detected inside a raw app. | | `workspace` | Detected inside a raw app. | | `token` | A token, or a function returning one (called before every request, so it can fetch a short-lived token from your backend). Omit it inside a raw app. | @@ -190,6 +229,8 @@ await chat.sendMessage('Hello') | `pollDelayMs` | How often, in ms, the server polls a running turn for the stream (Enterprise; 50 at the fastest, other servers ignore it). Unset, the server relaxes from 100 ms to 3 s over a long turn; set it when tokens must keep flowing at that pace. | | `onFinish`, `onError` | Called when a turn has its answer, or could not run at all. | | `run` | Runs the flow for a turn yourself and returns the job id, instead of the deployed flow at `flowPath` (Windmill's editor chats with an undeployed flow through a preview run this way). Pass `memory_id` = the conversation id. | +| `follow` | With `run`: follows the job through your own client, calling `onStream` with each chunk of its result stream and resolving with its result. The chat then makes no request of its own; `runnable` sets both for a raw app's agent runnable, see [A saved agent](#a-saved-agent-through-an-agent-runnable). | +| `runnable`, `wmill` | In a raw app, chat through the agent runnable of that name, going through the app's generated module (`import * as wmill from './wmill'`). Replaces `flowPath`, `run` and `follow`. | ## State @@ -294,7 +335,8 @@ That store is keyed by the **Windmill user**, so it fits a viewer session or a t issued per user. With one token shared by every visitor of a site, all visitors would see each other's conversations. For that setup use `history: 'local'` (the default with an explicit `token`): the conversation list and messages stay in the browser's -`localStorage`, per Windmill instance, workspace and flow. `'none'` keeps nothing +`localStorage`, per Windmill instance, workspace and flow, and the chat starts with that +list in `state.conversations`, without a `loadConversations()` call. `'none'` keeps nothing beyond the page. When the default `'server'` mode turns out unreadable (a token or sandboxed app @@ -333,7 +375,8 @@ custom integrations. When asked to add a chat over a Windmill flow: the flow must be deployed with chat mode on. Pick the entry point from the table at the top (`useChat` → `@windmill-labs/chat-sdk/ai-sdk`, assistant-ui → `@windmill-labs/chat-sdk/assistant-ui`, otherwise `@windmill-labs/chat-sdk/react`). Inside a -Windmill raw app pass only `flowPath`. Elsewhere pass `baseUrl`, `workspace` and a +Windmill raw app pass only `flowPath`; for a saved agent, use an `agent` runnable and pass +`{ wmill, runnable }`. Elsewhere pass `baseUrl`, `workspace` and a `token`; for a public page use a token scoped to `jobs:run:flows:<flowPath>` and leave `history` at its default. Render `role`, `content`, `pending`, `success` and `tool.status`; never build the SSE handling yourself. diff --git a/chat-sdk/package-lock.json b/chat-sdk/package-lock.json index 7b7da8884e..56e888035e 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/chat-sdk", - "version": "1.833.0", + "version": "1.847.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/chat-sdk", - "version": "1.833.0", + "version": "1.847.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 29488b7e47..74fb87fc20 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.833.0", + "version": "1.847.0", "author": "Ruben Fiszel", "license": "Apache-2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme", diff --git a/chat-sdk/src/assistant-ui.ts b/chat-sdk/src/assistant-ui.ts index b9a76db679..04ab23793e 100644 --- a/chat-sdk/src/assistant-ui.ts +++ b/chat-sdk/src/assistant-ui.ts @@ -7,7 +7,7 @@ import { } from '@assistant-ui/react' import { useEffect, useMemo } from 'react' import { useWindmillChat } from './react' -import type { ChatMessage, ChatOptions } from './types' +import type { ChatMessage, RawAppWmill, TypedChatOptions } from './types' import { parseJsonOr } from './utils' /** One assistant turn: the assistant and tool rows between two user messages. */ @@ -17,7 +17,7 @@ export interface WindmillTurn { messages: ChatMessage[] } -export interface WindmillRuntimeOptions extends ChatOptions { +export type WindmillRuntimeOptions<W extends RawAppWmill = RawAppWmill> = TypedChatOptions<W> & { /** Load the conversation list on mount so `ThreadListPrimitive` has something to show. Default true. */ threadList?: boolean } @@ -27,7 +27,7 @@ export interface WindmillRuntimeOptions extends ChatOptions { * Conversations become threads, so the thread list primitives switch, create and * delete Windmill conversations. */ -export function useWindmillRuntime(options: WindmillRuntimeOptions): AssistantRuntime { +export function useWindmillRuntime<W extends RawAppWmill>(options: WindmillRuntimeOptions<W>): AssistantRuntime { const chat = useWindmillChat(options) const turns = useMemo(() => groupTurns(chat.messages), [chat.messages]) const withThreadList = options.threadList !== false diff --git a/chat-sdk/src/attachments.ts b/chat-sdk/src/attachments.ts index ac5e71c7d3..a11b0106ba 100644 --- a/chat-sdk/src/attachments.ts +++ b/chat-sdk/src/attachments.ts @@ -88,10 +88,9 @@ export async function uploadAttachments( attachments.map(async (attachment, index) => { try { const blob = attachmentBlob(attachment) - const filename = storedAttachmentName( - attachment.name || `attachment-${index + 1}`, - blob.type - ) + // The last segment only: the name may come from whoever sent the file. + const name = attachment.name?.split(/[\\/]/).pop()?.replace(/^\.+$/, '') + const filename = storedAttachmentName(name || `attachment-${index + 1}`, blob.type) const { file_key } = await api.uploadFile(`${prefix}/${index}/${filename}`, blob, { contentType: blob.type, signal: batch.signal diff --git a/chat-sdk/src/chat.ts b/chat-sdk/src/chat.ts index dfef438f33..2d48d383a0 100644 --- a/chat-sdk/src/chat.ts +++ b/chat-sdk/src/chat.ts @@ -11,11 +11,13 @@ import { resolveConfig, type ResolvedConfig } from './config' import { followJob } from './follow' import { createLocalHistory, type LocalHistory } from './history' import { uploadAttachments } from './attachments' -import type { AgentStreamEvent } from './stream' +import { createStreamEventParser, type AgentStreamEvent } from './stream' import type { Chat, ChatMessage, ChatOptions, + RawAppWmill, + TypedChatOptions, ChatState, Conversation, SendMessageOptions, @@ -62,7 +64,7 @@ interface Turn { streamedText: boolean } -export function createChat(options: ChatOptions): Chat { +export function createChat<W extends RawAppWmill>(options: TypedChatOptions<W>): Chat { return new ChatImpl(options) } @@ -102,7 +104,9 @@ class ChatImpl implements Chat { messages: [], status: 'idle', error: undefined, - conversations: [], + // Browser history is read synchronously, so a chat starts with its list as server history + // cannot: an app that never calls `loadConversations` still shows its conversations. + conversations: this.#config.history === 'local' ? this.#local.listConversations() : [], history: this.#config.history, loadingMessages: false, hasMoreMessages: false @@ -134,6 +138,9 @@ class ChatImpl implements Chat { if (attachments.length > 0 && !attachmentsInput) { throw new Error('windmill-chat: attachments need `attachmentsInput`, the flow input that takes them') } + if (attachments.length > 0 && this.#config.follow) { + throw new Error('windmill-chat: attachments upload with the SDK\'s own requests, which `follow` replaces') + } if (attachmentsInput && !attachmentsInput.multiple && attachments.length > 1) { // Uploading all of them would run with the first and leave the rest stranded in storage. throw new Error(`windmill-chat: \`${attachmentsInput.name}\` holds one file; got ${attachments.length}`) @@ -338,7 +345,7 @@ class ChatImpl implements Chat { this.#set({ messages: finalized(this.#state.messages), status: 'idle' }) this.#persistLocal() } - if (turn.jobId) { + if (turn.jobId && !this.#config.follow) { // Needs `jobs:write` on a token; the stream is closed either way. await this.#api.cancelJob(turn.jobId).catch(() => {}) } @@ -577,6 +584,27 @@ class ChatImpl implements Chat { async #follow(turn: Turn, onStreamStart: () => void): Promise<unknown> { let started = false + const { follow } = this.#config + if (follow) { + const { signal } = turn.controller + const parser = createStreamEventParser() + const onStream = (chunk: string) => { + if (signal.aborted) return + if (!started) { + started = true + onStreamStart() + } + this.#applyEvents(turn, parser.push(chunk)) + } + // A caller's follow may not honor the signal; the turn stops waiting on it either way. + const aborted = new Promise<never>((_, reject) => { + if (signal.aborted) reject(abortError()) + signal.addEventListener('abort', () => reject(abortError()), { once: true }) + }) + const result = await Promise.race([follow(turn.jobId!, onStream, { signal }), aborted]) + this.#applyEvents(turn, parser.flush()) + return result + } for await (const event of followJob(this.#api, turn.jobId!, { signal: turn.controller.signal })) { if (event.type === 'completed') { if (event.streamLost) this.#dropCutRound(turn) @@ -722,8 +750,9 @@ class ChatImpl implements Chat { let messages = this.#state.messages let failed = false if (isErrorResult(result)) { - // The envelope is also a legitimate result shape; the job's own status decides. - failed = await this.#api + // The envelope is also a legitimate result shape; the job's own status decides, when it + // can be read. + failed = this.#config.follow ? true : await this.#api .getCompletedResult(turn.jobId!, turn.controller.signal) .then((r) => r.success === false) .catch(() => true) diff --git a/chat-sdk/src/config.ts b/chat-sdk/src/config.ts index 1903149d58..98cf97f2b2 100644 --- a/chat-sdk/src/config.ts +++ b/chat-sdk/src/config.ts @@ -1,4 +1,5 @@ import type { ChatOptions, FetchLike, HistoryMode, StorageLike, TokenSource } from './types' +import { rawAppRunnable } from './runnable' export interface ResolvedConfig { flowPath: string @@ -15,6 +16,7 @@ export interface ResolvedConfig { pageSize: number pollDelayMs: number | undefined run: ChatOptions['run'] + follow: ChatOptions['follow'] onFinish: ChatOptions['onFinish'] onError: ChatOptions['onError'] } @@ -51,13 +53,31 @@ export function detectRawApp(): RawAppContext | undefined { } export function resolveConfig(options: ChatOptions): ResolvedConfig { - if (!options.flowPath) throw new Error('windmill-chat: flowPath is required') + if (options.runnable !== undefined) { + if (!options.wmill) throw new Error('windmill-chat: `runnable` needs `wmill`, the app\'s generated module') + if (options.flowPath || options.run || options.follow) { + throw new Error('windmill-chat: `runnable` replaces `flowPath`, `run` and `follow`') + } + // Unsandboxed apps share their origin's storage, so the app's path keeps their histories apart. + const app = (globalThis as { ctx?: { path?: unknown } }).ctx?.path + options = { + ...options, + ...rawAppRunnable(options.wmill.backendAsync[options.runnable], options.wmill.streamJob), + flowPath: `runnable:${typeof app === 'string' ? `${app}:` : ''}${options.runnable}` + } + } + const follow = options.follow + if (follow && !options.run) throw new Error('windmill-chat: `follow` needs `run`, which starts the job it follows') + if (follow && options.history === 'server') { + throw new Error('windmill-chat: server history is read with the SDK\'s own requests, which `follow` replaces') + } + if (!options.flowPath && !follow) throw new Error('windmill-chat: flowPath is required') const explicitToken = options.token !== undefined const detected = options.baseUrl && options.workspace && explicitToken ? undefined : detectRawApp() const baseUrl = options.baseUrl ?? detected?.baseUrl const workspace = options.workspace ?? detected?.workspace - if (!baseUrl || !workspace) { + if ((!baseUrl || !workspace) && !follow) { throw new Error( 'windmill-chat: pass baseUrl and workspace. They are only detected inside a raw app: an unsandboxed one on the Windmill origin, or a sandboxed one whose policy declares frontend SDK scopes.' ) @@ -66,11 +86,11 @@ export function resolveConfig(options: ChatOptions): ResolvedConfig { const token = options.token ?? (options.baseUrl === undefined || options.baseUrl === detected?.baseUrl ? detected?.token : undefined) return { - flowPath: options.flowPath, - baseUrl, - workspace, + flowPath: options.flowPath ?? '', + baseUrl: baseUrl ?? '', + workspace: workspace ?? '', token, - history: options.history ?? (explicitToken ? 'local' : 'server'), + history: options.history ?? (explicitToken || follow ? 'local' : 'server'), historyExplicit: options.history !== undefined, inputs: options.inputs ?? {}, fetch: options.fetch, @@ -79,6 +99,7 @@ export function resolveConfig(options: ChatOptions): ResolvedConfig { pageSize: options.pageSize ?? 50, pollDelayMs: options.pollDelayMs, run: options.run, + follow, onFinish: options.onFinish, onError: options.onError } diff --git a/chat-sdk/src/index.ts b/chat-sdk/src/index.ts index a050edce37..d05a5ab217 100644 --- a/chat-sdk/src/index.ts +++ b/chat-sdk/src/index.ts @@ -31,6 +31,8 @@ export type { Conversation, FetchLike, HistoryMode, + RawAppWmill, + TypedChatOptions, SendMessageOptions, StorageLike, TokenSource, diff --git a/chat-sdk/src/react.ts b/chat-sdk/src/react.ts index c32324ee18..4159008ce7 100644 --- a/chat-sdk/src/react.ts +++ b/chat-sdk/src/react.ts @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react' import { createChat } from './chat' -import type { Chat, ChatOptions, ChatState } from './types' +import type { Chat, ChatOptions, ChatState, RawAppWmill, TypedChatOptions } from './types' export type UseWindmillChat = ChatState & Pick< @@ -19,22 +19,23 @@ export type UseWindmillChat = ChatState & /** * A chat on a chat-mode flow. The chat is created once per `flowPath`, `baseUrl`, - * `workspace`, `history`, `storageKey`, credential and presence of `run`, and + * `workspace`, `history`, `storageKey`, credential and presence of `run` and `follow`, and * destroyed on unmount. A credential change is a new user, whose chat must not * carry the previous one's state: a different token string, or a switch between no * token, a token string and a token function, all recreate it. A token function is * read through a ref on every call, so a new closure per render changes what the * next call runs and nothing else; pass a `storageKey` per user when local history - * must not be shared. `run`, the callbacks and `inputs` are read the same way: the + * must not be shared. `run`, `follow`, the callbacks and `inputs` are read the same way: the * latest render's values go with the next message. */ -export function useWindmillChat(options: ChatOptions): UseWindmillChat { - const latest = useRef(options) +export function useWindmillChat<W extends RawAppWmill>(options: TypedChatOptions<W>): UseWindmillChat { + const latest = useRef<ChatOptions>(options) latest.current = options const credential = typeof options.token === 'function' ? 'fn' : typeof options.token === 'string' ? `str:${options.token}` : 'none' // A custom runner replaces the deployed flow call, so its presence is part of what the chat is. const customRun = options.run !== undefined + const customFollow = options.follow !== undefined const chat = useMemo( () => createChat({ @@ -49,11 +50,14 @@ export function useWindmillChat(options: ChatOptions): UseWindmillChat { } : options.token, run: customRun ? (args, turn) => (latest.current.run ?? options.run!)(args, turn) : undefined, + follow: customFollow + ? (jobId, onStream, turn) => (latest.current.follow ?? options.follow!)(jobId, onStream, turn) + : undefined, onFinish: (turn) => latest.current.onFinish?.(turn), onError: (error, turn) => latest.current.onError?.(error, turn) }), // eslint-disable-next-line react-hooks/exhaustive-deps - [options.flowPath, options.baseUrl, options.workspace, options.history, options.storageKey, credential, customRun] + [options.flowPath, options.runnable, options.wmill, options.baseUrl, options.workspace, options.history, options.storageKey, credential, customRun, customFollow] ) useEffect(() => () => chat.destroy(), [chat]) const state = useSyncExternalStore(chat.subscribe, chat.getState, chat.getState) diff --git a/chat-sdk/src/runnable.ts b/chat-sdk/src/runnable.ts new file mode 100644 index 0000000000..08ddf592e7 --- /dev/null +++ b/chat-sdk/src/runnable.ts @@ -0,0 +1,29 @@ +import { turnRunningError } from './api' +import type { ChatOptions } from './types' + +/** + * `run` and `follow` over a raw app's agent runnable (a path runnable of type `agent`, naming + * a saved AI agent), behind the `runnable` option. `memory_id` is the conversation id, the + * agent's memory key. + */ +export function rawAppRunnable( + // The generated signature is the runnable's own. + start: (args: any) => Promise<string>, + streamJob: (jobId: string, onUpdate?: (update: { new_result_stream?: string }) => void) => Promise<unknown> +): Required<Pick<ChatOptions, 'run' | 'follow'>> { + return { + run: async (args, { conversationId }) => { + try { + return String(await start({ ...args, memory_id: conversationId })) + } catch (e) { + // The host relays the server's refusal as the error's text, possibly behind a stack. + const text = e instanceof Error ? e.message : String(e) + throw turnRunningError(text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1)) ?? e + } + }, + follow: (jobId, onStream) => + streamJob(jobId, (update) => { + if (update.new_result_stream) onStream(update.new_result_stream) + }) + } +} diff --git a/chat-sdk/src/types.ts b/chat-sdk/src/types.ts index 180238e50c..ad9f30cbfb 100644 --- a/chat-sdk/src/types.ts +++ b/chat-sdk/src/types.ts @@ -88,9 +88,33 @@ export type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response> +/** A raw app's generated `wmill.ts`, as far as a chat uses it. */ +export interface RawAppWmill { + backendAsync: Record<string, (args: any) => Promise<string>> + streamJob: (jobId: string, onUpdate?: (update: { new_result_stream?: string }) => void) => Promise<unknown> +} + +/** `ChatOptions` whose `runnable` must name one of `wmill`'s runnables. */ +export type TypedChatOptions<W extends RawAppWmill> = ChatOptions & { + wmill?: W + runnable?: keyof W['backendAsync'] & string +} + export interface ChatOptions { - /** Path of a deployed flow with chat mode enabled, e.g. `f/support/assistant`. */ - flowPath: string + /** + * Path of a deployed flow with chat mode enabled, e.g. `f/support/assistant`. Required + * unless `runnable` or `follow` is set, where it only names the local history. + */ + flowPath?: string + /** + * In a raw app, chat through its agent runnable of this name instead of a flow: + * `{ wmill, runnable: 'chat' }`. Each turn runs as the app, so a sandboxed app needs no + * frontend SDK scope and its viewers no access to the agent; the chat follows it as + * with `follow`. It also names the local history, so each runnable keeps its own. + */ + runnable?: string + /** The app's generated module, `import * as wmill from './wmill'`, which `runnable` goes through. */ + wmill?: RawAppWmill /** Windmill origin, e.g. `https://app.windmill.dev`. Detected inside a raw app. */ baseUrl?: string /** Detected inside a raw app. */ @@ -128,6 +152,15 @@ export interface ChatOptions { * Windmill's own editor uses this to chat with an undeployed flow through a preview run. */ run?: (args: Record<string, unknown>, turn: { conversationId: string; signal: AbortSignal }) => Promise<string> + /** + * Follows the job `run` returned through your own client instead of the SDK's requests: + * call `onStream` with each chunk of the job's result stream and resolve with its result. + * With it the chat makes no request of its own, so it needs no credential, `baseUrl` or + * `workspace`, and in exchange history is `local` (or `none`), `stop()` stops following + * without cancelling the job, and attachments are refused. `runnable` sets both for a raw + * app's agent runnable. + */ + follow?: (jobId: string, onStream: (chunk: string) => void, turn: { signal: AbortSignal }) => Promise<unknown> /** Called once a turn has its answer (a failed flow included: its error is the answer). */ onFinish?: (turn: { conversationId: string; jobId?: string; messages: ChatMessage[] }) => void /** Called when a turn could not run or be followed; `state.error` holds the same error. */ diff --git a/chat-sdk/test/chat.test.ts b/chat-sdk/test/chat.test.ts index 3845c91ac7..6c88af5a36 100644 --- a/chat-sdk/test/chat.test.ts +++ b/chat-sdk/test/chat.test.ts @@ -1829,6 +1829,7 @@ describe('createChat with server history', () => { await chat.selectConversation(older) await new Promise((r) => setTimeout(r, 400)) const again = createChat(options({ token: 'tok', storage }, fetch)) + expect(again.getState().conversations.map((c) => c.id)).toEqual([newer, older]) expect((await again.loadConversations()).map((c) => c.id)).toEqual([newer, older]) }) diff --git a/chat-sdk/test/runnable.test.ts b/chat-sdk/test/runnable.test.ts new file mode 100644 index 0000000000..4dcaa60b84 --- /dev/null +++ b/chat-sdk/test/runnable.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'bun:test' +import { TurnRunningError } from '../src/api' +import { createChat } from '../src/chat' +import { memoryStorage, ndjson } from './support' + +describe('raw app agent runnable', () => { + test('chats through the runnable without a request of its own', async () => { + const calls: unknown[] = [] + let refuse = false + const start = async (args: unknown) => { + // As the host relays a 409: the server's body behind the error's stack. + if (refuse) { + const body = { error: 'still answering', running_turn: { job_id: 'job-1', user_seq: 1 } } + throw new Error(`Error: ${JSON.stringify(body)}\n at x`) + } + calls.push(args) + return `job-${calls.length}` + } + const streamJob = async (jobId: string, onUpdate?: (u: { new_result_stream?: string }) => void) => { + const stream = ndjson({ type: 'token_delta', content: 'answer ' }, { type: 'token_delta', content: jobId }) + onUpdate?.({ new_result_stream: stream.slice(0, 10) }) + onUpdate?.({ new_result_stream: stream.slice(10) }) + if (jobId === 'job-3') return { error: { name: 'Error', message: 'agent failed' } } + return { output: `answer ${jobId}`, messages: [] } + } + const storage = memoryStorage() + const wmill = { backendAsync: { chat: start, other: start }, streamJob } + const chat = createChat({ + wmill, + runnable: 'chat', + storage, + fetch: () => Promise.reject(new Error('no request expected')) + }) + expect(chat.getState().history).toBe('local') + await chat.sendMessage('hi') + await chat.sendMessage('again') + const conversationId = chat.getState().conversationId + expect(calls).toEqual([ + { user_message: 'hi', memory_id: conversationId }, + { user_message: 'again', memory_id: conversationId } + ]) + expect(chat.getState().messages.map((m) => m.content)).toEqual(['hi', 'answer job-1', 'again', 'answer job-2']) + + await chat.sendMessage('third') + const messages = chat.getState().messages + const last = messages[messages.length - 1] + expect([last.content, last.success]).toEqual(['agent failed', false]) + + // A turn still running elsewhere takes the message back rather than failing a turn. + refuse = true + await expect(chat.sendMessage('fourth')).rejects.toBeInstanceOf(TurnRunningError) + expect(chat.getState().messages).toEqual(messages) + + // Each runnable keeps its own history: another agent never sees this conversation. + expect(createChat({ wmill, runnable: 'chat', storage }).getState().conversations).toHaveLength(1) + expect(createChat({ wmill, runnable: 'other', storage }).getState().conversations).toEqual([]) + // Nor does the same runnable name in another app, whose path the host puts in `ctx`. + const g = globalThis as { ctx?: unknown } + g.ctx = { path: 'u/admin/other_app' } + try { + expect(createChat({ wmill, runnable: 'chat', storage }).getState().conversations).toEqual([]) + } finally { + delete g.ctx + } + // @ts-expect-error a name the app's module does not declare + createChat({ wmill, runnable: 'missing', storage }) + }) +}) diff --git a/cli/src/commands/app/app.ts b/cli/src/commands/app/app.ts index 0060c2c422..b4d8230bdb 100644 --- a/cli/src/commands/app/app.ts +++ b/cli/src/commands/app/app.ts @@ -34,6 +34,7 @@ export interface AppFile { public?: boolean; sandbox?: boolean; frontend_api?: string[]; + custom_path?: string; summary: string; policy: Policy; // Mirrors granular ACLs on the app path. Omitted from app.yaml when no @@ -123,6 +124,36 @@ export function stripEditorState(app: any) { delete app.value.parent_version; } } +/** The backend requires admin for any update that carries `custom_path`, even + * an unchanged one, so a value read from a checkout must only be sent when it + * differs from the deployed one or a non-admin can no longer deploy the app. */ +export function changedCustomPath( + local: string | undefined, + deployed: { custom_path?: string } | undefined, +): string | undefined { + return (local ?? "") === (deployed?.custom_path ?? "") ? undefined : local; +} + +/** Applies a workspace's `<app>.<workspace>.custom_path.yaml`. An empty value + * clears the custom path; the app (low-code or raw) must already exist. */ +export async function pushAppCustomPath( + workspace: string, + remotePath: string, + localFile: { custom_path?: string } | undefined, +): Promise<void> { + remotePath = remotePath.replaceAll(SEP, "/"); + const deployed = await wmill.getAppByPath({ workspace, path: remotePath }); + const customPath = changedCustomPath(localFile?.custom_path ?? "", deployed); + if (customPath === undefined) { + return; + } + log.info(colors.bold.yellow(`Setting custom path of app ${remotePath}...`)); + await wmill.updateApp({ + workspace, + path: remotePath, + requestBody: { custom_path: customPath, skip_draft_deletion: true }, + }); +} export function isExecutionModeAnonymous(app: any) { return app?.["policy"]?.["execution_mode"] == "anonymous"; } @@ -269,7 +300,8 @@ export async function pushApp( remotePath: string, localPath: string, message?: string, - permissionedAsContext?: PermissionedAsContext + permissionedAsContext?: PermissionedAsContext, + customPathFromFile = true ): Promise<void> { if (alreadySynced.includes(localPath)) { return; @@ -326,14 +358,17 @@ export async function pushApp( extra_perms: localPerms, sandbox: _sandbox, frontend_api: _frontendApi, + custom_path: localCustomPath, ...localAppBody } = localApp as AppFile & { extra_perms?: Record<string, boolean>; }; + const customPath = customPathFromFile ? localCustomPath : undefined; if (app) { if ( isSuperset(localAppBody, app) && + changedCustomPath(customPath, app) === undefined && isolationUpToDate(isolation, deployedPolicy, false) ) { log.info(colors.green(`App ${remotePath} is up to date`)); @@ -345,6 +380,7 @@ export async function pushApp( requestBody: { deployment_message: message, ...localAppBody, + custom_path: changedCustomPath(customPath, app), ...preserveFields, // Preserve any user draft at this path (see backend skip_draft_deletion). skip_draft_deletion: true, @@ -360,6 +396,7 @@ export async function pushApp( path: remotePath, deployment_message: message, ...localAppBody, + ...(customPath ? { custom_path: customPath } : {}), ...preserveFields, // Preserve any user draft at this path (see backend skip_draft_deletion). skip_draft_deletion: true, diff --git a/cli/src/commands/app/dev.ts b/cli/src/commands/app/dev.ts index fdf26fa7da..eb9bb055d6 100644 --- a/cli/src/commands/app/dev.ts +++ b/cli/src/commands/app/dev.ts @@ -67,7 +67,7 @@ const DEFAULT_PORT = 4000; const DEFAULT_HOST = "localhost"; // HTML template with live reload and SQL migration modal -const createHTML = (jsPath: string, cssPath: string) => ` +const createHTML = (jsPath: string, cssPath: string, appCtx: unknown) => ` <!DOCTYPE html> <html lang="en"> <head> @@ -228,6 +228,7 @@ const createHTML = (jsPath: string, cssPath: string) => ` </div> </div> </div> + <script>window.ctx = ${JSON.stringify(appCtx).replace(/</g, "\\u003c")};</script> <script src="${jsPath}"></script> <script> // Live reload via EventSource @@ -406,6 +407,18 @@ async function dev(opts: DevOptions, appFolder?: string) { await requireLogin(opts); const workspaceId = workspace.workspaceId; + // What the deployed app receives as `window.ctx`: the viewer's membership in this + // workspace, shaped as the frontend's UserExt, or no viewer when it can't be looked up. + const viewer = await wmill.whoami({ workspace: workspaceId }).catch(() => undefined); + const appCtx = { + ctx: viewer && { + ...viewer, + workspace_id: workspaceId, + pgroups: (viewer.groups ?? []).map((g) => `g/${g}`), + }, + workspace: workspaceId, + }; + // Resolve relative imports in app inline scripts from local (not-yet-deployed) // content so previews use locally-edited workspace libs instead of deployed. // Computed here as a startup snapshot; re-run `wmill app dev` to pick up @@ -966,7 +979,7 @@ async function dev(opts: DevOptions, appFolder?: string) { // Serve injected HTML for root and any other path res.writeHead(200, { "Content-Type": "text/html" }); - res.end(createHTML("/dist/bundle.js", "/dist/bundle.css")); + res.end(createHTML("/dist/bundle.js", "/dist/bundle.css", appCtx)); }); // Create WebSocket server on the same HTTP server @@ -1168,6 +1181,7 @@ async function dev(opts: DevOptions, appFolder?: string) { runnableId, args, appTempRefs, + previewModules(runnables, runnableId), ); log.info(colors.gray(`[backend] Job started: ${uuid}`)); @@ -1215,6 +1229,7 @@ async function dev(opts: DevOptions, appFolder?: string) { runnable_id, v, appTempRefs, + previewModules(runnables, runnable_id), ); log.info(colors.gray(`[backendAsync] Job started: ${uuid}`)); @@ -1696,6 +1711,49 @@ export function buildWmillTs( return windmillUtils.genWmillTs(runnables); } +/** + * The inputs of an agent runnable, after the agent's memory (see the frontend's + * `agentRunnableSchema`): `memory_id` when it keeps managed memory, `previous_messages` when it + * is off, and neither for an older `manual` setting. + */ +function agentRunnableSchema(memory: any) { + const managed = + memory?.kind === "compaction" || + ((memory?.kind === "window" || memory?.kind === "auto") && + Boolean(memory.context_length)); + const legacy = memory?.kind === "manual"; + const history: Record<string, any> = {}; + if (managed) { + history.memory_id = { + type: "string", + description: + "The conversation the message belongs to: reuse it to continue one, or omit it for a single run", + }; + } else if (!legacy) { + history.previous_messages = { + type: "array", + description: + "The history to answer from, as chat messages: this agent keeps no memory", + items: { type: "object" }, + }; + } + return { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + user_message: { type: "string", description: "The message to answer" }, + ...history, + user_attachments: { + type: "array", + description: "Files the message carries, as object storage references", + items: { type: "object", resourceType: "s3object" }, + }, + }, + required: ["user_message"], + order: ["user_message", ...Object.keys(history), "user_attachments"], + }; +} + /** * Fetches schemas from the Windmill API for path-based runnables (script / flow). * Returns a map of runnableId -> schema. Path-based runnables don't store their @@ -1726,6 +1784,12 @@ export async function fetchPathRunnableSchemas( path: runnable.path, }); if (script.schema) schemas[runnableId] = script.schema; + } else if (runnable.runType === "agent") { + const agent = await wmill.getResource({ + workspace: workspaceId, + path: runnable.path, + }); + schemas[runnableId] = agentRunnableSchema((agent.value as any)?.memory); } else if (runnable.runType === "flow") { const flow = await wmill.getFlowByPath({ workspace: workspaceId, @@ -1756,7 +1820,8 @@ function convertRunnablesToApiFormat(runnables: Record<string, any>): void { if ( runnable?.type === "script" || runnable?.type === "hubscript" || - runnable?.type === "flow" + runnable?.type === "flow" || + runnable?.type === "agent" ) { // Convert from file format to API format // { type: "script" } -> { type: "path", runType: "script" } @@ -1800,6 +1865,55 @@ async function loadRunnables(): Promise<Record<string, Runnable>> { } } +function moduleExtension(language: string | undefined): string | undefined { + if (language === "bun" || language === "bunnative") return "ts"; + if (language === "python3") return "py"; + return undefined; +} + +/** Ids of the sibling runnables `content` imports as `./<id>` (TS) or `.<id>` (Python). */ +function siblingImports(content: string, ext: string): string[] { + const pattern = ext === "ts" + ? /(?:from|import)\s*['"]\.\/([^'"/]+?)(?:\.ts)?['"]/g + : /^\s*from\s+\.([A-Za-z_]\w*)\s+import\b/gm; + return Array.from(content.matchAll(pattern), (m) => m[1]); +} + +/** + * The app's other inline runnables that `runnableId` imports, directly or through one + * another, as the modules its preview ships with so those imports resolve to the local + * files. Deploy stores the same set on the runnable. + */ +function previewModules( + runnables: Record<string, Runnable>, + runnableId: string, +): Record<string, { content: string; language: string }> | undefined { + const inlineScriptOf = (r: Runnable | undefined) => + r && "inlineScript" in r ? r.inlineScript : undefined; + const own = inlineScriptOf(runnables[runnableId]); + const ext = moduleExtension(own?.language); + if (!own || !ext) return undefined; + const modules: Record<string, { content: string; language: string }> = {}; + const queue = siblingImports(own.content, ext); + while (queue.length > 0) { + const id = queue.pop()!; + const key = `${id}.${ext}`; + const inlineScript = inlineScriptOf(runnables[id]); + // `main.ts` is the Bun entrypoint's own file, so no module may take that name. The + // runnable itself is kept when a cycle reaches it: the entrypoint is not under its id. + if (key in modules || key === "main.ts") continue; + if (!inlineScript || moduleExtension(inlineScript.language) !== ext) { + continue; + } + modules[key] = { + content: inlineScript.content, + language: inlineScript.language, + }; + queue.push(...siblingImports(inlineScript.content, ext)); + } + return Object.keys(modules).length > 0 ? modules : undefined; +} + async function executeRunnable( runnable: Runnable, workspace: string, @@ -1807,6 +1921,7 @@ async function executeRunnable( runnableId: string, args: any, tempScriptRefs?: Record<string, string>, + modules?: Record<string, { content: string; language: string }>, ): Promise<string> { const requestBody: any = { component: runnableId, @@ -1839,12 +1954,18 @@ async function executeRunnable( if (inlineScript.id !== undefined) { requestBody.id = inlineScript.id; } + // A lock is not regenerated when only an imported runnable is edited, so with + // modules the preview resolves dependencies afresh. + const previewMods = inlineScript.id === undefined ? modules : undefined; requestBody.raw_code = { content: inlineScript.id === undefined ? inlineScript.content : "", language: inlineScript.language ?? "", path: `${appPath}/${runnableId}`, - lock: inlineScript.id === undefined ? inlineScript.lock : undefined, + lock: inlineScript.id === undefined && !previewMods + ? inlineScript.lock + : undefined, cache_ttl: inlineScript.cache_ttl, + ...(previewMods ? { modules: previewMods } : {}), }; if (inlineScript.id === undefined && tempScriptRefs) { requestBody.temp_script_refs = tempScriptRefs; @@ -1958,9 +2079,11 @@ async function streamJobWithSSE( ws: WebSocket, baseUrl: string, token: string, + streamOffset?: number, ): Promise<void> { const sseUrl = - `${baseUrl}api/w/${workspace}/jobs_u/getupdate_sse/${jobId}?fast=true`; + `${baseUrl}api/w/${workspace}/jobs_u/getupdate_sse/${jobId}?fast=true` + + (streamOffset !== undefined ? `&stream_offset=${streamOffset}` : ""); const extraHeaders = getHeaders(); const response = await fetch(sseUrl, { @@ -1986,6 +2109,7 @@ async function streamJobWithSSE( const decoder = new TextDecoder(); let buffer = ""; + let lastOffset = streamOffset; try { while (true) { @@ -2005,8 +2129,17 @@ async function streamJobWithSSE( if (type === "ping" || type === "timeout") { if (type === "timeout") { + // The server ends every stream after a while; the job goes on, so it is resumed. reader.cancel(); - return; + return streamJobWithSSE( + workspace, + jobId, + reqId, + ws, + baseUrl, + token, + lastOffset, + ); } continue; } @@ -2037,6 +2170,9 @@ async function streamJobWithSSE( return; } + if (update.stream_offset !== undefined) { + lastOffset = update.stream_offset; + } // Send stream update if there's new stream data if (update.new_result_stream !== undefined) { ws.send( diff --git a/cli/src/commands/app/metadata.ts b/cli/src/commands/app/metadata.ts index 1981949404..5f16e84220 100644 --- a/cli/src/commands/app/metadata.ts +++ b/cli/src/commands/app/metadata.ts @@ -14,7 +14,7 @@ export type Runnable = } | { type: "path" | "runnableByPath"; - runType: "script" | "hubscript" | "flow"; + runType: "script" | "hubscript" | "flow" | "agent"; path: string; fields?: Record<string, any>; }; diff --git a/cli/src/commands/app/new.ts b/cli/src/commands/app/new.ts index 40cc2a4416..68ed636640 100644 --- a/cli/src/commands/app/new.ts +++ b/cli/src/commands/app/new.ts @@ -593,6 +593,7 @@ CREATE SCHEMA IF NOT EXISTS ${schemaName}; // Create raw_app.yaml with data configuration const rawAppConfig: Record<string, unknown> = { summary, + sandbox: true, }; // Add data configuration if present diff --git a/cli/src/commands/app/raw_apps.ts b/cli/src/commands/app/raw_apps.ts index e77e1aed45..570b03f949 100644 --- a/cli/src/commands/app/raw_apps.ts +++ b/cli/src/commands/app/raw_apps.ts @@ -22,6 +22,7 @@ import { type AppExecutionMode, type AppIsolation, basePolicy, + changedCustomPath, executionModeForPush, finalizeDerivedPolicy, isolationUpToDate, @@ -229,7 +230,8 @@ export async function loadRunnablesFromBackend( } else if ( runnable?.type === "script" || runnable?.type === "hubscript" || - runnable?.type === "flow" + runnable?.type === "flow" || + runnable?.type === "agent" ) { // For path-based runnables, convert from file format to API format // { type: "script" } -> { type: "path", runType: "script" } @@ -376,6 +378,7 @@ export async function pushRawApp( message?: string, defaultTs: "bun" | "deno" = "bun", permissionedAsContext?: PermissionedAsContext, + customPathFromFile = true, ): Promise<void> { if (alreadySynced.includes(localPath)) { return; @@ -496,16 +499,19 @@ export async function pushRawApp( extra_perms: localPerms, sandbox: _sandbox, frontend_api: _frontendApi, + custom_path: localCustomPath, ...localAppNoPerms } = localApp as AppFile & { extra_perms?: Record<string, boolean>; }; + const customPath = customPathFromFile ? localCustomPath : undefined; if (app) { // Check both metadata/runnables AND files for changes // Files need separate comparison because isSuperset only checks if local keys exist in remote const metadataUpToDate = isSuperset({ ...localAppNoPerms, runnables }, app) && + changedCustomPath(customPath, app) === undefined && isolationUpToDate(isolation, deployedPolicy, true); const filesUpToDate = deepEqual(files, app.value?.files); if (metadataUpToDate && filesUpToDate) { @@ -526,9 +532,7 @@ export async function pushRawApp( deployment_message: message, // Preserve any user draft at this path (see backend skip_draft_deletion). skip_draft_deletion: true, - ...(localApp.custom_path - ? { custom_path: localApp.custom_path } - : {}), + custom_path: changedCustomPath(customPath, app), }, js, css, @@ -548,9 +552,7 @@ export async function pushRawApp( deployment_message: message, // Preserve any user draft at this path (see backend skip_draft_deletion). skip_draft_deletion: true, - ...(localApp.custom_path - ? { custom_path: localApp.custom_path } - : {}), + ...(customPath ? { custom_path: customPath } : {}), }, js, css, diff --git a/cli/src/commands/datatable/datatable.ts b/cli/src/commands/datatable/datatable.ts index 3c0c5d6c65..2b6505de75 100644 --- a/cli/src/commands/datatable/datatable.ts +++ b/cli/src/commands/datatable/datatable.ts @@ -50,13 +50,19 @@ async function run( } function migrateNew( - opts: GlobalOptions & { datatable?: string }, + opts: GlobalOptions & { datatable?: string; schemas?: string }, name: string, ) { - createMigration(opts.datatable ?? DEFAULT_DATATABLE_NAME, name); + const schemas = (opts.schemas ?? "") + .split(",") + .map((s) => s.trim()) + .filter((s) => s !== ""); + createMigration(opts.datatable ?? DEFAULT_DATATABLE_NAME, name, schemas); } -async function migrateUp(opts: GlobalOptions & { datatable?: string }) { +async function migrateUp( + opts: GlobalOptions & { datatable?: string; schema?: string }, +) { const workspace = await resolveWorkspace(opts); await requireLogin(opts); const dt = opts.datatable ?? DEFAULT_DATATABLE_NAME; @@ -74,7 +80,7 @@ async function migrateUp(opts: GlobalOptions & { datatable?: string }) { // Push any locally-created/edited migration files first (without running // them), so `migrate up` works even before a `wmill sync push`. await pushLocalMigrations(workspace.workspaceId, dt); - await runMigrations(workspace.workspaceId, dt); + await runMigrations(workspace.workspaceId, dt, opts.schema); } async function migrateDown(opts: GlobalOptions & { datatable?: string }) { @@ -97,7 +103,12 @@ async function migrateStatus( }); // `migrate up` pushes local files before running, so they count as pending too. const remote = new Set(status.migrations.map((m) => m.timestamp)); - const migrations: { timestamp: number; name: string; status: string }[] = [ + const migrations: { + timestamp: number; + name: string; + status: string; + schemas?: string[]; + }[] = [ ...status.migrations, ...listLocalMigrations(dt) .filter((m) => !remote.has(m.timestamp)) @@ -124,7 +135,7 @@ async function migrateStatus( local: "pending (not pushed)", }; new Table() - .header(["Timestamp", "Name", "Status"]) + .header(["Timestamp", "Name", "Status", "Schemas"]) .padding(2) .border(true) .body( @@ -132,6 +143,9 @@ async function migrateStatus( String(m.timestamp), m.name, label[m.status] ?? m.status, + m.status === "local" + ? "" + : (m.schemas?.join(", ") ?? "whole database"), ]), ) .render(); @@ -145,6 +159,10 @@ const migrateCommand = new Command() "-d --datatable <datatable:string>", "Target datatable (default: main)", ) + .option( + "-s --schemas <schemas:string>", + "Comma-separated schemas the migration changes, written as its `-- schemas` line. Without it the migration applies to the whole database and every later migration waits for it", + ) .action(migrateNew as any) .command( "up", @@ -154,6 +172,10 @@ const migrateCommand = new Command() "-d --datatable <datatable:string>", "Target datatable (default: main)", ) + .option( + "--schema <schema:string>", + "Apply only the pending migrations that declare this schema; refused while an earlier overlapping migration is pending", + ) .action(migrateUp as any) .command( "down", diff --git a/cli/src/commands/datatable_migrations.ts b/cli/src/commands/datatable_migrations.ts index a637f99968..49237257b0 100644 --- a/cli/src/commands/datatable_migrations.ts +++ b/cli/src/commands/datatable_migrations.ts @@ -44,16 +44,32 @@ function nextMigrationTimestamp(dir: string): string { return String(max >= now ? max + 1 : now); } +// Matches `is_annotatable_schema_name` on the server. +const SCHEMA_NAME_RE = /^[A-Za-z0-9_$-]{1,63}$/; + /** * Scaffold a new migration under migrations/datatable/<datatable>/ as empty * `<timestamp>_<name>.up.sql` and `.down.sql` files. Purely local — no network. + * `schemas` becomes the up file's leading `-- schemas` annotation, which the + * server reads as the migration's scope. */ -export function createMigration(datatable: string, name: string): void { +export function createMigration( + datatable: string, + name: string, + schemas: string[] = [], +): void { if (!MIGRATION_NAME_RE.test(name)) { throw new Error( `Invalid migration name '${name}': use only letters, digits, '_' and '-'`, ); } + for (const s of schemas) { + if (!SCHEMA_NAME_RE.test(s)) { + throw new Error( + `Invalid schema name '${s}': use only letters, digits, '_', '$' and '-'`, + ); + } + } const dir = path.join(process.cwd(), MIGRATIONS_DIR, datatable); fs.mkdirSync(dir, { recursive: true }); @@ -65,7 +81,9 @@ export function createMigration(datatable: string, name: string): void { // the template the UI's "New migration" modal seeds. const template = (direction: string) => `-- ${direction} migration: ${name}\nBEGIN;\n\n-- Add your migration here\n\nEND;\n`; - fs.writeFileSync(up, template("up"), "utf-8"); + const scope = + schemas.length > 0 ? `-- schemas ${schemas.join(", ")}\n` : ""; + fs.writeFileSync(up, scope + template("up"), "utf-8"); fs.writeFileSync(down, template("down"), "utf-8"); log.info( @@ -74,6 +92,13 @@ export function createMigration(datatable: string, name: string): void { for (const f of [up, down]) { log.info(colors.gray(` ${path.relative(process.cwd(), f)}`)); } + if (schemas.length === 0) { + log.warn( + "It declares no schemas, so it applies to the whole database and every later migration " + + "waits for it. Pass --schemas, or add a `-- schemas <name>, <name>` line at the top of " + + "the up file.", + ); + } } /** @@ -83,14 +108,20 @@ export function createMigration(datatable: string, name: string): void { export async function runMigrations( workspace: string, datatableName: string, + schema?: string, ): Promise<void> { const result = await wmill.runDatatableMigrations({ workspace, datatableName, + schema, }); const applied = result.applied ?? []; if (applied.length === 0) { - log.info(colors.gray(`No pending migrations to run on '${datatableName}'`)); + log.info( + colors.gray( + `No pending migrations to run on '${datatableName}'${schema ? ` for schema '${schema}'` : ""}`, + ), + ); return; } log.info( diff --git a/cli/src/commands/dev/dev.ts b/cli/src/commands/dev/dev.ts index 00a6f72f0e..562c38233e 100644 --- a/cli/src/commands/dev/dev.ts +++ b/cli/src/commands/dev/dev.ts @@ -30,8 +30,8 @@ import type { ScriptModule } from "../../../gen/types.gen.ts"; import { DBT_DESCRIPTOR_NAME, DBT_MODULE_SUFFIX, + getModuleFolderSuffix, getScriptBasePathFromModulePath, - isDbtModulePath, } from "../../utils/resource_folders.ts"; import { inferContentTypeFromFilePath } from "../../utils/script_common.ts"; import { OpenFlow } from "../../../gen/types.gen.ts"; @@ -148,6 +148,19 @@ export async function listWorkspacePaths(): Promise<WmPathItem[]> { items.push({ path: stripFolderSuffix(childRel, APP_SUFFIXES), kind: "raw_app" }); continue; } + // A script's own bundle, whose files are its modules rather than + // scripts. In folder layout the script itself is its `script.*`. + if (entry.name.endsWith(getModuleFolderSuffix())) { + const files = await readdir(childAbs).catch(() => [] as string[]); + if (files.some((f) => f.startsWith("script.") && hasScriptExt(f))) { + items.push({ + path: childRel.slice(0, -getModuleFolderSuffix().length), + kind: "script", + _metaPath: path.join(childAbs, "script.yaml"), + }); + } + continue; + } // A dbt script IS the project directory: its descriptor is optional, so // there may be no file here to recognize it by. Not descended into // either — the project's own `.sql` models would otherwise each be @@ -307,30 +320,25 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) { const flowMetadataFile = getMetadataFileName("flow", "yaml"); async function loadPaths(pathsToLoad: string[]) { - // A change ANYWHERE inside a dbt project is a change to that script: the - // bundle is the project, so the whole thing is re-read and rebroadcast. - // Treating the file as a script of its own would drop the edit — a bare - // `.sql` has no language to infer, and a `.yml` or `.csv` is filtered out - // below — leaving the browser previewing the snapshot taken at startup. + // Module edits belong to the parent script's bundle, even when the file + // has no standalone script extension, as with dbt's `.yml` and `.csv`. const rest: string[] = []; - const dbtProjects = new Set<string>(); + const scriptsWithModules = new Set<string>(); for (const raw of pathsToLoad) { const rel = (await realpath(raw).catch(() => raw)) .replace(base + SEP, "") .replaceAll("\\", "/"); - const wmPath = isDbtModulePath(rel) - ? getScriptBasePathFromModulePath(rel) - : undefined; + const wmPath = getScriptBasePathFromModulePath(rel); // Every project in the batch, and each only once: a save-all or a // `git checkout` touches many files at once, and returning on the first // would drop both the other projects and whatever else changed with them. - if (wmPath) dbtProjects.add(wmPath); + if (wmPath) scriptsWithModules.add(wmPath); else rest.push(raw); } - for (const wmPath of dbtProjects) { + for (const wmPath of scriptsWithModules) { const edit = await loadWmPath(wmPath); if (edit) { - log.info("Updated " + wmPath + " (dbt project)"); + log.info("Updated " + wmPath + " (script modules)"); broadcastChanges(edit); } } @@ -399,8 +407,7 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) { log.info("Updated " + wmFlowPath); broadcastChanges(currentLastEdit); } else if (typ == "script") { - const splitted = cpath.split("."); - const wmPath = splitted[0]; + const wmPath = removeExtensionToPath(cpath); const content = await readTextFile(cpath); const lang = inferContentTypeFromFilePath(cpath, opts.defaultTs); const typed = @@ -419,6 +426,9 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) { tag: typed?.tag, lock: typed?.lock, temp_script_refs: tempScriptRefs, + modules: await readModulesFromDisk( + wmPath + getModuleFolderSuffix(lang), opts.defaultTs, lang === "dbt", lang === "dbt" + ), }; log.info("Updated " + wmPath); broadcastChanges(currentLastEdit); @@ -433,9 +443,7 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) { tag?: string; lock?: string; temp_script_refs?: Record<string, string>; - /** The bundle the dev page forwards to a preview run. A dbt project cannot - * run without it: the worker looks for `dbt_project.yml` in the bundle and - * refuses the job when it is not there. */ + /** Module bundle forwarded by the dev page to preview runs. */ modules?: Record<string, ScriptModule>; }; @@ -494,9 +502,14 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) { // Not a flow, try as script } - // Try as script - for (const ext of exts) { - const filePath = wmPath + ext; + // Try as script: flat `<path>.<ext>`, then folder layout `<path>__mod/script.<ext>` + const candidates = [ + ...exts.map((ext) => ({ ext, filePath: wmPath + ext, folderLayout: false })), + ...exts + .filter((ext) => !ext.includes("/")) + .map((ext) => ({ ext, filePath: wmPath + getModuleFolderSuffix() + "/script" + ext, folderLayout: true })), + ]; + for (const { ext, filePath, folderLayout } of candidates) { try { // A dbt project's descriptor is optional, so what says "this is a dbt // script" is the project beside it. Requiring the descriptor to exist @@ -516,7 +529,7 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) { } const content = isAbsentDbtDescriptor ? "" : await readTextFile(filePath); const lang = inferContentTypeFromFilePath(filePath, opts.defaultTs); - const typed = (await parseMetadataFile(removeExtensionToPath(filePath), undefined))?.payload; + const typed = (await parseMetadataFile(wmPath, undefined))?.payload; const edit: LastEditScript = { type: "script", content, @@ -528,15 +541,12 @@ export async function dev(opts: GlobalOptions & SyncOptions & DevOpts) { // Read VERBATIM for dbt, the way push does: the project's `.sql` and // `.yml` files are dbt's, and inferring a language for each would // drop the ones that are not Windmill scripts. - modules: + modules: await readModulesFromDisk( + wmPath + getModuleFolderSuffix(lang), + opts.defaultTs, + folderLayout || lang === "dbt", lang === "dbt" - ? await readModulesFromDisk( - wmPath + DBT_MODULE_SUFFIX, - opts.defaultTs, - true, - true - ) - : undefined, + ), }; currentLastEdit = edit; return edit; diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index a266834f41..98339865be 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -19,7 +19,7 @@ import { toSyncRootRelativePath, validatePath, } from "../../core/context.ts"; -import { resolve, track_job, pollForJobResult } from "../script/script.ts"; +import { resolve, track_job, pollForJobResult, ensurePreviewOnBehalfOfSupported } from "../script/script.ts"; import { defaultFlowDefinition } from "../../../bootstrap/flow_bootstrap.ts"; import { SyncOptions, @@ -639,6 +639,7 @@ async function preview( remote?: boolean; step?: string; tag?: string; + onBehalfOf?: string; } & SyncOptions, flowPath: string ) { @@ -653,6 +654,7 @@ async function preview( } const workspace = await resolveWorkspace(opts); await requireLogin(opts); + await ensurePreviewOnBehalfOfSupported(workspace.workspaceId, opts.onBehalfOf); const codebases = useLocalPathScripts ? listSyncCodebases(opts) : []; const argPath = flowPath; @@ -758,7 +760,7 @@ async function preview( // when `useLocalPathScripts` is set, and tempScriptRefs covers relative // imports in inline scripts. if (opts.step) { - await previewStep(opts.step, localFlow, flowWmPath, workspace, input, tempScriptRefs, opts.silent, opts.tag); + await previewStep(opts.step, localFlow, flowWmPath, workspace, input, tempScriptRefs, opts.silent, opts.tag, opts.onBehalfOf); return; } @@ -775,6 +777,7 @@ async function preview( args: input, tag: opts.tag, temp_script_refs: tempScriptRefs, + on_behalf_of: opts.onBehalfOf, }, }); @@ -808,6 +811,7 @@ async function previewStep( tempScriptRefs: Record<string, string> | undefined, silent: boolean, tag: string | undefined, + onBehalfOf: string | undefined, ) { const module = findStepInFlowValue(localFlow.value, stepId); if (!module) { @@ -841,6 +845,7 @@ async function previewStep( args, tag, temp_script_refs: tempScriptRefs, + on_behalf_of: onBehalfOf, }, }); } else if (moduleValue?.type === "script") { @@ -868,9 +873,15 @@ async function previewStep( args, tag, temp_script_refs: tempScriptRefs, + on_behalf_of: onBehalfOf, }, }); } else if (moduleValue?.type === "flow") { + if (onBehalfOf) { + throw new Error( + `--on-behalf-of does not apply to step '${stepId}': a flow step runs the deployed flow ${moduleValue.path} by path, and this flag cannot override its identity.` + ); + } log.info(colors.yellow(`Previewing step '${stepId}' (flow ${moduleValue.path})...`)); jobId = await wmill.runFlowByPath({ workspace: workspace.workspaceId, @@ -1216,6 +1227,10 @@ const command = new Command() "--tag <tag:string>", "Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the flow's default tag)." ) + .option( + "--on-behalf-of <identity:string>", + "Run the preview as this user or group (an email, u/<username> or g/<group>) instead of yourself, e.g. to reproduce a run_on_behalf_of identity. Requires being an admin or a member of wm_deployers." + ) .action(preview as any) .command( "generate-locks", diff --git a/cli/src/commands/init/template.ts b/cli/src/commands/init/template.ts index 540d8c7978..1f78c13148 100644 --- a/cli/src/commands/init/template.ts +++ b/cli/src/commands/init/template.ts @@ -109,6 +109,9 @@ export const CONFIG_REFERENCE: ConfigOption[] = [ commented: true, templateValue: "true" }, { name: "includeSettings", type: "boolean", default: "false", description: "Include workspace settings in sync", commented: true, templateValue: "true" }, + { name: "includeDatatablePermissions", type: "boolean", default: "false", + description: "With includeSettings, also sync each data table's roles and who may connect as them", + commented: true, templateValue: "true" }, { name: "includeKey", type: "boolean", default: "false", description: "Include encryption key in sync", commented: true, templateValue: "true" }, diff --git a/cli/src/commands/resource-type/resource-type.ts b/cli/src/commands/resource-type/resource-type.ts index 505200f07a..e8e03757e5 100644 --- a/cli/src/commands/resource-type/resource-type.ts +++ b/cli/src/commands/resource-type/resource-type.ts @@ -91,6 +91,19 @@ async function push(opts: PushOptions, filePath: string, name: string) { log.info(colors.bold.underline.green("Resource pushed")); } +// The hub publishes hundreds of resource types and an instance seeds only a few of its own, +// so fewer than this means the instance never synced with the hub. +const UNSYNCED_RESOURCE_TYPE_COUNT = 20; + +function warnIfNotSyncedWithHub(resourceTypes: ResourceType[]) { + const count = new Set(resourceTypes.map((rt) => rt.name)).size; + if (count < UNSYNCED_RESOURCE_TYPE_COUNT) { + log.warnAlways( + `Only ${count} resource types exist on this instance, so it has most likely never synced with the Windmill Hub, which has types for most services. A superadmin can sync resource types from the hub with "Sync resource types with Hub" in the Add resource dialog of the Resources page.` + ); + } +} + async function list(opts: GlobalOptions & { schema?: boolean; json?: boolean }) { if (opts.json) log.setSilent(true); const workspace = await resolveWorkspace(opts); @@ -99,12 +112,9 @@ async function list(opts: GlobalOptions & { schema?: boolean; json?: boolean }) workspace: workspace.workspaceId, }); + warnIfNotSyncedWithHub(res); if (opts.json) { console.log(JSON.stringify(res)); - } else if (res.length === 0) { - log.info("No custom resource types found in this workspace."); - log.info("Built-in types like 'postgresql', 'slack', 'mysql', etc. are available from the Windmill Hub."); - return; } else if (opts.schema) { new Table() .header(["Workspace", "Name", "Schema"]) @@ -172,6 +182,7 @@ export async function generateRTNamespace(opts: GlobalOptions) { const rts = await wmill.listResourceType({ workspace: workspace.workspaceId, }); + warnIfNotSyncedWithHub(rts); let namespaceContent = "declare namespace RT {\n"; namespaceContent += rts diff --git a/cli/src/commands/resource/resource.ts b/cli/src/commands/resource/resource.ts index 7cef4caf1f..87c31b331a 100644 --- a/cli/src/commands/resource/resource.ts +++ b/cli/src/commands/resource/resource.ts @@ -20,6 +20,11 @@ import { Resource } from "../../../gen/types.gen.ts"; import { readInlinePathSync, readTextFile } from "../../utils/utils.ts"; import { isWorkspaceSpecificFile } from "../../core/specific_items.ts"; import { getCurrentGitBranch } from "../../utils/git.ts"; +import { + buildPermissionedAsContext, + type PermissionedAsContext, +} from "../../core/permissioned_as.ts"; +import { readEffectiveSyncBehavior } from "../../core/conf.ts"; export interface ResourceFile { value: any; @@ -83,6 +88,7 @@ export async function pushResource( // only round-trip the canonical layout); the standalone `resource push` // command pushes a single explicit file, where any pointer is fine. enforceCanonicalFileset?: boolean, + permissionedAsContext?: PermissionedAsContext, ): Promise<void> { remotePath = removeType(remotePath, "resource"); try { @@ -93,6 +99,11 @@ export async function pushResource( } catch { // flow doesn't exist } + const claimed = agentOnBehalfOf( + resource, + localResource, + permissionedAsContext + ); // Helper function to resolve inline content const resolveInlineContent = async () => { @@ -133,11 +144,23 @@ export async function pushResource( // Only resolve inline content if we're actually updating await resolveInlineContent(); + if (claimed) { + log.info(`Preserving ${claimed} as permissioned_as for agent ${remotePath}`); + } await wmill.updateResource({ workspace: workspace, path: remotePath.replaceAll(SEP, "/"), - requestBody: { ...localResource, ...(wsSpecific !== undefined ? { ws_specific: wsSpecific } : {}) }, + requestBody: { + ...localResource, + ...(wsSpecific !== undefined ? { ws_specific: wsSpecific } : {}), + ...(claimed + ? { + value: { ...localResource.value, on_behalf_of: claimed }, + preserve_on_behalf_of: true, + } + : {}), + }, }); } else { // New resource - resolve inline content @@ -163,6 +186,42 @@ export async function pushResource( } } +/** + * An agent with `run_on_behalf_of` runs as the identity in its value's + * `on_behalf_of`, which the backend sets to whoever writes the agent and clears + * while the toggle is off. Like an app's, it is never taken from the + * tracked file, which the pull leaves it out of: the deployed one is claimed + * back only by a pusher the backend lets keep it, an admin or a `wm_deployers` + * member. Strips the key from `localResource` (and from the deployed value, so + * an unchanged agent still compares equal) and returns the identity to claim. + */ +export function agentOnBehalfOf( + deployed: ResourceFile | Resource | undefined, + localResource: ResourceFile, + permissionedAsContext: PermissionedAsContext | undefined +): string | undefined { + // What the push writes is the tracked file's type: an agent retyped into + // another resource keeps its value exactly as the file states it. + if (localResource.resource_type !== "ai_agent") { + return undefined; + } + const local = localResource.value; + if (local && typeof local === "object") { + delete local.on_behalf_of; + } + const value = deployed?.resource_type === "ai_agent" ? deployed.value : undefined; + const onBehalfOf = + value && typeof value === "object" ? value.on_behalf_of : undefined; + if (deployed && onBehalfOf !== undefined) { + const { on_behalf_of: _, ...rest } = value; + deployed.value = rest; + } + if (!onBehalfOf || !permissionedAsContext?.userIsAdminOrDeployer) { + return undefined; + } + return onBehalfOf; +} + type PushOptions = GlobalOptions; async function push(opts: PushOptions, filePath: string, remotePath: string) { const workspace = await resolveWorkspace(opts); @@ -184,7 +243,13 @@ async function push(opts: PushOptions, filePath: string, remotePath: string) { remotePath, undefined, parseFromFile(filePath), - filePath // Pass the local file path for branch-specific inline content resolution + filePath, // Pass the local file path for branch-specific inline content resolution + undefined, + undefined, + await buildPermissionedAsContext( + workspace.workspaceId, + await readEffectiveSyncBehavior(opts, workspace) + ) ); log.info(colors.bold.underline.green(`Resource ${remotePath} pushed`)); } diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index 4938b6aeb3..f38a73d697 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -19,7 +19,7 @@ import * as log from "../../core/log.ts"; import { sep as SEP } from "node:path"; import * as path from "node:path"; import { stringify as yamlStringify } from "yaml"; -import { deepEqual, getHeaders, isFileResource, isFilesetResource, readTextFile, readTextFileSync } from "../../utils/utils.ts"; +import { deepEqual, getHeaders, isFileResource, isFilesetResource, readTextFile, readTextFileSync, sleep } from "../../utils/utils.ts"; import { detectAuthGatewayChallenge } from "../../utils/http_guards.ts"; import * as wmill from "../../../gen/services.gen.ts"; import * as specificItems from "../../core/specific_items.ts"; @@ -1492,45 +1492,49 @@ async function run( await track_job(workspace.workspaceId, id); } - const MAX_RETRIES = 600; // ~60 seconds at 100ms intervals - let retries = 0; - while (retries < MAX_RETRIES) { - try { - const completedJob = await wmill.getCompletedJob({ - workspace: workspace.workspaceId, - id, - }); - - if (completedJob.success === false) { - process.exitCode = 1; - } - - const result = completedJob.result ?? {}; - if (opts.silent) { - console.log(JSON.stringify(result)); - } else { - log.info(JSON.stringify(result, null, 2)); - } - - break; - } catch { - retries++; - await new Promise((resolve) => setTimeout(resolve, 100)); - } + let { result, success } = await pollForJobResult(workspace.workspaceId, id); + // Prefer getCompletedJob: unlike the poller, it counts a skipped + // (debounced) job as a success. + try { + const completedJob = await wmill.getCompletedJob({ + workspace: workspace.workspaceId, + id, + }); + result = completedJob.result; + success = completedJob.success !== false; + } catch { + /* keep the poller's result */ } - if (retries >= MAX_RETRIES) { - throw new Error(`Timed out waiting for job ${id} to complete`); + if (!success) { + process.exitCode = 1; + } + result = result ?? {}; + if (opts.silent) { + console.log(JSON.stringify(result)); + } else { + log.info(JSON.stringify(result, null, 2)); } } +function logJobCompleted(success: boolean | undefined) { + log.info("\n"); + log.info( + success === false + ? colors.bold.underline.red("Job Completed") + : colors.bold.underline.green("Job Completed") + ); + log.info("\n"); +} + +const JOB_UPDATES_POLL_INTERVAL_MS = 500; +const MAX_CONSECUTIVE_JOB_UPDATE_ERRORS = 5; + export async function track_job(workspace: string, id: string) { try { const result = await wmill.getCompletedJob({ workspace, id }); log.info(result.logs); - log.info("\n"); - log.info(colors.bold.underline.green("Job Completed")); - log.info("\n"); + logJobCompleted(result.success); return; } catch { /* ignore */ @@ -1538,15 +1542,13 @@ export async function track_job(workspace: string, id: string) { log.info(colors.yellow("Waiting for Job " + id + " to start...")); + // Server-side character position (1-based, as returned in `log_offset`), not + // a JS string length: UTF-16 lengths drift from it on non-BMP characters. let logOffset = 0; let running = false; - let retry = 0; + let errors = 0; while (true) { - let updates: { - running?: boolean | undefined; - completed?: boolean | undefined; - new_logs?: string | undefined; - }; + let updates: Awaited<ReturnType<typeof wmill.getJobUpdates>>; try { updates = await wmill.getJobUpdates({ workspace, @@ -1554,13 +1556,16 @@ export async function track_job(workspace: string, id: string) { logOffset, running, }); - } catch { - retry++; - if (retry > 3) { - log.info("failed to get job updated. skipping log streaming."); + errors = 0; + } catch (e: any) { + errors++; + if (errors >= MAX_CONSECUTIVE_JOB_UPDATE_ERRORS) { + log.warn( + `Failed to get job updates (${e?.message ?? e}), log streaming stopped. Waiting for the job to complete...` + ); break; } - await new Promise((resolve) => setTimeout(resolve, 500)); + await sleep(JOB_UPDATES_POLL_INTERVAL_MS); continue; } @@ -1571,11 +1576,10 @@ export async function track_job(workspace: string, id: string) { if (updates.new_logs) { process.stdout.write(updates.new_logs); - logOffset += updates.new_logs.length; } + logOffset = updates.log_offset ?? logOffset; if (updates.completed === true) { - running = false; break; } @@ -1583,24 +1587,25 @@ export async function track_job(workspace: string, id: string) { running = false; log.info(colors.yellow("Job suspended. Waiting for it to continue...")); } + await sleep(JOB_UPDATES_POLL_INTERVAL_MS); } - await new Promise((resolve, _) => setTimeout(() => resolve(undefined), 1000)); + await pollForJobResult(workspace, id); + + let success: boolean | undefined; try { const final_job = await wmill.getCompletedJob({ workspace, id }); - if ((final_job.logs?.length ?? -1) > logOffset) { - log.info(final_job.logs!.substring(logOffset)); + success = final_job.success; + const remaining = Array.from(final_job.logs ?? "") + .slice(Math.max(logOffset - 1, 0)) + .join(""); + if (remaining) { + log.info(remaining); } - log.info("\n"); - if (final_job.success) { - log.info(colors.bold.underline.green("Job Completed")); - } else { - log.info(colors.bold.underline.red("Job Completed")); - } - log.info("\n"); - } catch { - log.info("Job appears to have completed, but no data can be retrieved"); + } catch (e: any) { + log.warn(`Could not retrieve the job's remaining logs: ${e?.message ?? e}`); } + logJobCompleted(success); } export async function pollForJobResult( @@ -1610,6 +1615,32 @@ export async function pollForJobResult( return await pollJobWithQueueLogging(workspace, jobId); } +/** + * A server that predates preview `on_behalf_of` drops the field and runs the preview as the + * caller, which would pass for a reproduction of the other identity's run. Probe it with an + * `identity` preview, which runs no code, before submitting any. + */ +export async function ensurePreviewOnBehalfOfSupported( + workspace: string, + onBehalfOf: string | undefined, +) { + if (!onBehalfOf) return; + const probeId = await wmill.runScriptPreview({ + workspace, + requestBody: { kind: "identity", args: {}, on_behalf_of: onBehalfOf }, + }); + const job = await wmill.getJob({ workspace, id: probeId }); + if ( + job.permissioned_as === onBehalfOf || + job.email?.toLowerCase() === onBehalfOf.toLowerCase() + ) { + return; + } + throw new Error( + `The server does not support --on-behalf-of: it would run the preview as ${job.permissioned_as}, not ${onBehalfOf}. Upgrade the Windmill instance.` + ); +} + async function show(opts: GlobalOptions, path: string) { const workspace = await resolveWorkspace(opts); await requireLogin(opts); @@ -1873,6 +1904,7 @@ async function preview( data?: string; silent: boolean; tag?: string; + onBehalfOf?: string; } & SyncOptions, filePath: string ) { @@ -1884,6 +1916,7 @@ async function preview( opts = await mergeConfigWithConfigFile(opts); const workspace = await resolveWorkspace(opts); await requireLogin(opts); + await ensurePreviewOnBehalfOfSupported(workspace.workspaceId, opts.onBehalfOf); const argPath = filePath; filePath = toSyncRootRelativePath(filePath, cwdBeforeConfig); @@ -2053,6 +2086,7 @@ async function preview( kind: isTar ? "tarbundle" : "bundle", format: codebase?.format ?? "cjs", temp_script_refs: tempScriptRefs, + on_behalf_of: opts.onBehalfOf, }; form.append("preview", JSON.stringify(previewPayload)); form.append( @@ -2122,6 +2156,7 @@ async function preview( tag: opts.tag, modules: modules ?? undefined, temp_script_refs: tempScriptRefs, + on_behalf_of: opts.onBehalfOf, }, }); @@ -2272,6 +2307,10 @@ const command = new Command() "--tag <tag:string>", "Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the script's default tag)." ) + .option( + "--on-behalf-of <identity:string>", + "Run the preview as this user or group (an email, u/<username> or g/<group>) instead of yourself, e.g. to reproduce a run_on_behalf_of identity. Requires being an admin or a member of wm_deployers." + ) .action(preview as any) .command("new", "create a new script") .arguments("<path:file> <language:string>") diff --git a/cli/src/commands/sync/pull.ts b/cli/src/commands/sync/pull.ts index d75dc81912..51709b7d73 100644 --- a/cli/src/commands/sync/pull.ts +++ b/cli/src/commands/sync/pull.ts @@ -86,7 +86,8 @@ export async function downloadZip( skipWorkspaceDependencies?: boolean, skipDatatableMigrations?: boolean, defaultTs?: "bun" | "deno", - syncBehavior?: string + syncBehavior?: string, + includeDatatablePermissions?: boolean, ): Promise<JSZip | TarAsZip | undefined> { const requestHeaders = new Headers(); requestHeaders.set("Authorization", "Bearer " + workspace.token); @@ -112,6 +113,7 @@ export async function downloadZip( }&skip_secrets=${skipSecrets ?? false}&include_schedules=${includeSchedules ?? false }&include_triggers=${includeTriggers ?? false}&include_users=${includeUsers ?? false }&include_groups=${includeGroups ?? false}&include_settings=${includeSettings ?? false + }&include_datatable_permissions=${includeDatatablePermissions ?? false }&include_key=${includeKey ?? false}&include_workspace_dependencies=${includeWorkspaceDependenciesValue}&skip_datatable_migrations=${skipDatatableMigrations ?? false}&default_ts=${defaultTs ?? "bun"}&skip_resource_types=${skipResourceTypes ?? false}&settings_version=v2&preserve_extra_perms=true&sync_behavior_version=${syncBehavior ?? "v0"}`; const baseUrl = workspace.remote + "api/w/" + workspace.workspaceId + "/workspaces/tarball?"; diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 5ba8888332..43bba850ed 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -14,6 +14,7 @@ import { colors } from "@cliffy/ansi/colors"; import { Command } from "@cliffy/command"; import { Confirm } from "@cliffy/prompt/confirm"; import * as log from "../../core/log.ts"; +import { SETTINGS_MANAGED_ONLY_WHEN_PRESENT } from "../../core/settings.ts"; import * as path from "node:path"; import { sep as SEP } from "node:path"; import { @@ -100,6 +101,7 @@ import { isCurrentWorkspaceFile, isItemTypeConfigured, isSpecificItem, + isCustomPathFile, SpecificItemsConfig, } from "../../core/specific_items.ts"; import { @@ -1066,7 +1068,7 @@ async function pushFilesetParentResource( if (cachedWsName && isWorkspaceSpecificFile(resourceFilePath)) { serverPath = fromWorkspaceSpecificPath(resourceFilePath, cachedWsName); wsSpecific = true; - } else if (specificItems && isSpecificItem(childPath, specificItems)) { + } else if (isSpecificItem(childPath, specificItems)) { wsSpecific = true; } @@ -1366,6 +1368,8 @@ export function ZipFSElement( markAccessFromPolicy(app); markIsolationFromPolicy(app, false); app.policy = undefined; + // Rendered as its own workspace-specific file (see below). + delete app.custom_path; yield { isDirectory: false, path: path.join(finalPath, "app.yaml"), @@ -1386,8 +1390,7 @@ export function ZipFSElement( markIsolationFromPolicy(rawApp, true); // console.log("rawApp", rawApp); rawApp.policy = undefined; - // custom_path is derived from the file path, don't store it - delete rawApp?.["custom_path"]; + delete rawApp.custom_path; let inlineScripts; const value = rawApp?.["value"]; const runnables = value?.["runnables"] ?? {}; @@ -1844,6 +1847,24 @@ export function ZipFSElement( } } } + if (kind == "app" || kind == "raw_app") { + // Always workspace-specific: see isCustomPathFile. + const customPath = JSON.parse(await f.async("text"))?.custom_path; + if (customPath) { + const appPath = removeSuffix(p, kind == "app" ? ".app.json" : ".raw_app.json"); + const obj = { custom_path: customPath }; + r.push({ + isDirectory: false, + path: appPath + (useYaml ? ".custom_path.yaml" : ".custom_path.json"), + async *getChildren() {}, + async getContentText() { + return useYaml + ? yamlStringify(obj, yamlOptions) + : JSON.stringify(obj, null, 2); + }, + }); + } + } return r; } function _internal_folder(p: string, zip: JSZip): DynFSElement { @@ -2106,7 +2127,11 @@ export async function elementsToMap( continue; } if (skips.skipFlows && fileType === "flow") continue; - if (skips.skipApps && fileType === "app") continue; + if ( + skips.skipApps && + (fileType === "app" || fileType === "custom_path") + ) + continue; if (skips.skipFolders && fileType === "folder") continue; if ( skips.skipWorkspaceDependencies && @@ -2576,6 +2601,14 @@ const isWorkspaceSettingsFile = (p: string) => function stripUnappliedSettingsFields(local: any, remote: any) { delete local?.name; delete remote?.name; + for (const key of ["operator_settings", ...SETTINGS_MANAGED_ONLY_WHEN_PRESENT]) { + if (local?.[key] === undefined) delete remote?.[key]; + } + // push clears an omitted mute_critical_alerts to false + if (local?.mute_critical_alerts == null && remote?.mute_critical_alerts === false) { + delete local?.mute_critical_alerts; + delete remote.mute_critical_alerts; + } if (local?.color == null) { delete local?.color; delete remote?.color; @@ -2596,6 +2629,53 @@ function stripUnappliedSettingsFields(local: any, remote: any) { } } +/** + * A checkout made before custom paths had their own file still carries one in + * its app file, and a push deploys it. It compares as the workspace's + * custom-path file it stands for, unless that file exists, so such a checkout + * neither lists its app file as edited nor drops the custom path. + */ +function liftInFileCustomPaths( + localMap: Record<string, string>, + json: boolean, +): void { + for (const key of Object.keys(localMap)) { + const normalized = key.replaceAll(SEP, "/"); + const type = isRawAppFolderMetadataFile(normalized) + ? "raw_app" + : isAppFolderMetadataFile(normalized) + ? "app" + : undefined; + if (!type || normalized.endsWith(json ? ".yaml" : ".json")) continue; + const appPath = extractResourceName(normalized, type); + let obj: any; + try { + obj = json + ? JSON.parse(localMap[key]) + : yamlParseContent(key, localMap[key]); + } catch { + continue; + } + if (!appPath || !obj || typeof obj != "object" || !("custom_path" in obj)) { + continue; + } + const customPath = obj.custom_path; + delete obj.custom_path; + localMap[key] = json + ? JSON.stringify(obj, null, 2) + : yamlStringify(obj, yamlOptions); + const fileKey = + appPath.replaceAll("/", SEP) + + (json ? ".custom_path.json" : ".custom_path.yaml"); + if (customPath && !(fileKey in localMap)) { + const file = { custom_path: customPath }; + localMap[fileKey] = json + ? JSON.stringify(file, null, 2) + : yamlStringify(file, yamlOptions); + } + } +} + export async function compareDynFSElement( els1: DynFSElement, els2: DynFSElement | undefined, @@ -2723,6 +2803,22 @@ export async function compareDynFSElement( ); } + if (isEls1Remote === false) { + liftInFileCustomPaths(m1, json); + // `custom_path: ''` clears the deployed one, after which the export has + // no file to match it: an empty value with nothing deployed is in sync. + for (const [k, v] of Object.entries(m1)) { + if (!isCustomPathFile(k) || k in m2) continue; + let value: unknown; + try { + value = (json ? JSON.parse(v) : yamlParseContent(k, v))?.custom_path; + } catch { + continue; + } + if (!value) delete m1[k]; + } + } + const changes: Change[] = []; function parseYaml(k: string, v: string) { @@ -2872,6 +2968,11 @@ export async function compareDynFSElement( const remoteCodebase: Record<string, string> = {}; for (const [k] of Object.entries(m2)) { if (m1[k] === undefined) { + // A workspace without its own custom-path file leaves the deployed one + // alone: a promotion never carries the target's custom path. + if (isEls1Remote === false && isCustomPathFile(k)) { + continue; + } if ( !ignoreMetadataDeletion || (!k?.endsWith(".script.yaml") && !k?.endsWith(".script.json")) @@ -2975,8 +3076,10 @@ function getOrderFromPath(p: string) { return 11; } else if (typ == "app") { return 12; - } else if (typ == "schedule") { + } else if (typ == "custom_path") { return 13; + } else if (typ == "schedule") { + return 14; } else if ( typ == "http_trigger" || typ == "websocket_trigger" || @@ -2991,9 +3094,9 @@ function getOrderFromPath(p: string) { typ == "email_trigger" || typ == "native_trigger" ) { - return 14; - } else { return 15; + } else { + return 16; } } @@ -3843,6 +3946,7 @@ export async function pull( opts.skipDatatableMigrations, opts.defaultTs, opts.syncBehavior, + opts.includeDatatablePermissions, ); const remote = ZipFSElement( @@ -3913,7 +4017,7 @@ export async function pull( ...(change.name === "edited" && change.codebase ? { codebase_changed: true } : {}), - ...(specificItems && isSpecificItem(change.path, specificItems) + ...(isSpecificItem(change.path, specificItems) ? { workspace_specific: true, workspace_specific_path: getWorkspaceSpecificPath( @@ -3962,7 +4066,7 @@ export async function pull( const change: Change = rawChange; // Determine if this file should be written to a workspace-specific path let targetPath = change.path; - if (specificItems && isSpecificItem(change.path, specificItems)) { + if (isSpecificItem(change.path, specificItems)) { const workspaceSpecificPath = getWorkspaceSpecificPath( change.path, specificItems, @@ -4223,7 +4327,7 @@ export async function pull( ...(change.name === "edited" && change.codebase ? { codebase_changed: true } : {}), - ...(specificItems && isSpecificItem(change.path, specificItems) + ...(isSpecificItem(change.path, specificItems) ? { workspace_specific: true, workspace_specific_path: getWorkspaceSpecificPath( @@ -4503,7 +4607,7 @@ function prettyChanges( let wsNote = ""; // Check if this will be written as a workspace-specific file - if (specificItems && isSpecificItem(change.path, specificItems)) { + if (isSpecificItem(change.path, specificItems)) { const workspaceSpecificPath = getWorkspaceSpecificPath( change.path, specificItems, @@ -4946,6 +5050,7 @@ export async function push( opts.skipDatatableMigrations, opts.defaultTs, opts.syncBehavior, + opts.includeDatatablePermissions, ))!, !opts.json, opts.defaultTs ?? "bun", @@ -5574,7 +5679,7 @@ export async function push( ...(change.name === "edited" && change.codebase ? { codebase_changed: true } : {}), - ...(specificItems && isSpecificItem(change.path, specificItems) + ...(isSpecificItem(change.path, specificItems) ? { workspace_specific: true, workspace_specific_path: getWorkspaceSpecificPath( @@ -5744,8 +5849,12 @@ export async function push( // `alreadySynced`, and a push touching several files of one bundle then // deploys the script once per file: several versions in a row, of which // only the last is the one the asset graph ends up describing. + // An app's files and its custom-path file group under the app path in + // both folder layouts, so the custom path is applied after the app. const basePath = getScriptBasePathFromModulePath(change.path) ?? + extractResourceName(change.path, "raw_app")?.replaceAll("/", SEP) ?? + extractResourceName(change.path, "app")?.replaceAll("/", SEP) ?? change.path.split(".")[0]; if (!groupedChanges.has(basePath)) { groupedChanges.set(basePath, []); @@ -5835,10 +5944,14 @@ export async function push( changes = [deleteRawApp]; } else { // The app is one bundle, so a single change re-pushes all of it. - // That leaves the loop exactly one change: any skip it takes for - // a raw-app path drops the whole app from the push, and nothing - // downstream records that as a failure. - changes.splice(1, changes.length - 1); + // That leaves the loop exactly one raw-app change: any skip it + // takes for a raw-app path drops the whole app from the push, and + // nothing downstream records that as a failure. The custom path + // is not part of the bundle and is applied after it. + changes = [ + changes[0], + ...changes.slice(1).filter((c) => isCustomPathFile(c.path)), + ]; } } @@ -5952,6 +6065,7 @@ export async function push( resourceFilePath, isFileResWsSpecific ? true : undefined, true, + permissionedAsContext, ); } // Already-synced parents got the full content this run. @@ -6020,7 +6134,7 @@ export async function push( // Check if this is a branch-specific item and get the original workspace-specific path let originalWorkspaceSpecificPath: string | undefined; const isWsSpecific = - specificItems && isSpecificItem(change.path, specificItems); + isSpecificItem(change.path, specificItems); if (isWsSpecific) { originalWorkspaceSpecificPath = getWorkspaceSpecificPath( change.path, @@ -6049,6 +6163,7 @@ export async function push( (opts.yes ?? false) || !process.stdin.isTTY, skipReencrypt: opts.skipReencryptOnKeyChange, }, + includeDatatablePermissions: opts.includeDatatablePermissions, defaultTs: opts.defaultTs, }, ); @@ -6155,7 +6270,7 @@ export async function push( // For branch-specific items, we read from workspace-specific files but push to base server paths let localFilePath = change.path; const isAddedWsSpecific = - specificItems && isSpecificItem(change.path, specificItems); + isSpecificItem(change.path, specificItems); if (isAddedWsSpecific) { const workspaceSpecificPath = getWorkspaceSpecificPath( change.path, @@ -6187,6 +6302,7 @@ export async function push( (opts.yes ?? false) || !process.stdin.isTTY, skipReencrypt: opts.skipReencryptOnKeyChange, }, + includeDatatablePermissions: opts.includeDatatablePermissions, defaultTs: opts.defaultTs, }, ); @@ -6684,7 +6800,7 @@ export async function push( ...(change.name === "edited" && change.codebase ? { codebase_changed: true } : {}), - ...(specificItems && isSpecificItem(change.path, specificItems) + ...(isSpecificItem(change.path, specificItems) ? { workspace_specific: true, workspace_specific_path: getWorkspaceSpecificPath( @@ -6815,6 +6931,10 @@ const command = new Command() .option("--include-users", "Include syncing users") .option("--include-groups", "Include syncing groups") .option("--include-settings", "Include syncing workspace settings") + .option( + "--include-datatable-permissions", + "With --include-settings, also sync who may connect to each data table as which role", + ) .option("--include-key", "Include workspace encryption key") .option( "--keep-deleted", @@ -6878,6 +6998,10 @@ const command = new Command() .option("--include-users", "Include syncing users") .option("--include-groups", "Include syncing groups") .option("--include-settings", "Include syncing workspace settings") + .option( + "--include-datatable-permissions", + "With --include-settings, also sync who may connect to each data table as which role", + ) .option("--include-key", "Include workspace encryption key") .option( "--skip-reencrypt-on-key-change", diff --git a/cli/src/commands/trigger/trigger.ts b/cli/src/commands/trigger/trigger.ts index 4e6b0eeb16..b46897b9b5 100644 --- a/cli/src/commands/trigger/trigger.ts +++ b/cli/src/commands/trigger/trigger.ts @@ -281,6 +281,8 @@ export async function pushNativeTrigger( is_flow: localTrigger.is_flow, service_config: localTrigger.service_config, summary: localTrigger.summary, + // Used on create only; an update keeps the connection the webhook was registered under. + connection_path: localTrigger.connection_path, }; if (remoteTrigger) { diff --git a/cli/src/commands/workspace/settings.ts b/cli/src/commands/workspace/settings.ts new file mode 100644 index 0000000000..11aca145fa --- /dev/null +++ b/cli/src/commands/workspace/settings.ts @@ -0,0 +1,75 @@ +import { colors } from "@cliffy/ansi/colors"; +import { Confirm } from "@cliffy/prompt/confirm"; +import { stringify as yamlStringify } from "yaml"; +import { GlobalOptions } from "../../types.ts"; +import * as log from "../../core/log.ts"; +import { requireLogin } from "../../core/auth.ts"; +import { resolveWorkspace } from "../../core/context.ts"; +import { yamlParseFile } from "../../utils/yaml.ts"; +import * as wmill from "../../../gen/services.gen.ts"; + +export async function getSettings( + opts: GlobalOptions & { encrypted?: boolean; json?: boolean }, +) { + await requireLogin(opts); + const workspace = await resolveWorkspace(opts); + const document = await wmill.getWorkspaceSettingsDocument({ + workspace: workspace.workspaceId, + secrets: opts.encrypted ? "encrypted" : "redacted", + }); + console.log( + opts.json ? JSON.stringify(document, null, 2) : yamlStringify(document), + ); +} + +export async function applySettings( + opts: GlobalOptions & { dryRun?: boolean; replace?: boolean; yes?: boolean }, + file: string, +) { + await requireLogin(opts); + const workspace = await resolveWorkspace(opts); + const document = await yamlParseFile(file); + if (!document || typeof document !== "object" || Array.isArray(document)) { + throw new Error(`${file} must hold a settings document (a mapping of setting to value)`); + } + const apply = (dry_run: boolean) => + wmill.applyWorkspaceSettingsDocument({ + workspace: workspace.workspaceId, + requestBody: { document, replace: opts.replace ?? false, dry_run }, + }); + + const plan = await apply(true); + for (const warning of plan.warnings ?? []) { + log.warn(warning); + } + if (plan.changes.length === 0) { + log.info(colors.green("Workspace settings are already up to date")); + return; + } + for (const change of plan.changes) { + log.info( + `${colors.bold(change.key)}: ${JSON.stringify(change.before)} -> ${JSON.stringify(change.after)}`, + ); + } + if (opts.dryRun) { + return; + } + if ( + !opts.yes && + !(await Confirm.prompt({ + message: `Apply ${plan.changes.length} change(s) to workspace ${workspace.workspaceId}?`, + default: true, + })) + ) { + return; + } + const result = await apply(false); + for (const warning of result.warnings ?? []) { + log.warn(warning); + } + log.info( + colors.bold.underline.green( + `Applied ${result.changes.length} change(s) to workspace ${workspace.workspaceId}`, + ), + ); +} diff --git a/cli/src/commands/workspace/workspace.ts b/cli/src/commands/workspace/workspace.ts index 9e12afe54f..caac17136a 100644 --- a/cli/src/commands/workspace/workspace.ts +++ b/cli/src/commands/workspace/workspace.ts @@ -18,6 +18,7 @@ import { requireLogin } from "../../core/auth.ts"; import { createWorkspaceFork, deleteWorkspaceFork } from "./fork.ts"; import { mergeWorkspaces } from "./merge.ts"; import { connectSlack, disconnectSlack } from "./slack.ts"; +import { applySettings, getSettings } from "./settings.ts"; import * as wmill from "../../../gen/services.gen.ts"; @@ -905,6 +906,22 @@ Arguments (omit both to be prompted interactively): .description( "Clear slack_team_id / slack_name on the active workspace (marks the workspace as disconnected). Does NOT remove the bot token variable/resource/folder/group — delete those from the local sync folder and run 'wmill sync push' to tear them down. Does NOT remove the workspace-level OAuth override — set slack_oauth_client_id/_secret to '' in settings.yaml and push." ) - .action(disconnectSlack as any); + .action(disconnectSlack as any) + .command("get-settings") + .description( + "Print the active workspace's settings in the layout of the settings.yaml that 'wmill sync' writes. Secrets show as __SENSITIVE_AND_UNCHANGED__ unless --encrypted. Admin only." + ) + .option("--encrypted", "Write secrets encrypted with the workspace key, as 'wmill sync pull' does") + .option("--json", "Output as JSON") + .action(getSettings as any) + .command("apply-settings") + .description( + "Apply a settings document (YAML or JSON, in the layout of settings.yaml) to the active workspace. Only the settings the file states are changed, unless --replace; a setting set to null is cleared. A secret left as __SENSITIVE_AND_UNCHANGED__ keeps its value. Shows the changes and asks before applying. Admin only." + ) + .arguments("<file:string>") + .option("--dry-run", "Only show the changes") + .option("--replace", "Also clear the settings the file omits, as 'wmill sync push' does") + .option("-y --yes", "Apply without asking") + .action(applySettings as any); export default command; diff --git a/cli/src/core/conf.ts b/cli/src/core/conf.ts index 1ad0b69c1b..b010b1f8e3 100644 --- a/cli/src/core/conf.ts +++ b/cli/src/core/conf.ts @@ -89,6 +89,9 @@ export interface SyncOptions { includeUsers?: boolean; includeGroups?: boolean; includeSettings?: boolean; + /** With includeSettings: settings.yaml also states who may connect to each data table as which + * role. Off by default, since tenants and roles are specific to a workspace and an instance. */ + includeDatatablePermissions?: boolean; includeKey?: boolean; skipReencryptOnKeyChange?: boolean; skipBranchValidation?: boolean; @@ -388,6 +391,7 @@ export const DEFAULT_SYNC_OPTIONS: Readonly< | "includeUsers" | "includeGroups" | "includeSettings" + | "includeDatatablePermissions" | "includeKey" | "nonDottedPaths" | "syncBehavior" @@ -410,6 +414,7 @@ export const DEFAULT_SYNC_OPTIONS: Readonly< includeUsers: false, includeGroups: false, includeSettings: false, + includeDatatablePermissions: false, includeKey: false, skipWorkspaceDependencies: false, skipDatatableMigrations: false, diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index fe72d56a01..e02c5a2217 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.833.0"; +export const VERSION = "1.847.0"; diff --git a/cli/src/core/permissioned_as.ts b/cli/src/core/permissioned_as.ts index 87ee37a8cb..dd0b3de8be 100644 --- a/cli/src/core/permissioned_as.ts +++ b/cli/src/core/permissioned_as.ts @@ -250,6 +250,18 @@ export async function preCheckPermissionedAs( if (match) { currentOwner = match[1]; } + } else if (typeStr === "resource") { + // An agent with `run_on_behalf_of` runs as someone, and the file never says who. + if ( + /["']?resource_type["']?\s*:\s*["']?ai_agent\b/.test(beforeContent) && + /["']?run_on_behalf_of["']?\s*:\s*true\b/.test(beforeContent) + ) { + wouldChangeItems.push({ + path: change.path, + currentOwner: "(agent owner)", + }); + } + continue; } else if (typeStr.endsWith("_trigger")) { wouldChangeItems.push({ path: change.path, diff --git a/cli/src/core/settings.ts b/cli/src/core/settings.ts index 75dfa3b8da..502d0037bb 100644 --- a/cli/src/core/settings.ts +++ b/cli/src/core/settings.ts @@ -58,8 +58,35 @@ export interface SimplifiedSettings { slack_command_script?: string; slack_oauth_client_id?: string; slack_oauth_client_secret?: string; + ducklake?: any; + dbt_warehouses?: any; + deploy_ui?: any; + teams_command_script?: string | null; + public_app_execution_limit_per_minute?: number | null; + guest_access_enabled?: boolean; + guest_jwt_public_key?: string | null; + guest_jwt_jwks_url?: string | null; + error_handler_fallback_to_instance_alerts?: boolean; + add_admins_and_developers_to_forks?: boolean; + datatable_permissions?: Record<string, unknown> | null; } +/** Settings the server manages only when settings.yaml states them: a file written before + * they existed must neither clear them nor show as differing from the workspace. */ +export const SETTINGS_MANAGED_ONLY_WHEN_PRESENT = [ + "datatable_permissions", + "ducklake", + "dbt_warehouses", + "deploy_ui", + "teams_command_script", + "public_app_execution_limit_per_minute", + "guest_access_enabled", + "guest_jwt_public_key", + "guest_jwt_jwks_url", + "error_handler_fallback_to_instance_alerts", + "add_admins_and_developers_to_forks", +] as const; + // Legacy settings interface for reading old settings.yaml files interface LegacySimplifiedSettings { auto_invite_enabled?: boolean; @@ -108,6 +135,9 @@ export function migrateToGroupedFormat(settings: any): SimplifiedSettings { if (settings.slack_command_script !== undefined) result.slack_command_script = settings.slack_command_script; if (settings.slack_oauth_client_id !== undefined) result.slack_oauth_client_id = settings.slack_oauth_client_id; if (settings.slack_oauth_client_secret !== undefined) result.slack_oauth_client_secret = settings.slack_oauth_client_secret; + for (const key of SETTINGS_MANAGED_ONLY_WHEN_PRESENT) { + if (settings[key] !== undefined) (result as any)[key] = settings[key]; + } // Handle auto_invite: check if already grouped or needs migration if (settings.auto_invite && typeof settings.auto_invite === "object") { @@ -173,10 +203,37 @@ export async function pushWorkspaceSettings( workspace: string, _path: string, settings: SimplifiedSettings | undefined, - localSettings: SimplifiedSettings | any + localSettings: SimplifiedSettings | any, + opts: { includeDatatablePermissions?: boolean } = {}, ) { + if (localSettings?.protection_rules !== undefined) { + log.warn( + "Ignoring protection_rules in settings.yaml: protection-rules.yaml syncs them (wmill protection-rules push)." + ); + } // Migrate local settings from legacy format if needed localSettings = migrateToGroupedFormat(localSettings); + if (!opts.includeDatatablePermissions) { + delete localSettings.datatable_permissions; + } + + if (await applySettingsDocument(workspace, localSettings)) { + return; + } + // The per-setting endpoints of a server without the settings document know nothing of + // these keys, nor of a secret it would have to decrypt or keep. + for (const key of SETTINGS_MANAGED_ONLY_WHEN_PRESENT) { + delete (localSettings as any)[key]; + } + const secret = localSettings.slack_oauth_client_secret; + const slackSecretUnreadable = + typeof secret === "string" && + (secret.startsWith("$encrypted:") || secret === "__SENSITIVE_AND_UNCHANGED__"); + if (slackSecretUnreadable) { + log.warn( + "Leaving the Slack OAuth client and secret unchanged: this server cannot read an encrypted or redacted secret. Upgrade it, or write the secret in plain text." + ); + } try { const remoteSettings = await wmill.getSettings({ @@ -456,8 +513,9 @@ export async function pushWorkspaceSettings( // every other setting): both present → upsert; anything else → delete. // Pull always emits both fields as null when remote is NULL. if ( - localSettings.slack_oauth_client_id != settings.slack_oauth_client_id || - localSettings.slack_oauth_client_secret != settings.slack_oauth_client_secret + !slackSecretUnreadable && + (localSettings.slack_oauth_client_id != settings.slack_oauth_client_id || + localSettings.slack_oauth_client_secret != settings.slack_oauth_client_secret) ) { log.debug(`Updating slack oauth config...`); if ( @@ -477,6 +535,38 @@ export async function pushWorkspaceSettings( } } +/** Applies settings.yaml server-side, which owns the rules for what an omitted or null setting + * means. Returns false on a server that predates the settings document. */ +async function applySettingsDocument( + workspace: string, + document: SimplifiedSettings, +): Promise<boolean> { + let result; + try { + result = await wmill.applyWorkspaceSettingsDocument({ + workspace, + requestBody: { document: document as unknown as Record<string, unknown>, replace: true }, + }); + } catch (err: any) { + // A missing route answers 404 with no body; a handler's own 404 carries a message. + if (err?.status === 404 && !err?.body) { + return false; + } + throw new Error(`Failed to apply workspace settings: ${err?.body ?? err}`); + } + if (result.changes.length > 0) { + log.debug( + `Updated workspace settings: ${result.changes.map((c) => c.key).join(", ")}`, + ); + } else { + log.debug(`Workspace settings are up to date`); + } + for (const warning of result.warnings ?? []) { + log.warn(warning); + } + return true; +} + export interface PushWorkspaceKeyOptions { // True when no prompt may be shown (e.g. `--yes` was passed or stdin is not a // TTY). In that case the re-encryption decision is taken from `skipReencrypt` diff --git a/cli/src/core/specific_items.ts b/cli/src/core/specific_items.ts index cbfd52530e..12cc725b53 100644 --- a/cli/src/core/specific_items.ts +++ b/cli/src/core/specific_items.ts @@ -1,6 +1,10 @@ import { minimatch } from "minimatch"; import { getCurrentGitBranch, isGitRepository } from "../utils/git.ts"; import { isFileResource, isFilesetResource } from "../utils/utils.ts"; +import { + isFolderResourcePathAnyFormat, + isScriptModulePath, +} from "../utils/resource_folders.ts"; import { SyncOptions, findWorkspaceByGitBranch, @@ -37,10 +41,32 @@ function getWorkspaceSpecificTypeKinds(): string[] { 'variable', 'resource', 'schedule', + 'custom_path', ...TRIGGER_TYPES.map(t => `${t}_trigger`), ]; } +/** + * An app's custom path is always workspace-specific, with or without + * specificItems: unless routes are workspace-prefixed it is unique across the + * instance, so one shared value can never be deployed to two workspaces. + */ +export function isCustomPathFile(path: string): boolean { + const p = path.replaceAll('\\', '/'); + const slash = p.lastIndexOf('/'); + const parts = p.slice(slash + 1).split('.'); + return ( + (parts.length == 3 || parts.length == 4) && + parts[parts.length - 2] == 'custom_path' && + (parts[parts.length - 1] == 'yaml' || parts[parts.length - 1] == 'json') && + // It sits beside its app. A file inside an item's folder (app, flow, + // fileset, script module) is that item's content, whatever its name. + !p.slice(0, slash + 1).includes('.') && + !isFolderResourcePathAnyFormat(p) && + !isScriptModulePath(p) + ); +} + /** * Check if a path ends with any trigger type */ @@ -80,7 +106,7 @@ function getFileTypeSuffix(path: string): string | null { * Build regex pattern for all supported item-kind file types */ function buildItemTypePattern(): string { - const basicTypes = ['variable', 'resource', 'schedule']; + const basicTypes = ['variable', 'resource', 'schedule', 'custom_path']; const triggerTypes = TRIGGER_TYPES.map(t => `${t}_trigger`); return `((${basicTypes.join('|')})|(${triggerTypes.join('|')}))`; } @@ -190,6 +216,9 @@ function normalizeJsonToYaml(path: string): string { * Used to determine if workspace-specific files should be used for this type. */ export function isItemTypeConfigured(path: string, specificItems: SpecificItemsConfig | undefined): boolean { + if (isCustomPathFile(path)) { + return true; + } if (!specificItems) { return false; } @@ -231,6 +260,9 @@ export function isItemTypeConfigured(path: string, specificItems: SpecificItemsC * Check if a file path should be treated as workspace-specific */ export function isSpecificItem(path: string, specificItems: SpecificItemsConfig | undefined): boolean { + if (isCustomPathFile(path)) { + return true; + } if (!specificItems) { return false; } @@ -399,7 +431,7 @@ export function getWorkspaceSpecificPath( specificItems: SpecificItemsConfig | undefined, workspaceNameOverride?: string ): string | undefined { - if (!specificItems) { + if (!specificItems && !isCustomPathFile(basePath)) { return undefined; } @@ -434,8 +466,9 @@ export function isCurrentWorkspaceFile(path: string, workspaceNameOverride?: str // Their names are arbitrary, so one can look like typed metadata — and since // the patterns match a workspace-name segment with `[^.]` (which spans `/`), // an unguarded child is dropped from the diff or, worse, remapped onto a - // sibling's key and deployed over it. - if (isFilesetResource(path)) { + // sibling's key and deployed over it. The same holds for the files inside a + // flow or app folder. + if (isFilesetResource(path) || isFolderResourcePathAnyFormat(path)) { return false; } let currentWorkspace: string | null = null; @@ -473,8 +506,8 @@ export function isCurrentWorkspaceFile(path: string, workspaceNameOverride?: str * Used to identify and skip files from other branches during sync operations */ export function isWorkspaceSpecificFile(path: string): boolean { - // Same fileset exemption as isCurrentWorkspaceFile. - if (isFilesetResource(path)) { + // Same exemptions as isCurrentWorkspaceFile. + if (isFilesetResource(path) || isFolderResourcePathAnyFormat(path)) { return false; } const typePattern = buildItemTypePattern(); diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 7cc8f230ad..d932957a20 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -35,6 +35,7 @@ export const SKILLS: SkillMetadata[] = [ { name: "write-workflow-as-code", description: "MUST use when writing or modifying Windmill Workflow-as-Code scripts using workflow, task, step, sleep, approvals, taskScript, taskFlow, task_script, or task_flow." }, { name: "write-pipeline", description: "MUST use when creating or modifying a data pipeline, a set of scripts marked `pipeline` and wired together by `on` / `materialize` annotations." }, { name: "datatables", description: "MUST use when changing a data table's schema, writing or running its migrations, or working with its roles and permissions." }, + { name: "workspace-settings", description: "MUST use when reading or changing workspace settings, or the settings.yaml file `wmill sync` keeps." }, { name: "cli-commands", description: "MUST use when using the CLI, including debugging job failures and inspecting run history via `wmill job`." }, { name: "preview", description: "MUST use when opening the Windmill dev page / visual preview of a flow, script, or app. Triggers on words like preview, open, navigate to, visualize, see the flow/app/script, and after writing a flow/script/app for visual verification." }, ]; @@ -96,6 +97,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - 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 +- 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_<name>\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C<Name>\` in TypeScript, a \`c_<name>\` 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 +- 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 +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (\`wmill\`), use it to interact with the platform @@ -260,6 +264,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - 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 +- 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_<name>\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C<Name>\` in TypeScript, a \`c_<name>\` 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 +- 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 +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (\`wmill\`), use it to interact with the platform @@ -384,6 +391,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - 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 +- 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_<name>\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C<Name>\` in TypeScript, a \`c_<name>\` 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 +- 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 +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (\`wmill\`), use it to interact with the platform @@ -501,6 +511,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - 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 +- 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_<name>\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C<Name>\` in TypeScript, a \`c_<name>\` 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 +- 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 +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (\`wmill\`), use it to interact with the platform @@ -1335,6 +1348,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - 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 +- 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_<name>\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C<Name>\` in TypeScript, a \`c_<name>\` 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 +- 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 +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (\`wmill\`), use it to interact with the platform @@ -1403,7 +1419,7 @@ export async function main(url: string) { ## Pure computation: \`//no_network\` -A 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. It only takes effect on native scripts: without \`//native\` it is ignored. Use it only when the script needs no external data beyond its arguments: +A 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: \`\`\`typescript //native @@ -2181,6 +2197,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - 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 +- 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_<name>\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C<Name>\` in TypeScript, a \`c_<name>\` 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 +- 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 +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (\`wmill\`), use it to interact with the platform @@ -2297,6 +2316,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - 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 +- 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_<name>\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C<Name>\` in TypeScript, a \`c_<name>\` 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 +- 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 +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (\`wmill\`), use it to interact with the platform @@ -3133,6 +3155,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - 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 +- 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_<name>\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C<Name>\` in TypeScript, a \`c_<name>\` 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 +- 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 +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (\`wmill\`), use it to interact with the platform @@ -3283,6 +3308,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - 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 +- 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_<name>\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C<Name>\` in TypeScript, a \`c_<name>\` 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 +- 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 +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (\`wmill\`), use it to interact with the platform @@ -3416,6 +3444,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - 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 +- 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_<name>\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C<Name>\` in TypeScript, a \`c_<name>\` 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 +- 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 +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (\`wmill\`), use it to interact with the platform @@ -3536,6 +3567,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - 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 +- 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_<name>\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C<Name>\` in TypeScript, a \`c_<name>\` 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 +- 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 +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (\`wmill\`), use it to interact with the platform @@ -3649,6 +3683,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - 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 +- 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_<name>\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C<Name>\` in TypeScript, a \`c_<name>\` 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 +- 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 +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (\`wmill\`), use it to interact with the platform @@ -3765,6 +3802,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - 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 +- 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_<name>\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C<Name>\` in TypeScript, a \`c_<name>\` 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 +- 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 +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (\`wmill\`), use it to interact with the platform @@ -3882,6 +3922,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - 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 +- 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_<name>\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C<Name>\` in TypeScript, a \`c_<name>\` 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 +- 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 +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (\`wmill\`), use it to interact with the platform @@ -4014,6 +4057,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - 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 +- 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_<name>\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C<Name>\` in TypeScript, a \`c_<name>\` 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 +- 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 +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (\`wmill\`), use it to interact with the platform @@ -4129,6 +4175,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - 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 +- 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_<name>\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C<Name>\` in TypeScript, a \`c_<name>\` 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 +- 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 +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (\`wmill\`), use it to interact with the platform @@ -4259,6 +4308,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - 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 +- 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_<name>\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C<Name>\` in TypeScript, a \`c_<name>\` 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 +- 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 +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (\`wmill\`), use it to interact with the platform @@ -4679,15 +4731,31 @@ def get_flow_user_state(key: str) -> Any def version() # Convenient helpers that takes an S3 resource as input and returns the settings necessary to -# initiate an S3 connection from DuckDB +# initiate an S3 connection from DuckDB. +# +# Without a resource path, the connection goes to the workspace storage through Windmill's +# S3 proxy when that storage is S3: address it as \`\`s3://<bucket>/<key>\`\` with the +# storage's own bucket, returned as \`\`s3_bucket\`\`. Pass the resource path to connect with +# the storage's own credentials instead. def get_duckdb_connection_settings(s3_resource_path: str = '') -> DuckDbConnectionSettings | None # Convenient helpers that takes an S3 resource as input and returns the settings necessary to -# initiate an S3 connection from Polars +# initiate an S3 connection from Polars. +# +# Without a resource path, the connection goes to the workspace storage through Windmill's +# S3 proxy when that storage is S3: address it as \`\`s3://<bucket>/<key>\`\` with the +# storage's own bucket, returned as \`\`s3_bucket\`\`. Pass the resource path to connect with +# the storage's own credentials instead. def get_polars_connection_settings(s3_resource_path: str = '') -> PolarsConnectionSettings # Convenient helpers that takes an S3 resource as input and returns the settings necessary to -# initiate an S3 connection using boto3 +# initiate an S3 connection using boto3. +# +# Without a resource path, the connection goes to the workspace storage through Windmill's +# S3 proxy when that storage is S3: pass the storage's own bucket as \`\`Bucket\`\`. The proxy +# serves object reads, writes, deletes, listing and multipart uploads of that bucket only; +# presigned URLs, other buckets and bucket-level APIs need the storage's own credentials, +# which passing the resource path returns. def get_boto3_connection_settings(s3_resource_path: str = '') -> Boto3ConnectionSettings # Load a file from the workspace s3 bucket and returns its content as bytes. @@ -5285,6 +5353,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - 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 +- 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_<name>\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C<Name>\` in TypeScript, a \`c_<name>\` 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 +- 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 +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (\`wmill\`), use it to interact with the platform @@ -5445,6 +5516,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - 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 +- 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_<name>\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C<Name>\` in TypeScript, a \`c_<name>\` 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 +- 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 +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (\`wmill\`), use it to interact with the platform @@ -5595,6 +5669,9 @@ Use \`wmill resource-type list --schema\` to discover available resource types. ## General Principles - 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 +- 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_<name>\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C<Name>\` in TypeScript, a \`c_<name>\` 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 +- 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 +- Before relying on a resource type, a hub script's included, run \`wmill resource-type list\`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (\`wmill\`), use it to interact with the platform @@ -5916,6 +5993,10 @@ needs becomes unreachable. references. A spread, a call or a computed key leaves the composer unable to tell which input feeds which field, so it offers no control at all — a bare \`flow_input.x\` for the whole object is read instead as that one input carrying every field +- A step linked to a saved agent (\`agent\` set) has no \`provider\` of its own. It exposes the same + two controls through its \`model\` and \`reasoning_effort\` inputs, which replace what the agent + saved: \`"model": { "type": "javascript", "expr": "flow_input.model" }\`. An empty model keeps the + saved one, an empty effort sends none, and the provider resource always stays the agent's - \`memory\` is what lets the agent see earlier turns; without it every message starts from nothing - \`streaming\` on makes the answer and its thinking appear token by token instead of all at once - \`user_attachments\` points at a flow input typed as an array of s3 objects @@ -6351,6 +6432,14 @@ In the flow schema, set the property type to \`"object"\` with format \`"resourc } \`\`\` +### When No Resource Type Fits + +A 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_<name>\` 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. + +- 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. +- A plain variable is fine when the user asks for it. + ### As Step Input (Static Reference) Reference a specific resource using \`$res:\` prefix: @@ -6366,7 +6455,7 @@ Reference a specific resource using \`$res:\` prefix: ## OpenFlow Schema -{"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-<type>' (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":"JavaScript expression to return early from the flow"},"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.<step_id>'"}},"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/<flow_path>-<step_id>"},"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.<its_own_id>' 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"}}},"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).\\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: { <question name>: answer },\\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\\nthe answers, follow it with a branchone whose branch exprs read\\nresults.<id>.output.<question>.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"]}} +{"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-<type>' (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.<step_id>'"}},"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/<flow_path>-<step_id>"},"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.<its_own_id>' 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: { <question name>: answer },\\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\\nthe answers, follow it with a branchone whose branch exprs read\\nresults.<id>.output.<question>.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"]}} `, "raw-app": `--- name: raw-app @@ -6750,7 +6839,7 @@ An app can be demoed by recording a session: every interaction becomes a step ca Apply 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. -### Chat UIs over a flow in chat mode +### Chat UIs over a flow in chat mode or a saved agent A 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. @@ -6763,9 +6852,31 @@ const chat = useWindmillChat({ flowPath: 'f/support/assistant' }) \`@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. +#### A saved agent: an \`agent\` runnable + +Chat 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: + +In \`raw_app.yaml\`: + +\`\`\`yaml +runnables: + chat: + type: agent + path: f/support/agent +\`\`\` + +\`\`\`tsx +import { useWindmillChat } from '@windmill-labs/chat-sdk/react' +import * as wmill from './wmill' + +const chat = useWindmillChat({ wmill, runnable: 'chat' }) +\`\`\` + +\`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. + ## Backend runnables -Each runnable has a unique key (used to call it from the frontend) and one of four types: +Each runnable has a unique key (used to call it from the frontend) and one of five types: | Type | What it is | |---|---| @@ -6773,6 +6884,7 @@ Each runnable has a unique key (used to call it from the frontend) and one of fo | \`script\` | Reference to an existing workspace script by path. | | \`flow\` | Reference to an existing workspace flow by path. | | \`hubscript\` | Reference to a hub script by path. | +| \`agent\` | Reference to a saved AI agent by path. Each call is one turn of its chat (see the chat section above). | ### Inline runnables @@ -6802,6 +6914,10 @@ def main(user_id: str): return user \`\`\` +#### Sharing code between runnables + +An 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. + #### The \`wmill\` client is already authenticated An 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. @@ -6810,9 +6926,9 @@ An inline runnable runs as an ordinary Windmill job. \`import * as wmill from 'w Use 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. -### Path runnables (script / flow / hubscript) +### Path runnables (script / flow / hubscript / agent) -When \`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. +When \`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. Before 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. @@ -6821,7 +6937,7 @@ Before writing an inline runnable, look for a workspace script or flow that alre This decides whether an app works before anything is deployed: - **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. -- **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. +- **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. So 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. @@ -6845,14 +6961,18 @@ A draft app is reachable by nobody; deploying is what exposes it, and its backen Either one lets those people run the app's backend runnables, so never open an app up unless the user asks. \`public: true\` in \`raw_app.yaml\` deploys the app for anonymous users, and \`guests: true\` for guests. Remove the line and the next push closes the app again. +An app's custom URL is not in \`raw_app.yaml\`: each workspace has its own file next to the app folder, \`<app path>.<workspace>.custom_path.yaml\` (for example \`f/sales/dashboard.prod.custom_path.yaml\` holding \`custom_path: dashboard\`), where \`<workspace>\` is the workspace's name in \`wmill.yaml\`, or its id. A push applies only the target workspace's file and leaves the deployed custom URL alone when there is none, so deleting the file does not remove the URL: \`custom_path: ''\` does. Setting or clearing one takes a workspace admin. + ## Sandbox isolation and frontend API access -By default a deployed app's frontend runs with the viewer's own Windmill session. A sandboxed app is isolated from it: the frontend reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. The scopes are \`jobs:run\`, \`jobs:read\`, \`users:read\`, \`resources:read\`, \`variables:read\`, \`flow_conversations:read\` and \`flow_conversations:write\`; grant only the ones the frontend calls. +New 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. + +Inside 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. In \`raw_app.yaml\`: \`\`\`yaml sandbox: true -frontend_api: [jobs:run, users:read] +frontend_api: [jobs:run] \`\`\` A file with a \`sandbox\` key states both settings: \`sandbox: false\` turns isolation off, and a missing \`frontend_api\` means no scopes. A file without the key leaves the deployed app's isolation and scopes as they are, so removing \`sandbox: true\` does not unsandbox it. \`frontend_api\` needs \`sandbox: true\`; the push fails otherwise. @@ -6927,7 +7047,7 @@ def main(user_id: str): 7. **Mark sensitive UI with \`data-wm-no-record\`** — it is what keeps that data out of a recorded demo; passwords are handled for you. 8. **Reach for \`backendAsync\` + \`waitJob\`** for long work — never a hand-written job-polling runnable. 9. **Deploy what a path runnable points at** — a path runnable aimed at a draft fails at runtime; tell the user what needs deploying. -10. **Use \`@windmill-labs/chat-sdk\` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream. +10. **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 }\`. `, "triggers": `--- name: triggers @@ -7092,6 +7212,12 @@ Example: \`f/databases/postgres_prod.resource.json\` - \`value\` - Object containing the resource configuration - \`resource_type\` - Name of the resource type (e.g., "postgresql", "slack") +## Agents + +An agent, as the Agents page lists it, is a resource of type \`ai_agent\`. Its value holds the +agent's provider, system prompt, tools and memory settings, and a flow step uses a saved agent by +linking its path. + ## Variable References Reference variables in resource values: @@ -7272,13 +7398,23 @@ The prefix must be on the string itself. } \`\`\` +## When No Resource Type Fits + +The Windmill Hub publishes resource types for most services, and an instance gets them by syncing with the hub. When none fits the service: + +- 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. +- 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. +- 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. + ## Custom Resource Types +A 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. + Create custom resource types with JSON Schema: \`\`\`json { - "name": "custom_api", + "name": "c_custom_api", "schema": { "type": "object", "properties": { @@ -7292,7 +7428,7 @@ Create custom resource types with JSON Schema: } \`\`\` -Save as: \`custom_api.resource-type.json\` +Save as: \`c_custom_api.resource-type.json\` ## OAuth Resources @@ -8154,10 +8290,11 @@ Writing a migration: - 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. - Give it a \`down\` that undoes the \`up\` whenever the change is reversible. Without one it cannot be rolled back. +- 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. - Migrations apply oldest first. One that is applied is not edited: correct it with a new migration. - Row data is not schema. Insert, update and delete rows with plain SQL, migrations or not. -\`wmill datatable migrate new <name>\` scaffolds \`migrations/datatable/<datatable>/<timestamp>_<name>.up.sql\` and \`.down.sql\`, \`wmill datatable migrate up\` pushes the local files and applies what is pending, \`wmill datatable migrate down\` rolls back the most recent one and \`wmill datatable migrate status\` lists them. All take \`-d <datatable>\` and default to \`main\`. +\`wmill datatable migrate new <name>\` scaffolds \`migrations/datatable/<datatable>/<timestamp>_<name>.up.sql\` and \`.down.sql\`, \`wmill datatable migrate up\` pushes the local files and applies what is pending, \`wmill datatable migrate down\` rolls back the most recent one and \`wmill datatable migrate status\` lists them. All take \`-d <datatable>\` and default to \`main\`. \`migrate new --schemas sales,billing\` writes the \`-- schemas\` line, and \`migrate up --schema sales\` applies only the pending migrations declaring that schema. ## A migration is not a draft @@ -8180,6 +8317,91 @@ A data table under roles is reached as a Postgres role, and Postgres enforces wh - 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. Read 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. +`, + "workspace-settings": `--- +name: workspace-settings +description: MUST use when reading or changing workspace settings, or the settings.yaml file \`wmill sync\` keeps. +--- + +# Workspace settings + +Everything an admin sets on the Workspace settings page is one document: a mapping of top-level +keys, the same layout as the \`settings.yaml\` file \`wmill sync\` keeps. Only a workspace admin can +read or change it. + +## Applying a document + +- A partial document changes only the keys it states; every key it omits is left as it is. +- Each key is replaced whole, so read its current value and send it back edited: a nested object + given with one field replaces the stored object with that one field. +- A key set to \`null\` clears that setting; an on/off switch cleared this way reads as \`false\`. +- Each setting is written through the same path the settings page uses, with the same validation + and audit log. The writes are not one transaction: when one fails the apply stops, and the + error says what was already written. +- A dry run reports the changes, as \`before\` / \`after\` per key, without writing anything. +- \`name\` is never applied: rename a workspace on the settings page. \`color\` is applied only when + set to a value, so \`null\` does not clear it. +- A key the server does not know is ignored and named in the warnings: check them, since a typo + otherwise reads as "nothing to change". +- A setting this edition does not offer (Enterprise only) is refused, by the dry run too. + +## Secrets + +\`slack_oauth_client_secret\` is the one secret. A read shows \`__SENSITIVE_AND_UNCHANGED__\` in place +of a set secret; sending that placeholder back keeps the stored value. A value starting with +\`$encrypted:\` is decrypted with the workspace key, and one encrypted with another workspace's key +is left unchanged with a warning; any other value is taken as plaintext. Never +write a secret you were not given: keep the placeholder. + +## Keys + +| Key | Value | +|---|---| +| \`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. | +| \`error_handler\` / \`success_handler\` | \`{ path, extra_args?, muted_on_cancel?, muted_on_user_path? }\`, \`path\` being \`script/<path>\` or \`flow/<path>\`. | +| \`error_handler_fallback_to_instance_alerts\` | Report failures to the instance critical alert channels when no error handler is set. | +| \`webhook\` | URL POSTed a JSON event on every change to the workspace's items. | +| \`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_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, ... } } }\`. | +| \`dbt_warehouses\` | dbt warehouse connections. | +| \`default_app\` | Path of the app shown as the workspace home. | +| \`default_scripts\` | \`{ order?, hidden?, default_script_content? }\` for the script language picker. | +| \`deploy_ui\` | \`{ include_path?, include_type? }\`: what the deploy-to-workspace UI offers. | +| \`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. | +| \`mute_critical_alerts\` | Mute the workspace's critical alerts. | +| \`slack_team_id\`, \`slack_name\` | Read only: set by connecting Slack on the settings page. | +| \`slack_command_script\` / \`teams_command_script\` | Script or flow handling the Slack / Teams command. | +| \`slack_oauth_client_id\` / \`slack_oauth_client_secret\` | The workspace's own Slack OAuth app. Clearing either removes both. | +| \`public_app_execution_limit_per_minute\` | Rate limit for public apps, \`null\` for none. | +| \`guest_access_enabled\`, \`guest_jwt_public_key\`, \`guest_jwt_jwks_url\` | Guest access to public apps and the key that signs guest JWTs. | +| \`add_admins_and_developers_to_forks\` | Add the workspace's admins and developers to each fork. | +| \`color\` | Workspace color, \`#rrggbb\`. | + +## From the command line + +\`wmill workspace get-settings\` prints the document (\`--encrypted\` for secrets encrypted with the +workspace key, \`--json\` for JSON). \`wmill workspace apply-settings <file>\` applies a partial +document from a YAML or JSON file: it shows the changes and asks before writing (\`--dry-run\` to +only show them, \`-y\` to skip the question, \`--replace\` to clear what the file omits). + +## In a synced repository + +With \`includeSettings: true\` in \`wmill.yaml\`, \`wmill sync pull\` writes the document to +\`settings.yaml\`, secrets encrypted with the workspace key (plaintext only with \`plainSecrets\`), and +\`wmill sync push\` applies it. Unlike a partial apply, a push treats the file as the whole +document: a setting the file omits is cleared, except \`operator_settings\`, \`color\`, the auto-invite +instance groups, and the keys \`ducklake\`, \`dbt_warehouses\`, \`deploy_ui\`, \`teams_command_script\`, +\`public_app_execution_limit_per_minute\`, \`guest_access_enabled\`, \`guest_jwt_public_key\`, +\`guest_jwt_jwks_url\`, \`error_handler_fallback_to_instance_alerts\` and +\`add_admins_and_developers_to_forks\`, which are managed only when the file states them. +\`datatable_permissions\` is pushed only with \`includeDatatablePermissions: true\` (or +\`--include-datatable-permissions\`). A push ignores \`protection_rules\`: \`protection-rules.yaml\` and +\`wmill protection-rules push\` sync them. `, "cli-commands": `--- name: cli-commands @@ -8272,8 +8494,10 @@ datatable related commands - \`datatable migrate\` - manage datatable migrations - \`datatable migrate new <name:string>\` - scaffold a new migration (.up.sql / .down.sql files) - \`-d --datatable <datatable:string>\` - Target datatable (default: main) + - \`-s --schemas <schemas:string>\` - Comma-separated schemas the migration changes, written as its \`-- schemas\` line. Without it the migration applies to the whole database and every later migration waits for it - \`datatable migrate up\` - apply all pending migrations to the main datatable (or one via --datatable) - \`-d --datatable <datatable:string>\` - Target datatable (default: main) + - \`--schema <schema:string>\` - Apply only the pending migrations that declare this schema; refused while an earlier overlapping migration is pending - \`datatable migrate down\` - roll back the most recent migration on the main datatable (or one via --datatable) - \`-d --datatable <datatable:string>\` - Target datatable (default: main) - \`datatable migrate status\` - show applied and pending migrations on the main datatable (or one via --datatable) @@ -8369,6 +8593,7 @@ flow related commands - \`--remote\` - Use deployed workspace scripts for PathScript steps instead of local files. - \`--step <step_id:string>\` - Run only the named step instead of the whole flow. Honors --data as the step's args and --remote / local-PathScript resolution the same way the full-flow preview does. - \`--tag <tag:string>\` - Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the flow's default tag). + - \`--on-behalf-of <identity:string>\` - Run the preview as this user or group (an email, u/<username> or g/<group>) instead of yourself, e.g. to reproduce a run_on_behalf_of identity. Requires being an admin or a member of wm_deployers. - \`flow new <flow_path:string>\` - create a new empty flow - \`--summary <summary:string>\` - flow summary - \`--description <description:string>\` - flow description @@ -8767,6 +8992,7 @@ script related commands - \`-d --data <data:file>\` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-. A resource argument is the bare string $res:<path> as its whole value, and a variable argument is the bare string $var:<path> — not an object wrapper keyed on $res/$var, and not a plain path. - \`-s --silent\` - Do not output anything other than the final output. Useful for scripting. - \`--tag <tag:string>\` - Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the script's default tag). + - \`--on-behalf-of <identity:string>\` - Run the preview as this user or group (an email, u/<username> or g/<group>) instead of yourself, e.g. to reproduce a run_on_behalf_of identity. Requires being an admin or a member of wm_deployers. - \`script new <path:file> <language:string>\` - create a new script - \`--summary <summary:string>\` - script summary - \`--description <description:string>\` - script description @@ -8804,6 +9030,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--include-users\` - Include syncing users - \`--include-groups\` - Include syncing groups - \`--include-settings\` - Include syncing workspace settings + - \`--include-datatable-permissions\` - With --include-settings, also sync who may connect to each data table as which role - \`--include-key\` - Include workspace encryption key - \`--keep-deleted\` - Do not delete local files for items that no longer exist on the remote workspace. Only adds and updates. - \`--skip-branch-validation\` - Skip git branch validation and prompts @@ -8835,6 +9062,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--include-users\` - Include syncing users - \`--include-groups\` - Include syncing groups - \`--include-settings\` - Include syncing workspace settings + - \`--include-datatable-permissions\` - With --include-settings, also sync who may connect to each data table as which role - \`--include-key\` - Include workspace encryption key - \`--skip-reencrypt-on-key-change\` - When the pushed encryption key differs from the remote, do NOT re-encrypt existing remote secrets. Only safe if they are already encrypted with the new key (e.g. workspace/instance migration). Default is to re-encrypt. - \`--keep-deleted\` - Do not delete remote items that no longer exist locally. Only adds and updates. @@ -9028,6 +9256,13 @@ Arguments (omit both to be prompted interactively): - \`--team-id <team_id:string>\` - Slack team id - \`--team-name <team_name:string>\` - Slack team name - \`workspace disconnect-slack\` - Clear slack_team_id / slack_name on the active workspace (marks the workspace as disconnected). Does NOT remove the bot token variable/resource/folder/group — delete those from the local sync folder and run 'wmill sync push' to tear them down. Does NOT remove the workspace-level OAuth override — set slack_oauth_client_id/_secret to '' in settings.yaml and push. +- \`workspace get-settings\` - Print the active workspace's settings in the layout of the settings.yaml that 'wmill sync' writes. Secrets show as __SENSITIVE_AND_UNCHANGED__ unless --encrypted. Admin only. + - \`--encrypted\` - Write secrets encrypted with the workspace key, as 'wmill sync pull' does + - \`--json\` - Output as JSON +- \`workspace apply-settings <file:string>\` - Apply a settings document (YAML or JSON, in the layout of settings.yaml) to the active workspace. Only the settings the file states are changed, unless --replace; a setting set to null is cleared. A secret left as __SENSITIVE_AND_UNCHANGED__ keeps its value. Shows the changes and asks before applying. Admin only. + - \`--dry-run\` - Only show the changes + - \`--replace\` - Also clear the settings the file omits, as 'wmill sync push' does + - \`-y --yes\` - Apply without asking diff --git a/cli/src/types.ts b/cli/src/types.ts index 866b2be21e..3ae2b8d6a4 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -6,7 +6,7 @@ import { sep as SEP } from "node:path"; import { stringify as yamlStringify } from "yaml"; import { yamlParseContent } from "./utils/yaml.ts"; import { isDbtDescriptorPath } from "./utils/resource_folders.ts"; -import { pushApp } from "./commands/app/app.ts"; +import { pushApp, pushAppCustomPath } from "./commands/app/app.ts"; import { pushFolder } from "./commands/folder/folder.ts"; import { pushFlow } from "./commands/flow/flow.ts"; import { pushResource } from "./commands/resource/resource.ts"; @@ -187,6 +187,8 @@ export interface PushObjOptions { wsSpecific?: boolean; /** encryption_key push: non-interactive flag and explicit re-encryption choice */ keyPushOpts?: PushWorkspaceKeyOptions; + /** settings push: whether settings.yaml manages the data tables' roles */ + includeDatatablePermissions?: boolean; /** TypeScript runtime a bare `.ts` denotes, for raw-app runnables */ defaultTs?: "bun" | "deno"; /** schedule push into a fork: the file's `enabled` is the parent's */ @@ -218,6 +220,7 @@ export async function pushObj( permissionedAsContext, wsSpecific, keyPushOpts, + includeDatatablePermissions, defaultTs, enabledOwnedByParent, } = opts; @@ -228,13 +231,16 @@ export async function pushObj( if (!appName) { throw new Error(`Could not extract app name from path: ${p}`); } - await pushApp(workspace, appName, buildFolderPath(appName, "app"), message, permissionedAsContext); + // A sync carries the custom path in its own workspace-specific file. + await pushApp(workspace, appName, buildFolderPath(appName, "app"), message, permissionedAsContext, false); } else if (typeEnding === "raw_app") { const rawAppName = extractResourceName(p, "raw_app"); if (!rawAppName) { throw new Error(`Could not extract raw app name from path: ${p}`); } - await pushRawApp(workspace, rawAppName, buildFolderPath(rawAppName, "raw_app"), message, defaultTs, permissionedAsContext); + await pushRawApp(workspace, rawAppName, buildFolderPath(rawAppName, "raw_app"), message, defaultTs, permissionedAsContext, false); + } else if (typeEnding === "custom_path") { + await pushAppCustomPath(workspace, removeType(p, "custom_path"), newObj); } else if (typeEnding === "folder") { await pushFolder(workspace, p, befObj, newObj); } else if (typeEnding === "variable") { @@ -248,7 +254,7 @@ export async function pushObj( } else if (typeEnding === "resource") { if (!alreadySynced.includes(p)) { alreadySynced.push(p); - await pushResource(workspace, p, befObj, newObj, originalLocalPath || p, wsSpecific, true); + await pushResource(workspace, p, befObj, newObj, originalLocalPath || p, wsSpecific, true, permissionedAsContext); } } else if (typeEnding === "resource-type") { await pushResourceType(workspace, p, befObj, newObj); @@ -285,7 +291,9 @@ export async function pushObj( } else if (typeEnding === "workspace_dependencies") { await pushWorkspaceDependencies(workspace, p, befObj, newObj); } else if (typeEnding === "settings") { - await pushWorkspaceSettings(workspace, p, befObj, newObj); + await pushWorkspaceSettings(workspace, p, befObj, newObj, { + includeDatatablePermissions, + }); } else if (typeEnding === "encryption_key") { await pushWorkspaceKey(workspace, p, befObj, newObj, keyPushOpts); } else { @@ -353,6 +361,7 @@ export function getTypeStrFromPath( | "folder" | "app" | "raw_app" + | "custom_path" | "schedule" | "http_trigger" | "websocket_trigger" @@ -447,6 +456,7 @@ export function getTypeStrFromPath( typeEnding === "resource" || typeEnding === "resource-type" || typeEnding === "app" || + typeEnding === "custom_path" || typeEnding === "schedule" || typeEnding === "http_trigger" || typeEnding === "websocket_trigger" || diff --git a/cli/src/utils/git.ts b/cli/src/utils/git.ts index 354e15c36e..29796c2f7c 100644 --- a/cli/src/utils/git.ts +++ b/cli/src/utils/git.ts @@ -388,10 +388,12 @@ export function gitSyncIncludePattern( switch (pathType) { case "flow": return `${path}.flow/*,${path}__flow/*`; + // An app's custom path is a sibling workspace-specific file: the export's + // `<path>.custom_path.*` and the checkout's `<path>.<workspace>.custom_path.*`. case "app": - return `${path}.app/*,${path}__app/*`; + return `${path}.app/*,${path}__app/*,${path}.custom_path.*,${path}.*.custom_path.*`; case "raw_app": - return `${path}.raw_app/**,${path}__raw_app/**`; + return `${path}.raw_app/**,${path}__raw_app/**,${path}.custom_path.*,${path}.*.custom_path.*`; case "folder": return `${path}/folder.meta.*`; case "resourcetype": diff --git a/cli/src/utils/job_polling.ts b/cli/src/utils/job_polling.ts index bf72c2bfe5..6cc1631747 100644 --- a/cli/src/utils/job_polling.ts +++ b/cli/src/utils/job_polling.ts @@ -135,8 +135,10 @@ export async function pollJobWithQueueLogging( ); } + // Errors back off to the slow interval: at the fast one, the consecutive + // error budget would be spent on a ~1s blip. const delayMs = - Date.now() - startedAt < fastPollDurationMs + consecutiveErrors === 0 && Date.now() - startedAt < fastPollDurationMs ? fastPollIntervalMs : slowPollIntervalMs; await new Promise((resolve) => setTimeout(resolve, delayMs)); diff --git a/cli/test/deploy_on_behalf_of_unit.test.ts b/cli/test/deploy_on_behalf_of_unit.test.ts index 7e21b24832..a0db663589 100644 --- a/cli/test/deploy_on_behalf_of_unit.test.ts +++ b/cli/test/deploy_on_behalf_of_unit.test.ts @@ -105,3 +105,28 @@ test("deployItem: never sends the source workspace's on_behalf_of", async () => expect("on_behalf_of" in JSON.parse(JSON.stringify(identity))).toBe(false); } }); + +// An agent carries its principal inside its resource value, and is handed the chosen one as a +// principal. The source's must never be sent: without a choice the key is dropped, leaving the +// backend to make the agent the deployer's. +test("deployItem: an agent takes the chosen principal, never the source's", async () => { + const captured: any[] = []; + const provider = { + existsResource: async () => true, + getResource: async () => ({ + path: "f/x/agent", + resource_type: "ai_agent", + description: "", + value: { system_prompt: "hi", on_behalf_of: "u/alice" }, + }), + updateResource: async (p: any) => void captured.push(p.requestBody), + } as any; + + await deployItem(provider, "resource" as any, "f/x/agent", "src", "dst", "u/bob"); + await deployItem(provider, "resource" as any, "f/x/agent", "src", "dst", undefined); + + expect(captured[0].value).toEqual({ system_prompt: "hi", on_behalf_of: "u/bob" }); + expect(captured[0].preserve_on_behalf_of).toBe(true); + expect(captured[1].value).toEqual({ system_prompt: "hi" }); + expect(captured[1].preserve_on_behalf_of).toBe(false); +}); diff --git a/cli/test/dev_server.test.ts b/cli/test/dev_server.test.ts index 558fea0241..b4f630b3fa 100644 --- a/cli/test/dev_server.test.ts +++ b/cli/test/dev_server.test.ts @@ -82,6 +82,9 @@ test( `summary: "test"\ndescription: ""\nlock: ""\nschema:\n $schema: "https://json-schema.org/draft/2020-12/schema"\n type: object\n properties: {}\n required: []\n`, "utf-8", ); + const moduleDir = join(scriptDir, "hello__mod"); + await mkdir(moduleDir); + await writeFile(join(moduleDir, "query.gql"), "query { __typename }\n"); // Push the script so the workspace has content const pushResult = await backend.runCLICommand( @@ -94,6 +97,13 @@ test( } expect(pushResult.code).toEqual(0); + // Folder layout: the script's content is its own module folder's `script.*` + const folderModuleDir = join(scriptDir, "folder__mod"); + await mkdir(folderModuleDir); + await writeFile(join(folderModuleDir, "script.ts"), 'export function main() { return "folder"; }\n'); + await writeFile(join(folderModuleDir, "script.yaml"), "summary: \"\"\ndescription: \"\"\nlock: \"\"\n"); + await writeFile(join(folderModuleDir, "query.gql"), "query { __typename }\n"); + // Build the CLI command for `wmill dev` const cliMainPath = getCLIMainPath(); const args = [ @@ -178,14 +188,13 @@ test( expect(ws.readyState).toEqual(WebSocket.OPEN); - // Set up a promise to receive the next WebSocket message const isWindows = process.platform === "win32"; - const messagePromise = new Promise<any>((resolve, reject) => { + const nextMessage = () => new Promise<any>((resolve, reject) => { const timeout = setTimeout( () => reject(new Error("WebSocket message timeout")), isWindows ? 30000 : 10000, ); - ws!.on("message", (data) => { + ws!.once("message", (data) => { clearTimeout(timeout); try { resolve(JSON.parse(data.toString())); @@ -195,6 +204,21 @@ test( }); }); + const initialMessage = nextMessage(); + ws.send(JSON.stringify({ type: "loadWmPath", path: "f/test/hello" })); + expect((await initialMessage).modules["query.gql"]).toEqual({ + content: "query { __typename }\n", + language: "graphql", + }); + + const folderMessage = nextMessage(); + ws.send(JSON.stringify({ type: "loadWmPath", path: "f/test/folder" })); + const folder = await folderMessage; + expect(folder.content).toContain("folder"); + expect(Object.keys(folder.modules)).toEqual(["query.gql"]); + + const messagePromise = nextMessage(); + // Modify the script file on disk // Windows fs.watch() needs more time to initialize with recursive: true await new Promise((r) => setTimeout(r, isWindows ? 2000 : 300)); @@ -212,6 +236,14 @@ test( expect(message.content).toContain("modified"); expect(message.path).toContain("f/test/hello"); expect(message.language).toBeTruthy(); + expect(message.modules["query.gql"].content).toEqual("query { __typename }\n"); + + const moduleMessage = nextMessage(); + await writeFile(join(moduleDir, "query.gql"), "query Updated { __typename }\n"); + const updated = await moduleMessage; + expect(updated.path).toEqual("f/test/hello"); + expect(updated.content).toContain("modified"); + expect(updated.modules["query.gql"].content).toEqual("query Updated { __typename }\n"); } finally { if (ws) { ws.close(); diff --git a/cli/test/git_unit.test.ts b/cli/test/git_unit.test.ts index 92d44e09d9..2cce1617e2 100644 --- a/cli/test/git_unit.test.ts +++ b/cli/test/git_unit.test.ts @@ -364,9 +364,11 @@ describe("gitSyncIncludePattern", () => { }); test("flow/app expand to dotted + __ folder patterns", () => { expect(gitSyncIncludePattern("flow", "f/x")).toBe("f/x.flow/*,f/x__flow/*"); - expect(gitSyncIncludePattern("app", "f/x")).toBe("f/x.app/*,f/x__app/*"); + expect(gitSyncIncludePattern("app", "f/x")).toBe( + "f/x.app/*,f/x__app/*,f/x.custom_path.*,f/x.*.custom_path.*" + ); expect(gitSyncIncludePattern("raw_app", "f/x")).toBe( - "f/x.raw_app/**,f/x__raw_app/**" + "f/x.raw_app/**,f/x__raw_app/**,f/x.custom_path.*,f/x.*.custom_path.*" ); }); test("folder and triggers", () => { diff --git a/cli/test/precheck_permissioned_as_apps_unit.test.ts b/cli/test/precheck_permissioned_as_apps_unit.test.ts index 4a224a43c0..9651b59cc1 100644 --- a/cli/test/precheck_permissioned_as_apps_unit.test.ts +++ b/cli/test/precheck_permissioned_as_apps_unit.test.ts @@ -145,3 +145,16 @@ test("an app is listed once however many of its files changed", async () => { expect(message).toContain("2 item(s)"); }); + +// An agent has an identity only while `run_on_behalf_of` is on, so only then +// can a push take it over. +test("an agent warns only while it runs on behalf of someone", async () => { + const agent = (run: boolean) => ({ + name: "edited" as const, + path: "f/test/bot.resource.yaml", + before: `resource_type: ai_agent\nvalue:\n system_prompt: hi\n run_on_behalf_of: ${run}\n`, + }); + + expect(await precheck([agent(true)])).toContain("f/test/bot.resource.yaml"); + expect(await precheck([agent(false)])).toBeUndefined(); +}); diff --git a/cli/test/push_diff_convergence_unit.test.ts b/cli/test/push_diff_convergence_unit.test.ts index a6f2c132c0..fd20f0d263 100644 --- a/cli/test/push_diff_convergence_unit.test.ts +++ b/cli/test/push_diff_convergence_unit.test.ts @@ -8,6 +8,8 @@ import { compareDynFSElement, ZipFSElement, } from "../src/commands/sync/sync.ts"; +import { changedCustomPath } from "../src/commands/app/app.ts"; +import { isCustomPathFile } from "../src/core/specific_items.ts"; // The differ also reads the working tree (shared lockfiles, dependency files); // an empty one keeps that out of the picture. @@ -346,3 +348,150 @@ test("push: settings.yaml without instance_groups is not a change", async () => }); expect(await diff(otherGroups, remote, skips)).toEqual(["edited settings.yaml"]); }); + +function remoteRawApp(customPath?: string, files: Record<string, string> = {}) { + const zip = new JSZip(); + zip.file( + "f/test/myapp.raw_app.json", + JSON.stringify({ + summary: "My app", + policy: { execution_mode: "publisher" }, + ...(customPath ? { custom_path: customPath } : {}), + value: { files, runnables: {} }, + }), + { createFolders: false }, + ); + return ZipFSElement(zip, true, "bun", {}, {}, false, true) as any; +} + +async function rendered(el: Mock): Promise<Record<string, string>> { + const files: Record<string, string> = {}; + const walk = async (el: Mock) => { + for await (const child of el.getChildren()) { + if (child.isDirectory) await walk(child); + else files[slashPath(child.path)] = await child.getContentText(); + } + }; + await walk(el); + return files; +} + +// Workspace-named as push and pull name it, without any specificItems. +async function diffIn(ws: string, localEl: Mock, remoteEl: Mock, pull: boolean) { + const { changes } = await compareDynFSElement( + (pull ? remoteEl : localEl) as any, + (pull ? localEl : remoteEl) as any, + noIgnore, + false, + {} as any, + !pull, + [], + pull, + undefined, + ws, + pull, + false, + ); + return changes.map((c) => `${c.name} ${slashPath(c.path)}`); +} + +test("a custom path is its own workspace-specific file", async () => { + const files = await rendered(remoteRawApp("my-dashboard")); + expect(files["f/test/myapp.custom_path.yaml"]).toBe( + "custom_path: my-dashboard\n", + ); + const rawAppYaml = files["f/test/myapp.raw_app/raw_app.yaml"]; + expect(rawAppYaml).toBeDefined(); + expect(rawAppYaml).not.toContain("custom_path"); + + const { "f/test/myapp.custom_path.yaml": _, ...appFiles } = files; + const checkout = (extra: Record<string, string>) => + local({ ...appFiles, ...extra }); + + // The workspace's own file is the one compared; another's is ignored. + const own = checkout({ + "f/test/myapp.prod.custom_path.yaml": "custom_path: my-dashboard\n", + "f/test/myapp.staging.custom_path.yaml": "custom_path: staging-dash\n", + }); + for (const pull of [true, false]) { + expect(await diffIn("prod", own, remoteRawApp("my-dashboard"), pull)).toEqual([]); + } + expect( + await diffIn("staging", own, remoteRawApp("my-dashboard"), false), + ).toEqual(["edited f/test/myapp.custom_path.yaml"]); + + // Promotion: a checkout without the target's file leaves its path alone on + // push, and a pull brings it in as the target's file. + const promoted = checkout({ + "f/test/myapp.staging.custom_path.yaml": "custom_path: staging-dash\n", + }); + expect(await diffIn("prod", promoted, remoteRawApp("my-dashboard"), false)).toEqual([]); + expect(await diffIn("prod", promoted, remoteRawApp("my-dashboard"), true)).toEqual([ + "added f/test/myapp.custom_path.yaml", + ]); + // A pull mirrors a cleared custom path by deleting the workspace's file. + expect(await diffIn("prod", own, remoteRawApp(), true)).toEqual([ + "deleted f/test/myapp.custom_path.yaml", + ]); + // `custom_path: ''` clears it on push, and is in sync once it is cleared. + const cleared = checkout({ + "f/test/myapp.prod.custom_path.yaml": "custom_path: ''\n", + }); + expect(await diffIn("prod", cleared, remoteRawApp("my-dashboard"), false)).toEqual([ + "edited f/test/myapp.custom_path.yaml", + ]); + expect(await diffIn("prod", cleared, remoteRawApp(), false)).toEqual([]); +}); + +test("an app file named like a custom-path file is app content", async () => { + const remote = () => + remoteRawApp(undefined, { "/settings.prod.custom_path.json": "{}" }); + const files = await rendered(remote()); + expect(files["f/test/myapp.raw_app/settings.prod.custom_path.json"]).toBe("{}"); + expect(await diffIn("prod", local({}), remote(), true)).toContain( + "added f/test/myapp.raw_app/settings.prod.custom_path.json", + ); + expect(await diffIn("prod", local(files), remote(), false)).toEqual([]); +}); + +test("a fileset child named like a custom-path file is resource content", async () => { + const child = "f/res/data.fileset/settings.prod.custom_path.json"; + expect(isCustomPathFile(child)).toBe(false); + expect(isCustomPathFile("f/test/myapp.prod.custom_path.yaml")).toBe(true); + expect( + await diffIn("prod", local({ [child]: "{\"v\":2}" }), local({ [child]: "{\"v\":1}" }), false), + ).toEqual([`edited ${child}`]); +}); + +// A checkout from before custom paths had their own file keeps the value in +// raw_app.yaml: a push must converge on it, and a pull moves it out. +test("an in-file custom path converges on push", async () => { + const files = await rendered(remoteRawApp("my-dashboard")); + const { "f/test/myapp.custom_path.yaml": _, ...appFiles } = files; + const inFile = (value: string) => + local({ + ...appFiles, + "f/test/myapp.raw_app/raw_app.yaml": + files["f/test/myapp.raw_app/raw_app.yaml"] + `custom_path: ${value}\n`, + }); + + expect(await diffIn("prod", inFile("my-dashboard"), remoteRawApp("my-dashboard"), false)).toEqual([]); + expect(await diffIn("prod", inFile("other"), remoteRawApp("my-dashboard"), false)).toEqual([ + "edited f/test/myapp.custom_path.yaml", + ]); + expect(await diffIn("prod", inFile("my-dashboard"), remoteRawApp("my-dashboard"), true)).toEqual([ + "edited f/test/myapp.raw_app/raw_app.yaml", + "added f/test/myapp.custom_path.yaml", + ]); +}); + +test("push: an unchanged custom_path is not sent", () => { + expect( + changedCustomPath("my-dashboard", { custom_path: "my-dashboard" }), + ).toBeUndefined(); + expect(changedCustomPath(undefined, { custom_path: "my-dashboard" })).toBeUndefined(); + expect(changedCustomPath("", undefined)).toBeUndefined(); + expect(changedCustomPath("new", { custom_path: "my-dashboard" })).toBe("new"); + expect(changedCustomPath("", { custom_path: "my-dashboard" })).toBe(""); + expect(changedCustomPath("new", undefined)).toBe("new"); +}); diff --git a/cli/test/push_workspace_settings_auto_invite_unit.test.ts b/cli/test/push_workspace_settings_auto_invite_unit.test.ts index f4564c25b2..d04b7f4337 100644 --- a/cli/test/push_workspace_settings_auto_invite_unit.test.ts +++ b/cli/test/push_workspace_settings_auto_invite_unit.test.ts @@ -21,6 +21,10 @@ const remoteAutoInvite = { // Every wmill.* call reachable from pushWorkspaceSettings is stubbed so the // function runs without a backend. mockServices({ + // A server without the settings document, so the per-setting endpoints run. + applyWorkspaceSettingsDocument: async () => { + throw { status: 404 }; + }, getSettings: async () => ({ auto_invite: remoteAutoInvite }), getWorkspaceName: async () => "phoenix", changeWorkspaceName: async () => {}, diff --git a/cli/test/push_workspace_settings_identity_unit.test.ts b/cli/test/push_workspace_settings_identity_unit.test.ts index d11b5efac4..16b3bd34b4 100644 --- a/cli/test/push_workspace_settings_identity_unit.test.ts +++ b/cli/test/push_workspace_settings_identity_unit.test.ts @@ -17,6 +17,10 @@ let remoteWebhook: string | undefined = undefined; // Every wmill.* call reachable from pushWorkspaceSettings is stubbed so the // function runs without a backend; only the three we assert on record calls. mockServices({ + // A server without the settings document, so the per-setting endpoints run. + applyWorkspaceSettingsDocument: async () => { + throw { status: 404 }; + }, getSettings: async (_a: { workspace: string }) => ({ webhook: remoteWebhook, color: remoteColor, diff --git a/cli/test/resource_push_agent_obo_unit.test.ts b/cli/test/resource_push_agent_obo_unit.test.ts new file mode 100644 index 0000000000..d9b8361c8a --- /dev/null +++ b/cli/test/resource_push_agent_obo_unit.test.ts @@ -0,0 +1,57 @@ +/** + * An agent's run-as identity lives in its value, which the pull leaves out. A + * push must neither see that gap as a change nor let the file pick who the + * agent runs as: only the deployed identity is claimed back, and only by a + * pusher the backend lets keep it. + */ + +import { expect, test } from "bun:test"; +import { isSuperset } from "../src/types.ts"; +import { agentOnBehalfOf } from "../src/commands/resource/resource.ts"; + +const ctx = (userIsAdminOrDeployer: boolean) => ({ + userCache: new Map(), + userIsAdminOrDeployer, + userEmail: "me@x.dev", +}); + +function agents(localObo?: string) { + const deployed = { + resource_type: "ai_agent", + value: { system_prompt: "hi", on_behalf_of: "u/bob" }, + } as any; + const local = { + resource_type: "ai_agent", + value: { system_prompt: "hi", ...(localObo ? { on_behalf_of: localObo } : {}) }, + } as any; + return { deployed, local }; +} + +test("an unchanged agent still compares equal to the deployed one", () => { + const { deployed, local } = agents(); + agentOnBehalfOf(deployed, local, ctx(true)); + expect(isSuperset(local, deployed)).toBe(true); +}); + +test("an admin or deployer claims the deployed identity, never the file's", () => { + const { deployed, local } = agents("u/admin"); + expect(agentOnBehalfOf(deployed, local, ctx(true))).toBe("u/bob"); + expect(local.value.on_behalf_of).toBeUndefined(); +}); + +test("anyone else claims nothing", () => { + const { deployed, local } = agents("u/bob"); + expect(agentOnBehalfOf(deployed, local, ctx(false))).toBeUndefined(); + expect(agentOnBehalfOf(agents().deployed, agents().local, undefined)).toBeUndefined(); + expect(local.value.on_behalf_of).toBeUndefined(); +}); + +test("other resources are left alone, an agent retyped into one included", () => { + for (const deployedType of ["json", "ai_agent"]) { + const deployed = { resource_type: deployedType, value: { on_behalf_of: "u/bob" } } as any; + const local = { resource_type: "json", value: { on_behalf_of: "u/x" } } as any; + expect(agentOnBehalfOf(deployed, local, ctx(true))).toBeUndefined(); + expect(local.value.on_behalf_of).toBe("u/x"); + expect(deployed.value.on_behalf_of).toBe("u/bob"); + } +}); diff --git a/cli/test/standalone_commands.test.ts b/cli/test/standalone_commands.test.ts index 271b13867b..561ccf9d42 100644 --- a/cli/test/standalone_commands.test.ts +++ b/cli/test/standalone_commands.test.ts @@ -139,10 +139,7 @@ describe("resource-type commands", () => { ); expect(result.code).toEqual(0); - // When empty, shows helpful message; when populated, shows table with Name header - const hasTable = result.stdout.includes("Name"); - const hasEmptyMessage = result.stdout.includes("No custom resource types"); - expect(hasTable || hasEmptyMessage).toBe(true); + expect(result.stdout).toContain("Name"); }); }); diff --git a/cli/windmill-utils-internal/package-lock.json b/cli/windmill-utils-internal/package-lock.json index a2fb09f3dc..b9508332f0 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.7.0", + "version": "1.8.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-utils-internal", - "version": "1.7.0", + "version": "1.8.3", "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 525bb0fc0b..cf4138e6fc 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.2", + "version": "1.8.3", "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 71447c6b1f..b2c11253af 100644 --- a/cli/windmill-utils-internal/src/deploy.ts +++ b/cli/windmill-utils-internal/src/deploy.ts @@ -637,15 +637,26 @@ export async function deployItem( workspace: workspaceFrom, path, }); + let value = resource.value ?? ""; + // An agent's value carries the principal it runs as, as a trigger's row does: the source's + // names nobody in the target, so it is replaced by the chosen one or dropped, leaving the + // backend to make it the deployer's. + const agent = resource.resource_type === "ai_agent" && typeof value === "object"; + if (agent) { + const { on_behalf_of: _, ...rest } = value; + value = onBehalfOf ? { ...rest, on_behalf_of: onBehalfOf } : rest; + } + const agentPreserve = agent ? { preserve_on_behalf_of: preserveOnBehalfOf } : {}; if (alreadyExists) { await provider.updateResource({ workspace: workspaceTo, path, requestBody: { path, - value: resource.value ?? "", + value, description: resource.description ?? "", labels: resource.labels, + ...agentPreserve, }, }); } else { @@ -653,10 +664,11 @@ export async function deployItem( workspace: workspaceTo, requestBody: { path, - value: resource.value ?? "", + value, resource_type: resource.resource_type, description: resource.description ?? "", labels: resource.labels, + ...agentPreserve, }, }); } @@ -914,9 +926,18 @@ export async function getItemValue( path, decryptSecret: true, }); - return variable.value; + return { + value: variable.value, + is_secret: variable.is_secret, + description: variable.description, + }; } else if (kind === "resource") { const resource = await provider.getResource({ workspace, path }); + // Each workspace resolves its own agent identity, which a deploy never copies. + if (resource.resource_type === "ai_agent" && resource.value) { + const { on_behalf_of: _, ...value } = resource.value; + return value; + } return resource.value; } else if (kind === "resource_type") { const rt = await provider.getResourceType({ workspace, path }); @@ -1002,7 +1023,8 @@ function stripTriggerOrScheduleRuntimeFields(row: unknown): unknown { /** * Fetch the on_behalf_of value for a deployable item. - * Returns an email for flows/scripts/apps, or undefined if not applicable. + * Returns an email for flows/scripts/apps, a principal (u/username, g/group) for + * triggers/schedules and agents, or undefined if not applicable. */ export async function getOnBehalfOf( provider: DeployProvider, @@ -1020,6 +1042,12 @@ export async function getOnBehalfOf( } else if (kind === "app" || kind === "raw_app") { const app = await provider.getAppByPath({ workspace, path }); return app.policy?.on_behalf_of_email; + } else if (kind === "resource") { + // Only an agent runs as anyone, and like a trigger it records the principal. + const resource = await provider.getResource({ workspace, path }); + return resource.resource_type === "ai_agent" + ? resource.value?.on_behalf_of + : undefined; } else if (kind === "schedule") { const schedule = await provider.getSchedule({ workspace, path }); return schedule.permissioned_as; diff --git a/cli/windmill-utils-internal/src/inline-scripts/replacer.ts b/cli/windmill-utils-internal/src/inline-scripts/replacer.ts index d926467b86..b6ed4a4376 100644 --- a/cli/windmill-utils-internal/src/inline-scripts/replacer.ts +++ b/cli/windmill-utils-internal/src/inline-scripts/replacer.ts @@ -190,7 +190,7 @@ export async function replacePathScriptsWithLocal( } const localScript = await scriptReader(toolValue.path); if (localScript) { - (tool as AiAgent["tools"][number]).value = { + (tool as NonNullable<AiAgent["tools"]>[number]).value = { tool_type: "flowmodule", type: "rawscript", content: localScript.content, diff --git a/cli/windmill-utils-internal/src/path-utils/path-assigner.ts b/cli/windmill-utils-internal/src/path-utils/path-assigner.ts index 6aaeea4db1..c718e64e0c 100644 --- a/cli/windmill-utils-internal/src/path-utils/path-assigner.ts +++ b/cli/windmill-utils-internal/src/path-utils/path-assigner.ts @@ -5,7 +5,7 @@ const INLINE_SCRIPT_PREFIX = "inline_script"; /** * Union type of all supported programming languages in Windmill */ -export type SupportedLanguage = RawScript["language"] | "frontend" | "bunnative" | "oracledb" | "rust" | "csharp" | "nu" | "ansible" | "java" | "duckdb"; +export type SupportedLanguage = RawScript["language"] | "frontend" | "bunnative" | "oracledb" | "rust" | "csharp" | "nu" | "ansible" | "java" | "duckdb" | "dbt"; /** * Mapping of supported languages to their file extensions diff --git a/debugger/Dockerfile b/debugger/Dockerfile index 6b423920a0..be30570425 100644 --- a/debugger/Dockerfile +++ b/debugger/Dockerfile @@ -19,7 +19,7 @@ FROM ghcr.io/windmill-labs/windmill-ee:main AS windmill-source # Stage 2: Build the debug service -FROM oven/bun:1.4.0 AS runtime +FROM oven/bun:1.4.4 AS runtime # Install Python and required system dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ diff --git a/docker/DockerfileFull b/docker/DockerfileFull index c3a82c8312..d145a5ffff 100644 --- a/docker/DockerfileFull +++ b/docker/DockerfileFull @@ -25,6 +25,10 @@ RUN wget https://dot.net/v1/dotnet-install.sh -O dotnet-install.sh \ && ./dotnet-install.sh --channel 9.0 --install-dir /usr/share/dotnet \ && ln -s /usr/share/dotnet/dotnet /usr/bin/dotnet \ && rm dotnet-install.sh +# PHP +COPY --from=php:8.3.36-cli-trixie /usr/local/bin/php /usr/bin/php +COPY --from=composer:2.9.5 /usr/bin/composer /usr/bin/composer + # Nushell COPY --from=ghcr.io/nushell/nushell:0.101.0-bookworm /usr/bin/nu /usr/bin/nu diff --git a/docker/DockerfileFullEe b/docker/DockerfileFullEe index bb83195f62..8ca1067f7a 100644 --- a/docker/DockerfileFullEe +++ b/docker/DockerfileFullEe @@ -53,6 +53,10 @@ RUN apt-get -y update && apt-get install -y libaio1t64 \ && ln -s "$(basename "$libaio")" "$(dirname "$libaio")/libaio.so.1" RUN echo /opt/oracle/23/lib > /etc/ld.so.conf.d/oracle-instantclient.conf && ldconfig +# PHP +COPY --from=php:8.3.36-cli-trixie /usr/local/bin/php /usr/bin/php +COPY --from=composer:2.9.5 /usr/bin/composer /usr/bin/composer + # Nushell COPY --from=ghcr.io/nushell/nushell:0.101.0-bookworm /usr/bin/nu /usr/bin/nu diff --git a/docker/DockerfileSlim b/docker/DockerfileSlim index ef976f83d3..0a1e5a0c42 100644 --- a/docker/DockerfileSlim +++ b/docker/DockerfileSlim @@ -23,7 +23,9 @@ RUN apt-get -y update \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* -RUN git clone -b master --single-branch https://github.com/google/nsjail.git . && git checkout dccf911fd2659e7b08ce9507c25b2b38ec2c5800 +COPY docker/nsjail-patches/ /nsjail-patches/ +RUN git clone -b master --single-branch https://github.com/google/nsjail.git . && git checkout dccf911fd2659e7b08ce9507c25b2b38ec2c5800 \ + && git apply /nsjail-patches/*.patch RUN make FROM ${DEBIAN_IMAGE} @@ -59,7 +61,7 @@ RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmo ENV TZ=Etc/UTC # Install UV -RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.13.0/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv +RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.13.4/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv # Preinstall python runtime to temp location (will copy with world-writable perms later) # --compile-bytecode precompiles the stdlib to .pyc so jobs don't recompile it on every run @@ -75,7 +77,7 @@ RUN mkdir -p /tmp/windmill/cache && \ rm -rf /tmp/build_cache && \ mkdir -p -m 777 /tmp/windmill/cache/uv -COPY --from=oven/bun:1.4.0 /usr/local/bin/bun /usr/bin/bun +COPY --from=oven/bun:1.4.4 /usr/local/bin/bun /usr/bin/bun # Install windmill CLI (node symlink needed for bun install) RUN ln -s /usr/bin/bun /usr/bin/node \ diff --git a/docker/DockerfileSlimEe b/docker/DockerfileSlimEe index 67de36eac7..46d0d2d98f 100644 --- a/docker/DockerfileSlimEe +++ b/docker/DockerfileSlimEe @@ -23,7 +23,9 @@ RUN apt-get -y update \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* -RUN git clone -b master --single-branch https://github.com/google/nsjail.git . && git checkout dccf911fd2659e7b08ce9507c25b2b38ec2c5800 +COPY docker/nsjail-patches/ /nsjail-patches/ +RUN git clone -b master --single-branch https://github.com/google/nsjail.git . && git checkout dccf911fd2659e7b08ce9507c25b2b38ec2c5800 \ + && git apply /nsjail-patches/*.patch RUN make FROM ${DEBIAN_IMAGE} @@ -59,7 +61,7 @@ RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmo ENV TZ=Etc/UTC # Install UV -RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.13.0/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv +RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.13.4/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv # Preinstall python runtime to temp location (will copy with world-writable perms later) # --compile-bytecode precompiles the stdlib to .pyc so jobs don't recompile it on every run @@ -75,7 +77,7 @@ RUN mkdir -p /tmp/windmill/cache && \ rm -rf /tmp/build_cache && \ mkdir -p -m 777 /tmp/windmill/cache/uv -COPY --from=oven/bun:1.4.0 /usr/local/bin/bun /usr/bin/bun +COPY --from=oven/bun:1.4.4 /usr/local/bin/bun /usr/bin/bun # Install windmill CLI (node symlink needed for bun install) RUN ln -s /usr/bin/bun /usr/bin/node \ diff --git a/docker/RHEL8/Dockerfile b/docker/RHEL8/Dockerfile index ea53feabf1..a7eca5fbd8 100644 --- a/docker/RHEL8/Dockerfile +++ b/docker/RHEL8/Dockerfile @@ -93,7 +93,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release RUN mkdir -p /usr/src/app && \ - cp windmill-duckdb-ffi-internal/target/release/libwindmill_duckdb_ffi_internal.so /usr/src/app/ + cp windmill-duckdb-ffi-internal/target/release/windmill_duckdb_runner /usr/src/app/ # Runtime Kerberos packages (installed separately from build deps) RUN yum install -y krb5-workstation cyrus-sasl-gssapi diff --git a/docker/RHEL9/Dockerfile b/docker/RHEL9/Dockerfile index ab94df5fc4..cc972884d8 100644 --- a/docker/RHEL9/Dockerfile +++ b/docker/RHEL9/Dockerfile @@ -93,7 +93,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release RUN mkdir -p /usr/src/app && \ - cp windmill-duckdb-ffi-internal/target/release/libwindmill_duckdb_ffi_internal.so /usr/src/app/ + cp windmill-duckdb-ffi-internal/target/release/windmill_duckdb_runner /usr/src/app/ # Runtime Kerberos packages (installed separately from build deps) RUN yum install -y krb5-workstation cyrus-sasl-gssapi diff --git a/docker/nsjail-patches/gvisor-compat.patch b/docker/nsjail-patches/gvisor-compat.patch new file mode 100644 index 0000000000..70176b7272 --- /dev/null +++ b/docker/nsjail-patches/gvisor-compat.patch @@ -0,0 +1,153 @@ +gvisor_compat: an opt-in nsjail option (config field and --gvisor_compat) for running inside +gVisor (runsc). With it false, nsjail behaves as upstream. With it true: +- no rtnetlink link cache when no interface is moved into the jail: gVisor acks NLM_F_DUMP + requests before NLMSG_DONE and libnl rejects that (google/gvisor#15945); +- an EPERM from setrlimit(RLIMIT_MSGQUEUE) is a warning: gVisor refuses that limit for any + value (google/gvisor#15944); +- an EPERM from PR_SET_SECUREBITS is tolerated when the UID does not change, where the bits + have no effect (SECBIT_NO_SETUID_FIXUP lands in gVisor with google/gvisor#15130). +Applied to google/nsjail dccf911fd2659e7b08ce9507c25b2b38ec2c5800 by the Dockerfiles. + +diff --git a/cmdline.cc b/cmdline.cc +index 1b52b33..b49ce16 100644 +--- a/cmdline.cc ++++ b/cmdline.cc +@@ -115,6 +115,7 @@ struct custom_option custom_opts[] = { + { { "rlimit_rtprio", required_argument, NULL, 0x0210 }, "RLIMIT_RTPRIO, 'max' or 'hard' for the current hard limit, 'def' or 'soft' for the current soft limit, 'inf' for RLIM64_INFINITY (default: 'soft')" }, + { { "rlimit_msgqueue", required_argument, NULL, 0x0211 }, "RLIMIT_MSGQUEUE in bytes, 'max' or 'hard' for the current hard limit, 'def' or 'soft' for the current soft limit, 'inf' for RLIM64_INFINITY (default: 'soft')" }, + { { "disable_rlimits", no_argument, NULL, 0x0208 }, "Disable all rlimits, default to limits set by parent" }, ++ { { "gvisor_compat", no_argument, NULL, 0x0510 }, "Tolerate what gVisor (runsc) does not implement (see config.proto)" }, + { { "persona_addr_compat_layout", no_argument, NULL, 0x0301 }, "personality(ADDR_COMPAT_LAYOUT)" }, + { { "persona_mmap_page_zero", no_argument, NULL, 0x0302 }, "personality(MMAP_PAGE_ZERO)" }, + { { "persona_read_implies_exec", no_argument, NULL, 0x0303 }, "personality(READ_IMPLIES_EXEC)" }, +@@ -437,6 +438,7 @@ std::unique_ptr<nsjconf_t> parseArgs(int argc, char* argv[]) { + nsjconf->rl_rtpr = parseRLimit(RLIMIT_RTPRIO, "soft", 1); + nsjconf->rl_msgq = parseRLimit(RLIMIT_MSGQUEUE, "soft", 1); + nsjconf->disable_rl = false; ++ nsjconf->gvisor_compat = false; + nsjconf->personality = 0; + nsjconf->clone_newnet = true; + nsjconf->clone_newuser = true; +@@ -603,6 +605,9 @@ std::unique_ptr<nsjconf_t> parseArgs(int argc, char* argv[]) { + case 0x0208: + nsjconf->disable_rl = true; + break; ++ case 0x0510: ++ nsjconf->gvisor_compat = true; ++ break; + case 0x0301: + nsjconf->personality |= ADDR_COMPAT_LAYOUT; + break; +diff --git a/config.cc b/config.cc +index 620d08e..9427069 100644 +--- a/config.cc ++++ b/config.cc +@@ -166,6 +166,7 @@ static bool configParseInternal(nsjconf_t* nsjconf, const nsjail::NsJailConfig& + configRLimit(RLIMIT_MSGQUEUE, njc.rlimit_msgqueue_type(), njc.rlimit_msgqueue()); + + nsjconf->disable_rl = njc.disable_rl(); ++ nsjconf->gvisor_compat = njc.gvisor_compat(); + + if (njc.persona_addr_compat_layout()) { + nsjconf->personality |= ADDR_COMPAT_LAYOUT; +diff --git a/config.proto b/config.proto +index cd297ae..96b477c 100644 +--- a/config.proto ++++ b/config.proto +@@ -266,4 +266,10 @@ message NsJailConfig { + /* Binary path (with arguments) to be executed. If not specified here, it + can be specified with cmd-line as "-- /path/to/command arg1 arg2" */ + optional Exe exec_bin = 90; ++ ++ /* Tolerate what gVisor (runsc) does not implement: an EPERM from ++ setrlimit(RLIMIT_MSGQUEUE) or from PR_SET_SECUREBITS when the UID does ++ not change, and no rtnetlink link cache when no interface is moved ++ into the jail */ ++ optional bool gvisor_compat = 93 [default = false]; + } +diff --git a/contain.cc b/contain.cc +index b5120cc..41e69f8 100644 +--- a/contain.cc ++++ b/contain.cc +@@ -172,8 +172,12 @@ static bool containSetLimits(nsjconf_t* nsjconf) { + } + rl.rlim_cur = rl.rlim_max = nsjconf->rl_msgq; + if (setrlimit64(RLIMIT_MSGQUEUE, &rl) == -1) { +- PLOG_E("setrlimit64(0, RLIMIT_MSGQUEUE , %" PRIu64 ")", nsjconf->rl_msgq); +- return false; ++ if (!nsjconf->gvisor_compat || errno != EPERM) { ++ PLOG_E("setrlimit64(0, RLIMIT_MSGQUEUE , %" PRIu64 ")", nsjconf->rl_msgq); ++ return false; ++ } ++ PLOG_W("setrlimit64(0, RLIMIT_MSGQUEUE , %" PRIu64 "), ignored (gvisor_compat)", ++ nsjconf->rl_msgq); + } + return true; + } +diff --git a/net.cc b/net.cc +index 87c3df9..b4c64f4 100644 +--- a/net.cc ++++ b/net.cc +@@ -138,6 +138,10 @@ bool initNsFromParent(nsjconf_t* nsjconf, int pid) { + if (!nsjconf->clone_newnet) { + return true; + } ++ /* gVisor's rtnetlink replies are rejected by libnl's link cache, which is unused here */ ++ if (nsjconf->gvisor_compat && nsjconf->ifaces.empty() && nsjconf->iface_vs.empty()) { ++ return true; ++ } + struct nl_sock* sk = nl_socket_alloc(); + if (!sk) { + LOG_E("Could not allocate socket with nl_socket_alloc()"); +diff --git a/nsjail.h b/nsjail.h +index 5687ca7..fa2b9fa 100644 +--- a/nsjail.h ++++ b/nsjail.h +@@ -119,6 +119,7 @@ struct nsjconf_t { + uint64_t rl_rtpr; + uint64_t rl_msgq; + bool disable_rl; ++ bool gvisor_compat; + unsigned long personality; + bool clone_newnet; + bool clone_newuser; +diff --git a/user.cc b/user.cc +index a335e2d..f3e578b 100644 +--- a/user.cc ++++ b/user.cc +@@ -260,10 +260,24 @@ bool initNsFromChild(nsjconf_t* nsjconf) { + * Make sure all capabilities are retained after the subsequent setuid/setgid, as they will + * be needed for privileged operations: mounts, uts change etc. + */ ++ bool securebits_set = true; + if (prctl(PR_SET_SECUREBITS, SECBIT_KEEP_CAPS | SECBIT_NO_SETUID_FIXUP, 0UL, 0UL, 0UL) == + -1) { +- PLOG_E("prctl(PR_SET_SECUREBITS, SECBIT_KEEP_CAPS | SECBIT_NO_SETUID_FIXUP)"); +- return false; ++ /* ++ * The bits only matter when setresuid() below changes the UID: without them, ++ * leaving UID 0 clears the capabilities still needed by the jail setup. ++ */ ++ uid_t ruid, euid, suid; ++ const uid_t uid = nsjconf->uids[0].inside_id; ++ if (!nsjconf->gvisor_compat || errno != EPERM || ++ getresuid(&ruid, &euid, &suid) == -1 || ruid != uid || euid != uid || ++ suid != uid) { ++ PLOG_E("prctl(PR_SET_SECUREBITS, SECBIT_KEEP_CAPS | SECBIT_NO_SETUID_FIXUP)"); ++ return false; ++ } ++ PLOG_W("prctl(PR_SET_SECUREBITS, SECBIT_KEEP_CAPS | SECBIT_NO_SETUID_FIXUP), " ++ "ignored (gvisor_compat, UID unchanged)"); ++ securebits_set = false; + } + + /* +@@ -306,7 +320,7 @@ bool initNsFromChild(nsjconf_t* nsjconf) { + * unexpectedly retaining capabilities after a UID/GID + * change. + */ +- if (prctl(PR_SET_SECUREBITS, 0UL, 0UL, 0UL, 0UL) == -1) { ++ if (securebits_set && prctl(PR_SET_SECUREBITS, 0UL, 0UL, 0UL, 0UL) == -1) { + PLOG_E("prctl(PR_SET_SECUREBITS, 0)"); + return false; + } diff --git a/docs/duckdb-execution.md b/docs/duckdb-execution.md new file mode 100644 index 0000000000..70f9218ee4 --- /dev/null +++ b/docs/duckdb-execution.md @@ -0,0 +1,118 @@ +# How DuckDB jobs run + +A DuckDB job runs in `windmill_duckdb_runner`, a binary built from +`backend/windmill-duckdb-ffi-internal` (`src/bin`) and shipped next to the `windmill` binary. +The worker spawns one runner per DuckDB connection a job opens: the job itself, and the +`on_schema_change=sync` pre-pass. It never loads the engine into its own process. + +The runner gets the same job isolation as every other language. `duckdb_runner::runner_mode` +decides it per job: + +| worker isolates with | runner runs | what it can see | +|---|---|---| +| nsjail | inside `run.duckdb.config.proto` | read-only `/usr`, `/lib`, `/etc`; its own `/tmp`; its job dir read-only, except `duckdb/` (spill + result); a read-only extension cache | +| unshare | under `build_command_with_isolation` | the worker user's file system, its own PID namespace, a cleared environment | +| nothing | as a plain child process | the worker user's file system, a cleared environment | + +There is no SQL-level policy on top. A script can read and write what the jail lets it and load +any extension DuckDB will sign, as a Python job can read the same files and load native code. +Under nsjail its disk use is capped from the worker (below). Network egress is open in every +mode, as it is for the other languages: the S3 proxy, metadata endpoints and internal hosts are +all reachable. + +## What crosses to the runner + +`spawn_runner` writes one JSON request to the runner's stdin, so nothing secret is put on argv or +into the environment: + +- the blocks, already rewritten by the worker: resource `ATTACH`es, DuckLake and datatable + attaches with their credentials inlined, workspace macros; +- the job args, the job token, `base_internal_url` and the workspace id. The runner derives the + S3/GCS secrets for the workspace's S3 proxy from the token, which it reaches over the network; +- `memory_limit` (`DUCKDB_MEMORY_LIMIT`, or 80% of the worker's cgroup), the temp directory (the + job dir), and the result-size cap (`MAX_SQL_RESULT_SIZE`). + +The environment is cleared except `PATH`, `HOME` (outside nsjail), `DUCKDB_MAX_TEMP_DIRECTORY_SIZE`, +`GOOGLE_APPLICATION_CREDENTIALS` (BigQuery `ATTACH`, whose credentials file nsjail mounts) and the +proxy variables. In particular a script cannot use the worker's own `AWS_*` credential chain. + +Under nsjail the job dir is mounted read-only at its host path, so every path the worker wrote +into the blocks (Postgres root certificates) resolves unchanged. The one writable place on the +host is `{job dir}/duckdb/`: the spill directory and the result file, which the worker opens +without following symlinks, since the script can write there. + +The runner writes its answer to a file in that directory: a first line with the last statement's +column order, then either the collected results or `ERROR <json-encoded message>`. +`finish_run` / `finish_prepare` in `duckdb_executor.rs` turn that into the job result. + +## Failure, cancellation and memory + +- **A crash fails the job, never the worker.** A runner that exits without writing its result + becomes `The DuckDB engine process … The worker is unaffected.` with the tail of its stderr. + An engine `std::terminate` (for example a remote `temp_directory` that has to spill) and a + panic in result rendering both land here. +- **Cancellation and timeouts kill the engine.** The executor drops the job future, and + `KillGroupOnDrop` kills the runner's whole process group. Killing the spawned process alone is + not enough: `unshare --fork` leaves its child running. +- **The kernel OOM-kills the runner, not the worker.** `JOB_OOM_SCORE_ADJ` is written in + `pre_exec`, before nsjail or `unshare` is exec'd, so the runner inherits it. Written after spawn + it would race their fork, and the jail's `/proc` is read-only. Within the job, DuckDB bounds + itself through `memory_limit` and spills past it. +- **Runner output is bounded.** stdout/stderr are drained keeping only the last 4 KB: a script + controls how much the engine prints (`CALL enable_logging(storage='stdout')`). + +## Extensions + +Outside nsjail the runner uses the worker user's `~/.duckdb/extensions` and installs whatever a +script names. + +Under nsjail it gets its own cache, `~/.duckdb/windmill_jail_extensions`, mounted read-only. +Only the worker writes to it: before a jailed run it starts the runner unjailed in `install` +mode, with only `INSTALL` statements it built itself, for `httpfs`, `azure`, `parquet`, `json` +and every extension a block installs by name. That runs no script SQL and loads nothing, so no +extension code runs outside the jail. It happens once per extension per worker process, except +for `FORCE INSTALL`, which is honored every time and becomes a plain `INSTALL` inside the jail. +A job can therefore neither add to nor replace what the next job loads from that cache. An +extension used without being named and outside those four (`icu` behind `SET TimeZone`, say) +fails on the read-only mount: name it with `INSTALL`. + +A script can still point `extension_directory` somewhere writable and install into it. That only +affects its own connection, and is no more than a Python job could do. + +## The engine + +The runner links duckdb-rs from crates.io, pinned with `=` so `cargo update` cannot move the +engine. The engine loads the **prebuilt** extensions from `extensions.duckdb.org`, compiled for the +exact release it reports, so an engine bump is a version change in `Cargo.toml` and nothing else. + +Confinement belongs to the job isolation, not to engine settings. DuckDB spills through the same +file system a query reads with, so `disabled_filesystems='LocalFileSystem'` also stops every query +that outgrows `memory_limit`, and no combination of stock settings gives "spill, but no other local +file access". Fencing the engine at the SQL level again would need a patched engine to keep +spilling. + +## Bounding disk use + +The cap is `DUCKDB_MAX_TEMP_DIRECTORY_SIZE`, or `6GiB` on a `CLOUD_HOSTED` worker that does not +set it. The cloud runs one worker per pod on nodes with no per-pod ephemeral-storage limit, and a +worker pod already holds several GiB of runtime caches, so the cap is sized against a mostly full +node; re-measure before raising it. Unset elsewhere, DuckDB's own default applies: 90% of the +temp volume's free space. + +It is applied twice: + +- **DuckDB's `max_temp_directory_size`**, set on every connection, so an ordinary query that + outgrows it fails with the engine's `failed to offload data block … max_temp_directory_size` + error and cleans up its temp files. +- **A worker-side watchdog**, under nsjail. A script can raise the engine setting, so on its own + it is only a default. The worker sums the allocated size of `{job dir}/duckdb/` once a second + and kills the runner when it passes the cap, whatever wrote the bytes: spill, `COPY … TO`, or + the result file. With a disk-backed jail `/tmp` (`NSJAIL_TMP_BACKING=disk`) that `/tmp` lives +inside the same directory, so it counts too; a RAM-backed one is bounded by its tmpfs size. +Enforcement lags by up to a second of writing. Outside nsjail the script can + write anywhere the worker user can, as other languages can, so there the cap stays a default. + +The value is DuckDB's spelling, not Kubernetes': `KiB`/`MiB`/`GiB`/`TiB` for 1024-based units, +`KB`/`MB`/`GB`/`TB` for 1000-based. An unparseable value fails every DuckDB job on the worker +rather than being ignored. `DUCKDB_MEMORY_LIMIT` is spelled the same way, and the two are set in +separate statements so a parse error names the right variable. diff --git a/docs/duckdb-isolation.md b/docs/duckdb-isolation.md deleted file mode 100644 index fb85085b2c..0000000000 --- a/docs/duckdb-isolation.md +++ /dev/null @@ -1,223 +0,0 @@ -# DuckDB isolation and the patched engine - -DuckDB runs **in-process** in the worker, through the FFI cdylib in -`backend/windmill-duckdb-ffi-internal`. It is therefore the one language that the -nsjail/unshare isolation cannot confine: there is no child process to jail. On a worker -that isolates jobs, `windmill-worker/src/duckdb_isolation_ee.rs` mirrors that policy at -the connection level instead, rewriting the block list handed to the engine so it starts -with three irreversible settings: - -```sql -SET lock_temp_directory=true; -SET disabled_filesystems='LocalFileSystem'; -SET allow_community_extensions=false; -``` - -`disabled_filesystems` is the fence. `lock_temp_directory` is what keeps out-of-core -execution working behind it, and it is **not a stock DuckDB setting** — see below. - -## Why the engine had to change - -DuckDB spills through the same file system a query would use to read `/etc/hostname`, so -`disabled_filesystems='LocalFileSystem'` also stops the buffer manager. Every query whose -working set outgrows `memory_limit` then fails instead of going out-of-core. No -combination of stock settings gives "spill, but no other local file access": - -- `allowed_directories` / `allowed_paths` enforce nothing while `enable_external_access` - is true — `DBConfig::CanAccessFile` returns early. -- Turning `enable_external_access` off does preserve spilling (startup adds the temp - directory to `allowed_directories`), but the postgres and mysql extensions refuse to - `ATTACH` on that flag alone, with no allowlist entry able to re-permit it. That would - take DuckLake catalogs and datatables down with it. -- A remote `temp_directory` is impossible: DuckDB cannot open an HTTP file for both - reading and writing, and the attempt aborts the process. - -The engine is also not arranged the way the layering suggests. `FileSystem::GetLocal(db)` -looks like an escape hatch — it is what secret storage and extension installation use — -but `LocalDatabaseFileSystem::GetFileSystem()` re-applies the `disabled_filesystems` -check by hand, so it is not one. (That is also why extension autoload broke behind the -fence in DuckDB 1.5.3, which `EXTENSION_ALLOWLIST` and `PRELOAD_EXTENSIONS` exist to work -around.) The buffer manager, meanwhile, reaches its temp files through -`FileSystem::GetFileSystem(db)`, an opener-wrapped virtual file system, so both the path -allowlist and the disable apply to it. - -## What the patches add - -`lock_temp_directory` (BOOLEAN, default false, global, irreversible). Setting it: - -1. fixes `temp_directory` in place — it can no longer be modified, and the setting itself - cannot be unset or reset; -2. fixes `max_temp_directory_size` the same way, so the ceiling on what may be written - there cannot be raised either; -3. routes the buffer manager's temporary files through a local file system that - `disabled_filesystems` does not apply to. - -The halves are what make it safe as a set. The exemption is only sound because the -directory can no longer be redirected: without (1), a script could point `temp_directory` -at an arbitrary path and get a blind write primitive through the exemption. And it is only -bounded because of (2): DuckDB accepts `SET max_temp_directory_size='100GB'` from any -statement, so a cap a script can lift is not a cap. -`allowed_directories` still applies either way. - -The isolation transform therefore emits `SET lock_temp_directory=true` **before** -`disabled_filesystems`, while the local file system is still reachable, and after the FFI -has already pointed `temp_directory` at the job dir -(`configure_duckdb_resource_limits`). Everything a script authored runs after all three -settings, so a script can neither move the temp directory nor raise the cap on it. - -## Bounding the spill - -`configure_duckdb_resource_limits` sets `max_temp_directory_size` from -`DUCKDB_MAX_TEMP_DIRECTORY_SIZE`, immediately before it takes the lock. Unset — the -self-hosted default — leaves DuckDB's own default of 90% of the temp volume's free space, -so nothing changes for an install that does not opt in. The cloud sets `6GiB` per worker -pod (`cloudee/cloud/cloud.yml` in `infra-aws`); those pods run a single worker each, so it -is effectively a per-pod cap and is not divided by `NUM_WORKERS`. - -Those pods carry no `ephemeral-storage` limit, so what the cap has to stay clear of is the -node's eviction threshold rather than a per-pod one, and the budget is tighter than the -node's size suggests: a worker pod already holds 4.7-6.9 GiB of language-runtime caches and -job dirs before any spill, so a ~100 GB node running 5-6 of them sits at 45-60 GB used at -rest. Every pod on the fullest node spilling to its cap at the same instant would land near -the kubelet's default `nodefs.available<10%`. That is the tail, not the common case, and it -is still bounded where the previous behaviour was not — one query could take all the free -space by itself — but the cap is sized against a mostly-full node, not an empty one. Re-measure -before raising it, and re-measure if the runtime-cache baseline grows. - -It is read from the environment rather than passed across the FFI: the cdylib is versioned -separately from the worker and agent workers ship it out of band, so a signature change -would mean an ABI bump and a coordinated redeploy for what is a config value. - -The value is DuckDB's spelling, not Kubernetes': `KiB`/`MiB`/`GiB`/`TiB` for 1024-based -units, `KB`/`MB`/`GB`/`TB` for 1000-based. Kubernetes' `6Gi` is not a unit DuckDB knows, -and an unparseable value fails every DuckDB job on the worker rather than being ignored — -a cap that quietly disappeared would be worse. `DUCKDB_MEMORY_LIMIT` is spelled the same -way and DuckDB's parse error says only "memory", so the cap's `SET` is issued on its own -statement: batched together, a bad value in either would produce the same message and name -neither. - -Enforcement is graceful. A query that needs more spill than the cap fails with -`Out of Memory Error: failed to offload data block of size … This limit was set by the -'max_temp_directory_size' setting.`, its temp files are removed, and the worker carries on. -Size it from measurement, not from the data: DuckDB compresses temp files, so the logical -spill a given cap permits is well above the number set. - -That engine message goes on to suggest `PRAGMA max_temp_directory_size='10GiB'`, which the -lock refuses — advice that was actionable before the cap was frozen and is now a dead end -ending in a second, unrelated-looking permission error. `decode_ffi_error` -(`duckdb_executor.rs`) appends a correction whenever the message appears, on every worker -rather than only isolating ones, since the lock is taken on every connection. It matches -DuckDB's wording verbatim, which -`spilling_past_the_configured_cap_fails_the_query_and_cleans_up` asserts against real engine -output so an engine bump that rewords it fails the test instead of silently dropping the -correction. - -The engine emits that same message for **its own default cap**, so which limit was hit -cannot be read off the message: with `DUCKDB_MAX_TEMP_DIRECTORY_SIZE` unset it means the -worker is low on disk, not that anyone configured a ceiling. `spill_cap_hint` takes the -answer from the worker's config instead and says whichever is true — naming the env var on a -worker that never set it would point the reader at a knob that does not exist. - -## Where the patched engine comes from - -`backend/windmill-duckdb-ffi-internal` depends on -[`windmill-labs/duckdb-rs`](https://github.com/windmill-labs/duckdb-rs), branch -`windmill/duckdb-1.5.5`, pinned by rev. That fork is duckdb-rs 1.10505.0 (DuckDB 1.5.5) -plus the windmill commits; `duckdb.tar.gz` is left byte-identical to upstream's and -`crates/libduckdb-sys/windmill/0*.patch` are applied to the extracted sources at build -time, in order, so `git diff` against `duckdb/duckdb-rs` shows the whole delta as text. -`0001` adds the setting and the exemption, `0002` extends the lock to -`max_temp_directory_size`. `crates/libduckdb-sys/windmill/README.md` in the fork carries -the rebase recipe. - -### The patches may not move a struct member - -The engine loads the **prebuilt** extensions from `extensions.duckdb.org` — httpfs, azure, -postgres, ducklake, parquet, and the rest of `EXTENSION_ALLOWLIST` — which are compiled -against the struct layouts of the release the engine claims to be. A patch that shifts a -member those extensions reach breaks them, and not loudly: the extension reads the -neighbouring field and `LOAD httpfs` **blocks forever** rather than failing, so every -DuckDB job hangs until its timeout. - -The first revision of `0001` declared its new `DatabaseInstance` member next to -`local_db_file_system`, which pushed `create_api_v1` — the extension C-API entry point — -8 bytes along, and every extension load hung. It is declared last for that reason, and -`lock_temp_directory` is declared at the end of `DBConfigOptions`' run of bools so it takes -a byte of padding the struct already had. - -The invariant is **no pre-existing member changes offset**, which is strictly stronger than -`sizeof` holding — the first attempt at that field went in next to `temporary_directory`, -kept `sizeof(DBConfigOptions)` at 920 by consuming interior padding, and still slid four -bools along by a byte each. A rebase must diff the full record layouts -(`clang++ -Xclang -fdump-record-layouts`, recipe in the fork's README), not the sizes. -`prebuilt_extensions_still_load` in the FFI crate is the end-to-end guard — a timeout, not -an assertion, because the failure mode is a hang — but it covers extension *entry*, not -every field an extension reads, so it does not substitute for the layout diff. - -Alternatives considered and rejected: vendoring a patched `libduckdb-sys` in this repo -would put a 6 MB binary tarball into windmill's history on every engine bump and would -need the FFI builder stage in `Dockerfile` to copy more than -`backend/windmill-duckdb-ffi-internal`; linking a prebuilt engine through -`DUCKDB_LIB_DIR` would drop the `bundled` build, so every developer and every CI job -would need a patched `libduckdb` on the box before `build_dev.sh` could work. - -## What stops the patches disappearing silently - -An unpatched engine compiles, links and passes every existing test. Three things catch it: - -- **The fork's build script** asserts each patch's marker string is present in the patched - sources after applying it, and panics if one no longer applies. -- **`patched_engine_tests` in the FFI crate** runs a query that must spill, first - without the lock (asserting the fence stops it, which is what proves the query really - spills) and then with it (asserting it succeeds); it also pins that the lock leaves - neither `temp_directory` nor `max_temp_directory_size` settable, and that a configured - connection stops at its cap. It fails outright on a stock engine, where the `SET` is an - unrecognized parameter. CI runs it via the FFI crate's own `cargo test` step in - `backend-test.yml`. -- **The transform itself fails closed**: on a stock engine `SET lock_temp_directory=true` - errors, so every DuckDB job on an isolating worker fails immediately rather than - quietly losing the ability to spill. - -## Upstream - -Both patches are written against `duckdb/duckdb` `v1.5.5` in the shape upstream takes -changes. Besides the engine sources they carry `src/common/settings.json` (the settings -generator's input, regenerated with `scripts/generate_settings.py` and formatted with -clang-format 11.0.1), two sqllogictests under `test/sql/settings/`, and one line in -`test/api/test_reset.cpp` — that test requires every setting to survive a `RESET`, so an -irreversible one has to join the exclusion list `disabled_filesystems` and -`lock_configuration` are already on. They are `git format-patch` files, so they apply with -`git am`: - -```bash -git clone https://github.com/duckdb/duckdb.git && cd duckdb && git checkout v1.5.5 -git am .../crates/libduckdb-sys/windmill/0*.patch -GEN=ninja make && ./build/release/test/unittest "test/sql/settings/lock_temp_directory*" -``` - -`0001` is filed as -**[duckdb/duckdb#24694](https://github.com/duckdb/duckdb/pull/24694)**, against `main` -rather than `v1.5.5`. Filing was a human decision on purpose: their `CONTRIBUTING.md` asks -contributors not to submit LLM-generated pull requests, to discuss the change on GitHub -first, and to run CI on a fork before opening one. - -**`0002` is windmill-only and is not on #24694.** It is kept as a separate commit rather -than folded into `0001` for exactly that reason: `0001` stays byte-identical to what the -open PR carries, and `0002` applies to it verbatim as a second commit whenever a human -chooses to push it. That is the one sanctioned divergence; when it is pushed, say so here -and in the fork's `windmill/README.md`, and the "same change" rule below covers it too. - -**[duckdb/duckdb#24695](https://github.com/duckdb/duckdb/issues/24695)** states the -underlying problem without prescribing a fix, and is where a different design would land: -`lock_temp_directory` is one shape, and an enforceable `allowed_directories` would solve it -equally well. Watch the issue rather than the PR for whether this fork can be retired. - -`0001` and #24694 are the same change and must stay that way. They differ only -where the base version forces it: the `DUCKDB_SETTING_ALIAS` indices, `vfs` versus -`db.config.file_system` in `FileSystem::GetLocal`, and the sqllogictest directory -placeholder. Nothing else should diverge. Note that the build applies neither the `test/` -hunks nor `settings.json` (`NOT_IN_TARBALL` in `build_windmill_patch.rs`), so drift in -those is invisible here and only surfaces when #24694 is rebased. - -Until it lands in a released DuckDB the fork is the delivery mechanism, and the exit path -is to delete `crates/libduckdb-sys/windmill/` and go back to the crates.io crate. diff --git a/docs/external-instance-datatables.md b/docs/external-instance-datatables.md index acfb93b90a..38e810b03d 100644 --- a/docs/external-instance-datatables.md +++ b/docs/external-instance-datatables.md @@ -83,6 +83,6 @@ Worth knowing while testing: - A worker needs the `postgresql` and `duckdb` tags for those jobs (`update config set config = jsonb_set(config, '{worker_tags}', …) where name = 'worker__default'`). -- DuckDB jobs load `libwindmill_duckdb_ffi_internal.so` by name, so a binary built into its own - `CARGO_TARGET_DIR` needs that library on `LD_LIBRARY_PATH`. +- DuckDB jobs run `windmill_duckdb_runner` from next to the worker binary, so a binary built + into its own `CARGO_TARGET_DIR` needs `DUCKDB_RUNNER_PATH` pointed at the runner. - Setup holds the lifecycle lock for its whole run, so a settings save during it waits. diff --git a/docs/fork-object-storage.md b/docs/fork-object-storage.md new file mode 100644 index 0000000000..7428119f97 --- /dev/null +++ b/docs/fork-object-storage.md @@ -0,0 +1,68 @@ +# Fork-isolated workspace object storage + +A fork clones its parent's `large_file_storage` settings, so without isolation it reads and +writes the parent's objects: a script iterating in a fork overwrites production's files. Each +storage the fork isolates (a `fork_storage_namespace` row, chosen at fork creation, default +isolated) instead maps logical keys: + +- **Writes** (put, multipart, delete, copy destination) go to `__wm_forks/<fork segment>/<key>`, + the same one-component segment DuckLake's fork namespaces use (`fork_data_dir_segment`). +- **Reads** try the fork's own key, then each ancestor's mapping, nearest first, then the root's + raw key. They stop at the first ancestor whose storage is a different one + (`lfs_entry_storage_ref`): its keys are not in this bucket. A shared fork maps like its parent. +- **Listings** show the workspace's own objects only, as logical keys. +- **Deletes** remove the fork's copy; an ancestor's object stays readable through it. + +The mapping (`windmill_common::fork_storage::ForkStorageKeys`) is applied wherever Windmill +turns a workspace-storage key into a storage operation: the `ForkStore` object-store wrapper +(`windmill_object_store::fork_store::workspace_object_store`) for every server-side helper, and +key rewriting in the S3 proxy's raw S3 forwarding (URI key, list `prefix`/`start-after`/`marker` +and response keys, `x-amz-copy-source`). A named resource (`s3_resource_path`) is not workspace +storage and is never mapped. A workspace whose keys are mapped is refused its workspace +storage's own credentials (`s3_resource_info`, the duckdb/polars settings endpoints): a client +holding them would write past the mapping. + +Not mapped, on purpose: AI sessions and volumes (their keys already carry the workspace id), +SMTP attachments (job-id keys), the storage usage count (it counts the whole bucket). Known +gaps: the worker's result-cache hash HEADs the logical key unmapped, and a read of an object +the fork does not own costs one HEAD per chain level before the GET. + +## Invariants + +- **`__wm_forks/` is reachable only through the workspace's own chain.** A key under it maps to + itself when it is in the workspace's own area (writes) or an area of the chain (reads), and is + refused otherwise; listings hide it. This is also what keeps DuckLake working: its fork data + paths arrive already prefixed, and its defer attaches read ancestors' raw keys through the + read chain. Every workspace gets the rule, isolating or not, so pre-existing forks' lakes keep + working. +- **No `.` or `..` key segments**, in any workspace: the proxy forwards mapped keys in a URL, + where they collapse and would climb out of the fork's prefix. +- **The prefix derives from the workspace id**, so an isolating fork's id cannot be changed + (`change_workspace_id` refuses): under a new id it would see none of its objects. Nor can an + isolated storage be repointed, since the purge targets the storage recorded at creation. +- **A shared lake needs its storage shared**: its files must land at the parent's keys, where the + parent's catalog looks for them. Fork creation refuses the other combination. +- **Children before parents**: deleting a fork that has forks of its own is refused when anything + in its chain isolates a storage, shared forks included. A child reads through, or writes into, + the isolating prefix via that link, and without it the child's chain would map like the root's. +- **Deletion and same-id creation are serialized** on the `fork:<id>` advisory lock, and a purge + skips an id that exists again: the rows are then the new fork's. The proxy caches only the raw + client, never a mapping, so fork creation and deletion invalidate the one mapping cache. +- **Deletion always purges** the fork's area of each isolated storage, post-commit, from the + storage identity recorded at creation: nothing can reach it once the fork is gone. Rows have + no FK and outlive the workspace when the purge fails; creating a fork with the same id retries + them and refuses while they cannot be purged, since a same-id fork would read them. +- Isolation is a per-fork-creation choice (`shared_storages` on create-fork), never inherited. + Forks created before the registry existed have no rows and keep sharing. + +## Requiring isolation + +The `RequireIsolatedForks` protection rule on a workspace makes its forks leave nothing shared +with it, for every user the ruleset does not exempt (admins always are): no `shared_storages`, +no `shared_ducklakes`, and every data table whose database it owns cloned rather than kept as a +pointer. A data table whose database another workspace owns is left alone: that is not its data. +A clone governed from another workspace still has its own database, so it is not exempt. It is +checked against the workspace being forked, in +`create_workspace_fork` and in the git-branch pre-check (so a refused fork leaves no branch +behind), and the data table part again under the parent's `lock_fork_datatables`, which data table +edits also take: a table added between the two would otherwise reach the fork as a pointer. diff --git a/docs/operator-builder-rights.md b/docs/operator-builder-rights.md index 8ace42a1e5..8445fa0dde 100644 --- a/docs/operator-builder-rights.md +++ b/docs/operator-builder-rights.md @@ -14,12 +14,12 @@ without the second, so **never gate on "either"**: a gate that does authorizes t workspace never granted, which is the whole point of the split. `BuilderKind` exists to make each call site say which one it means. -These are write rights, unlike the visibility flags beside them, and unlike the withdrawable -rights in `docs/operator-write-rights.md` they are granted on request and cost a seat. Read them -with `windmill_common::workspaces::operator_builder_rights` (60s cache, shared with the -withdrawable rights), which returns an `OperatorBuilderRights { flows, apps }`, and gate a write -with `check_operator_can_build`, naming the `BuilderKind`. `.any()` is only for surfaces that are -genuinely not per-kind: the seat a membership costs, and listing drafts. +These rights are now the `compose` level of a role's `flows` and `apps` capabilities +(`docs/workspace-roles.md`): `builder_flows` / `builder_apps` in the legacy operator settings set +the `operator` role to it. Gate a write with `check_operator_can_build`, naming the `BuilderKind`: +it refuses `none` and returns the level held, and the caller MUST run the composition-only checks +below at `compose` and may skip them only at `full`. `OperatorBuilderRights::any()` is only for +surfaces that are genuinely not per-kind, such as listing drafts. ## What the check has to cover @@ -66,7 +66,8 @@ resolves the runnable it runs on the root handle, so `validate_operator_composed referenced path under the caller's RLS and refuses hub ones. For a script it checks the version a run would pick: grants are per version, and a path reused after its versions were archived starts with none while the archived ones keep theirs. But it has to check **two** surfaces, -because they are not the same list: the policy's `script/<path>` and `flow/<path>` triggerables, +because they are not the same list: the policy's `script/<path>`, `flow/<path>` and +`agent/<path>` triggerables (an agent is checked as an `ai_agent` resource the caller can read), and the by-path entries of the app value's `runnables` map, which is what the editor resolves a `runnable_id` against and sends. Read only that map, and refuse an entry whose `type`, `path` or `runType` is not text. @@ -77,8 +78,8 @@ at run time for `component = "a:b"`. Every colon is a possible split, so the che suffix that parses as a runnable rather than guessing which one a request will use. What makes those checks bind is that **`ExecutionMode::Viewer` is refused for a builder app**. In -Viewer mode `execute_component` falls back to a default triggerable for any `script/`/`flow/` -path, so the policy stops being the list of what the app may invoke, and the job runs as the +Viewer mode `execute_component` falls back to a default triggerable for any `script/`/`flow/`/ +`agent/` path, so the policy stops being the list of what the app may invoke, and the job runs as the *viewer*: an admin who merely opened the app would run anything in the workspace as themselves. `Publisher`, `Guest` and `Anonymous` have no such fallback; all three run as the publisher. If you ever relax the Viewer refusal, the deploy-time path checks above stop being an authorization boundary. @@ -146,16 +147,13 @@ artifacts, and there is no half-author, so granting both costs no more than gran introduce per-right pricing — it turns one boolean into a capability matrix that billing, the cap check and the displayed count would each have to agree on. -`consumes_operator_seat` is the seat-role helper; the EE counting queries share -`OPERATOR_SEAT_SQL` so the displayed, enforced and reported numbers agree. The one exception is -`get_user_usage` in `stats_ee.rs`: it is a compile-checked `query!`, which cannot interpolate the -constant, so it spells the predicate out. Change both together. On cloud, `billable_seats` applies -the same rule; see its doc for the out-of-repo invoice it must match. +Every count reads the role's author flag from `role_seat` through `AUTHOR_SEAT_SQL`; see +`docs/workspace-roles.md`. On cloud, `billable_seats` applies the same rule; see its doc for the +out-of-repo invoice it must match. -Granting the first right runs `check_seat_cap_for_operator_builder`, which prices the change by -counting seats twice rather than by counting the workspace's operators: an operator who already -authors elsewhere must not be charged again. Adding the second right later, or re-saving settings -that already have one on, is a zero delta and never blocks. +A role change runs `check_seat_cap_for_role_change` inside the transaction that writes it, which +prices the change by counting seats twice rather than by counting the role's members: a member who +already authors elsewhere must not be charged again. A change that adds no seat never blocks. ## Accepted risks @@ -186,7 +184,6 @@ that already have one on, is a zero delta and never blocks. carries none makes the deployer its run identity, as any deploy does. An admin deploying such a draft, from the editor or Compare & Deploy, therefore publishes the builder's code running as that admin; the deploy is the admin's explicit act, as it is for a developer's draft. -- All-or-nothing per workspace: there is no per-user builder role. - `operator_settings` is git-synced, so a pull can flip every operator's class in a workspace and the billed seat count with it. - A builder writes JavaScript expressions: step inputs, branch predicates, `stop_after_if` and diff --git a/docs/operator-write-rights.md b/docs/operator-write-rights.md index 2c18ecd290..5fda1547f0 100644 --- a/docs/operator-write-rights.md +++ b/docs/operator-write-rights.md @@ -54,6 +54,32 @@ It refuses with `PermissionDenied` (403), never `NotAuthorized` (401): the front uncaught 401 as an expired session and logs the user out, so 401 here ejects an operator from the app rather than telling them why. Both integration tests assert the status for that reason. +## Drafts + +An operator may save a draft of a kind they could deploy, and no other: a resource or variable +draft always, since no operator setting gates those routes; a schedule draft while they hold +`manage_schedules`, a trigger draft (every trigger kind, native ones included) while they hold +`manage_triggers`, a flow or full-code app draft under `builder_flows` / `builder_apps`. +drafts.rs `require_can_write_path` asks `operator_can_draft`, which reads the same cached rights +as the router gate, and `UserDraftItemKind::manage_kind` maps each draft kind to its right. The AI +session chat writes resources, variables, schedules and triggers only as drafts and then deploys +them, so without this rule an operator who may create a schedule on its page could not ask the +chat for one. + +A secret variable draft is stored as an `$encrypted:` marker and decrypted by the variable create +and update routes on deploy. Those routes accept the marker from any caller, so a draft gives an +operator nothing the direct route does not. + +The schedule, trigger, resource and variable list routes append an operator's draft-only rows +under the same `operator_can_draft` check, so a saved draft shows on its list page exactly while +the operator may still save it. + +Deploying a draft goes through the ordinary create and update routes, so the router gate is still +what decides the write. The draft rule only has to agree with it: a draft the gate would refuse to +deploy should not be saved in the first place. The frontend mirrors the rule in `roleCanDraft` +(`editRights.ts`), which the chat's `write_schedule_draft` / `write_trigger_draft` capabilities +come from. + ## In the UI The shared lists (`TriggerList`, `SchedulesList`, `NativeTriggerTable`) derive a per-row `canEdit` @@ -69,20 +95,22 @@ toolbar, which takes its permissions from `can_write`, needs no lock of its own. `ShareModal`, which locks itself off the kind it was opened on rather than relying on each of the dozen menu entries that open it. -The cache is per process, so withdrawing a right has to reach every replica: an `AFTER UPDATE OF -operator_settings` trigger writes a `notify_operator_settings_change` row and `process_notify_event` -drops the entry. Keep both ends if you touch either, or a workspace that withdrew a right keeps -authorizing writes on every other replica until its own entry expires. +These rights are now capabilities of a member's role (`docs/workspace-roles.md`), and the legacy +operator settings write them as an override of the `operator` role. The roles cache is per process, +so withdrawing a right has to reach every replica: `write_role_overrides` calls +`roles::notify_role_overrides_change` in its transaction and `process_notify_event` drops the +entry. A new writer of `role_overrides` has to call it too, or a workspace that withdrew a right +keeps authorizing writes on every other replica until its own entry expires. ## Granted unless withdrawn These name capabilities operators already hold, so absence has to mean "never configured", not a value. That is easy to get wrong in two places, and the obvious implementation gets both wrong: -- The read coalesces to **true** (`operator_rights`), including for a workspace with no - `workspace_settings` row, which is what `OperatorManageRights::default` is for. Coalescing to - false instead revokes the right on upgrade for every workspace that ever saved operator settings, - since those rows carry explicit keys and none of them is this one. +- An unset key falls through to **true** (`RoleCapabilities::OPERATOR`), including for a + workspace with no override. A default of false instead revokes the right on upgrade for every + workspace that ever saved operator settings, since those rows carry explicit keys and none of + them is this one. - The update endpoint **merges** into the stored jsonb and takes these two as `Option<bool>`, so an omitted key keeps its stored value. It has to: `operator_settings` is git-synced as a whole object (`cli/src/core/settings.ts` posts the file's contents verbatim), so a settings file diff --git a/docs/workspace-roles.md b/docs/workspace-roles.md new file mode 100644 index 0000000000..953b4c023c --- /dev/null +++ b/docs/workspace-roles.md @@ -0,0 +1,66 @@ +# Workspace roles + +What a non-admin member may author, manage and see is its **role**. `developer` and `operator` are +the default roles; a licensed EE instance defines its own list in the `workspace_roles` global +setting (at least one role), and any workspace may override a role's capabilities in +`workspace_settings.role_overrides`, or, when licensed, add roles of its own there. A lapsed +license freezes them rather than dropping them (`custom_roles_enabled`): the stored list and the +workspace-only roles keep applying, writes that change them are refused, and resetting the list to +the defaults or removing a workspace-only role stays open. Falling back to the defaults instead +would hand a restricted `developer` everything. Workspace admin is not a +role: it is the `is_admin` flag on top of whichever role a member holds, and holds everything. The +code is `windmill_common::roles`. + +## Capabilities + +| Capability | Values | Enforced | +|---|---|---| +| `code` | bool | yes: scripts, previews, dependency jobs, dynamic selects, resolving jobs, data table migrations, eval datasets | +| `flows` | `none` / `compose` / `full` | yes | +| `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 | + +`compose` is the former operator builder right: the composition-only checks of +`docs/operator-builder-rights.md` run for it and are skipped only at `full`. `full` flows or apps +without `code` is refused at write, since both carry inline code. + +Stored values are **patches**: an unset key falls through to the layer below, so a key added later +reads as "never configured". The layers, top first: the workspace override, the instance +definition, then `RoleCapabilities::base_for` (`FULL` for `developer`, the legacy operator for any +other name). A role name no longer defined resolves to `NONE`, so deleting a role fails closed; +deleting one that a member, invite, auto-invite or instance group mapping still names is refused, +and so is deleting an instance role that a workspace still overrides (the override would fall onto +`base_for` instead of the definition it was written against). + +## Who is restricted + +`ApiAuthed::is_operator` means "this member's role is restricted", computed at authentication from +the role, never from `usr.operator` alone. Every gate fast-paths on it (`caller_capabilities` +returns `FULL` for an unrestricted or admin caller without reading the role), so it must stay +derived from the role: a stale flag lets a restricted member through. The authentication cache +holds it for up to 120s. + +`usr.operator` is kept as "this role cannot write code", which is what clients that predate roles +read it as. A member's role is `usr.workspace_role`, or, while that is NULL, `operator` / +`developer` from `usr.operator`: rows written by paths that know nothing of roles stay meaningful. +The old `usr.role` column is unrelated and holds legacy display strings. + +## Seats + +A role that writes or composes anything costs a full seat; anything else half. Billing SQL cannot +merge capability patches, so `role_seat` materializes the author flag per role +(`workspace_id = ''` for the instance, a row per workspace override), rewritten by +`sync_role_seats` after every write to either setting. Every count reads it through +`AUTHOR_SEAT_SQL`, and usage telemetry reports builders (authors anywhere) against non-builders the +same way. A change that adds seats under an offline license is priced inside the transaction that +writes it, by `check_seat_cap_for_role_change`. + +## Legacy `operator_settings` + +`/operator_settings` still works: it is applied as an override of the `operator` role, dropping any +key equal to what the instance gives that role, and the payload is mirrored into the +`operator_settings` column for git sync and older servers. The migration moved every key a +workspace had changed from its default into `role_overrides.operator` the same way. The workspace +list still returns an `operator_settings` object for a restricted member, synthesized from its +role, alongside `role` and `capabilities`. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6b0974c793..e1fa01dbaa 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.833.0", + "version": "1.847.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.833.0", + "version": "1.847.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.2", + "windmill-utils-internal": "1.8.3", "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.2", - "resolved": "https://registry.npmjs.org/windmill-utils-internal/-/windmill-utils-internal-1.8.2.tgz", - "integrity": "sha512-Otdn4iCE0QiOtMKPdHX5I89oECfZa3vmO2jdLdSETf7MBSv/gw9gvQm3oUQ29ymIHXLeuhQk/ebPD+WYolQJEA==", + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/windmill-utils-internal/-/windmill-utils-internal-1.8.3.tgz", + "integrity": "sha512-Lp8fVM/uHUvO6q01rIODIR46hTQ9D9R9BEiKoZHQqw9NJoy1OxhcsvX4usBbkXff/GbHlkWiQfZXCOn0ozJeKQ==", "license": "Apache 2.0" }, "node_modules/word-wrap": { diff --git a/frontend/package.json b/frontend/package.json index 42b99101dd..d417cee0a4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.833.0", + "version": "1.847.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", @@ -16,6 +16,7 @@ "package": "svelte-package -o package && node scripts/package-system-prompts.js && node scripts/fix-svelte-module-imports.js", "generate-backend-client": "openapi-ts --input ../backend/windmill-api/openapi.yaml --output ./src/lib/gen --useOptions --enums javascript --format false", "generate-backend-client-mac": "openapi-ts --input ../backend/windmill-api/openapi.yaml --output ./src/lib/gen --useOptions --enums javascript", + "gen:mcp-session": "WM_GEN_MCP_SESSION=1 vitest run --project server src/lib/components/copilot/chat/global/mcpSessionGen.test.ts", "pretest": "tsc --incremental -p tests/tsconfig.json", "filter-classes": "node filterTailwindClasses.js", "test:unit": "vitest", @@ -176,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.2", + "windmill-utils-internal": "1.8.3", "xterm": "^5.3.0", "xterm-readline": "^1.1.2", "y-monaco": "^0.1.4", diff --git a/frontend/scripts/ui_builder_artifact.json b/frontend/scripts/ui_builder_artifact.json index c82e327126..82e53ad1a5 100644 --- a/frontend/scripts/ui_builder_artifact.json +++ b/frontend/scripts/ui_builder_artifact.json @@ -1,5 +1,5 @@ { "baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev", - "version": "d3fdb70", - "sha256": "8c894b3f02d5d1872a966c37d43314f05ce42900f7330bdcf45a18a772deb2b9" + "version": "6880a7f", + "sha256": "05d49a6ed4790b444058574ccb650c8d3882161bbf004c0f8c0370964e853367" } diff --git a/frontend/src/app.html b/frontend/src/app.html index 3c9e232e0f..d637491e74 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -3,6 +3,10 @@ <head> <meta charset="utf-8" /> <link rel="icon" href="/logo.svg" /> + <link rel="apple-touch-icon" href="/apple-touch-icon.png" /> + <meta name="theme-color" media="(prefers-color-scheme: light)" content="#fbfbfd" /> + <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#3b4252" /> + <meta name="mobile-web-app-capable" content="yes" /> <style type="text/css"> .st2 { fill: #bcd4fc; diff --git a/frontend/src/lib/assets/app.css b/frontend/src/lib/assets/app.css index 4217c95831..f7f9b53972 100644 --- a/frontend/src/lib/assets/app.css +++ b/frontend/src/lib/assets/app.css @@ -426,3 +426,46 @@ input[type='time']::-webkit-calendar-picker-indicator { .svelte-flow__edges { z-index: -10; } + +/* The composer's glide, defined once: the Home ↔ AI Sessions view transition below and the + in-page move on a session's first message (the spacer under AIChatDisplay's composer) both + read it. */ +:root { + --composer-glide-duration: 380ms; + --composer-glide-easing: cubic-bezier(0.2, 0.8, 0.2, 1); +} + +/* Home ↔ AI Sessions (homeSessionsTransition.svelte.ts). The composer glides from where it + was to where it lands; everything else, Home's list and the session's greeting included, is + the page cross-fading, kept short. The pseudo-elements hang off :root itself, so no space + before `::view-transition-*`: with one, the selector looks for a descendant and never + matches. */ +:root[data-home-sessions-transition]::view-transition-group(composer) { + animation-duration: var(--composer-glide-duration); + animation-timing-function: var(--composer-glide-easing); +} +:root[data-home-sessions-transition]::view-transition-old(root), +:root[data-home-sessions-transition]::view-transition-new(root) { + animation-duration: 150ms; +} +/* The session's greeting sits above where the composer lands, so it waits for the composer: + shown while the composer is still gliding, it reads as text placed in mid-air. It starts as + the composer settles and rises into place on the same easing, so the two read as one motion. + Leaving, it goes with the page. */ +:root[data-home-sessions-transition='enter']::view-transition-new(greeting) { + animation: greeting-in 260ms var(--composer-glide-easing) + calc(var(--composer-glide-duration) * 0.6) both; +} +:root[data-home-sessions-transition]::view-transition-old(greeting) { + animation-duration: 150ms; +} +@keyframes greeting-in { + from { + opacity: 0; + transform: translateY(4px); + filter: blur(2px); + } +} +:root[data-home-sessions-transition='fade'] * { + view-transition-name: none !important; +} diff --git a/frontend/src/lib/components/AddUser.svelte b/frontend/src/lib/components/AddUser.svelte index 1369e33fb2..8cf1693bc7 100644 --- a/frontend/src/lib/components/AddUser.svelte +++ b/frontend/src/lib/components/AddUser.svelte @@ -1,7 +1,7 @@ <script lang="ts"> import { createEventDispatcher } from 'svelte' import { globalEmailInvite, superadmin, workspaceStore, enterpriseLicense } from '$lib/stores' - import { SettingService, UserService, WorkspaceService } from '$lib/gen' + import { SettingService, UserService, WorkspaceService, type EffectiveRole } from '$lib/gen' import { Button } from './common' import Popover from './meltComponents/Popover.svelte' import { sendUserToast } from '$lib/toast' @@ -14,6 +14,15 @@ import { UserPlus } from 'lucide-svelte' import Select from './select/Select.svelte' import { resource } from 'runed' + import RolePicker from './settings/RolePicker.svelte' + import { roleRequestFields, validRoleOr } from '$lib/workspaceRoles' + + interface Props { + /** The workspace's roles; undefined while loading. */ + roles: EffectiveRole[] | undefined + } + + let { roles }: Props = $props() const dispatch = createEventDispatcher() @@ -84,7 +93,7 @@ getAutomateUsernameCreationSetting() async function addUser() { - if (selected === 'service_account') { + if (isServiceAccount) { if (!username) return await WorkspaceService.createServiceAccount({ workspace: $workspaceStore!, @@ -99,15 +108,19 @@ } else { // Read once: the picker's own state changes as soon as the request lands. const email = effectiveEmail! - await WorkspaceService.addUser({ - workspace: $workspaceStore!, - requestBody: { - email, - username: automateUsernameCreation ? undefined : username, - is_admin: selected == 'admin', - operator: selected == 'operator' - } - }) + try { + await WorkspaceService.addUser({ + workspace: $workspaceStore!, + requestBody: { + email, + username: automateUsernameCreation ? undefined : username, + ...roleRequestFields(selected) + } + }) + } catch (e) { + sendUserToast(`Failed to add ${email}: ${e?.body ?? e}`, true) + return + } sendUserToast(`Added ${email}`) // The picker excludes members server-side, so it has to refetch to drop this one. addedCount++ @@ -140,12 +153,13 @@ dispatch('new') } - type UserRole = 'operator' | 'developer' | 'admin' | 'service_account' type ServiceAccountRole = 'operator' | 'developer' | 'admin' - let selected: UserRole = $state('developer' as UserRole) + // A role name, or `admin`. + let picked: string | undefined = $state(undefined) + const selected = $derived(validRoleOr(roles, picked)) let serviceAccountRole: ServiceAccountRole = $state('operator' as ServiceAccountRole) let addToDeployers: boolean = $state(true) - let isServiceAccount = $derived(selected === 'service_account') + let isServiceAccount = $state(false) </script> <Popover placement="bottom-end" bind:isOpen={popoverOpen} onClose={() => (typedEmail = '')}> @@ -157,6 +171,18 @@ {#snippet content()} <div class="flex flex-col w-[28rem] p-4"> <span class="text-sm mb-2 leading-6 font-semibold">Add a new user</span> + <div class="mb-2"> + <Toggle + bind:checked={isServiceAccount} + disabled={!$enterpriseLicense} + size="xs" + options={{ + right: $enterpriseLicense ? 'Service account' : 'Service account (EE)', + rightTooltip: + 'A service account is a workspace-scoped identity for automation. It cannot log in directly and can be impersonated by admins.' + }} + /> + </div> {#if isServiceAccount} <span class="text-xs mb-1 leading-6">Username</span> @@ -217,38 +243,12 @@ {/if} {/if} - <span class="text-xs mb-1 pt-6 leading-6">Role</span> - <ToggleButtonGroup bind:selected class="mb-4"> - {#snippet children({ item })} - <ToggleButton - value="operator" - label="Operator" - tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on." - {item} - /> - <ToggleButton - value="developer" - label="Developer" - tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)." - {item} - /> - <ToggleButton - value="admin" - label="Admin" - tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace." - {item} - /> - <ToggleButton - value="service_account" - label={$enterpriseLicense ? 'Service Account' : 'Service Account (EE)'} - tooltip="A service account is a workspace-scoped identity for automation. It cannot log in directly and can be impersonated by admins." - disabled={!$enterpriseLicense} - {item} - /> - {/snippet} - </ToggleButtonGroup> - - {#if isServiceAccount} + {#if !isServiceAccount} + <span class="text-xs mb-1 pt-6 leading-6">Role</span> + <div class="mb-4"> + <RolePicker {roles} disablePortal value={selected} onchange={(v) => (picked = v)} /> + </div> + {:else} <span class="text-xs mb-1 leading-6">Service account role</span> <ToggleButtonGroup bind:selected={serviceAccountRole} class="mb-4"> {#snippet children({ item })} diff --git a/frontend/src/lib/components/AppConnectDrawer.svelte b/frontend/src/lib/components/AppConnectDrawer.svelte index 8de484929e..6cdfdc2998 100644 --- a/frontend/src/lib/components/AppConnectDrawer.svelte +++ b/frontend/src/lib/components/AppConnectDrawer.svelte @@ -9,17 +9,20 @@ import GoogleSigninButton from './GoogleSigninButton.svelte' import IconedResourceType from './IconedResourceType.svelte' import { addResourceTitle } from './resourceTypeDisplay' + import type { NativeConnect } from './triggers/native/utils' interface Props { expressOAuthSetup?: boolean workspace?: string disableChatOffset?: boolean + native?: NativeConnect } let { expressOAuthSetup = false, workspace = undefined, - disableChatOffset = false + disableChatOffset = false, + native = undefined }: Props = $props() /** Set by `open(rt, fillPath)`, not by the parent: which resource this run fills is a @@ -82,7 +85,7 @@ {disableChatOffset} > <DrawerContent - title={addResourceTitle(step > 1 ? resourceType : undefined)} + title={native?.title ?? addResourceTitle(step > 1 ? resourceType : undefined)} id="add-resource-drawer" on:close={drawer?.closeDrawer} tooltip="Resources represent connections to third party systems. Learn more on how to integrate external APIs." @@ -105,6 +108,7 @@ express={expressOAuthSetup} {workspace} {fillPath} + {native} /> {#snippet actions()} <div class="flex gap-1"> @@ -124,7 +128,7 @@ {disabled} onClick={() => appConnectInner?.next()} > - {#if step == 2 && !manual} + {#if (step == 2 && !manual) || native} Connect {:else if step == 1} Next diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index 4dc05972ca..d29d97a1dc 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -53,6 +53,7 @@ import ResourcePathHint from './ResourcePathHint.svelte' import SchemaForm from './SchemaForm.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import type { NativeConnect } from './triggers/native/utils' const operatingWorkspace = useOperatingWorkspace() @@ -73,6 +74,11 @@ * Opt-in: without it this flow still refuses to write over anything. */ fillPath?: string + /** + * Connect a native trigger account: only the path is asked, and `connect` runs the + * popup through the trigger service's OAuth app, whose callback saves the connection. + */ + native?: NativeConnect } let { @@ -83,7 +89,8 @@ manual = $bindable(true), express = false, workspace = undefined, - fillPath = undefined + fillPath = undefined, + native = undefined }: Props = $props() let effectiveWorkspace = $derived(workspace ?? $operatingWorkspace!) @@ -419,6 +426,13 @@ let pathError = $state('') export async function open(rt?: string) { + if (native) { + resourceType = rt ?? '' + manual = false + path = '' + step = 4 + return + } if (!rt) { loadResourceTypes() loadPopularity() @@ -507,7 +521,7 @@ // authorize endpoint counts; `google` itself is a login provider, not a registry entry. run(() => { isGoogleSignin = - step == 2 && + (step == 2 || (!!native && step == 4)) && !manual && (resourceType == 'google' || (registryEntryFor(resourceType)?.auth_url?.startsWith('https://accounts.google.com/') ?? @@ -532,7 +546,7 @@ clientSecret.trim() == '' || (!!ccInstanceMeta && ccInstance.trim() == '')))) || step == 3 || - (step == 4 && pathError != '') || + (step == 4 && (pathError != '' || (!!native && !path))) || !isValid }) @@ -729,6 +743,16 @@ } } export async function next() { + if (native) { + step = 3 + try { + await native.connect(path) + dispatch('close') + } catch { + step = 4 + } + return + } if (step == 1) { linkedSecrets = [] // Both branches: the OAuth one fills `resourceFields` into the same map, and fields @@ -939,9 +963,9 @@ args['realmId'] = responseExtra['realmId'] } - let account: number | undefined = undefined + let accountData: any = undefined if (valueToken?.expires_in != undefined) { - const accountData: any = { + accountData = { refresh_token: valueToken.refresh_token ?? '', expires_in: valueToken.expires_in, client: connectClient, @@ -970,13 +994,6 @@ accountData.cc_token_url = tokenUrl.trim() } } - - account = Number( - await OauthService.createAccount({ - workspace: effectiveWorkspace, - requestBody: accountData - }) - ) } // A copy: the form is still mounted and bound to `args` across the awaits below, and @@ -995,21 +1012,27 @@ // OAuth flow: single secret variable for the token if (typeof value == 'string' && value != '' && !value.startsWith('$var:')) { savedVariableCount++ - await VariableService.createVariable({ - workspace: effectiveWorkspace, - requestBody: { - path, - value: value, - is_secret: true, - description: emptyString(description) - ? `OAuth token for ${resourceType}` - : description, - is_oauth: true, - account: account, - ws_specific: wsSpecific - } - }) - resourceValue['token'] = `$var:${path}` + const tokenVariable = { + path, + value: value, + is_secret: true, + description: emptyString(description) ? `OAuth token for ${resourceType}` : description, + is_oauth: true, + ws_specific: wsSpecific + } + if (accountData) { + // Creates the variable with the account it refreshes through, in one transaction. + await OauthService.createAccount({ + workspace: effectiveWorkspace, + requestBody: { ...accountData, variable: tokenVariable } + }) + } else { + await VariableService.createVariable({ + workspace: effectiveWorkspace, + requestBody: tokenVariable + }) + } + resourceValue['token'] = `$var:${tokenVariable.path}` } } else if (linkedSecrets.length === 1) { // Single secret: use the resource path as variable name (original behavior) @@ -1654,47 +1677,56 @@ <Label label="Path"> <Path initialPath="" - namePlaceholder={defaultName} + namePlaceholder={native?.defaultName ?? defaultName} + fullNamePlaceholder={native?.defaultName} bind:error={pathError} bind:path kind="resource" /> </Label> - <LabelsInput bind:labels class="-mt-5" /> - {#if deployTo} - <Label - label="Workspace specific" - tooltip="Prevents this resource from being deployed to prod/staging" - > - <Toggle bind:checked={wsSpecific} /> - </Label> - {/if} - <!-- Not for express or `fillPath`, which save as soon as the token arrives: the fields + {#if native} + <p class="text-xs text-secondary"> + The account is saved as a resource and its token as a variable at this path. Anyone who can + read them can create triggers that act as this account. + </p> + {:else} + <LabelsInput bind:labels class="-mt-5" /> + {#if deployTo} + <Label + label="Workspace specific" + tooltip="Prevents this resource from being deployed to prod/staging" + > + <Toggle bind:checked={wsSpecific} /> + </Label> + {/if} + <!-- Not for express or `fillPath`, which save as soon as the token arrives: the fields are then filled by editing the resource. --> - {#if step == 4 && !manual && !express && !fillPath && resourceFields.length > 0} - <Label - label="Connection details" - tooltip="Saved on the resource with the token, and editable later" - class="mt-6" - > - <SchemaForm onlyMaskPassword noDelete schema={resourceFieldsSchema} bind:args /> - </Label> - {/if} - {#if apiTokenApps[resourceType] || !manual} - <ul class="mt-6"> - <li class="text-xs text-primary font-normal"> - 1. A secret variable containing the {apiTokenApps[resourceType]?.linkedSecret ?? 'token'} - <span class="font-semibold text-emphasis">{truncateRev(value, 5, '*****')}</span> - will be stored a - <span class="font-mono whitespace-nowrap text-emphasis">{path}</span>. - </li> - <li class="mt-2 text-xs text-primary font-normal"> - 2. The resource containing that token will be stored at the same path <span - class="font-mono whitespace-nowrap text-emphasis">{path}</span - >. The Variable and Resource will be "linked together", they will be deleted and renamed - together. - </li></ul - > + {#if step == 4 && !manual && !express && !fillPath && resourceFields.length > 0} + <Label + label="Connection details" + tooltip="Saved on the resource with the token, and editable later" + class="mt-6" + > + <SchemaForm onlyMaskPassword noDelete schema={resourceFieldsSchema} bind:args /> + </Label> + {/if} + {#if apiTokenApps[resourceType] || !manual} + <ul class="mt-6"> + <li class="text-xs text-primary font-normal"> + 1. A secret variable containing the {apiTokenApps[resourceType]?.linkedSecret ?? + 'token'} + <span class="font-semibold text-emphasis">{truncateRev(value, 5, '*****')}</span> + will be stored a + <span class="font-mono whitespace-nowrap text-emphasis">{path}</span>. + </li> + <li class="mt-2 text-xs text-primary font-normal"> + 2. The resource containing that token will be stored at the same path <span + class="font-mono whitespace-nowrap text-emphasis">{path}</span + >. The Variable and Resource will be "linked together", they will be deleted and renamed + together. + </li></ul + > + {/if} {/if} {/if} {/if} diff --git a/frontend/src/lib/components/AuthSettings.svelte b/frontend/src/lib/components/AuthSettings.svelte index d1dd5fa08b..cdf847e8fb 100644 --- a/frontend/src/lib/components/AuthSettings.svelte +++ b/frontend/src/lib/components/AuthSettings.svelte @@ -90,7 +90,6 @@ 'visma', 'sage_intacct', 'spotify', - 'teams', 'zoho', 'xero', 'apify', @@ -98,7 +97,8 @@ 'salesforce', 'outreach', 'notion', - 'hubspot' + 'hubspot', + 'asana' ] // Providers whose registry entry (`backend/oauth_connect.json`) carries a // `sandbox` URL block. Each one gets a sibling `<name>_sandbox` dropdown @@ -120,6 +120,8 @@ const connectConfigTemplates: Record<string, any> = Object.fromEntries( Object.entries(oauthConnectRegistry) .filter(([, cfg]) => cfg && typeof cfg === 'object' && 'connect_config_template' in cfg) + // ms_teams has the pinned Teams tile instead: its tenant field fills the template. + .filter(([name]) => name !== 'ms_teams') .map(([name, cfg]) => [name, (cfg as any).connect_config_template]) ) const windmillBuiltinsTemplated = Object.keys(connectConfigTemplates) @@ -490,12 +492,13 @@ <div class="h-1"></div> <OAuthSetting login={false} name="slack" bind:value={oauths['slack']} /> <div class="h-6"></div> - <OAuthSetting login={false} name="teams" eeOnly={true} bind:value={oauths['teams']} /> + <OAuthSetting login={false} name="ms_teams" bind:value={oauths['ms_teams']} /> <div class="h-6"></div> {#each Object.keys(oauths) as k} {#if oauths[k] && !(oauths[k] && 'login_config' in oauths[k])} - {#if !['slack', 'teams'].includes(k) && oauths[k]} + <!-- 'teams' is the pre-rename key of ms_teams, kept for rolling upgrades --> + {#if !['slack', 'ms_teams', 'teams'].includes(k) && oauths[k]} {@const IconComponent = getOAuthProviderIcon(k) as any} {@const headerLabel = k.endsWith('_sandbox') ? `${k.slice(0, -'_sandbox'.length)} (sandbox)` diff --git a/frontend/src/lib/components/AutosaveIndicator.svelte b/frontend/src/lib/components/AutosaveIndicator.svelte index 1e318ed9b2..c6aa20ab48 100644 --- a/frontend/src/lib/components/AutosaveIndicator.svelte +++ b/frontend/src/lib/components/AutosaveIndicator.svelte @@ -25,7 +25,8 @@ // Count of OTHER users with a draft here. >0 makes the on-mount hint // take priority over "Loaded from draft" and offers "See others' drafts". othersDraftsCount = 0, - onOpenOthersDrafts + onOpenOthersDrafts, + compact = false }: { workspace: string itemKind: UserDraftItemKind @@ -35,6 +36,8 @@ loadedFromDraft?: boolean othersDraftsCount?: number onOpenOthersDrafts?: () => void + /** The icon alone, for a bar with no room for the label, which stays for screen readers. */ + compact?: boolean } = $props() // Reactive to the in-memory draft appearing/disappearing. @@ -365,7 +368,7 @@ </Popover> {#if label} <span - class="relative text-2xs pr-1.5 {labelIsError + class="{compact ? 'sr-only' : 'relative'} text-2xs pr-1.5 {labelIsError ? 'text-red-500 font-semibold' : 'text-secondary'}">{label}</span > diff --git a/frontend/src/lib/components/AutoscalingConfigEditor.svelte b/frontend/src/lib/components/AutoscalingConfigEditor.svelte index 3ebbb5d41c..885b6dd55c 100644 --- a/frontend/src/lib/components/AutoscalingConfigEditor.svelte +++ b/frontend/src/lib/components/AutoscalingConfigEditor.svelte @@ -15,15 +15,27 @@ import Select from './select/Select.svelte' import ScriptPicker from './ScriptPicker.svelte' import { sendUserToast } from '$lib/toast' + import { untrack } from 'svelte' interface Props { config: AutoscalingConfig | undefined worker_tags: string[] | undefined + /** Whether the draft config gives the group any tags (worker_tags or dedicated workers). */ + group_has_tags: boolean + /** Tags reported by the group's live workers, which for agent workers come from their token. */ + reported_tags: string[] disabled: boolean eeOnly?: boolean } - let { config = $bindable(), worker_tags, disabled, eeOnly = false }: Props = $props() + let { + config = $bindable(), + worker_tags, + group_has_tags, + reported_tags, + disabled, + eeOnly = false + }: Props = $props() let test_input: number = $state(3) let healthCheckLoading: boolean = $state(false) let healthCheckResult: { success: boolean; error?: string } | null = $state(null) @@ -54,7 +66,9 @@ } } - let collapsed: boolean = $state(true) + let noTagsToCount = $derived(!!config?.enabled && !group_has_tags && !config.custom_tags?.length) + + let collapsed: boolean = $state(untrack(() => !noTagsToCount)) </script> <Section @@ -99,6 +113,29 @@ </div> {/snippet} + {#if noTagsToCount} + <Alert + type="warning" + size="xs" + title="Queued jobs won't trigger autoscaling" + actions={reported_tags.length && !disabled + ? [ + { + label: `Use the tags these workers report: ${reported_tags.join(', ')}`, + onClick: () => { + if (config) config.custom_tags = [...reported_tags] + } + } + ] + : []} + > + Autoscaling counts waiting jobs on this group's worker tags, or on the custom tags under + Advanced, and this group has neither. Agent workers don't need either to run, since their tags + come from their token, but to scale on their queue set worker tags or custom tags to the tags + their token was issued with. + </Alert> + {/if} + <div class="flex flex-row gap-4"> <Label label="Min # of workers" disabled={config === undefined} class="grow min-w-0"> <span class="text-xs text-secondary">The minimum number of workers to scale down to</span> diff --git a/frontend/src/lib/components/BreadcrumbNavPicker.svelte b/frontend/src/lib/components/BreadcrumbNavPicker.svelte new file mode 100644 index 0000000000..1f053c0d92 --- /dev/null +++ b/frontend/src/lib/components/BreadcrumbNavPicker.svelte @@ -0,0 +1,122 @@ +<!-- +@component +The chevron after the breadcrumb's last segment: it opens the same picker a session's preview tab +uses — the workspace pages, then the folders with their flows, scripts and apps — and picking a +row navigates the app there. + +It lands among the current page's siblings: an item opens in its folder with the item +highlighted, a workspace page inside Pages with that page highlighted. +--> +<script lang="ts"> + import { ChevronDown } from 'lucide-svelte' + import { goto } from '$app/navigation' + import { page } from '$app/state' + import Popover from '$lib/components/meltComponents/Popover.svelte' + import PreviewRouterPicker, { type Scope } from './sessions/PreviewRouterPicker.svelte' + import { pageKey, parsePreviewItemRoute, type PreviewTarget } from './sessions/previewRouter' + import { editPathFor, leafKeyFor, viewPathFor, type WorkspaceItem } from './workspacePicker' + import { logFeatureUsage } from '$lib/utils/featureUsage' + import type { PageHeaderItem } from './pageHeaderRegistry.svelte' + + let { + item, + pagePath, + actingWorkspaceId, + iconSize = 14 + }: { + /** The item the page is about, to land in its folder. */ + item?: PageHeaderItem + /** The route of a workspace page, to highlight it among the pages. */ + pagePath?: string + /** Load from, and navigate within, this workspace rather than the one the app points at. */ + actingWorkspaceId?: string + iconSize?: number + } = $props() + + let isOpen = $state(false) + + // Two paths for one item, which differ on a draft: the header names it by the path it will + // deploy to, which is the folder the tree files it under, while its row is keyed by where it + // is stored, which is the path in an editor's URL. A viewer's URL is no help: a script page + // can be addressed by a version hash, so there the header's path is the row's. + const route = $derived(item?.path ? parsePreviewItemRoute(page.url.pathname) : null) + const currentItem = $derived.by<WorkspaceItem | undefined>(() => { + const kind = route?.kind ?? item?.kind + const path = route?.mode === 'edit' ? route.itemPath : item?.path + if (!kind || !path) return undefined + return { path, kind, raw_app: route?.raw_app, summary: item?.summary ?? '' } + }) + + // The item's own folder is its path minus the name. + const initialScope = $derived.by<Scope>(() => { + const parts = item?.path?.split('/') ?? [] + return parts.length >= 3 ? { kind: 'all', dir: parts.slice(0, -1).join('/') } : undefined + }) + const initialHighlight = $derived( + currentItem + ? leafKeyFor(currentItem.kind, currentItem.path) + : pagePath + ? pageKey(pagePath) + : undefined + ) + + // A page acting on another workspace than the app's (a session in a fork) keeps acting on it: + // the route reads `?workspace=` before the store. + function withWorkspace(href: string): string { + if (!actingWorkspaceId) return href + const sep = href.includes('?') ? '&' : '?' + return `${href}${sep}workspace=${encodeURIComponent(actingWorkspaceId)}` + } + + function hrefFor(target: PreviewTarget): string | undefined { + if (target.type === 'page') return target.href + if (target.type === 'item') + return target.mode === 'view' ? viewPathFor(target.item) : editPathFor(target.item) + return undefined + } + + /** The whole vocabulary of the `header_nav` / `pick` counter: what kind of destination was + * picked, never which one. */ + type PickKey = 'page' | 'item_view' | 'item_edit' + + function pickKey(target: PreviewTarget): PickKey | undefined { + if (target.type === 'page') return 'page' + if (target.type === 'item') return target.mode === 'view' ? 'item_view' : 'item_edit' + return undefined + } + + function pick(target: PreviewTarget) { + isOpen = false + const href = hrefFor(target) + if (!href) return + const key = pickKey(target) + if (key) logFeatureUsage('header_nav', 'pick', { key, workspace: actingWorkspaceId }) + goto(withWorkspace(href)) + } +</script> + +<Popover + placement="bottom-start" + usePointerDownOutside + excludeSelectors=".drawer" + disableFocusTrap + closeOnOtherPopoverOpen + bind:isOpen + openFocus="[data-workspace-picker-search]" + contentClasses="flex flex-col overflow-hidden" + class="shrink-0 flex items-center h-9 px-1.5 rounded text-tertiary hover:bg-surface-hover hover:text-primary transition-colors" + triggerAttrs={{ 'aria-label': 'Navigate to', title: 'Navigate to' }} +> + {#snippet trigger()} + <ChevronDown size={iconSize} class="flex-shrink-0" /> + {/snippet} + {#snippet content()} + <PreviewRouterPicker + {initialScope} + {initialHighlight} + {currentItem} + workspaceId={actingWorkspaceId} + onPick={pick} + /> + {/snippet} +</Popover> diff --git a/frontend/src/lib/components/CompareDrafts.svelte b/frontend/src/lib/components/CompareDrafts.svelte index a05511ea42..976f82c382 100644 --- a/frontend/src/lib/components/CompareDrafts.svelte +++ b/frontend/src/lib/components/CompareDrafts.svelte @@ -34,6 +34,7 @@ } from '$lib/workspaceDrafts.svelte' import type { Kind as LayoutKind } from '$lib/utils_deployable' import { userStore, userWorkspaces } from '$lib/stores' + import { capabilitiesIn } from '$lib/roleCapabilities' import { childWorkspaceNoun } from '$lib/utils/devWorkspaceLabel' interface Props { @@ -329,7 +330,12 @@ workspaceDeployPerm, visibleItems .filter((i) => selectedItems.includes(i.key) && isDeployable(i)) - .map((i) => i.draftKind) + .map((i) => i.draftKind), + capabilitiesIn( + // Superadmin is global; a workspace's admin flag is not. + { is_super_admin: $userStore?.is_super_admin }, + $userWorkspaces.find((w) => w.id === currentWorkspaceId) + ) ) ) // Select all on the first non-empty load (acting on everything is the common diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte index 18c765e13b..1c718287fc 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -37,6 +37,7 @@ import WorkspaceDeployItemSummary from './WorkspaceDeployItemSummary.svelte' import ParentWorkspaceProtectionAlert from './ParentWorkspaceProtectionAlert.svelte' import { userWorkspaces, workspaceStore } from '$lib/stores' + import { capabilitiesIn } from '$lib/roleCapabilities' import type { Kind } from '$lib/utils_deployable' import { @@ -612,12 +613,13 @@ } async function fetchOnBehalfOfInfo(diffs: WorkspaceItemDiff[]) { - // Runnables carry an email; triggers/schedules carry `permissioned_as`. - // `getOnBehalfOf` handles the dispatch — we just need to skip kinds that - // have no on_behalf_of concept (resource, variable, folder, resource_type). + // Runnables carry an email; triggers/schedules and agents carry `permissioned_as`. + // `getOnBehalfOf` handles the dispatch (a resource that is not an agent has none) — we + // just need to skip kinds that have no on_behalf_of concept (variable, folder, resource_type). const itemsWithOnBehalfOf = diffs.filter( (d) => - ['flow', 'script', 'app', 'raw_app'].includes(d.kind) || isTriggerOrScheduleKind(d.kind) + ['flow', 'script', 'app', 'raw_app', 'resource'].includes(d.kind) || + isTriggerOrScheduleKind(d.kind) ) const pair = [ { workspace: currentWorkspaceId, side: 'fork' as const }, @@ -679,17 +681,22 @@ }) ) + /** Triggers, schedules and agents record who they run as by principal, the rest by address. */ + function usesPrincipal(kind: Kind): boolean { + return kind === 'trigger' || isTriggerOrScheduleKind(kind) || kind === 'resource' + } + /** * Get the on_behalf_of value for deployment based on user's choice. - * Returns an email for flows/scripts/apps, or permissioned_as (u/username, g/group) for triggers/schedules. + * Returns an email for flows/scripts/apps, or permissioned_as (u/username, g/group) for + * triggers/schedules and agents. */ function getOnBehalfOfForDeploy(itemKey: string, kind: Kind): string | undefined { const choice = onBehalfOfChoice[itemKey] if (choice === 'target') return getTargetOnBehalfOf(itemKey) if (choice === 'custom') { const details = customOnBehalfOf[itemKey] - const wantsPermissionedAs = kind === 'trigger' || isTriggerOrScheduleKind(kind) - return wantsPermissionedAs ? details?.permissionedAs : details?.email + return usesPrincipal(kind) ? details?.permissionedAs : details?.email } // 'me' or undefined = don't pass, backend will use deploying user's identity return undefined @@ -701,7 +708,7 @@ * returning undefined clears the source item's value rather than keeping it. */ function getOnBehalfOfPermissionedAsForDeploy(itemKey: string, kind: Kind): string | undefined { - if (kind === 'trigger' || isTriggerOrScheduleKind(kind)) return undefined + if (usesPrincipal(kind)) return undefined if (onBehalfOfChoice[itemKey] !== 'custom') return undefined return customOnBehalfOf[itemKey]?.permissionedAs } @@ -807,7 +814,19 @@ } let deploymentErrorMessage = $state('') - let allowBehindChangesOverride = $state(false) + // The override acknowledges the outdated items listed next to it, not the + // selection, so it holds while every outdated item is one the user ticked it + // for: a refresh bringing in a new one asks again, one leaving the list does not. + let acknowledgedBehindKeys = $state<Set<string> | undefined>() + let allowBehindChangesOverride = $derived( + acknowledgedBehindKeys != undefined && + itemsWithBehindChanges.every((d) => acknowledgedBehindKeys!.has(getItemKey(d))) + ) + function setBehindChangesOverride(allow: boolean) { + acknowledgedBehindKeys = allow + ? new Set(itemsWithBehindChanges.map((d) => getItemKey(d))) + : undefined + } async function isComparisonUpToDate(): Promise<boolean> { if (!comparison) { @@ -1072,7 +1091,12 @@ workspaceDeployPerm, (comparison?.diffs ?? []) .filter((d) => selectedItems.includes(getItemKey(d))) - .map((d) => d.kind) + .map((d) => d.kind), + capabilitiesIn( + // Superadmin is global; a workspace's admin flag is not. + { is_super_admin: $userStore?.is_super_admin }, + $userWorkspaces.find((w) => w.id == deployTargetWorkspace) + ) ) ) @@ -1116,18 +1140,17 @@ const current = new Set(diffs.filter(removesInTarget).map((d) => getItemKey(d))) const flipped = [...current].filter((k) => !removalKeys.has(k)) removalKeys = current - if (flipped.some((k) => selectedItems.includes(k))) { - selectedItems = selectedItems.filter((k) => !flipped.includes(k)) + // A row that left the list (made workspace specific, synced elsewhere) takes its + // tick with it: the deploy button counts the selection, and a key with no row + // would be reported as undeployable. + const listed = new Set(selectableDiffs.map((d) => getItemKey(d))) + const kept = selectedItems.filter((k) => listed.has(k) && !flipped.includes(k)) + if (kept.length !== selectedItems.length) { + selectedItems = kept } }) }) - // Reset override when selection or direction changes - $effect(() => { - ;[selectedItems, mergeIntoParent] - allowBehindChangesOverride = false - }) - // Trigger and schedule rows now flow through `comparison.diffs` like every // other deployable kind — the backend's `compareWorkspaces` populates them // from `workspace_diff`, with runtime fields ignored by `compare_two_*`. @@ -1576,7 +1599,8 @@ <span class="font-medium flex flex-row gap-1 text-red-500"> <input type="checkbox" - bind:checked={allowBehindChangesOverride} + checked={allowBehindChangesOverride} + onchange={(e) => setBehindChangesOverride(e.currentTarget.checked)} class="rounded max-w-4" /> Override: Deploy despite {itemsWithBehindChanges.length} outdated item{itemsWithBehindChanges.length !== diff --git a/frontend/src/lib/components/CronBuilder.svelte b/frontend/src/lib/components/CronBuilder.svelte index 3bceabb417..81f1f4490f 100644 --- a/frontend/src/lib/components/CronBuilder.svelte +++ b/frontend/src/lib/components/CronBuilder.svelte @@ -15,7 +15,7 @@ contentClasses="p-4" > {#snippet trigger()} - <Button variant="default" unifiedSize="sm" nonCaptureEvent={true} startIcon={{ icon: Clock }}> + <Button variant="default" unifiedSize="md" nonCaptureEvent={true} startIcon={{ icon: Clock }}> Cron builder </Button> {/snippet} diff --git a/frontend/src/lib/components/CronInput.svelte b/frontend/src/lib/components/CronInput.svelte index d2b629d33e..ae06974c66 100644 --- a/frontend/src/lib/components/CronInput.svelte +++ b/frontend/src/lib/components/CronInput.svelte @@ -278,39 +278,46 @@ <div class="w-full flex flex-col gap-1"> <div class="text-emphasis font-semibold text-xs leading-none">Execute schedule every</div> - <div class="w-full flex gap-4"> - <div class="w-full flex flex-col gap-1 mb-2"> - <select {disabled} name="execute_every" id="execute_every" bind:value={executeEvery}> - <option value="second">Second(s)</option> - <option value="minute">Minute(s)</option> - <option value="hour">Hour(s)</option> - <option value="day-month">Day of the month</option> - <option value="month">Month(s)</option> - <option value="day-week">Day of the week</option> - </select> - </div> + <div class="w-full flex gap-4 items-start"> + <Select + class="flex-1 min-w-0" + disablePortal + {disabled} + id="execute_every" + bind:value={executeEvery} + items={[ + { value: 'second', label: 'Second(s)' }, + { value: 'minute', label: 'Minute(s)' }, + { value: 'hour', label: 'Hour(s)' }, + { value: 'day-month', label: 'Day of the month' }, + { value: 'month', label: 'Month(s)' }, + { value: 'day-week', label: 'Day of the week' } + ]} + /> - <div class="w-full flex flex-col gap-1 justify-center"> - {#if executeEvery == 'second'} - <input {disabled} type="number" min="0" max="59" bind:value={seconds} /> - <small class="text-secondary text-2xs">Valid range 0-59</small> - {:else if executeEvery == 'minute'} - <input {disabled} type="number" min="0" max="59" bind:value={minutes} /> - <small class="text-secondary text-2xs">Valid range 0-59</small> - {:else if executeEvery == 'hour'} - <input {disabled} type="number" min="0" max="23" bind:value={hours} /> - <small class="text-secondary text-2xs">Valid range 0-23</small> - {:else if executeEvery == 'day-month'} - <!-- <div class="w-full flex"> - <label for="lastDayOfMonth" class="w-full flex items-center gap-2"> - <div class="flex"> - <input type="checkbox" id="lastDayOfMonth" bind:checked={lastDayOfMonth} /> + {#if executeEvery == 'second' || executeEvery == 'minute' || executeEvery == 'hour'} + <div class="flex-1 min-w-0 flex flex-col gap-1"> + {#if executeEvery == 'second'} + <TextInput + inputProps={{ type: 'number', min: 0, max: 59, disabled }} + bind:value={seconds} + /> + <small class="text-secondary text-2xs">Valid range 0-59</small> + {:else if executeEvery == 'minute'} + <TextInput + inputProps={{ type: 'number', min: 0, max: 59, disabled }} + bind:value={minutes} + /> + <small class="text-secondary text-2xs">Valid range 0-59</small> + {:else} + <TextInput + inputProps={{ type: 'number', min: 0, max: 23, disabled }} + bind:value={hours} + /> + <small class="text-secondary text-2xs">Valid range 0-23</small> + {/if} </div> - <small> Last day of the month </small> - </label> - </div> --> - {/if} - </div> + {/if} </div> </div> @@ -319,6 +326,7 @@ <MultiSelect disablePortal {disabled} + class="w-full" bind:value={monthsOfYear} items={safeSelectItems(monthsOfYearOptions)} placeholder="Every month" @@ -331,6 +339,7 @@ <MultiSelect disablePortal {disabled} + class="w-full" bind:value={daysOfWeek} items={safeSelectItems(daysOfWeekOptions)} placeholder="Every day" @@ -343,28 +352,14 @@ {#if executeEvery == 'month'} <small class="text-secondary text-2xs font-normal">On day of the month</small> {/if} - <div class="w-full flex gap-4"> - <div class="w-full flex"> - <MultiSelect - disablePortal - {disabled} - bind:value={daysOfMonth} - items={safeSelectItems(daysOfMonthOptions)} - placeholder="Every day" - /> - </div> - - <!-- {#if executeEvery == 'month'} - <div class="w-full flex"> - <label for="lastDayOfMonth" class="w-full flex items-center gap-2"> - <div class="flex"> - <input type="checkbox" id="lastDayOfMonth" bind:checked={lastDayOfMonth} /> - </div> - <small> Last day of the month </small> - </label> - </div> - {/if} --> - </div> + <MultiSelect + class="w-full" + disablePortal + {disabled} + bind:value={daysOfMonth} + items={safeSelectItems(daysOfMonthOptions)} + placeholder="Every day" + /> <small class="text-secondary text-2xs font-normal" >Schedule will only execute on valid calendar days</small > @@ -374,7 +369,10 @@ {#if executeEvery == 'day-month' || executeEvery == 'month' || executeEvery == 'day-week'} <div class="w-full flex flex-col gap-1"> <small class="font-semibold text-emphasis text-xs">At time</small> - <input {disabled} type="time" name="atUTCTime" id="atUTCTime" bind:value={UTCTime} /> + <TextInput + inputProps={{ type: 'time', name: 'atUTCTime', id: 'atUTCTime', disabled }} + bind:value={UTCTime} + /> </div> {/if} @@ -390,7 +388,7 @@ <div class="mt-8"> <Button variant="accent" - size="xs" + unifiedSize="md" on:click={() => { schedule = nschedule close() diff --git a/frontend/src/lib/components/DatatableSchemaDiff.svelte b/frontend/src/lib/components/DatatableSchemaDiff.svelte index 1fb6918580..e3ff8633a6 100644 --- a/frontend/src/lib/components/DatatableSchemaDiff.svelte +++ b/frontend/src/lib/components/DatatableSchemaDiff.svelte @@ -202,7 +202,7 @@ // The merge migration gets the highest timestamp, so any still-pending // migration on the target is earlier: running only the merge applies it // out of order. Warn like the row-level Run action does. - const pending = pendingMigrations(status.migrations) + const pending = pendingMigrations(status.migrations, migrationSql) if (pending.length > 0) { const confirmed = await outOfOrderModal.ask({ title: 'Run migration out of order', diff --git a/frontend/src/lib/components/DateTimeInput.svelte b/frontend/src/lib/components/DateTimeInput.svelte index ac95a1a566..55e1dbc0e1 100644 --- a/frontend/src/lib/components/DateTimeInput.svelte +++ b/frontend/src/lib/components/DateTimeInput.svelte @@ -8,6 +8,7 @@ import { twMerge } from 'tailwind-merge' import { createDispatcherIfMounted } from '$lib/createDispatcherIfMounted' import TextInput from './text_input/TextInput.svelte' + import { parseDateTime } from './dateTimeInput' // import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' interface Props { @@ -45,7 +46,7 @@ // let format: 'local' | 'utc' = 'local' function parseValue(value: string | null | undefined = undefined) { - let dateFromValue: Date | undefined = value ? new Date(value) : undefined + let dateFromValue: Date | undefined = value ? parseDateTime(value, timezone) : undefined if (!isValidDate(dateFromValue)) { date = undefined time = '12:00' diff --git a/frontend/src/lib/components/DefaultTagsInner.svelte b/frontend/src/lib/components/DefaultTagsInner.svelte index 87cfa0f89b..229c0d5e62 100644 --- a/frontend/src/lib/components/DefaultTagsInner.svelte +++ b/frontend/src/lib/components/DefaultTagsInner.svelte @@ -19,6 +19,11 @@ import Section from './Section.svelte' import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' + import { + devWorkspaceTagSuffix, + loadDevWorkspaceTagSuffix, + withDevSuffix + } from '$lib/devWorkspaceTags' interface Props { defaultTagPerWorkspace?: boolean | undefined defaultTagWorkspaces?: string[] @@ -134,6 +139,7 @@ loadDefaultTags() loadWorkspaces() + loadDevWorkspaceTagSuffix() </script> <Section label="Default tags"> @@ -208,6 +214,22 @@ /> {/snippet} </ToggleButtonGroup> + {#if $devWorkspaceTagSuffix} + <span class="text-2xs text-secondary"> + Dev workspace isolation (Workers page) is on and applies after this: a fork's + <code + >{forkAppendForkSuffix ? 'python3-{parent_id}-fork' : 'python3-{parent_id}'}</code + > + runs as + <code + >{withDevSuffix( + forkAppendForkSuffix ? 'python3-{parent_id}-fork' : 'python3-{parent_id}', + $devWorkspaceTagSuffix + )}</code + >, on the worker groups serving dev workspaces. To split root and dev jobs across + worker pools, isolation alone is enough. + </span> + {/if} </div> <Toggle bind:checked={limitToWorkspaces} diff --git a/frontend/src/lib/components/DeployWorkspace.svelte b/frontend/src/lib/components/DeployWorkspace.svelte index 7913240188..4c7fae7ccd 100644 --- a/frontend/src/lib/components/DeployWorkspace.svelte +++ b/frontend/src/lib/components/DeployWorkspace.svelte @@ -110,16 +110,22 @@ return needsOnBehalfOfSelection(kind, sourceOnBehalfOfInfo[statusPath]) } + /** Triggers and agents record who they run as by principal, the rest by address. */ + function usesPrincipal(kind: Kind): boolean { + return kind === 'trigger' || kind === 'resource' + } + /** * Get the on_behalf_of value for deployment based on user's choice. - * Returns an email for flows/scripts/apps, or permissioned_as (u/username, g/group) for triggers/schedules. + * Returns an email for flows/scripts/apps, or permissioned_as (u/username, g/group) for + * triggers/schedules and agents. */ function getOnBehalfOfForDeploy(statusPath: string, kind: Kind): string | undefined { const choice = onBehalfOfChoice[statusPath] if (choice === 'target') return targetOnBehalfOfInfo[statusPath] if (choice === 'custom') { const details = customOnBehalfOf[statusPath] - return kind === 'trigger' ? details?.permissionedAs : details?.email + return usesPrincipal(kind) ? details?.permissionedAs : details?.email } // 'me' or undefined = don't pass, backend will use deploying user's identity return undefined @@ -134,7 +140,7 @@ statusPath: string, kind: Kind ): string | undefined { - if (kind === 'trigger' || onBehalfOfChoice[statusPath] !== 'custom') return undefined + if (usesPrincipal(kind) || onBehalfOfChoice[statusPath] !== 'custom') return undefined return customOnBehalfOf[statusPath]?.permissionedAs } @@ -205,7 +211,7 @@ // Fetch on_behalf_of_email for flows, scripts, apps, and triggers from both workspaces for (const dep of sortedSet.filter((d) => - ['flow', 'script', 'app', 'trigger'].includes(d.kind) + ['flow', 'script', 'app', 'trigger', 'resource'].includes(d.kind) )) { const key = computeStatusPath(dep.kind, dep.path) let source: string | undefined @@ -277,6 +283,8 @@ result.push({ kind: 'script', path: runnable.path }) } else if (runnable.runType == 'flow') { result.push({ kind: 'flow', path: runnable.path }) + } else if (runnable.runType == 'agent') { + result.push({ kind: 'resource', path: runnable.path }) } } } diff --git a/frontend/src/lib/components/DevWorkspaceIsolation.svelte b/frontend/src/lib/components/DevWorkspaceIsolation.svelte new file mode 100644 index 0000000000..61bc56fb69 --- /dev/null +++ b/frontend/src/lib/components/DevWorkspaceIsolation.svelte @@ -0,0 +1,613 @@ +<script lang="ts"> + import { Alert, Button, Section } from './common' + import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte' + import Toggle from './Toggle.svelte' + import TextInput from './text_input/TextInput.svelte' + import Select from './select/Select.svelte' + import Tooltip from './meltComponents/Tooltip.svelte' + import { ArrowRight, ChevronDown, ChevronRight, Save, Settings } from 'lucide-svelte' + import { + SettingService, + WorkerService, + WorkspaceService, + type GetQueueStatusResponse, + type Workspace + } from '$lib/gen' + import { + DEV_WORKSPACE_ISOLATION_SETTING, + DEV_WORKSPACE_ISOLATION_SKIP_NON_DEV_FORKS_SETTING, + DEV_WORKSPACE_TAG_SUFFIX_SETTING + } from '$lib/consts' + import { + DEFAULT_DEV_WORKSPACE_TAG_SUFFIX, + DEV_WORKSPACE_TAG_SUFFIX_PATTERN, + devWorkspaceIsolation, + devWorkspaceListenSuffix, + devWorkspaceSkipNonDevForks, + devWorkspaceTagSuffix, + isDevOrForkWorkspace, + isIsolatedWorkspace, + loadDevWorkspaceTagSuffix, + withDevSuffix + } from '$lib/devWorkspaceTags' + import { enterpriseLicense } from '$lib/stores' + import { sendUserToast } from '$lib/toast' + import { pluralize } from '$lib/utils' + import { onMount } from 'svelte' + + interface Props { + /** Worker group configs, by group name. */ + workerGroups: Record<string, any> | undefined + /** Workers of each group that pinged in the last 15 seconds. */ + liveWorkers: Record<string, number> + canEdit: boolean + onConfigureGroup: (name: string) => void + } + + let { workerGroups, liveWorkers, canEdit, onConfigureGroup }: Props = $props() + + let suffix = $derived($devWorkspaceTagSuffix) + let enabled = $derived(suffix != null) + let effectiveSuffix = $derived($devWorkspaceListenSuffix) + + // Overridden while a confirmation is pending, reset to the saved state otherwise. + let toggleChecked = $derived(enabled) + let suffixDraft = $derived(effectiveSuffix) + let suffixError = $derived( + DEV_WORKSPACE_TAG_SUFFIX_PATTERN.test(suffixDraft.trim()) + ? undefined + : "Use 1 to 32 letters, digits, '-', '_' or '.'" + ) + + let skipNonDevForks = $derived($devWorkspaceSkipNonDevForks) + // Overridden while saving, reset to the saved state otherwise. + let skipChecked = $derived(skipNonDevForks) + let workspaces = $state<Workspace[] | undefined>(undefined) + let devWorkspaces = $derived( + (workspaces ?? []).filter( + (w) => !w.deleted && isIsolatedWorkspace(w, workspaces ?? [], skipNonDevForks) + ) + ) + // Workspaces whose new forks skip isolation while non-dev forks are not isolated: the roots and + // their non-dev forks. A forking rule does not cascade to forks, so each needs its own. + let forkSources = $derived( + (workspaces ?? []).filter((w) => !w.deleted && !isIsolatedWorkspace(w, workspaces ?? [], true)) + ) + let nonDevForkCount = $derived( + (workspaces ?? []).filter( + (w) => + !w.deleted && isDevOrForkWorkspace(w) && !isIsolatedWorkspace(w, workspaces ?? [], true) + ).length + ) + // Roots anyone may fork: no rule withholds forking, or their rules could not be read, which + // must not hide the warning. `undefined` until checked. + let forkableRoots = $state<string[] | undefined>(undefined) + let forkableRootsRun = 0 + async function loadForkableRoots(roots: string[]) { + const run = ++forkableRootsRun + const unprotected: string[] = [] + for (let i = 0; i < roots.length; i += 8) { + const batch = await Promise.all( + roots.slice(i, i + 8).map(async (id) => { + try { + const rules = await WorkspaceService.listProtectionRules({ workspace: id }) + return !rules.some((r) => r.rules.includes('DisableWorkspaceForking')) + } catch { + return true + } + }) + ) + if (run !== forkableRootsRun) return + unprotected.push(...roots.slice(i, i + 8).filter((_, j) => batch[j])) + } + forkableRoots = unprotected + } + $effect(() => { + if (skipNonDevForks && workspaces !== undefined) { + loadForkableRoots(forkSources.map((w) => w.id)) + } + }) + let workspaceNames = $derived(Object.fromEntries((workspaces ?? []).map((w) => [w.id, w.name]))) + + async function loadWorkspaces() { + const all: Workspace[] = [] + for (let page = 1; ; page++) { + const batch = await WorkspaceService.listWorkspacesAsSuperAdmin({ page, perPage: 100 }) + all.push(...batch) + if (batch.length < 100) break + } + workspaces = all + } + + let queueStatus = $state<GetQueueStatusResponse | undefined>(undefined) + async function loadQueueStatus() { + try { + queueStatus = await WorkerService.getQueueStatus() + } catch { + queueStatus = undefined + } + } + + onMount(() => { + loadDevWorkspaceTagSuffix(true) + loadWorkspaces().catch((e) => console.error('Could not list workspaces', e)) + loadQueueStatus() + const interval = setInterval(loadQueueStatus, 15_000) + return () => clearInterval(interval) + }) + + type GroupEntry = { name: string; live: number; tags: string[] } + + function isDedicated(config: any): boolean { + return config?.dedicated_worker != undefined || (config?.dedicated_workers?.length ?? 0) > 0 + } + + let flaggedGroups: GroupEntry[] = $derived( + Object.entries(workerGroups ?? {}) + .filter(([_, c]) => c?.serves_dev_workspaces === true && !isDedicated(c)) + .map(([name, c]) => ({ name, live: liveWorkers[name] ?? 0, tags: c?.worker_tags ?? [] })) + ) + let liveFlaggedWorkers = $derived(flaggedGroups.reduce((acc, g) => acc + g.live, 0)) + let unflaggedGroupNames = $derived( + Object.entries(workerGroups ?? {}) + .filter(([_, c]) => c?.serves_dev_workspaces !== true && !isDedicated(c)) + .map(([name]) => name) + ) + // The only way to break isolation: an unflagged group listing a suffixed tag takes dev jobs. + let leakingGroups = $derived( + Object.entries(workerGroups ?? {}) + .filter(([_, c]) => c?.serves_dev_workspaces !== true) + .map(([name, c]) => ({ + name, + tags: ((c?.worker_tags ?? []) as string[]).filter((t) => t.endsWith(effectiveSuffix)) + })) + .filter((g) => g.tags.length > 0) + ) + let unservedDevTags = $derived( + enabled + ? (queueStatus ?? []).filter( + (s) => s.tag.endsWith(effectiveSuffix) && s.waiting > 0 && s.workers === 0 + ) + : [] + ) + + let groupToFlag = $state<string | undefined>(undefined) + let collapsed = $state(true) + let showWorkspaces = $state(false) + let showAdvanced = $state(false) + + let confirmEnable = $state(false) + let confirmDisable = $state(false) + let saving = $state(false) + + async function setIsolation(on: boolean) { + saving = true + try { + await SettingService.setGlobal({ + key: DEV_WORKSPACE_ISOLATION_SETTING, + requestBody: { value: on } + }) + devWorkspaceIsolation.update((i) => ({ + suffix: i?.suffix ?? effectiveSuffix, + skip_non_dev_forks: i?.skip_non_dev_forks ?? false, + enabled: on + })) + sendUserToast( + on + ? `Dev workspace and fork jobs now run on tags suffixed with ${effectiveSuffix}` + : 'Dev workspace isolation turned off' + ) + } catch (e) { + sendUserToast(`Could not save dev workspace isolation: ${e}`, true) + toggleChecked = enabled + } finally { + saving = false + } + } + + async function saveSuffix(value: string) { + saving = true + try { + await SettingService.setGlobal({ + key: DEV_WORKSPACE_TAG_SUFFIX_SETTING, + requestBody: { value } + }) + devWorkspaceIsolation.update((i) => ({ + enabled: i?.enabled ?? false, + skip_non_dev_forks: i?.skip_non_dev_forks ?? false, + suffix: value + })) + sendUserToast(`Dev workspace tag suffix set to ${value}`) + } catch (e) { + sendUserToast(`Could not save the dev workspace tag suffix: ${e}`, true) + } finally { + saving = false + } + } + + async function setSkipNonDevForks(skip: boolean) { + saving = true + try { + await SettingService.setGlobal({ + key: DEV_WORKSPACE_ISOLATION_SKIP_NON_DEV_FORKS_SETTING, + requestBody: { value: skip } + }) + devWorkspaceIsolation.update((i) => ({ + enabled: i?.enabled ?? false, + suffix: i?.suffix ?? effectiveSuffix, + skip_non_dev_forks: skip + })) + sendUserToast( + skip + ? 'Forks of non-dev workspaces now run on the workers of the workspace they fork' + : 'Forks of non-dev workspaces now run on dev workers' + ) + } catch (e) { + sendUserToast(`Could not save dev workspace isolation: ${e}`, true) + skipChecked = skipNonDevForks + } finally { + saving = false + } + } + + function onToggle(checked: boolean) { + if (checked === enabled) return + collapsed = false + if (checked) { + if (liveFlaggedWorkers === 0) { + confirmEnable = true + } else { + setIsolation(true) + } + } else { + confirmDisable = true + } + } + + function workspaceKind(w: Workspace): string { + if (w.parent_workspace_id) { + const parent = workspaceNames[w.parent_workspace_id] ?? w.parent_workspace_id + return w.is_dev_workspace ? `dev workspace of ${parent}` : `fork of ${parent}` + } + return 'fork whose parent was deleted' + } +</script> + +<ConfirmationModal + open={confirmEnable} + title="Turn on dev workspace isolation" + confirmationText="Turn on" + type="info" + onCanceled={() => { + confirmEnable = false + toggleChecked = enabled + }} + onConfirmed={async () => { + confirmEnable = false + await setIsolation(true) + }} +> + <div class="flex flex-col gap-2 text-sm"> + <p> + {#if workspaces === undefined} + Every dev workspace and fork + {:else} + <b>{pluralize(devWorkspaces.length, 'dev workspace or fork', 'dev workspaces and forks')}</b + > + {/if} + will have their jobs queued until a worker group serves them. + </p> + <p class="text-secondary text-xs"> + {#if flaggedGroups.length === 0} + No worker group is flagged as serving dev workspaces yet. + {:else} + The worker groups serving dev workspaces ({flaggedGroups.map((g) => g.name).join(', ')}) + have no live worker. + {/if} + Jobs never fall back to the other worker groups: they wait in the queue and run as soon as a serving + worker is up. + </p> + {#if unflaggedGroupNames.length > 0 && flaggedGroups.length === 0} + <div> + <Button + variant="default" + unifiedSize="sm" + startIcon={{ icon: Settings }} + onClick={() => { + confirmEnable = false + toggleChecked = enabled + onConfigureGroup(unflaggedGroupNames[0]) + }} + > + Flag a worker group first + </Button> + </div> + {/if} + </div> +</ConfirmationModal> + +<ConfirmationModal + open={confirmDisable} + title="Turn off dev workspace isolation" + confirmationText="Turn off" + type="danger" + onCanceled={() => { + confirmDisable = false + toggleChecked = enabled + }} + onConfirmed={async () => { + confirmDisable = false + await setIsolation(false) + }} +> + <p class="text-sm"> + New jobs of dev workspaces and forks will run on the same worker groups as root workspaces + again. Jobs already queued on a <code>{effectiveSuffix}</code> tag keep it, and the worker groups + serving dev workspaces still run them. + </p> +</ConfirmationModal> + +{#snippet isolationToggle()} + <Toggle + bind:checked={toggleChecked} + on:change={(e) => onToggle(e.detail)} + disabled={!canEdit || !$enterpriseLicense || saving || suffix === undefined} + options={{ right: 'Run dev workspaces and forks on separate workers' }} + size="xs" + /> +{/snippet} + +<Section + label="Dev workspace isolation" + tooltip="Dev workspaces and forks run code that has not been reviewed or deployed to a root workspace. Route their jobs to worker groups of their own, so they never run on the workers of your root workspaces." + eeOnly={!$enterpriseLicense} + collapsable + bind:collapsed + wrapperClass="mt-4 mb-6 rounded-md border border-light p-4 bg-surface-tertiary" +> + {#snippet action()} + {@render isolationToggle()} + {/snippet} + {#snippet badge()} + {@render isolationToggle()} + {/snippet} + + <div class="flex flex-col gap-4"> + <div class="grid grid-cols-[auto_auto_1fr] gap-x-3 gap-y-2 items-center text-xs max-w-2xl"> + <span class="font-semibold text-emphasis">Root workspaces</span> + <ArrowRight size={14} class="text-secondary" /> + <span class="text-primary"> + Worker groups not serving dev workspaces, on their usual tags: + <code>python3</code>, <code>gpu</code> + </span> + <span class="font-semibold text-emphasis">Dev workspaces and forks</span> + <ArrowRight size={14} class="text-secondary" /> + <span class="text-primary"> + {#if enabled} + Worker groups serving dev workspaces, on tags suffixed with + <code>{effectiveSuffix}</code>: + {:else} + Once on, worker groups serving dev workspaces, on suffixed tags: + {/if} + <code>{withDevSuffix('python3', effectiveSuffix)}</code>, + <code>{withDevSuffix('gpu', effectiveSuffix)}</code> + </span> + </div> + <p class="text-2xs text-secondary max-w-2xl"> + A workspace with a parent, and every fork (including a fork whose parent was deleted), counts + as a dev workspace, unless forks of non-dev workspaces are left out below. Every job is + covered: runs, previews, schedules, triggers, flow steps, AI agent tools and dependency jobs, + and inline runs are refused on workers not serving dev workspaces. A job whose tag no serving + worker listens to stays queued: it never falls back to the other worker groups. Dedicated + workers keep their own tags. + </p> + + <div class="flex flex-col gap-1 max-w-2xl"> + <Toggle + bind:checked={skipChecked} + on:change={(e) => setSkipNonDevForks(e.detail)} + disabled={!canEdit || !$enterpriseLicense || saving || suffix === undefined} + options={{ right: "Don't isolate forks of non-dev workspaces" }} + size="xs" + /> + <span class="text-2xs text-secondary"> + A fork with no dev workspace above it, like a fork of a production workspace or a fork of + that fork, runs on the same workers as that production workspace. Dev workspaces and their + forks stay isolated. Restrict who can create such forks with the <b + >Disable workspace forking</b + > + protection rule of each such workspace and fork. + {#if skipNonDevForks && workspaces !== undefined} + {pluralize(nonDevForkCount, 'fork')} currently {nonDevForkCount === 1 ? 'runs' : 'run'} + outside isolation. + {/if} + </span> + {#if skipNonDevForks && forkableRoots && forkableRoots.length > 0} + <Alert type="warning" size="xs" title="Workspaces anyone can fork onto their workers"> + Anyone who can fork + {#each forkableRoots.slice(0, 5) as id, i (id)} + {i > 0 ? ', ' : ''}<b>{workspaceNames[id] ?? id}</b> + {/each}{forkableRoots.length > 5 ? ` and ${forkableRoots.length - 5} more` : ''} + can run jobs on {forkableRoots.length === 1 ? 'its' : 'their'} workers. Add a + <b>Disable workspace forking</b> protection rule to {forkableRoots.length === 1 + ? 'it' + : 'them'}, bypassed by the people allowed to fork, or isolate these forks again. + </Alert> + {/if} + </div> + + <!-- Worker groups serving dev workspaces --> + <div class="flex flex-col gap-2"> + <span class="text-xs font-semibold text-emphasis">Worker groups serving dev workspaces</span> + {#if flaggedGroups.length > 0} + <div class="flex flex-wrap gap-2"> + {#each flaggedGroups as g (g.name)} + <Button + variant="default" + unifiedSize="sm" + startIcon={{ icon: Settings }} + onClick={() => onConfigureGroup(g.name)} + > + {g.name} + <span class={g.live > 0 ? 'text-secondary' : 'text-red-500'}> + · {pluralize(g.live, 'live worker')} + </span> + </Button> + {/each} + </div> + {:else} + <span class="text-xs text-secondary">None yet.</span> + {/if} + {#if canEdit && unflaggedGroupNames.length > 0} + <div class="flex gap-2 items-center"> + <Select + size="sm" + class="w-56" + placeholder="Pick a worker group" + items={unflaggedGroupNames.map((n) => ({ value: n }))} + bind:value={groupToFlag} + /> + <Button + variant="default" + unifiedSize="sm" + disabled={!groupToFlag} + onClick={() => groupToFlag && onConfigureGroup(groupToFlag)} + > + Flag as serving dev workspaces + </Button> + <Tooltip> + {#snippet text()} + Opens the group's config, where "Serves dev workspaces and forks" sits under its tags. + A dedicated group to start from: create a new group config, flag it, and start workers + with its name as <code>WORKER_GROUP</code>. + {/snippet} + </Tooltip> + </div> + {/if} + </div> + + {#if enabled && liveFlaggedWorkers === 0} + <Alert type="warning" size="xs" title="No worker serves dev workspaces"> + Jobs of dev workspaces and forks stay queued until a worker group flagged as serving dev + workspaces has live workers{flaggedGroups.length === 0 + ? ': flag a worker group as serving dev workspaces' + : ''}. + </Alert> + {/if} + + {#if unservedDevTags.length > 0} + <Alert type="warning" size="xs" title="Dev jobs waiting with no worker"> + No live worker listens to + {#each unservedDevTags as s, i (s.tag)} + {i > 0 ? ', ' : ''}<code>{s.tag}</code> ({s.waiting} waiting) + {/each}. These tags carry the <code>{effectiveSuffix}</code> suffix of dev workspace jobs: add + the unsuffixed tag to a worker group flagged as serving dev workspaces, or flag a group that + already lists it. + </Alert> + {/if} + + {#if leakingGroups.length > 0} + <Alert + type="warning" + size="xs" + title="A worker group not serving dev workspaces takes dev jobs" + > + {#each leakingGroups as g, i (g.name)} + {i > 0 ? '; ' : ''}<b>{g.name}</b> lists + {#each g.tags as t, j (t)}{j > 0 ? ', ' : ''}<code>{t}</code>{/each} + {/each}. Jobs of dev workspaces and forks run on these tags, so this group runs them next to + root workspace jobs. Remove the tags, or flag the group as serving dev workspaces. + </Alert> + {/if} + + <!-- Affected workspaces --> + <div class="flex flex-col gap-1"> + <div class="w-fit"> + <Button + variant="subtle" + unifiedSize="sm" + startIcon={{ icon: showWorkspaces ? ChevronDown : ChevronRight }} + onClick={() => (showWorkspaces = !showWorkspaces)} + > + {#if workspaces === undefined} + Workspaces routed to dev workers + {:else} + {pluralize(devWorkspaces.length, 'workspace')} + {enabled ? 'routed' : 'that would be routed'} to dev workers + {/if} + </Button> + </div> + {#if showWorkspaces} + {#if workspaces === undefined} + <span class="text-xs text-secondary">Loading…</span> + {:else if devWorkspaces.length === 0} + <span class="text-xs text-secondary" + >No dev workspace or fork yet. Only root workspaces exist.</span + > + {:else} + <ul class="flex flex-col gap-1 text-xs max-h-60 overflow-auto"> + {#each devWorkspaces as w (w.id)} + <li class="flex gap-2"> + <span class="font-mono text-primary">{w.id}</span> + <span class="text-secondary">{workspaceKind(w)}</span> + </li> + {/each} + </ul> + {/if} + {/if} + </div> + + <!-- Advanced --> + <div class="flex flex-col gap-2"> + <div class="w-fit"> + <Button + variant="subtle" + unifiedSize="sm" + startIcon={{ icon: showAdvanced ? ChevronDown : ChevronRight }} + onClick={() => (showAdvanced = !showAdvanced)} + > + Advanced + </Button> + </div> + {#if showAdvanced} + <div class="flex flex-col gap-1 max-w-md"> + <span class="text-xs font-semibold text-emphasis">Tag suffix</span> + <span class="text-2xs text-secondary"> + Appended last, after every other tag setting: with workspace-specific default tags a + fork's <code>python3-$workspace-fork</code> becomes + <code>{withDevSuffix('python3-$workspace-fork', effectiveSuffix)}</code>. Changing it + moves the worker groups serving dev workspaces to the new suffix right away, while jobs + already queued keep the old one: change it when no dev job is waiting. A dev job whose + tag would exceed 50 characters once suffixed is refused. + </span> + <div class="flex gap-2 items-start"> + <TextInput + bind:value={suffixDraft} + size="sm" + error={suffixError} + inputProps={{ + placeholder: DEFAULT_DEV_WORKSPACE_TAG_SUFFIX, + disabled: !canEdit || !$enterpriseLicense + }} + /> + <Button + variant="accent" + unifiedSize="sm" + startIcon={{ icon: Save }} + disabled={!canEdit || + !!suffixError || + suffixDraft.trim() === effectiveSuffix || + saving} + onClick={() => saveSuffix(suffixDraft.trim())} + > + Save + </Button> + </div> + {#if suffixError} + <span class="text-2xs text-red-500">{suffixError}</span> + {/if} + </div> + {/if} + </div> + </div> +</Section> diff --git a/frontend/src/lib/components/DevWorkspaceSetting.svelte b/frontend/src/lib/components/DevWorkspaceSetting.svelte index 5346cab7b0..9f8f536bd7 100644 --- a/frontend/src/lib/components/DevWorkspaceSetting.svelte +++ b/frontend/src/lib/components/DevWorkspaceSetting.svelte @@ -28,7 +28,14 @@ isRuleUnconditionallyActiveInRulesets, DEV_WORKSPACE_LOCK_RULE_NAME } from '$lib/workspaceProtectionRules.svelte' - import { GitFork, ExternalLink, Check, Minus, Pen } from 'lucide-svelte' + import { GitFork, ExternalLink, Check, Minus, Pen, Server } from 'lucide-svelte' + import { + devWorkspaceTagSuffix, + devWorkspaceSkipNonDevForks, + isIsolatedWorkspaceId, + loadDevWorkspaceTagSuffix, + withDevSuffix + } from '$lib/devWorkspaceTags' import { resource } from 'runed' import type { Snippet } from 'svelte' @@ -40,6 +47,7 @@ deployTarget }: { deployTarget?: Snippet } = $props() + loadDevWorkspaceTagSuffix() let currentWs = $derived($userWorkspaces.find((w) => w.id === $workspaceStore)) let isDev = $derived(currentWs?.is_dev_workspace ?? false) let currentLabel = $derived(devLabelKey(currentWs?.dev_workspace_label)) @@ -455,6 +463,15 @@ Pair this workspace with a dev workspace: the same code with a different environment (resource and variable values). Edits are made in the dev workspace and promoted here. </p> + <p class="text-2xs text-secondary"> + Attaching an existing workspace copies nothing: it keeps its own resources and variables. + Creating a new dev workspace copies this workspace's resources and variables, credentials + included; point them at staging before developers use it. + {#if $devWorkspaceTagSuffix} + Either way, its jobs then run on dev workers (tags suffixed with + <code>{$devWorkspaceTagSuffix}</code>). + {/if} + </p> <div class="flex flex-col gap-1"> <span class="text-xs font-semibold text-emphasis">Attach an existing workspace as dev</span> <Select @@ -539,11 +556,26 @@ {/if} {/snippet} +{#snippet devWorkersNotice()} + {#if $devWorkspaceTagSuffix && isIsolatedWorkspaceId($workspaceStore, $userWorkspaces, $devWorkspaceSkipNonDevForks)} + <p class="text-2xs text-secondary max-w-2xl flex items-center gap-1.5"> + <Server size={12} /> + <span> + Jobs in this workspace run on dev workers (tags suffixed with + <code>{$devWorkspaceTagSuffix}</code>, e.g. + <code>{withDevSuffix('python3', $devWorkspaceTagSuffix)}</code>), set by the instance's dev + workspace isolation. + </span> + </p> + {/if} +{/snippet} + {#if isThrowawayFork} <p class="text-sm text-secondary max-w-2xl"> Dev workspace pairing is not available on a throwaway fork. This workspace is a fork of <b>{parentId}</b>. </p> + <div class="mt-2">{@render devWorkersNotice()}</div> {@render deployTarget?.()} {:else if isDev && parentId} <div class="flex flex-col gap-3 max-w-2xl"> @@ -563,6 +595,7 @@ <span class="font-mono">{currentLabel}</span> branch. </span> </div> + {@render devWorkersNotice()} </div> {@render deployTarget?.()} <div class="flex flex-col gap-3 max-w-2xl mt-6"> diff --git a/frontend/src/lib/components/DraftBadge.svelte b/frontend/src/lib/components/DraftBadge.svelte index 22f7c4e55a..4d21e81b29 100644 --- a/frontend/src/lib/components/DraftBadge.svelte +++ b/frontend/src/lib/components/DraftBadge.svelte @@ -18,7 +18,8 @@ import { goto } from '$lib/navigation' import { OtherUserDraftLoad, editRouteFor } from '$lib/components/otherUserDraftLoad.svelte' import { fetchDeployedValueForDiff } from '$lib/components/otherUserDraftDiff' - import { userStore } from '$lib/stores' + import { roleCapabilities, userStore } from '$lib/stores' + import { roleCanAuthor } from '$lib/editRights' type DraftUser = { username?: string | null } @@ -116,6 +117,10 @@ // Inline actions need full context to fetch and fork drafts. const actionsEnabled = $derived(!!workspace && !!itemKind && !!path && draft_users.length > 0) + // The editors these open are the full ones, which a role without code cannot use. + const canOpenEditor = $derived( + !!itemKind && $roleCapabilities.code && roleCanAuthor(itemKind, $roleCapabilities) + ) const kindLabel = $derived( itemKind === 'flow' ? 'flow' : itemKind === 'app' || itemKind === 'raw_app' ? 'app' : 'script' @@ -303,7 +308,7 @@ </span> {#if actionsEnabled && isSelf} <!-- Own draft: jump straight into the editor. --> - {#if !$userStore?.operator && itemKind && path} + {#if canOpenEditor && itemKind && path} <Button variant="subtle" size="xs3" @@ -326,9 +331,9 @@ View Diff </Button> {/if} - <!-- Operators can't edit items, so Load is hidden (View Diff stays, it's - read-only). `allowFork=false` (deploy/review page) hides it too. --> - {#if allowFork && !$userStore?.operator} + <!-- Load is hidden from a role that can't edit the item (View Diff stays, + it's read-only). `allowFork=false` (deploy/review page) hides it too. --> + {#if allowFork && canOpenEditor} <Button variant="subtle" size="xs3" diff --git a/frontend/src/lib/components/DrillPicker.svelte b/frontend/src/lib/components/DrillPicker.svelte index 99f2c3a9b1..a92ef11ddf 100644 --- a/frontend/src/lib/components/DrillPicker.svelte +++ b/frontend/src/lib/components/DrillPicker.svelte @@ -204,6 +204,9 @@ leaves and ignores the current scope. ) ) + /** Whether any row in this list has a second line, which sets every row's height. */ + const entriesTwoLine = $derived(entryList.some((e) => e.type === 'leaf' && !!e.node.secondary)) + // Browse rows annotated with the section header rendered above them. A // header is emitted when a leaf's section differs from the previous LEAF's // section — comparing against the previous entry of any type would insert a @@ -502,7 +505,12 @@ leaves and ignores the current scope. {/if} {/snippet} -{#snippet leafRow(leaf: DrillLeaf<L>, secondary: string | undefined, baseClass: string)} +{#snippet leafRow( + leaf: DrillLeaf<L>, + secondary: string | undefined, + baseClass: string, + twoLineHeight: boolean +)} {@const key = leaf.key} {@const isHl = key === highlightedKey} {@const isCur = !!leaf.current} @@ -541,9 +549,12 @@ leaves and ignores the current scope. onclick={(e) => pick(leaf, e.metaKey || e.ctrlKey)} > {@render defaultLeafIcon(leaf)} - <!-- Centred in the row's full height so a one-line leaf sits where a two-line - one's pair does, rather than riding high against the top. --> - <div class="min-w-0 flex-1 flex flex-col justify-center min-h-[2.25rem]"> + <!-- Centred in a two-line row's height so a one-line leaf sits where a two-line + one's pair does, rather than riding high against the top. A list with no + second line anywhere (a picker's pages) keeps its rows one line tall. --> + <div + class="min-w-0 flex-1 flex flex-col justify-center {twoLineHeight ? 'min-h-[2.25rem]' : ''}" + > {#if leaf.secondary} <div class="text-xs text-primary font-normal truncate">{leaf.label}</div> <div class="text-2xs text-hint font-normal font-mono truncate"> @@ -657,6 +668,7 @@ leaves and ignores the current scope. <div role="status" class="px-3 py-2 text-xs text-tertiary">No matches</div> {:else} {#each searchResultsByGroup as { key, label, items } (key)} + {@const twoLineHeight = items.some((r) => !!r.leaf.secondary)} {#if label} <div class="px-3 pt-3 pb-1 mt-2 first:mt-0 text-3xs uppercase tracking-wide text-hint font-medium" @@ -666,7 +678,9 @@ leaves and ignores the current scope. {/if} <ul class="pb-1"> {#each items as r (r.leaf.key)} - <li>{@render leafRow(r.leaf, r.leaf.secondary ?? r.leaf.label, 'py-1.5')}</li> + <li> + {@render leafRow(r.leaf, r.leaf.secondary ?? r.leaf.label, 'py-1.5', twoLineHeight)} + </li> {/each} </ul> {/each} @@ -692,7 +706,8 @@ leaves and ignores the current scope. {@render leafRow( entry.node, leafSecondary?.(entry.node, scope) ?? entry.node.secondary, - 'py-1.5' + 'py-1.5', + entriesTwoLine )} {:else} <button diff --git a/frontend/src/lib/components/DropdownV2Inner.svelte b/frontend/src/lib/components/DropdownV2Inner.svelte index 70ed83398d..5a18a09157 100644 --- a/frontend/src/lib/components/DropdownV2Inner.svelte +++ b/frontend/src/lib/components/DropdownV2Inner.svelte @@ -45,7 +45,8 @@ item?.disabled && 'text-disabled cursor-not-allowed pointer-events-none', item?.type === 'delete' && !item?.disabled && - 'text-red-600 dark:text-red-400 data-[highlighted]:bg-red-500/10 dark:data-[highlighted]:bg-red-900/80 dark:data-[highlighted]:text-red-300 ' + 'text-red-600 dark:text-red-400 data-[highlighted]:bg-red-500/10 dark:data-[highlighted]:bg-red-900/80 dark:data-[highlighted]:text-red-300 ', + item?.type === 'ai' && !item?.disabled && 'text-ai' )} item={meltItem} aiId={`${aiId ? `${aiId}-${item.displayName}` : undefined}`} diff --git a/frontend/src/lib/components/EditableSchemaForm.svelte b/frontend/src/lib/components/EditableSchemaForm.svelte index 11429cb6fe..fd15c7f9e7 100644 --- a/frontend/src/lib/components/EditableSchemaForm.svelte +++ b/frontend/src/lib/components/EditableSchemaForm.svelte @@ -19,7 +19,7 @@ import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' import Label from './Label.svelte' import { sendUserToast } from '$lib/toast' - import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' + import { useRoleCapabilities } from '$lib/operatorWriteRights' import Toggle from './Toggle.svelte' import { DynamicInput, @@ -36,14 +36,12 @@ import Section from '$lib/components/Section.svelte' import Editor from './Editor.svelte' import AddPropertyV2 from './schema/AddPropertyV2.svelte' - import { - useOperatingUser, - useOperatingWorkspace - } from '$lib/components/operatingWorkspace.svelte' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() - const operatingUser = useOperatingUser() - const operatorBuilderFlows = useOperatorBuilderFlows() + const roleCaps = useRoleCapabilities() + // Dynamic input functions are inline code a compose-only flow role cannot author. + const composeOnlyFlows = $derived($roleCaps.flows === 'compose') // export let openEditTab: () => void = () => {} const dispatch = createEventDispatcher() @@ -482,7 +480,7 @@ dynCode, dynLang, deployedFlowPath, - operatingUser.current?.operator + !$roleCaps.code )} prettifyHeader={isAppInput} disabled={!!previewSchema} @@ -496,7 +494,7 @@ {@render runButton?.()} - {#if dynamicFunctions.length > 0 && !$operatorBuilderFlows} + {#if dynamicFunctions.length > 0 && !composeOnlyFlows} <Section label="Dynamic input functions" collapsable={true} @@ -825,7 +823,7 @@ {#each typeOptions as x} <ToggleButton value={x[1]} label={x[0]} {item} /> {/each} - {#if showDynOpt && !$operatorBuilderFlows} + {#if showDynOpt && !composeOnlyFlows} {#each DYNAMIC_OPTIONS as x} <ToggleButton value={x[1]} label={x[0]} {item} /> {/each} diff --git a/frontend/src/lib/components/ExploreAssetButton.svelte b/frontend/src/lib/components/ExploreAssetButton.svelte index d48688a871..de0d70e26e 100644 --- a/frontend/src/lib/components/ExploreAssetButton.svelte +++ b/frontend/src/lib/components/ExploreAssetButton.svelte @@ -37,14 +37,11 @@ import { isS3Uri } from '$lib/utils' import { Database, File, HardDriveIcon } from 'lucide-svelte' import DucklakeIcon from './icons/DucklakeIcon.svelte' - import { - useOperatingUser, - useOperatingWorkspace - } from '$lib/components/operatingWorkspace.svelte' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { useRoleCapabilities } from '$lib/operatorWriteRights' const operatingWorkspace = useOperatingWorkspace() - const operatingUser = useOperatingUser() - const actingUser = $derived(operatingUser.current) + const roleCaps = useRoleCapabilities() const { asset, @@ -83,7 +80,7 @@ </script> <Button - disabled={actingUser?.operator || disabled} + disabled={!$roleCaps.code || disabled} unifiedSize={'md'} variant={buttonVariant} wrapperClasses={className} diff --git a/frontend/src/lib/components/FilterSearchbar.svelte b/frontend/src/lib/components/FilterSearchbar.svelte index 0a65701b06..b496ac1088 100644 --- a/frontend/src/lib/components/FilterSearchbar.svelte +++ b/frontend/src/lib/components/FilterSearchbar.svelte @@ -71,6 +71,19 @@ return Object.values(val).some((v) => v !== undefined && v !== null && v !== '' && v !== false) } + /** + * Whether typed text names a filter: every word of the query must occur in its label or + * key, in any order, so "archive" and "archived only" both find "Only archived". + */ + export function filterMatchesQuery(key: string, filter: FilterSchema, query: string): boolean { + const words = query.replace(/\\(.)/g, '$1').toLowerCase().split(/\s+/).filter(Boolean) + const haystack = [filter.label, key, key.replaceAll('_', ' ')] + .filter(Boolean) + .join(' ') + .toLowerCase() + return words.every((w) => haystack.includes(w)) + } + /** * Converts a FilterSchemaRec to a Zod schema for validation */ @@ -437,12 +450,7 @@ return Object.entries(schema) .filter(([_, s]) => !s.hidden) .filter(([k, _]) => !(k in value)) - .filter(([k, filterSchema]) => { - if (!searchText) return true - const label = (filterSchema.label || k).toLowerCase() - const key = k.toLowerCase() - return label.includes(searchText) || key.includes(searchText) - }) + .filter(([k, filterSchema]) => filterMatchesQuery(k, filterSchema, searchText)) .map(([key, filterSchema]) => ({ type: 'filter' as const, key, @@ -542,6 +550,15 @@ return [] }) + // Free-text mode keeps the keyboard for the surrounding list, but a term that names a + // filter ("archive" → "Only archived") still lists the matching filters, click-only. + let freeTextFilterHint = $derived( + open && + !dropdownVisible && + currentTextSegment.text.trim().length >= 3 && + menuItems.some((i) => i.type === 'filter') + ) + // Reset highlighted index when menu items change $effect(() => { menuItems @@ -647,7 +664,13 @@ function handleKeyDown(e: KeyboardEvent) { // In free-text mode the dropdown is hidden; let arrow/enter keys pass through to // the surrounding UI (e.g. list navigation) rather than steering a hidden menu. - if (!dropdownVisible) return + if (!dropdownVisible) { + // The hint sits over the list's first rows, so it gets out of the way once the list + // is being navigated; the next typed character brings it back. + if (freeTextFilterHint && ['Escape', 'ArrowUp', 'ArrowDown', 'Enter'].includes(e.key)) + open = false + return + } if (e.key === 'Escape') { open = false return @@ -786,7 +809,7 @@ </div> <GenericDropdown - open={dropdownVisible} + open={dropdownVisible || freeTextFilterHint} instantClose={hideDropdownOnFreeText} getInputRect={() => { const r = inputElement?.getBoundingClientRect() ?? new DOMRect() @@ -800,15 +823,19 @@ <!-- svelte-ignore a11y_click_events_have_key_events --> <div class="py-1 p-2 overflow-y-auto" onmousedown={(e) => e.stopPropagation()}> {#if !currentTag || !schema[currentTag]} - {#if presets.length} - <div class="text-xs px-2 my-2 font-bold">Presets</div> + {#if presets.length && !freeTextFilterHint} + <div class="text-2xs uppercase tracking-wide font-semibold text-secondary px-2 my-2"> + Presets + </div> <div class="mb-3 px-2 flex gap-2 flex-wrap max-h-24 overflow-y-auto"> {#each presets as preset} {@render presetTag(preset)} {/each} </div> {/if} - <div class="text-xs px-2 my-2 font-bold">Filters</div> + <div class="text-2xs uppercase tracking-wide font-semibold text-secondary px-2 my-2"> + Filters + </div> {#each menuItems as item, index} {#if item.type === 'filter' && item.filterSchema} {@render menuItem({ @@ -816,7 +843,7 @@ onClick: item.onClick, label: item.filterSchema.label || item.key, description: item.filterSchema.description, - highlighted: index === highlightedIndex + highlighted: index === highlightedIndex && !freeTextFilterHint })} {/if} {/each} @@ -947,18 +974,18 @@ <!-- svelte-ignore a11y_click_events_have_key_events --> <div class={twMerge( - 'py-1.5 px-2 rounded-md hover:bg-surface-hover cursor-pointer text-sm flex items-center gap-3', + 'py-1.5 px-2 rounded-md hover:bg-surface-hover cursor-pointer text-xs text-primary flex items-center gap-2', highlighted && 'bg-surface-hover' )} onclick={onClick} > {#if Icon} - <Icon size={16} class="inline" /> + <Icon size={14} class="inline shrink-0" /> {/if} <div class="inline flex-1 relative min-w-0"> - <div class="text-sm ellipsize">{label}</div> + <div class="ellipsize">{label}</div> {#if description} - <div class="text-xs text-hint">{description}</div> + <div class="text-2xs text-hint">{description}</div> {/if} </div> {#if onNegativeClick} diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 17f984bac9..a919fc82f5 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -18,7 +18,13 @@ linkedAgentToolsVersion, migrateLinkedAgentToolsScope } from '$lib/components/flows/linkedAgentToolsStore.svelte' - import { enterpriseLicense, userStore, userWorkspaces, usedTriggerKinds } from '$lib/stores' + import { + enterpriseLicense, + userStore, + userWorkspaces, + usedTriggerKinds, + workspaceStore + } from '$lib/stores' import { generateRandomString, orderedJsonStringify, @@ -49,6 +55,7 @@ import WorkspaceScriptSettingsDrawer from './flows/content/WorkspaceScriptSettingsDrawer.svelte' import FlowEditorDrawer from './flows/content/FlowEditorDrawer.svelte' import { dfs as dfsApply } from './flows/dfs' + import { dropStaleStepLocks } from './flows/staleStepLocks' import { claimLinkedToolsFetch, invalidateLinkedToolsFetches, @@ -77,6 +84,7 @@ import { type TriggerContext, type ScheduleTrigger } from './triggers' import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes' import DeployButton from './DeployButton.svelte' + import OpenInSessionButton from './sessions/OpenInSessionButton.svelte' import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte' import type { Trigger } from './triggers/utils' import { deployTriggers, handleSelectTriggerFromKind } from './triggers/utils' @@ -110,13 +118,14 @@ import { UserDraft } from '$lib/userDraft.svelte' import { setOpenInSessionHandoff } from './sessions/openInSessionContext' import { getEditorStoragePath, setEditorStoragePath } from './editorStoragePathContext' - import { useEditRights, useOperatorBuilderFlows, useTriggerLock } from '$lib/operatorWriteRights' + import { useEditRights, useRoleCapabilities, useTriggerLock } from '$lib/operatorWriteRights' + import { capabilitiesIn } from '$lib/roleCapabilities' import { useOperatingUser, useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const triggerLock = useTriggerLock() - const operatorBuilderFlows = useOperatorBuilderFlows() + const roleCaps = useRoleCapabilities() const editRights = useEditRights() const operatingWorkspace = useOperatingWorkspace() @@ -551,15 +560,15 @@ // One lookup for the whole list: an agent lives in a folder, and the groups and admin // flag that answer for it are per workspace, so the nav user would answer for the wrong // membership when a session editor operates on another workspace. - const user = await getUserExt(opWorkspace ?? '').catch(() => undefined) - const operatorSettings = $userWorkspaces.find( - (w) => w.id === opWorkspace - )?.operator_settings + const user = + (await getUserExt(opWorkspace ?? '')) ?? + (opWorkspace === $workspaceStore ? $userStore : undefined) + const caps = capabilitiesIn( + user, + $userWorkspaces.find((w) => w.id === opWorkspace) + ) agentCanWrite = Object.fromEntries( - draftAgents.map((a) => [ - a.path, - agentDraftCanWrite(a, user ?? $userStore ?? undefined, operatorSettings) - ]) + draftAgents.map((a) => [a.path, agentDraftCanWrite(a, user, caps)]) ) // The path is passed, so a draft that renames the agent is refused here too: a rename is // the agent editor's to deploy, and this dialog lists the agent under the path the flow @@ -627,6 +636,16 @@ } } + // The editor clears a step's lock when its code is edited, but a draft written elsewhere + // can hold a step whose code changed and whose lock did not. A failed lookup skips the + // check rather than blocking the deploy. + const liveFlowValue = isNewFlow + ? undefined + : await FlowService.getFlowByPath({ workspace: opWorkspace!, path: initialPath }) + .then((f) => f.value) + .catch(() => undefined) + dropStaleStepLocks(flow.value, liveFlowValue) + if (isNewFlow) { await FlowService.createFlow({ workspace: opWorkspace!, @@ -890,6 +909,9 @@ // falling back to `$pathStore` in drawer mounts that carry no storage path. const sessionTargetPath = $derived(liveEditorDraftStoragePath || $pathStore) + const aiBuilderShown = $derived(!disableAi && customUi?.topBar?.aiBuilder != false) + const showFlowAiButton = $derived(aiBuilderShown && $roleCaps.flows !== 'compose') + const sessionOpen = $derived( sessionTargetPath ? { @@ -899,6 +921,7 @@ } : undefined ) + const headerSessionOpen = $derived(aiBuilderShown ? sessionOpen : undefined) // Reaches the AI entry point in a step's inline-editor toolbar, which the // recursive module wrapper sits too deep under to be handed a prop. `selected` @@ -1593,6 +1616,7 @@ : undefined }} actions={flowHeaderActions} + sessionSource={headerSessionOpen} actionFolds={2} contexts={headerContexts} separator="none" @@ -1775,6 +1799,7 @@ {@render previewButtons()} {/if} + <OpenInSessionButton source={headerSessionOpen} /> <DeployButton on:save={async ({ detail }) => await handleSaveFlow(detail)} {loading} @@ -1857,9 +1882,7 @@ {forceTestTab} {highlightArg} aiChatOpen={aiChatManager.open} - showFlowAiButton={!disableAi && - customUi?.topBar?.aiBuilder != false && - !$operatorBuilderFlows} + {showFlowAiButton} toggleAiChat={() => aiChatManager.toggleOpen()} {sessionOpen} onOpenPreview={flowPreviewButtons?.openPreview} diff --git a/frontend/src/lib/components/FlowLoopIterationPreview.svelte b/frontend/src/lib/components/FlowLoopIterationPreview.svelte index f136cbfe39..8da0ee390e 100644 --- a/frontend/src/lib/components/FlowLoopIterationPreview.svelte +++ b/frontend/src/lib/components/FlowLoopIterationPreview.svelte @@ -10,9 +10,14 @@ import FlowProgressBar from './flows/FlowProgressBar.svelte' import { CornerDownLeft, Play, RefreshCw, X } from 'lucide-svelte' import type { Schema } from '$lib/common' - import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { + useOperatingUser, + useOperatingWorkspace + } from '$lib/components/operatingWorkspace.svelte' + import { previewOnBehalfOf } from '$lib/utils/previewOnBehalfOf' const operatingWorkspace = useOperatingWorkspace() + const operatingUser = useOperatingUser() interface Props { open: boolean @@ -112,7 +117,12 @@ restartedFrom, undefined, undefined, - opWorkspace?.() + opWorkspace?.(), + previewOnBehalfOf( + operatingUser.current, + flowStore.val.on_behalf_of, + flowStore.val.on_behalf_of_email + ) ) } catch (err: any) { // `runFlowPreview` resolves a linked agent's draft first and refuses when it cannot be read. diff --git a/frontend/src/lib/components/FlowPreviewContent.svelte b/frontend/src/lib/components/FlowPreviewContent.svelte index 151d35be77..67b1dbadff 100644 --- a/frontend/src/lib/components/FlowPreviewContent.svelte +++ b/frontend/src/lib/components/FlowPreviewContent.svelte @@ -48,9 +48,12 @@ useOperatingUser, useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { previewOnBehalfOf } from '$lib/utils/previewOnBehalfOf' + import { useRoleCapabilities } from '$lib/operatorWriteRights' const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() + const roleCaps = useRoleCapabilities() interface Props { previewMode: 'upTo' | 'whole' @@ -211,7 +214,12 @@ restartedFrom, conversationId, devTempScriptRefs?.(), - opWorkspace?.() + opWorkspace?.(), + previewOnBehalfOf( + operatingUser.current, + flowStore.val.on_behalf_of, + flowStore.val.on_behalf_of_email + ) ) jobId = newJobId isRunning = true @@ -562,7 +570,7 @@ | ScriptLang | undefined, $initialPathStore, - operatingUser.current?.operator + !$roleCaps.code )} /> </div> diff --git a/frontend/src/lib/components/ForkWorkspaceBanner.svelte b/frontend/src/lib/components/ForkWorkspaceBanner.svelte index 11779afb11..1736520eed 100644 --- a/frontend/src/lib/components/ForkWorkspaceBanner.svelte +++ b/frontend/src/lib/components/ForkWorkspaceBanner.svelte @@ -3,7 +3,13 @@ import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' import { Badge } from './common' import { AlertTriangle, ArrowDown, ArrowUp, Pencil } from 'lucide-svelte' - import { workspaceStore, userWorkspaces, userStore, type UserExt } from '$lib/stores' + import { + roleCapabilities, + workspaceStore, + userWorkspaces, + userStore, + type UserExt + } from '$lib/stores' import type { WorkspaceComparison } from '$lib/gen' import { fetchWorkspaceComparison } from '$lib/workspaceComparison' import { Button } from './common' @@ -27,18 +33,18 @@ // prefix) also avoids a parentless "Fork of ()" banner when the linkage is dropped. let isFork = $derived(parentWorkspaceId != null) let currentNoun = $derived(childWorkspaceNoun(currentWorkspaceData)) - // Operators run scripts and flows, they never deploy a fork, so the banner and - // its CTA are noise for them. Gates the fetches too, not just the markup: the - // fork/parent comparison is an expensive tally no operator can act on. + // A role without `code` never deploys a fork, so the banner and its CTA are noise + // for it. Gates the fetches too, not just the markup: the fork/parent comparison + // is an expensive tally such a role cannot act on. // - // Only a role fetched for the workspace we are on answers this. `$workspaceStore` + // Only a user fetched for the workspace we are on answers this. `$workspaceStore` // flips synchronously on a switch while `$userStore` still holds the workspace we // left, so trusting it unqualified would flash the banner at (and start the tally - // for) an operator entering a fork from a workspace where they are not one. - function isConfirmedNonOperator(user: UserExt | undefined, ws: string | undefined): boolean { - return !!user && !!ws && user.workspace_id === ws && !user.operator + // for) a restricted member entering a fork from a workspace where they are not one. + function isUserFor(user: UserExt | undefined, ws: string | undefined): boolean { + return !!user && !!ws && user.workspace_id === ws } - let isNotOperator = $derived(isConfirmedNonOperator($userStore, $workspaceStore)) + let isNotOperator = $derived(isUserFor($userStore, $workspaceStore) && $roleCapabilities.code) let showBanner = $derived(isFork && isNotOperator) // Drafts in this fork. When the fork is otherwise in sync with its parent, a diff --git a/frontend/src/lib/components/GroupEditor.svelte b/frontend/src/lib/components/GroupEditor.svelte index b4229cf9f6..44d7dc5eb1 100644 --- a/frontend/src/lib/components/GroupEditor.svelte +++ b/frontend/src/lib/components/GroupEditor.svelte @@ -97,7 +97,10 @@ ) let group: Group | undefined let instance_group: InstanceGroup | undefined = $state() - let usernames: string[] = $state([]) + // Every username of the workspace, for the Add member picker: loaded on its first open, + // since it grows with the workspace and most opens of this editor add no one. + let usernames: string[] | undefined = $state() + let usernamesLoading = $state(false) let groupNames: string[] = $state([]) let loaded = $state(false) let reloadHistory = $state(0) @@ -140,9 +143,41 @@ } async function loadUsernames(): Promise<void> { - usernames = await UserService.listUsernames({ workspace: $operatingWorkspace! }) + if (usernames !== undefined || usernamesLoading) return + usernamesLoading = true + try { + usernames = await UserService.listUsernames({ workspace: $operatingWorkspace! }) + } catch (e) { + sendUserToast(`Could not load users: ${e?.body ?? e}`, true) + } finally { + usernamesLoading = false + } } + // From this many rows a table no longer fits its 320px box: it scrolls, and a search shows. + // Both follow every row, not the matching ones, so typing never resizes the drawer. + const MANY_ROWS = 8 + let memberSearch = $state('') + const memberQuery = $derived( + draft.members.length >= MANY_ROWS ? memberSearch.trim().toLowerCase() : '' + ) + const shownMembers = $derived( + draft.members + .filter((m) => !memberQuery || m.member_name.toLowerCase().includes(memberQuery)) + .sort((a, b) => a.member_name.localeCompare(b.member_name)) + ) + + let emailSearch = $state('') + const instanceEmails = $derived(instance_group?.emails ?? []) + const emailQuery = $derived( + instanceEmails.length >= MANY_ROWS ? emailSearch.trim().toLowerCase() : '' + ) + const shownEmails = $derived( + instanceEmails + .filter((e) => !emailQuery || e.toLowerCase().includes(emailQuery)) + .sort((a, b) => a.localeCompare(b)) + ) + async function loadGroupNames(): Promise<void> { groupNames = (await GroupService.listGroupNames({ workspace: $operatingWorkspace! })) ?? [] } @@ -156,7 +191,6 @@ } async function load() { - loadAside(loadUsernames) if (isNew) { loadAside(loadGroupNames) writeVerdict = true @@ -330,10 +364,10 @@ } catch (e) { sendUserToast(e.body ?? String(e), true) // A failed create is not proof the group is absent: `create_group` commits before a - // git-sync step that can still fail the request, including with a 4xx. Only the name - // conflict says it was never written. Report rather than resolve — a group found by + // git-sync step that can still fail the request, including with a 4xx. Only a name + // conflict or reservation proves absence. Report rather than resolve — a group found by // name may be someone else's, and adopting it would send this draft's writes there. - const nameTaken = String(e?.body ?? '').includes('already exists') + const nameTaken = /already exists|is reserved/.test(String(e?.body ?? '')) if (created && !alreadyCreated && !nameTaken) { sendUserToast(`Group ${name} may have been created anyway — reopen it to check`, true) } @@ -410,69 +444,87 @@ <Label label={`Members (${draft.members.length})`} tooltip={MEMBERS_EXPLAINER}> {#snippet action()} - {#if can_write && !restricted} - <Popover - placement="bottom-end" - onClose={() => { - memberToAdd = '' - newMemberRole = 'member' - }} - > - {#snippet trigger()} - <Button - variant="default" - unifiedSize="sm" - nonCaptureEvent={true} - startIcon={{ icon: Plus }} - > - Add member - </Button> - {/snippet} - {#snippet content({ close })} - <div class="flex flex-col w-72 p-4 gap-4"> - <span class="text-sm leading-6 font-semibold">Add a member</span> - <Label label="User"> - <Select - items={safeSelectItems( - usernames.filter((x) => !draft.members.some((m) => m.member_name === x)) - )} - bind:value={memberToAdd} - size="sm" - class="grow min-w-0" - /> - </Label> - <Label label="Role"> - <ToggleButtonGroup bind:selected={newMemberRole}> - {#snippet children({ item })} - <ToggleButton - value="member" - label="Member" - tooltip={ROLE_TOOLTIPS.member} - {item} - size="sm" - /> - <ToggleButton - value="admin" - label="Admin" - tooltip={ROLE_TOOLTIPS.admin} - {item} - size="sm" - /> - {/snippet} - </ToggleButtonGroup> - </Label> + <div class="flex items-center gap-2 min-w-0"> + {#if draft.members.length >= MANY_ROWS} + <TextInput + size="sm" + class="w-56 min-w-0" + bind:value={memberSearch} + inputProps={{ + type: 'search', + placeholder: `Search ${draft.members.length} members`, + 'aria-label': 'Search members' + }} + /> + {/if} + {#if can_write && !restricted} + <Popover + placement="bottom-end" + on:openChange={(e) => e.detail && loadUsernames()} + onClose={() => { + memberToAdd = '' + newMemberRole = 'member' + }} + > + {#snippet trigger()} <Button - variant="accent" + variant="default" unifiedSize="sm" - disabled={memberToAdd == ''} - onClick={() => addMember(close)} + nonCaptureEvent={true} + startIcon={{ icon: Plus }} > - Add + Add member </Button> - </div> - {/snippet} - </Popover> - {/if} + {/snippet} + {#snippet content({ close })} + <div class="flex flex-col w-72 p-4 gap-4"> + <span class="text-sm leading-6 font-semibold">Add a member</span> + <Label label="User"> + <Select + items={safeSelectItems( + (usernames ?? []).filter( + (x) => !draft.members.some((m) => m.member_name === x) + ) + )} + loading={usernamesLoading} + bind:value={memberToAdd} + size="sm" + class="grow min-w-0" + /> + </Label> + <Label label="Role"> + <ToggleButtonGroup bind:selected={newMemberRole}> + {#snippet children({ item })} + <ToggleButton + value="member" + label="Member" + tooltip={ROLE_TOOLTIPS.member} + {item} + size="sm" + /> + <ToggleButton + value="admin" + label="Admin" + tooltip={ROLE_TOOLTIPS.admin} + {item} + size="sm" + /> + {/snippet} + </ToggleButtonGroup> + </Label> + <Button + variant="accent" + unifiedSize="sm" + disabled={memberToAdd == ''} + onClick={() => addMember(close)} + > + Add + </Button> + </div> + {/snippet} + </Popover> + {/if} + </div> {/snippet} <div class="flex flex-col gap-2"> {#if can_write && restricted} @@ -480,19 +532,31 @@ {/if} {#if loaded} - <DataTable size="sm"> + <!-- A definite height, not a max: it is what lets the table's own box scroll under + its sticky header. Fixed column widths keep the columns still as rows change. --> + <DataTable + size="sm" + tableFixed + containerClass={draft.members.length >= MANY_ROWS ? 'h-80' : ''} + > <Head> <tr> <Cell head first class="text-secondary">Name</Cell> - <Cell head class="text-secondary">Role</Cell> - <Cell head last actions class="text-secondary">Actions</Cell> + <Cell head class="text-secondary {can_write && !restricted ? 'w-60' : 'w-24'}"> + Role + </Cell> + <Cell head last actions class="text-secondary {isNew ? 'w-40' : 'w-20'}"> + Actions + </Cell> </tr> </Head> <tbody class="divide-y"> - {#each draft.members as member, idx (member.member_name)} + {#each shownMembers as member (member.member_name)} <Row> <Cell first> - <span class="text-emphasis font-medium">{member.member_name}</span> + <span class="block truncate text-emphasis font-medium" title={member.member_name}> + {member.member_name} + </span> </Cell> <Cell> {#if can_write && !restricted} @@ -501,7 +565,7 @@ disabled={isFixedCreatorRow(member.member_name)} selected={member.role} on:selected={(e) => { - draft.members[idx].role = e.detail + member.role = e.detail }} > {#snippet children({ item })} @@ -559,6 +623,13 @@ </Cell> </Row> {/each} + {#if memberQuery && shownMembers.length === 0} + <Row> + <Cell first last colspan={3} class="text-hint truncate"> + No one matches “{memberSearch.trim()}” + </Cell> + </Row> + {/if} </tbody> </DataTable> {:else} @@ -573,18 +644,45 @@ {#if instance_group?.emails} <Label label="Members from the instance group"> - <DataTable size="sm"> + {#snippet action()} + {#if instanceEmails.length >= MANY_ROWS} + <TextInput + size="sm" + class="w-56 min-w-0" + bind:value={emailSearch} + inputProps={{ + type: 'search', + placeholder: `Search ${instanceEmails.length} members`, + 'aria-label': 'Search members from the instance group' + }} + /> + {/if} + {/snippet} + <DataTable + size="sm" + tableFixed + containerClass={instanceEmails.length >= MANY_ROWS ? 'h-80' : ''} + > <Head> <tr> <Cell head first last class="text-secondary">Email</Cell> </tr> </Head> <tbody class="divide-y"> - {#each instance_group?.emails ?? [] as email} + {#each shownEmails as email (email)} <Row> - <Cell first last>{email}</Cell> + <Cell first last> + <span class="block truncate" title={email}>{email}</span> + </Cell> </Row> {/each} + {#if emailQuery && shownEmails.length === 0} + <Row> + <Cell first last class="text-hint truncate"> + No one matches “{emailSearch.trim()}” + </Cell> + </Row> + {/if} </tbody> </DataTable> </Label> diff --git a/frontend/src/lib/components/InheritedLabels.svelte b/frontend/src/lib/components/InheritedLabels.svelte index bfc8190ea4..f15aaf5567 100644 --- a/frontend/src/lib/components/InheritedLabels.svelte +++ b/frontend/src/lib/components/InheritedLabels.svelte @@ -1,33 +1,15 @@ <script lang="ts"> - import Badge from './common/badge/Badge.svelte' + import LabelList from './common/badge/LabelList.svelte' + import type { BadgeColor } from './common/badge/model' import { Folder } from 'lucide-svelte' interface Props { labels: string[] | undefined max?: number + color?: BadgeColor } - let { labels, max = 3 }: Props = $props() + let { labels, max, color }: Props = $props() </script> -{#if labels?.length} - <div class="flex items-center gap-0.5"> - {#each labels.slice(0, max) as label (label)} - <Badge color="gray" small class="px-1" title="Label inherited from folder: {label}"> - <Folder size={10} class="mr-0.5 shrink-0" /> - {label} - </Badge> - {/each} - {#if labels.length > max} - <Badge - color="gray" - small - class="px-1" - title={labels - .slice(max) - .map((l) => 'Label inherited from folder: ' + l) - .join('\n')}>+{labels.length - max}</Badge - > - {/if} - </div> -{/if} +<LabelList {labels} icon={Folder} titlePrefix="Label inherited from folder" {max} {color} /> diff --git a/frontend/src/lib/components/InputTransformForm.svelte b/frontend/src/lib/components/InputTransformForm.svelte index 9f8c77ce77..b87afb00f1 100644 --- a/frontend/src/lib/components/InputTransformForm.svelte +++ b/frontend/src/lib/components/InputTransformForm.svelte @@ -105,6 +105,8 @@ pickableProperties?: PickableProperties | undefined enableAi?: boolean hideHelpButton?: boolean + /** A footnote under the input, sharing the Help toggle's row when there is one. */ + hint?: string class?: string editor?: SimpleEditor | undefined otherArgs?: Record<string, InputTransform> @@ -150,6 +152,7 @@ pickableProperties = undefined, enableAi = false, hideHelpButton = false, + hint = undefined, class: className = '', editor = $bindable(undefined), otherArgs = {}, @@ -907,7 +910,9 @@ step-input autocompletion (ghost text, accepted with Tab) doesn't hide the Help dropdown — the two stay independent. --> {#if !hideHelpButton && propertyType === 'javascript' && argKind === 'javascript' && arg.expr != undefined} - <DynamicInputHelpBox /> + <DynamicInputHelpBox {hint} /> + {:else if hint} + <p class="mt-1 text-2xs text-hint">{hint}</p> {/if} {#snippet innerInput()} diff --git a/frontend/src/lib/components/InstanceSetting.svelte b/frontend/src/lib/components/InstanceSetting.svelte index d6740c6c9b..ad2ee14b99 100644 --- a/frontend/src/lib/components/InstanceSetting.svelte +++ b/frontend/src/lib/components/InstanceSetting.svelte @@ -21,6 +21,8 @@ import SimpleEditor from './SimpleEditor.svelte' import CriticalAlertChannels from './instanceSettings/CriticalAlertChannels.svelte' import RetentionPeriodOverrides from './instanceSettings/RetentionPeriodOverrides.svelte' + import AgentWorkloadIdentityIssuers from './instanceSettings/AgentWorkloadIdentityIssuers.svelte' + import WorkspaceRolesSetting from './instanceSettings/WorkspaceRolesSetting.svelte' import SmtpSettings from './instanceSettings/SmtpSettings.svelte' import SecretBackendConfig from './instanceSettings/SecretBackendConfig.svelte' import ExternalInstancePgSettings from './instanceSettings/ExternalInstancePgSettings.svelte' @@ -341,6 +343,8 @@ getInstanceConfig() has populated the persisted overrides (which would let a save drop them). --> <RetentionPeriodOverrides {values} disabled={!$enterpriseLicense || loading} /> + {:else if setting.fieldType == 'workspace_roles'} + <WorkspaceRolesSetting {values} {loading} unlicensed={!$enterpriseLicense} /> {:else} <SettingCard label={setting.key === 'disable_stats' @@ -629,6 +633,8 @@ </Button> </div> </div> + {:else if setting.fieldType == 'agent_workload_identity_issuers'} + <AgentWorkloadIdentityIssuers {values} disabled={!$enterpriseLicense} /> {:else if setting.fieldType == 'critical_error_channels'} <CriticalAlertChannels {values} {openSmtpSettings} {oauths} /> {:else if setting.fieldType == 'otel'} diff --git a/frontend/src/lib/components/InstanceSettings.svelte b/frontend/src/lib/components/InstanceSettings.svelte index a1a0f214d3..d60cbeda56 100644 --- a/frontend/src/lib/components/InstanceSettings.svelte +++ b/frontend/src/lib/components/InstanceSettings.svelte @@ -307,6 +307,8 @@ const connectConfigTemplates: Record<string, any> = Object.fromEntries( Object.entries(oauthConnectRegistry) .filter(([, cfg]) => cfg && typeof cfg === 'object' && 'connect_config_template' in cfg) + // ms_teams takes its tenant from the pinned Teams tile; the backend fills the URLs. + .filter(([name]) => name !== 'ms_teams') .map(([name, cfg]) => [name, (cfg as any).connect_config_template]) ) @@ -326,6 +328,12 @@ // instance name + its registry template (substituting {instance} into the // URLs). Replaces the old per-provider setup functions. function setupTemplatedOauthUrls() { + // The backend falls back to the pre-rename `teams` key when `ms_teams` is absent, so + // turning the Teams tile off (ms_teams saved before, gone now) has to drop both. + // TODO: remove with the backend fallback. + if (oauths && initialOauths?.['ms_teams'] && !oauths['ms_teams'] && oauths['teams']) { + delete oauths['teams'] + } for (const [name, tmpl] of Object.entries(connectConfigTemplates)) { if (!oauths?.[name]) continue const key = tmpl.extra_params_key ?? 'instance' @@ -1132,6 +1140,12 @@ description="Configure default timeouts and retention policies for job execution." link="https://www.windmill.dev/docs/advanced/instance_settings#jobs" /> + {:else if category == 'Workspace roles'} + <SettingsPageHeader + title="Workspace roles" + description="Define the roles workspace members can hold and what each one may author, manage and see. Workspace admin is separate and holds everything." + link="https://www.windmill.dev/docs/core_concepts/roles_and_permissions" + /> {:else if category == 'Service logs'} <SettingsPageHeader title="Service logs" @@ -1196,6 +1210,12 @@ {#if $enterpriseLicense} <OidcSigningStatus /> {/if} + {:else if category == 'Agent workers'} + <SettingsPageHeader + title="Agent workers" + description="Optional. Agent workers authenticate with the agent token (AGENT_TOKEN) you create on the Workers page with New agent worker, which needs nothing set here. Add an issuer only to also accept workload identity tokens." + link="https://www.windmill.dev/docs/core_concepts/agent_workers" + /> {:else if category == 'Auth/OAuth/SAML'} <AuthSettings bind:oauths diff --git a/frontend/src/lib/components/InviteUser.svelte b/frontend/src/lib/components/InviteUser.svelte index 7aded95461..83093d969f 100644 --- a/frontend/src/lib/components/InviteUser.svelte +++ b/frontend/src/lib/components/InviteUser.svelte @@ -1,16 +1,19 @@ <script lang="ts"> import { Button } from './common' import Popover from './meltComponents/Popover.svelte' - - import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' - import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' + import RolePicker from './settings/RolePicker.svelte' + import type { EffectiveRole } from '$lib/gen' + import { validRoleOr } from '$lib/workspaceRoles' import { MailPlus } from 'lucide-svelte' interface Props { - inviteUser: (email: string, selected: 'operator' | 'developer' | 'admin') => Promise<void> + /** `selected` is a role name, or `admin`. */ + inviteUser: (email: string, selected: string) => Promise<void> + /** The workspace's roles; undefined while loading. */ + roles: EffectiveRole[] | undefined } - const { inviteUser }: Props = $props() + const { inviteUser, roles }: Props = $props() let email: string = $state('') @@ -23,7 +26,8 @@ } } - let selected: 'operator' | 'developer' | 'admin' = $state('developer') + let picked: string | undefined = $state(undefined) + const selected = $derived(validRoleOr(roles, picked)) </script> <Popover floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}> @@ -46,30 +50,7 @@ bind:value={email} class="mr-4" /> - <ToggleButtonGroup bind:selected> - {#snippet children({ item })} - <ToggleButton - value="operator" - label="Operator" - tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on." - {item} - /> - - <ToggleButton - value="developer" - label="Developer" - tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)." - {item} - /> - - <ToggleButton - value="admin" - label="Admin" - tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace." - {item} - /> - {/snippet} - </ToggleButtonGroup> + <RolePicker {roles} disablePortal value={selected} onchange={(v) => (picked = v)} /> <Button variant="accent" unifiedSize="md" diff --git a/frontend/src/lib/components/JobLoader.svelte b/frontend/src/lib/components/JobLoader.svelte index c59f4266b7..fa812cc6e4 100644 --- a/frontend/src/lib/components/JobLoader.svelte +++ b/frontend/src/lib/components/JobLoader.svelte @@ -268,7 +268,8 @@ args: Record<string, any>, flow: OpenFlow & { tag?: string }, callbacks?: Callbacks, - path?: string + path?: string, + onBehalfOf?: string ): Promise<string> { return abstractRun( () => @@ -278,7 +279,8 @@ args, value: flow.value, tag: flow.tag, - path + path, + on_behalf_of: onBehalfOf } }), callbacks @@ -336,7 +338,8 @@ flowPath?: string, modules?: Record<string, import('$lib/gen').ScriptModule> | null, tempScriptRefs?: Record<string, string>, - timeout?: number + timeout?: number, + previewAs?: { onBehalfOf?: string; viewerDataAccess?: boolean } ): Promise<string> { return abstractRun( () => @@ -353,7 +356,9 @@ script_hash: hash, flow_path: flowPath, modules: modules ?? undefined, - temp_script_refs: tempScriptRefs + temp_script_refs: tempScriptRefs, + on_behalf_of: previewAs?.onBehalfOf, + viewer_data_access: previewAs?.viewerDataAccess || undefined } }), callbacks diff --git a/frontend/src/lib/components/JobStatus.svelte b/frontend/src/lib/components/JobStatus.svelte index 7945081482..5e79303371 100644 --- a/frontend/src/lib/components/JobStatus.svelte +++ b/frontend/src/lib/components/JobStatus.svelte @@ -34,7 +34,7 @@ <Badge {large} color="orange" title={job.resolution_note}> <!-- `resolved_by` is absent both for an automatic resolution and for a manual one outside EE, so the automatic wording comes from `resolved_automatically`. --> - Failed after {msToReadableTime(job.duration_ms)}, resolved{job.resolved_automatically + {job.canceled ? 'Canceled' : 'Failed'} after {msToReadableTime(job.duration_ms)}, resolved{job.resolved_automatically ? ' automatically' : job.resolved_by ? ` by ${job.resolved_by}` @@ -49,7 +49,7 @@ </Badge> {:else if job && 'success' in job} <Badge {large} color="red"> - Failed after {msToReadableTime(job.duration_ms)} + {job.canceled ? 'Canceled' : 'Failed'} after {msToReadableTime(job.duration_ms)} {#if job.self_wait_time_ms || job.aggregate_wait_time_ms} <WaitTimeWarning self_wait_time_ms={job.self_wait_time_ms} diff --git a/frontend/src/lib/components/JsonInputs.svelte b/frontend/src/lib/components/JsonInputs.svelte index 2f4b04f8e4..546ab36069 100644 --- a/frontend/src/lib/components/JsonInputs.svelte +++ b/frontend/src/lib/components/JsonInputs.svelte @@ -13,13 +13,18 @@ * payload nobody has typed into tracks the schema instead of going stale. The first edit * hands the buffer to the user and later changes stop overwriting it. */ initialCode?: string + /** Show the payload without taking edits. For a caller that must keep this editor + * mounted while refusing writes: swapping it for the fields instead would mount the + * widgets, and a `password` field mints a variable from whatever literal it opens on. */ + readOnly?: boolean } let { updateOnBlur = true, placeholder = 'Write a JSON payload. The input schema will be inferred.<br/><br/>Example:<br/><br/>{<br/>  "foo": "12"<br/>}', selected = false, - initialCode = '' + initialCode = '', + readOnly = false }: Props = $props() let pendingJson = $state(untrack(() => initialCode)) @@ -130,6 +135,7 @@ bind:code={pendingJson} lang="json" class="h-full json-inputs-editor" + {readOnly} {placeholder} /> </div> diff --git a/frontend/src/lib/components/Login.svelte b/frontend/src/lib/components/Login.svelte index 1199d01079..d76136c728 100644 --- a/frontend/src/lib/components/Login.svelte +++ b/frontend/src/lib/components/Login.svelte @@ -28,7 +28,7 @@ import PocketIdIcon from '$lib/components/icons/PocketIdIcon.svelte' import { OauthService, UserService, WorkspaceService } from '$lib/gen' - import { usersWorkspaceStore, workspaceStore, userStore } from '$lib/stores' + import { usersWorkspaceStore, workspaceStore, userStore, roleCapabilities } from '$lib/stores' import { emptyString, escapeHtml, parseQueryParams } from '$lib/utils' import { getUserExt } from '$lib/user' import { sendUserToast } from '$lib/toast' @@ -337,7 +337,7 @@ workspaceStore.set(allWorkspaces[0].id) $userStore = await getUserExt($workspaceStore!) - if (!$userStore?.is_super_admin && $userStore?.operator) { + if (!$roleCapabilities.code) { let defaultApp = await WorkspaceService.getWorkspaceDefaultApp({ workspace: $workspaceStore! }) @@ -350,9 +350,10 @@ } else { goto(resolvedRd ?? '/') } - // See (root)/+layout.svelte for why /projects/import skips the picker. + // See (root)/+layout.svelte for why these skip the picker. } else if ( resolvedRd?.startsWith('/user/workspaces') || + resolvedRd?.startsWith('/oauth/mcp_authorize') || resolvedRd?.startsWith('/projects/import') ) { goto(resolvedRd) diff --git a/frontend/src/lib/components/ModuleTest.svelte b/frontend/src/lib/components/ModuleTest.svelte index 8d2b51e8d0..e7a0769960 100644 --- a/frontend/src/lib/components/ModuleTest.svelte +++ b/frontend/src/lib/components/ModuleTest.svelte @@ -19,11 +19,15 @@ normalizeAgentRef, type LinkedAgentDraft } from './flows/linkedAgentDrafts' - import { AGENT_FLOW_LOCAL_KEYS } from './flows/agentResourceUtils' - import { agentTestInputTransforms } from './flows/agentFormFields' + import { AGENT_LINKED_STEP_KEYS } from './flows/agentResourceUtils' + import { AGENT_OVERRIDE_KEYS, agentTestInputTransforms } from './flows/agentFormFields' import { sendUserToast } from '$lib/toast' import { quickjsReady } from '$lib/utils/quickjsEval.svelte' - import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { + useOperatingUser, + useOperatingWorkspace + } from '$lib/components/operatingWorkspace.svelte' + import { previewOnBehalfOf } from '$lib/utils/previewOnBehalfOf' const operatingWorkspace = useOperatingWorkspace() @@ -57,6 +61,16 @@ } = getContext<FlowEditorContext>('FlowEditorContext') let previewBase = $derived($pathStore ?? '') + const operatingUser = useOperatingUser() + // A step runs as the flow does, so its test does too; a workspace script with its own + // `on_behalf_of` runs as that instead, in the flow as here. + let onBehalfOf = $derived( + previewOnBehalfOf( + operatingUser.current, + flowStore?.val?.on_behalf_of, + flowStore?.val?.on_behalf_of_email + ) + ) // Acting workspace when the flow editor runs in an AI session; else the nav workspace. let opWs = $derived(opWorkspace?.() ?? $operatingWorkspace) @@ -126,7 +140,8 @@ previewBase, undefined, devTempScriptRefs?.(), - timeout + timeout, + { onBehalfOf } ) } else if (val.type == 'script') { const script = val.hash @@ -144,7 +159,15 @@ previewBase, undefined, undefined, - timeout + timeout, + { + onBehalfOf: + previewOnBehalfOf( + operatingUser.current, + script.on_behalf_of, + script.on_behalf_of_email + ) ?? onBehalfOf + } ) } else if (val.type == 'flow') { await jobLoader?.runFlowByPath(val.path, args, callbacks) @@ -186,7 +209,13 @@ const inputTransforms = agentTestInputTransforms( agentVal.agent ? {} : ((agentVal.input_transforms ?? {}) as Record<string, InputTransform>), args, - draft ? AGENT_FLOW_LOCAL_KEYS : Object.keys(args) + // An override the step does not hold is left out: fed from the form it would arrive as an + // authored empty value, which clears the agent's saved effort. + (draft ? AGENT_LINKED_STEP_KEYS : Object.keys(args)).filter( + (key) => + !(AGENT_OVERRIDE_KEYS as readonly string[]).includes(key) || + val.input_transforms?.[key] != undefined + ) ) await jobLoader?.runFlowPreview( @@ -213,7 +242,8 @@ schema }, callbacks, - previewBase + previewBase, + onBehalfOf ) } else if (val.type == 'aidecision') { const { schema } = await loadSchemaFromModule(mod, opWs) @@ -235,7 +265,8 @@ schema }, callbacks, - previewBase + previewBase, + onBehalfOf ) } else { throw Error('Not supported module type') diff --git a/frontend/src/lib/components/NavBreadcrumb.svelte b/frontend/src/lib/components/NavBreadcrumb.svelte index 4df66e0362..2169149426 100644 --- a/frontend/src/lib/components/NavBreadcrumb.svelte +++ b/frontend/src/lib/components/NavBreadcrumb.svelte @@ -17,7 +17,7 @@ not this component) — so the lead can run the whole line. import { Menu, Menubar } from '$lib/components/meltComponents' import MeltButton from '$lib/components/meltComponents/MeltButton.svelte' import WorkspacePickerBody from '$lib/components/sidebar/WorkspacePickerBody.svelte' - import BreadcrumbSegment from '$lib/components/BreadcrumbSegment.svelte' + import BreadcrumbNavPicker from './BreadcrumbNavPicker.svelte' import { userWorkspaces, workspaceStore, workspaceColor, superadmin } from '$lib/stores' import { findWorkspaceRoot } from '$lib/utils/workspaceHierarchy' import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte' @@ -29,7 +29,7 @@ not this component) — so the lead can run the whole line. import { page } from '$app/state' import { navPageFor } from './sidebar/navPages' import { copyToClipboard, getContrastTextColor } from '$lib/utils' - import { KIND_LABEL_LOWER, kindKey } from '$lib/components/workspacePicker' + import { KIND_LABEL_LOWER } from '$lib/components/workspacePicker' import type { PageHeaderItem, PageHeaderSection } from './pageHeaderRegistry.svelte' import type { Snippet } from 'svelte' import { @@ -258,6 +258,20 @@ not this component) — so the lead can run the whole line. {/if} {/snippet} +{#snippet navPicker(measure: boolean)} + <!-- The page's own name is the last segment, so the chevron after it moves to a sibling page or + item. Not in a session's preview band, which must not lead out of the host's workspace. --> + {#if !nameOnly && !section?.noNavPicker} + {#if measure} + <span class="flex items-center h-9 px-1.5 shrink-0"> + <ChevronDown size={ICON} class="flex-shrink-0" /> + </span> + {:else} + <BreadcrumbNavPicker {item} pagePath={routePage?.path} {actingWorkspaceId} iconSize={ICON} /> + {/if} + {/if} +{/snippet} + {#snippet slash(extra = '')} <!-- `extra` tops the workspace part's own 4px gap up to the 6px the breadcrumb puts around its other separators, so the slash before a fork is spaced like every other slash on the line. --> @@ -433,6 +447,7 @@ not this component) — so the lead can run the whole line. {@render pathLabel()} </button> {/if} + {#if itemIsLast}{@render navPicker(measure)}{/if} {@render afterNameSlot(measure)} {#if pathCopied && !measure} <span @@ -458,21 +473,14 @@ not this component) — so the lead can run the whole line. </span> {/if} {:else if section?.kind} - {#if measure} - <span class={twMerge('inline-flex items-center px-1', SEGMENT, LAST)}> - {@render sectionKindIcon()}<span>{KIND_LABEL_LOWER[section.kind]}</span> - </span> - {:else} - <BreadcrumbSegment - label={KIND_LABEL_LOWER[section.kind]} - icon={sectionKindIcon} - extraClass={twMerge(SEGMENT, LAST)} - isCurrent - initialHighlight={kindKey(section.kind)} - initialScope={{ kind: section.kind }} - onPick={() => {}} - /> - {/if} + <span + class={twMerge(SEGMENT, LAST, 'shrink-0 hover:bg-transparent hover:text-emphasis')} + aria-current={measure ? undefined : 'page'} + > + {@render sectionKindIcon()} + <span class="truncate">{KIND_LABEL_LOWER[section.kind]}</span> + </span> + {@render navPicker(measure)} {:else if section} {#if section.parent} <!-- Gives width back with the workspace part, before the section's own name does. --> @@ -507,6 +515,7 @@ not this component) — so the lead can run the whole line. >{/if} </span> {/if} + {@render navPicker(measure)} {@render afterNameSlot(measure)} {:else if routePage} {@const RouteIcon = routePage.icon} @@ -519,6 +528,7 @@ not this component) — so the lead can run the whole line. {#if RouteIcon}<RouteIcon size={ICON} class="flex-shrink-0 text-tertiary" />{/if} <span class="truncate">{routePage.label}</span> </span> + {@render navPicker(measure)} {@render afterNameSlot(measure)} {/if} {/snippet} diff --git a/frontend/src/lib/components/NoDirectDeployAlert.svelte b/frontend/src/lib/components/NoDirectDeployAlert.svelte index a3a249caf5..8ddf5c760a 100644 --- a/frontend/src/lib/components/NoDirectDeployAlert.svelte +++ b/frontend/src/lib/components/NoDirectDeployAlert.svelte @@ -25,9 +25,11 @@ useOperatingWorkspace, useOperatingUser } from '$lib/components/operatingWorkspace.svelte' + import { useRoleCapabilities } from '$lib/operatorWriteRights' const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() + const roleCaps = useRoleCapabilities() // 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. @@ -97,7 +99,7 @@ ) </script> -{#if !operatingUser.current?.operator && activeDeployRulesets.length > 0} +{#if ($roleCaps.code || $roleCaps.flows !== 'none' || $roleCaps.apps !== 'none') && activeDeployRulesets.length > 0} <div class="my-2"> <Popover placement="bottom-start" diff --git a/frontend/src/lib/components/OAuthSetting.svelte b/frontend/src/lib/components/OAuthSetting.svelte index 60f8e8ef5f..974cae68e5 100644 --- a/frontend/src/lib/components/OAuthSetting.svelte +++ b/frontend/src/lib/components/OAuthSetting.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import { ExternalLink, Plus, X } from 'lucide-svelte' + import { Plus, X } from 'lucide-svelte' import CollapseLink from './CollapseLink.svelte' import IconedResourceType from './IconedResourceType.svelte' import Toggle from './Toggle.svelte' @@ -9,6 +9,7 @@ import TextInput from './text_input/TextInput.svelte' import Password from './Password.svelte' import SettingCard from './instanceSettings/SettingCard.svelte' + import SlackAppSetup from './common/slack/SlackAppSetup.svelte' interface Props { name: string @@ -90,7 +91,7 @@ disabled={eeOnly && !$enterpriseLicense} on:change={(e) => { if (e.detail) { - if (name === 'teams') { + if (name === 'ms_teams') { value = { id: '', secret: '', tenant: '' } } else { value = { id: '', secret: '' } @@ -106,7 +107,7 @@ </label> {#if enabled} <SettingCard class="mb-4 flex flex-col gap-6"> - {#if name != 'slack' && name != 'teams'} + {#if name != 'slack' && name != 'ms_teams'} <label class="flex flex-col gap-1"> <span class="text-emphasis font-semibold text-xs">Custom Name</span> <TextInput @@ -130,10 +131,24 @@ bind:password={value['secret']} /> </label> - {#if name == 'microsoft' || name == 'teams'} + {#if name == 'slack'} + <label for="slack_signing_secret" class="flex flex-col gap-1"> + <span class="text-emphasis font-semibold text-xs">Signing Secret</span> + <Password + id="slack_signing_secret" + placeholder="Signing Secret" + bind:password={value['signing_secret']} + /> + <span class="text-2xs text-hint"> + Lets Windmill check that requests come from Slack. Required for AI agents; recommended + for <code>/windmill</code> commands. + </span> + </label> + {/if} + {#if name == 'microsoft' || name == 'ms_teams'} <label class="flex flex-col gap-1"> <span class="text-emphasis font-semibold text-xs">Tenant Id</span> - {#if name === 'teams'} + {#if name === 'ms_teams'} <TextInput inputProps={{ type: 'text', placeholder: 'Tenant Id' }} bind:value={value['tenant']} @@ -203,17 +218,7 @@ </CollapseLink> {:else if name == 'slack'} <CollapseLink text="Set up slack"> - <div class="text-xs text-primary rounded-md"> - To use Slack OAuth, create a new Slack app <a - href="https://api.slack.com/apps?new_app=1" - target="_blank" - >in slack API console - <ExternalLink size={12} class="inline-block" /> - </a>. Pick "From a manifest", then YAML and paste manifest template found on - <a href="https://www.windmill.dev/docs/misc/setup_oauth#slack" target="_blank" - >Windmill docs <ExternalLink size={12} class="inline-block" /></a - > and then paste Client ID and Client Secret here. - </div> + <SlackAppSetup /> </CollapseLink> {:else if name == 'microsoft'} <CollapseLink text="Instructions"> @@ -280,14 +285,18 @@ </div> </div> </CollapseLink> - {:else if name == 'teams'} + {:else if name == 'ms_teams'} <CollapseLink text="Instructions"> <div class="text-xs text-primary rounded-md"> Follow this guide on <a href="https://www.windmill.dev/docs/misc/setup_oauth#microsoft-teams" target="_blank">Windmill Docs</a - > to create a new Microsoft Teams App. Then paste Client ID, Tenant ID, and Client Secret - here. + > + to create a new Microsoft Teams App. Then paste Client ID, Tenant ID, and Client Secret here. + The same app serves the Teams bot (Enterprise Edition) and the Microsoft Teams OAuth resource: + to connect that resource, also add the redirect URI + <code>BASE_URL/oauth/callback/ms_teams</code> and the delegated Microsoft Graph permissions + you need to the app. </div> </CollapseLink> {/if} diff --git a/frontend/src/lib/components/OnBehalfOfSelector.svelte b/frontend/src/lib/components/OnBehalfOfSelector.svelte index 67648936ae..127e21a9e2 100644 --- a/frontend/src/lib/components/OnBehalfOfSelector.svelte +++ b/frontend/src/lib/components/OnBehalfOfSelector.svelte @@ -19,14 +19,16 @@ kind === 'app' || kind === 'raw_app' || kind === 'trigger' || - isTriggerOrScheduleKind(kind) + isTriggerOrScheduleKind(kind) || + // Only an agent's value carries an identity, so no other resource has a source value. + kind === 'resource' if (!supported) return false return !!sourceValue } </script> <script lang="ts"> - import { Check, UserCog, Users, ExternalLink } from 'lucide-svelte' + import { Check, User as UserIcon, UserCog, Users, ExternalLink } from 'lucide-svelte' import MeltPopover from './meltComponents/Popover.svelte' import Portal from './Portal.svelte' import Modal from './common/modal/Modal.svelte' @@ -74,6 +76,9 @@ * one is the wrong name whenever the deploy targets a different workspace. */ myPermissionedAs?: string + /** Without `canPreserve`, show the current user as plain text instead of a popover whose + * only enabled option is "me". The host must treat that as the choice already made. */ + readOnlyWithoutPreserve?: boolean } let { @@ -88,7 +93,8 @@ folderDefault = undefined, aboveConfirmationModal = false, onPickerOpenChange = undefined, - myPermissionedAs = undefined + myPermissionedAs = undefined, + readOnlyWithoutPreserve = false }: Props = $props() const isTrigger = $derived(kind === 'trigger' || isTriggerOrScheduleKind(kind)) @@ -187,77 +193,84 @@ }) </script> -<MeltPopover - placement="bottom" - contentClasses={aboveConfirmationModal ? 'z-[10001]' : ''} - on:openChange={(e) => e.detail && loadUsers()} -> - {#snippet trigger()} - <span class="inline-flex items-center gap-1"> - <UserCog class="w-4 h-4 {selected ? 'text-green-500' : 'text-yellow-500'}" /> - {#if selectedDisplayName} - <span class="text-xs truncate max-w-24">{selectedDisplayName}</span> - {/if} - {#if isFolderDefaultSelected} - <span - class="text-[10px] px-1 py-0.5 rounded bg-blue-50 dark:bg-blue-950/50 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-900" - title="From this folder's default_permissioned_as rule" +{#if readOnlyWithoutPreserve && !canPreserve} + <span class="inline-flex items-center gap-1 text-xs text-secondary"> + <UserIcon class="w-4 h-4" /> + {myDisplayName} + </span> +{:else} + <MeltPopover + placement="bottom" + contentClasses={aboveConfirmationModal ? 'z-[10001]' : ''} + on:openChange={(e) => e.detail && loadUsers()} + > + {#snippet trigger()} + <span class="inline-flex items-center gap-1"> + <UserCog class="w-4 h-4 {selected ? 'text-green-500' : 'text-yellow-500'}" /> + {#if selectedDisplayName} + <span class="text-xs truncate max-w-24">{selectedDisplayName}</span> + {/if} + {#if isFolderDefaultSelected} + <span + class="text-[10px] px-1 py-0.5 rounded bg-blue-50 dark:bg-blue-950/50 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-900" + title="From this folder's default_permissioned_as rule" + > + folder default + </span> + {/if} + </span> + {/snippet} + {#snippet content({ close: closePopover })} + <div class="p-3 flex flex-col gap-2 min-w-48"> + <div class="text-xs font-medium text-secondary mb-1">{label}</div> + <!-- Target option --> + {#if targetValue} + <button + class="flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs hover:bg-surface-hover {!canPreserve + ? 'opacity-50 cursor-not-allowed' + : ''}" + disabled={!canPreserve} + onclick={() => onSelect('target')} + > + <Check class="w-3 h-3 {selected === 'target' ? 'opacity-100' : 'opacity-0'}" /> + <span class="truncate max-w-40">{targetDisplayName}</span> + <span class="text-xs text-tertiary">{isDeployment ? '(target)' : '(current)'}</span> + </button> + {/if} + <!-- Me option --> + <button + class="flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs hover:bg-surface-hover" + onclick={() => onSelect('me')} > - folder default - </span> - {/if} - </span> - {/snippet} - {#snippet content({ close: closePopover })} - <div class="p-3 flex flex-col gap-2 min-w-48"> - <div class="text-xs font-medium text-secondary mb-1">{label}</div> - <!-- Target option --> - {#if targetValue} + <Check class="w-3 h-3 {selected === 'me' ? 'opacity-100' : 'opacity-0'}" /> + <span class="truncate max-w-40">{myDisplayName}</span> + <span class="text-xs text-tertiary">(me)</span> + </button> + <!-- Custom / Pick from workspace --> <button class="flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs hover:bg-surface-hover {!canPreserve ? 'opacity-50 cursor-not-allowed' : ''}" disabled={!canPreserve} - onclick={() => onSelect('target')} + onclick={() => { + closePopover() + openModal() + }} > - <Check class="w-3 h-3 {selected === 'target' ? 'opacity-100' : 'opacity-0'}" /> - <span class="truncate max-w-40">{targetDisplayName}</span> - <span class="text-xs text-tertiary">{isDeployment ? '(target)' : '(current)'}</span> + {#if selected === 'custom' && customDisplayName} + <Check class="w-3 h-3 opacity-100" /> + <span class="truncate max-w-40">{customDisplayName}</span> + <span class="text-xs text-tertiary">(custom)</span> + {:else} + <Check class="w-3 h-3 opacity-0" /> + <Users class="w-3 h-3 text-tertiary" /> + <span>Pick from workspace…</span> + {/if} </button> - {/if} - <!-- Me option --> - <button - class="flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs hover:bg-surface-hover" - onclick={() => onSelect('me')} - > - <Check class="w-3 h-3 {selected === 'me' ? 'opacity-100' : 'opacity-0'}" /> - <span class="truncate max-w-40">{myDisplayName}</span> - <span class="text-xs text-tertiary">(me)</span> - </button> - <!-- Custom / Pick from workspace --> - <button - class="flex items-center gap-2 px-2 py-1.5 rounded text-left text-xs hover:bg-surface-hover {!canPreserve - ? 'opacity-50 cursor-not-allowed' - : ''}" - disabled={!canPreserve} - onclick={() => { - closePopover() - openModal() - }} - > - {#if selected === 'custom' && customDisplayName} - <Check class="w-3 h-3 opacity-100" /> - <span class="truncate max-w-40">{customDisplayName}</span> - <span class="text-xs text-tertiary">(custom)</span> - {:else} - <Check class="w-3 h-3 opacity-0" /> - <Users class="w-3 h-3 text-tertiary" /> - <span>Pick from workspace…</span> - {/if} - </button> - </div> - {/snippet} -</MeltPopover> + </div> + {/snippet} + </MeltPopover> +{/if} <!-- User selection modal. Portalled: the modal positions itself with `fixed`, which resolves against the nearest transformed ancestor — inside a dialog card (which is transformed for diff --git a/frontend/src/lib/components/PageHeaderBar.svelte b/frontend/src/lib/components/PageHeaderBar.svelte index 8c3dd509d5..e7e32df604 100644 --- a/frontend/src/lib/components/PageHeaderBar.svelte +++ b/frontend/src/lib/components/PageHeaderBar.svelte @@ -7,11 +7,12 @@ section name — and the route's own buttons at the far end. The row's height matches the sidebar's own header row, so the two read as one band. --> <script lang="ts"> - import { PanelLeft } from 'lucide-svelte' + import { ChevronDown, PanelLeft } from 'lucide-svelte' import { Button } from '$lib/components/common' import { navDetached } from './sidebar/navDetached.svelte' import { navHandleSlot } from './sidebar/navHandlePlacement.svelte' import NavBreadcrumb from './NavBreadcrumb.svelte' + import BreadcrumbNavPicker from './BreadcrumbNavPicker.svelte' import { BAR_HEIGHT, pageHeader, PHONE_BAR } from './pageHeaderRegistry.svelte' import ContextBridge from './ContextBridge.svelte' import { afterNavigate } from '$app/navigation' @@ -32,12 +33,14 @@ The row's height matches the sidebar's own header row, so the two read as one ba const content = $derived(pageHeader.content) const actions = $derived(pageHeader.actions) + const hasActions = $derived(actions.length > 0) const item = $derived(content?.item) const section = $derived(content?.section) const hasSummary = $derived(!!item && !!(item.summaryContent || item.summary)) /** A summary control can be a placeholder offering to add one; the text is what names the item. */ const summaryNamesItem = $derived(!!item?.summary?.trim()) + const pickerAfterSummary = $derived(hasSummary && !navHidden && !!item?.path) /** A phone's bar: a tighter gutter around the same row. */ const phone = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR) @@ -80,8 +83,8 @@ The row's height matches the sidebar's own header row, so the two read as one ba trail: trailMeasures.trail, summary: BAR_STAGES.map(() => (hasSummary ? GAP + summaryFloorWidth : 0)), actions: - actions.length === 0 ? [0] : actionsRigid ? actionWidths : [GAP + FLEXIBLE_ACTIONS_RESERVE], - folds: actions.length > 0 && actionsRigid ? (content?.actionFolds ?? 0) : 0, + !hasActions ? [0] : actionsRigid ? actionWidths : [GAP + FLEXIBLE_ACTIONS_RESERVE], + folds: hasActions && actionsRigid ? (content?.actionFolds ?? 0) : 0, earlyFolds: content?.earlyActionFolds, summaryNamesItem, natural: { @@ -304,10 +307,15 @@ The row's height matches the sidebar's own header row, so the two read as one ba >{item.summary}</span > {/if} + <!-- The summary ends the trail here, so the breadcrumb's chevron follows it instead. Not + before the path lands: the picker opens in the item's folder, which it reads once. --> + {#if pickerAfterSummary} + <BreadcrumbNavPicker {item} actingWorkspaceId={content?.actingWorkspaceId} /> + {/if} </div> {/if} - {#if actions.length > 0} + {#if hasActions} <!-- A plain row of buttons holds its width (shrink-0): squeezing it only pushes buttons out of the bar, so the bar makes room by dropping breadcrumb parts and then by asking the page to fold (`actionFolds`). Flexible actions (the Runs filter row) are a control that @@ -356,6 +364,9 @@ The row's height matches the sidebar's own header row, so the two read as one ba >{item.summary}</span > {/if} + {#if pickerAfterSummary} + <span class="flex items-center h-9 px-1.5 shrink-0"><ChevronDown size={14} /></span> + {/if} </div> <span class="inline-block whitespace-nowrap text-xs font-medium" diff --git a/frontend/src/lib/components/PageHeaderContent.svelte b/frontend/src/lib/components/PageHeaderContent.svelte index cc37b727cb..94450cf9ab 100644 --- a/frontend/src/lib/components/PageHeaderContent.svelte +++ b/frontend/src/lib/components/PageHeaderContent.svelte @@ -15,6 +15,7 @@ the header, so nesting is decided by the caller rather than guessed from context section, afterName, actions, + sessionSource, actionsOrder, actionsFlexible, actionsFill, @@ -35,6 +36,7 @@ the header, so nesting is decided by the caller rather than guessed from context section, afterName, actions, + sessionSource, actionsOrder, actionsFlexible, actionsFill, diff --git a/frontend/src/lib/components/ParentWorkspaceProtectionAlert.svelte b/frontend/src/lib/components/ParentWorkspaceProtectionAlert.svelte index cd3c208813..f3d1fce472 100644 --- a/frontend/src/lib/components/ParentWorkspaceProtectionAlert.svelte +++ b/frontend/src/lib/components/ParentWorkspaceProtectionAlert.svelte @@ -1,6 +1,6 @@ <script lang="ts"> import type { ProtectionRuleset } from '$lib/gen' - import { userStore, type UserExt } from '$lib/stores' + import { roleCapabilities, type UserExt } from '$lib/stores' import { getUserExt } from '$lib/user' import { fetchProtectionRulesForWorkspace, @@ -71,7 +71,7 @@ }) </script> -{#if !$userStore?.operator && activeDeployRulesets.length > 0} +{#if ($roleCapabilities.code || $roleCapabilities.flows !== 'none' || $roleCapabilities.apps !== 'none') && activeDeployRulesets.length > 0} <!-- Named for the deploy destination, not the lineage: the merge UI also points this at a target outside the fork lineage. --> <Alert type="info" title="Target workspace protection active" class="my-2"> diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index bd9d51638d..3e842e040f 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -9,6 +9,7 @@ } from '$lib/gen' import { canEditItem } from '$lib/editRights' import { userWorkspaces } from '$lib/stores' + import { capabilitiesIn } from '$lib/roleCapabilities' import { createEventDispatcher, onDestroy, untrack } from 'svelte' import { sendUserToast } from '$lib/toast' import { clearJsonSchemaResourceCache } from './schema/jsonSchemaResource.svelte' @@ -269,8 +270,12 @@ path: string, extraPerms: Record<string, boolean> | undefined ): boolean { - const settings = $userWorkspaces.find((w) => w.id === ws)?.operator_settings - return canEditItem('resource', path, extraPerms, acting.in(ws), settings) + const user = acting.in(ws) + const caps = capabilitiesIn( + user, + $userWorkspaces.find((w) => w.id === ws) + ) + return canEditItem('resource', path, extraPerms, user, caps) } const dirtyWorkspaces = $derived( @@ -652,6 +657,11 @@ const s = states[ws].draft! const ini = initialStates[ws] if (existedInitially[ws]) { + const fetched = fetchedResources[ws] + // An agent keeps the identity its value names, as its own editor keeps it: without + // the flag an admin's save would take it over. The backend resets it to the writer + // for anyone not allowed to keep it. + const keepAgentObo = fetched?.resource_type === 'ai_agent' && !!s.args?.on_behalf_of await ResourceService.updateResource({ workspace: ws, path: ini.path, @@ -660,10 +670,10 @@ value: s.args, description: s.description, labels: s.labels, - ws_specific: s.wsSpecific + ws_specific: s.wsSpecific, + preserve_on_behalf_of: keepAgentObo ? true : undefined } }) - const fetched = fetchedResources[ws] if (fetched?.resource_type === 'json_schema') { clearJsonSchemaResourceCache(ini.path, ws) } diff --git a/frontend/src/lib/components/RunForm.svelte b/frontend/src/lib/components/RunForm.svelte index 7be09785b1..c01f7598ca 100644 --- a/frontend/src/lib/components/RunForm.svelte +++ b/frontend/src/lib/components/RunForm.svelte @@ -75,6 +75,13 @@ } export async function run(overrideScheduledForStr?: string | undefined | null) { + // Ahead of everything, including the flush below: the secret processing further down + // creates a variable per `password` argument, so a refusal that waits for `runAction` + // has already written. The keybinding reaches here without the button. + if (runRefusal) { + sendUserToast(runRefusal, true) + return + } // An editor whose text does not parse never wrote it to `args`, so running now would send // the last value that did parse. Flush first: a keystroke still inside the editor debounce // has not been parsed yet, and per-field editors parse it in an effect, hence the tick. @@ -127,6 +134,15 @@ extra_perms?: Record<string, boolean> } | undefined + /** Set to refuse every write this form can make, and to say so when a run is + * attempted: the fields are disabled, the dynamic-select helper is not wired, the JSON + * editor is read-only, and `run()` refuses before it mints the `password` arguments it + * would send — so a caller cannot gate this in `runAction`, which comes after that + * mint. For a form standing in for a chat card under plan mode, which promised none of + * it. The caller must also leave this form on the side it is showing: swapping fields + * for JSON or back mounts a `password` widget, which mints from the literal it opens + * on regardless of being disabled. */ + runRefusal?: string runAction: ( scheduledForStr: string | undefined, args: Record<string, any>, @@ -160,6 +176,7 @@ let { runnable, runAction, + runRefusal = undefined, buttonText = 'Run', schedulable = true, detailed = true, @@ -178,6 +195,8 @@ actions = undefined }: Props = $props() + const argsReadonly = $derived(!!runRefusal) + let showPsCommonParams = $derived( runnable?.language === 'powershell' && runnable?.schema?.['x-windmill-ps-cmd-binding'] === true ) @@ -333,7 +352,10 @@ > <JsonInputs bind:this={jsonEditor} + readOnly={argsReadonly} on:select={(e) => { + // The other way into the arguments, and the one disabling the fields misses. + if (argsReadonly) return blockedByUnparseable = false if (e.detail) { args = enforceDisabledDefaults(e.detail, runnable?.schema).args @@ -350,11 +372,14 @@ {#key reloadArgs} <div bind:clientHeight={schemaHeight}> <SchemaForm - helperScript={{ - source: 'deployed', - path: runnable.path!, - runnable_kind: runnable.hash ? 'script' : 'flow' - }} + helperScript={argsReadonly + ? undefined + : { + source: 'deployed', + path: runnable.path!, + runnable_kind: runnable.hash ? 'script' : 'flow' + }} + disabled={argsReadonly} prettifyHeader {noVariablePicker} {autofocus} @@ -382,7 +407,7 @@ variant="accent" unifiedSize="md" btnClasses="!inline-flex" - disabled={!isValid && !jsonView} + disabled={(!isValid && !jsonView) || argsReadonly} on:click={() => run()} shortCut={{ Icon: CornerDownLeft, hide: !viewKeybinding }} > @@ -431,7 +456,7 @@ variant="accent" unifiedSize="md" btnClasses="!inline-flex" - disabled={!isValid && !jsonView} + disabled={(!isValid && !jsonView) || argsReadonly} on:click={() => run(null)} shortCut={{ Icon: CornerDownLeft, hide: !viewKeybinding }} > @@ -444,7 +469,7 @@ <Button btnClasses="!px-6 !py-1 w-full" variant="accent" - disabled={!isValid && !jsonView} + disabled={(!isValid && !jsonView) || argsReadonly} on:click={() => run(null)} shortCut={{ Icon: CornerDownLeft, hide: !viewKeybinding }} > diff --git a/frontend/src/lib/components/RunFormAdvancedPopup.svelte b/frontend/src/lib/components/RunFormAdvancedPopup.svelte index 8bf1bd1481..5513e70538 100644 --- a/frontend/src/lib/components/RunFormAdvancedPopup.svelte +++ b/frontend/src/lib/components/RunFormAdvancedPopup.svelte @@ -3,31 +3,30 @@ import Label from './Label.svelte' import Toggle from './Toggle.svelte' import Tooltip from './Tooltip.svelte' - import { userStore, workerTags, workspaceStore } from '$lib/stores' + import { roleCapabilities, userStore, workerTags, workspaceStore } from '$lib/stores' import { Button } from './common' import { WorkerService } from '$lib/gen' import DateTimeInput from './DateTimeInput.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' - interface Props { - runnable: - | { - summary?: string - description?: string - path?: string - is_template?: boolean - hash?: string - kind?: string - can_write?: boolean - created_at?: string - created_by?: string - extra_perms?: Record<string, boolean> - } - | undefined; - scheduledForStr: string | undefined; - invisible_to_owner: boolean | undefined; - overrideTag: string | undefined; + runnable: + | { + summary?: string + description?: string + path?: string + is_template?: boolean + hash?: string + kind?: string + can_write?: boolean + created_at?: string + created_by?: string + extra_perms?: Record<string, boolean> + } + | undefined + scheduledForStr: string | undefined + invisible_to_owner: boolean | undefined + overrideTag: string | undefined } let { @@ -35,7 +34,7 @@ scheduledForStr = $bindable(), invisible_to_owner = $bindable(), overrideTag = $bindable() - }: Props = $props(); + }: Props = $props() // As in WorkerTagPicker: the shared `workerTags` cache is the navigation workspace's, so // tags of another operating workspace live in a local list rather than overwriting it. const operatingWorkspace = useOperatingWorkspace() @@ -87,7 +86,7 @@ </div> {/if} </div> - {#if !$userStore?.operator} + {#if $roleCapabilities.code} {#if currentTags && currentTags.length > 0} <div class="w-full"> <select diff --git a/frontend/src/lib/components/RunsPage.svelte b/frontend/src/lib/components/RunsPage.svelte index ad5e13df6c..db30073c44 100644 --- a/frontend/src/lib/components/RunsPage.svelte +++ b/frontend/src/lib/components/RunsPage.svelte @@ -11,12 +11,10 @@ import { sendUserToast } from '$lib/toast' import { displayDate } from '$lib/utils' - import { userWorkspaces, superadmin, devopsRole, enterpriseLicense } from '$lib/stores' + import { superadmin, devopsRole, enterpriseLicense } from '$lib/stores' import { useHostedPage } from '$lib/components/hostedPage' - import { - useOperatingUser, - useOperatingWorkspace - } from '$lib/components/operatingWorkspace.svelte' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { useRoleCapabilities } from '$lib/operatorWriteRights' import { Button, Drawer, DrawerContent, Skeleton, Tab, Tabs } from '$lib/components/common' import TextInput from '$lib/components/text_input/TextInput.svelte' import RunChart from '$lib/components/RunChart.svelte' @@ -33,10 +31,12 @@ import Toggle from '$lib/components/Toggle.svelte' import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' import RunsQueue from '$lib/components/runs/RunsQueue.svelte' - import OpenInSessionButton from '$lib/components/sessions/OpenInSessionButton.svelte' import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' import { RUNS_PATH } from '$lib/components/sessions/previewPaths' + import OpenInSessionButton, { + type OpenInSessionSource + } from '$lib/components/sessions/OpenInSessionButton.svelte' import { computeJobKinds, useJobsLoader } from '$lib/components/runs/useJobsLoader.svelte' import ConcurrentJobsChart from '$lib/components/ConcurrentJobsChart.svelte' import BatchLoadProgress from '$lib/components/BatchLoadProgress.svelte' @@ -83,7 +83,7 @@ // the filter row renders in the page, and requests go to the workspace it names. const hosted = useHostedPage() const operatingWorkspace = useOperatingWorkspace() - const operatingUser = useOperatingUser() + const roleCaps = useRoleCapabilities() let paths: string[] = $state([]) let usernames: string[] = $state([]) @@ -152,6 +152,7 @@ title: string confirmBtnText: string loading?: boolean + description?: string preContent?: string onConfirm?: (forceCancel: boolean) => void type?: ConfirmationModal['$$prop_def']['type'] @@ -177,9 +178,7 @@ let manualTimeframe = $derived(timeframe.type === 'manual' ? timeframe : undefined) let graph: 'RunChart' | 'ConcurrencyChart' = $state( - typeOfChart( - (hosted ? new URLSearchParams(hosted.search) : page.url.searchParams).get('graph') - ) + typeOfChart((hosted ? new URLSearchParams(hosted.search) : page.url.searchParams).get('graph')) ) let innerWidth = $state(window.innerWidth) let jobsLoader = useJobsLoader(() => ({ @@ -229,7 +228,9 @@ } async function loadPaths() { - const npaths_scripts = await ScriptService.listScriptPaths({ workspace: $operatingWorkspace ?? '' }) + const npaths_scripts = await ScriptService.listScriptPaths({ + workspace: $operatingWorkspace ?? '' + }) const npaths_flows = await FlowService.listFlowPaths({ workspace: $operatingWorkspace ?? '' }) paths = npaths_scripts.concat(npaths_flows).sort() } @@ -437,6 +438,7 @@ async function reRunJobs(jobIdsToReRun: string[], batchReRunOptions: BatchReRunOptions) { if (!$operatingWorkspace) return + const batching = filterMatchedIds !== undefined if (askingForConfirmation) { askingForConfirmation.loading = true @@ -450,11 +452,14 @@ // workaround because EventSource does not support POST requests // https://medium.com/@david.richards.tech/sse-server-sent-events-using-a-post-request-without-eventsource-1c0bd6f14425 - const response = await fetch(`${OpenAPI.BASE}/w/${$operatingWorkspace}/jobs/run/batch_rerun_jobs`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) - }) + const response = await fetch( + `${OpenAPI.BASE}/w/${$operatingWorkspace}/jobs/run/batch_rerun_jobs`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + } + ) await new Promise(async (resolve) => { const reader = response?.body?.pipeThrough(new TextDecoderStream()).getReader() let reRanUuids: string[] = [] @@ -486,7 +491,11 @@ } if (reRanUuids.length !== jobIdsToReRun.length) { sendUserToast( - `Failed to re-run ${jobIdsToReRun.length - reRanUuids.length} jobs. Check console for details`, + `Failed to re-run ${jobIdsToReRun.length - reRanUuids.length} jobs. Check console for details` + + // Only a re-run resolves its source, so these stay first in line. + (batching + ? '. They stay unresolved and the next batch will try them again: resolve them to skip them' + : ''), true ) // We do not get explicit error from backend if the job script don't exist @@ -509,13 +518,31 @@ jobsLoader?.loadJobs(true, true) } + // Every failed or canceled job "Re-run all jobs matching filters" found, oldest first, so a + // batch limit takes the oldest ones. Re-running resolves them, so under "Hide resolved runs" + // the next click picks up where the previous batch stopped. + let filterMatchedIds: string[] | undefined = $state() + let rerunLimit: number | undefined = $state() + // The pane closes from several places, the table included; a stale match list would then + // offer its limit over a selection it no longer describes. + $effect(() => { + if (!batchRerunOptionsIsOpen) untrack(() => (filterMatchedIds = undefined)) + }) + + function setRerunLimit(limit: number | undefined) { + if (!filterMatchedIds) return + rerunLimit = limit && limit > 0 ? limit : undefined + selectedIds = filterMatchedIds.slice(0, rerunLimit ?? filterMatchedIds.length) + } + async function onRerunAllJobsMatchingFilters() { const selectedFilters = getSelectedFilters() selectedIds = [] const loadingToast = sendUserToast('Loading job ids', 'info') - if (filters.val.job_kinds !== 'runs') { + // Selecting "runs" deletes the key, since it is the default. + if ((filters.val.job_kinds ?? 'runs') !== 'runs') { sendUserToast('Batch re-run is only supported for scripts and flows', true) loadingToast.destroy() return @@ -524,11 +551,21 @@ // workspace. started_at >= minTs implies completed_at >= minTs, so completedAfter adds a // lossless indexed lower bound ((workspace_id, completed_at DESC)); started_at stays the // exact recheck. (completedBefore is omitted: it would drop jobs that finish after maxTs.) - selectedIds = await JobService.listFilteredJobsUuids({ + const ids = await JobService.listFilteredJobsUuids({ ...selectedFilters, completedAfter: selectedFilters.startedAfter, - jobKinds: 'script,flow' + orderDesc: false }) + // Batching needs both halves of its promise: only failed or canceled sources are resolved + // by a re-run, and only a status filter keeps the queue out of the lookup, whose union + // drops the ordering. Anything else re-runs every match, as one batch. + if (selectedFilters.status === 'failure' || selectedFilters.status === 'canceled') { + filterMatchedIds = ids + setRerunLimit(rerunLimit) + } else { + filterMatchedIds = undefined + selectedIds = ids + } loadingToast.destroy() batchRerunOptionsIsOpen = true } @@ -538,6 +575,10 @@ askingForConfirmation = { title: `Confirm re-running the selected jobs`, confirmBtnText: `Re-run ${jobIdsToReRun.length} jobs`, + // The server skips resolution for a role without `code`, which cannot resolve. + description: !$roleCaps.code + ? undefined + : "Failed or canceled jobs among them will be marked resolved, with the new run's id as the note.", type: 'reload', onConfirm: async () => { await reRunJobs(jobIdsToReRun, batchReRunOptions) @@ -612,11 +653,19 @@ } let resolutionNote = $state('') // The bar folds this row (`actionFolds` below) once its breadcrumb has given all it can: first - // the counters, which are a glance and repeat the chart below; then the session button; then - // the filter field moves to a row of its own rather than shrinking past usable. Hosted, the row - // is the page's own and wraps instead. + // the counters, which are a glance and repeat the chart below; then the filter field moves to a + // row of its own rather than shrinking past usable. Hosted, the row is the page's own and wraps + // instead. const fold = $derived(hosted ? 0 : pageHeader.actionFold) - const filterBelow = $derived(fold >= 3) + const filterBelow = $derived(fold >= 2) + + const sessionSource: OpenInSessionSource = $derived({ + // The filters are shallow-routed, so the search has to come off `window.location` at click + // time — `page.url` never sees them. Always the canonical `/runs`: only that is a recognized + // preview page, and the `/runs/<path>` route mirrors its path into `?path=` anyway. + page: () => RUNS_PATH + window.location.search, + workspaceId: $operatingWorkspace ?? undefined + }) </script> {#snippet runsHint()} @@ -837,19 +886,11 @@ items={runsTimeframes} bind:value={_timeframe.val} /> - <!-- The filters are shallow-routed, so the search has to come off `window.location` at click - time — `page.url` never sees them. Always the canonical `/runs`: only that is a recognized - preview page, and the `/runs/<path>` route mirrors its path into `?path=` anyway. --> - <div class={fold >= 2 || hosted ? 'hidden' : 'contents'}> - <OpenInSessionButton - source={{ - page: () => RUNS_PATH + window.location.search, - workspaceId: $operatingWorkspace ?? undefined - }} - btnProps={{ unifiedSize: 'sm' }} - /> - </div> </div> + {#if !hosted} + <!-- No main action on this page: the session button ends the row. --> + <OpenInSessionButton source={sessionSource} /> + {/if} {/snippet} <ConfirmationModal @@ -867,6 +908,9 @@ askingForConfirmation = undefined }} > + {#if askingForConfirmation?.description} + <p class="text-xs text-secondary">{askingForConfirmation.description}</p> + {/if} {#if askingForConfirmation?.preContent} <pre>{askingForConfirmation.preContent}</pre> <Toggle @@ -914,10 +958,10 @@ }} /> -{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.runs} +{#if $operatingWorkspace && !$roleCaps.visibility.runs} <div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert"> <p class="font-bold">Unauthorized</p> - <p>Page not available for operators</p> + <p>Page not available for your role</p> </div> {:else} <div class="w-full h-full flex flex-col" bind:clientWidth={innerWidth}> @@ -929,9 +973,10 @@ <PageHeaderContent afterName={runsHint} actions={headerActions} - actionFolds={3} + {sessionSource} + actionFolds={2} belowBar={filterRow} - belowBarFold={3} + belowBarFold={2} /> {/if} @@ -1121,14 +1166,15 @@ action: () => { manualSelectionMode = 'rerun' selectedIds = [] + filterMatchedIds = undefined batchRerunOptionsIsOpen = true } }, - ...(!operatingUser.current?.operator + ...($roleCaps.code ? [ { - // Operators are rejected by the endpoint, so offering it would only 403. - displayName: 'Resolve failed jobs', + // A role without `code` is rejected by the endpoint, so offering it would only 403. + displayName: 'Resolve failed or canceled jobs', action: () => ( (manualSelectionMode = 'resolve'), (selectedIds = []), @@ -1210,7 +1256,7 @@ class="rounded-md bg-surface-tertiary border absolute inset-0 mb-4 flex flex-col items-center justify-center gap-3 p-4" > <p class="text-xs text-secondary text-center max-w-xs"> - Resolving keeps the run a failure but stops it showing as one in the runs list. + Resolving keeps the run's status but marks it as handled in the runs list. </p> <TextInput bind:value={resolutionNote} @@ -1303,15 +1349,59 @@ {/snippet} {#snippet batchRerunOptions()} - {#await import('$lib/components/runs/BatchReRunOptionsPane.svelte') then BatchReRunOptionsPane} - <BatchReRunOptionsPane.default - {selectedIds} - onCancel={() => ((batchRerunOptionsIsOpen = false), (manualSelectionMode = undefined))} - onConfirm={async (options) => { - await onReRunSelectedJobs(options) - }} - /> - {/await} + <div class="flex flex-col h-full"> + {#if filterMatchedIds} + <div class="flex flex-col gap-1 pb-2 text-xs text-secondary"> + <div class="flex flex-row items-center gap-2"> + <span>{filterMatchedIds.length} jobs match the filters, re-run at most</span> + <TextInput + inputProps={{ type: 'number', min: 1, placeholder: 'All' }} + size="sm" + class="w-24" + bind:value={ + () => rerunLimit?.toString() ?? '', + (v) => setRerunLimit(v ? parseInt(v.toString()) : undefined) + } + /> + </div> + {#if filters.val.resolved === 'unresolved'} + <span class="text-hint"> + Oldest first. Re-run jobs are marked resolved, so the next batch starts where this one + stops. + </span> + {:else} + <div class="flex flex-row items-center gap-2 text-orange-600 dark:text-orange-400"> + <TriangleAlertIcon size={14} class="shrink-0" /> + <span> + Resolved runs are included, so the next batch will pick these same jobs again. + </span> + <Button + variant="default" + unifiedSize="xs" + btnClasses="shrink-0" + onClick={() => { + filters.val.resolved = 'unresolved' + onRerunAllJobsMatchingFilters() + }} + > + Hide resolved runs + </Button> + </div> + {/if} + </div> + {/if} + <div class="flex-1 min-h-0 flex flex-col"> + {#await import('$lib/components/runs/BatchReRunOptionsPane.svelte') then BatchReRunOptionsPane} + <BatchReRunOptionsPane.default + {selectedIds} + onCancel={() => ((batchRerunOptionsIsOpen = false), (manualSelectionMode = undefined))} + onConfirm={async (options) => { + await onReRunSelectedJobs(options) + }} + /> + {/await} + </div> + </div> {/snippet} {#snippet jobPreview(pending?: Snippet)} diff --git a/frontend/src/lib/components/SaveButton.svelte b/frontend/src/lib/components/SaveButton.svelte index 87084f460c..b83af769f5 100644 --- a/frontend/src/lib/components/SaveButton.svelte +++ b/frontend/src/lib/components/SaveButton.svelte @@ -52,7 +52,9 @@ }) .catch((error) => { console.error('Save failed:', error) - sendUserToast(error?.message ?? 'Save failed', true) + // An API error's message is only the status text; the server's reason is its body. + const body = typeof error?.body === 'string' && error.body ? error.body : undefined + sendUserToast(body ?? error?.message ?? 'Save failed', true) saveStatus = 'error' statusTimeout = setTimeout(() => { saveStatus = null diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index b11fd34362..3ee365be89 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -22,6 +22,10 @@ } from '$lib/script_helpers' import { isWorkflowAsCode } from './graph/wacToFlow' import AIFormSettings from './copilot/AIFormSettings.svelte' + import { SUPPORTED_CHAT_SCRIPT_LANGUAGES } from './copilot/chat/script/core' + import OpenInSessionButton, { + type OpenInSessionSource + } from './sessions/OpenInSessionButton.svelte' import { defaultScripts, enterpriseLicense, @@ -128,6 +132,7 @@ import { UserDraft } from '$lib/userDraft.svelte' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { useEditRights, useTriggerLock } from '$lib/operatorWriteRights' + import { previewOnBehalfOf } from '$lib/utils/previewOnBehalfOf' const triggerLock = useTriggerLock() const editRights = useEditRights() @@ -211,9 +216,13 @@ const compactTopbar = $derived( ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 900 ) - // The second fold holds the trail and one button. Diff and Settings join the menu that compact + // In the page header no fold drops a button's label: what does not fit goes into the menu. The + // second fold holds the trail and one button. Diff and Settings join the menu that compact // already shows, and Deploy stays out: it is what this page is for. const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2) + // The third fold leaves the autosave indicator as its icon, so the AI session button still fits + // right before Deploy on a phone. + const narrowTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 3) // The workspace this editor operates on: deploy, save-draft, trigger loading // and the AutosaveIndicator all target it. Falls back to the full-page @@ -914,6 +923,24 @@ } } + const sessionOpen: OpenInSessionSource | undefined = $derived( + userDraftPath + ? { + // URL draft path the editor loads/saves by, not the friendly + // `script.path` (a new script's has no row → "not found"). + target: { kind: 'script', path: userDraftPath }, + workspaceId: opWorkspace ?? undefined, + beforeOpen: persistDraftForSession + } + : undefined + ) + // The header's button, gated like the code editor's AI entry point it stands in for. + function headerSessionOpen(): OpenInSessionSource | undefined { + if (disableAi || customUi?.editorBar?.aiGen == false) return undefined + if (!SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(script?.language ?? '')) return undefined + return sessionOpen + } + // Inside an AI session pane (which injects an aiChatManager via context) the // extra deploy-dropdown options — Deploy & Stay here, Fork, Edit in workspace // fork, Export — don't make sense: the session always stays put and is already @@ -2322,7 +2349,8 @@ }} afterName={scriptMarks} actions={scriptHeaderActions} - actionFolds={2} + sessionSource={headerSessionOpen()} + actionFolds={3} separator="none" /> {:else} @@ -2480,6 +2508,7 @@ {loadedFromDraft} {othersDraftsCount} {onOpenOthersDrafts} + compact={narrowTopbar} /> {/if} {/snippet} @@ -2509,7 +2538,7 @@ unifiedSize={headerBtnSize} on:click={() => (metadataOpen = true)} startIcon={{ icon: Settings }} - iconOnly={compactTopbar} + iconOnly={compactTopbar && !ownsPageHeader} title="Settings" > <span> Settings </span> @@ -2533,7 +2562,7 @@ on:click={() => openDiffDrawer()} disabled={diffDisabled} btnClasses={diffDisabled ? 'pointer-events-none' : undefined} - iconOnly={compactTopbar} + iconOnly={compactTopbar && !ownsPageHeader} title={diffTitle} startIcon={{ icon: DiffIcon }} > @@ -2579,6 +2608,9 @@ {@render settingsButton()} {/if} + {#if ownsPageHeader} + <OpenInSessionButton source={headerSessionOpen()} /> + {/if} <DeployButton loading={!fullyLoaded} {loadingSave} @@ -2645,16 +2677,13 @@ {:else} <ScriptEditor {disableAi} + previewOnBehalfOf={previewOnBehalfOf( + actingUser, + script.on_behalf_of, + script.on_behalf_of_email + )} workspaceOverride={opWorkspace} - sessionOpen={userDraftPath - ? { - // URL draft path the editor loads/saves by, not the friendly - // `script.path` (a new script's has no row → "not found"). - target: { kind: 'script', path: userDraftPath }, - workspaceId: opWorkspace ?? undefined, - beforeOpen: persistDraftForSession - } - : undefined} + {sessionOpen} bind:selectedTab={selectedInputTab} {customUi} {onTestJob} diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index ef3df73f63..cedc274f4c 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -139,6 +139,8 @@ code: string path: string | undefined lang: Preview['language'] + /** Identity the test runs as instead of the user (see `previewOnBehalfOf`). */ + previewOnBehalfOf?: string kind?: string | undefined autoKind?: string | undefined template?: @@ -240,6 +242,7 @@ code = $bindable(), path, lang, + previewOnBehalfOf, kind = undefined, autoKind = undefined, template = 'script', @@ -994,7 +997,8 @@ // A `__mod` helper is tested alone, so its siblings are left out. onModule ? undefined : modules, undefined, - timeout + timeout, + { onBehalfOf: previewOnBehalfOf } ) if (job) { onTestJob?.({ jobId: job }) @@ -2716,7 +2720,7 @@ {/if} {#if !aiChatManager.open && !disableAi && !inSessionPane} {#if customUi?.editorBar?.aiGen != false && SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(lang ?? '')} - <OpenInSessionButton source={sessionOpen}> + <OpenInSessionButton source={sessionOpen} deferToHeader> {#snippet fallback()} <HideButton hidden={true} diff --git a/frontend/src/lib/components/Star.svelte b/frontend/src/lib/components/Star.svelte index 4f0f169109..127bd66efa 100644 --- a/frontend/src/lib/components/Star.svelte +++ b/frontend/src/lib/components/Star.svelte @@ -10,9 +10,10 @@ summary?: string workspaceId?: string size?: number + class?: string } - let { path, kind, workspaceId, summary, size = 16 }: Props = $props() + let { path, kind, workspaceId, summary, size = 16, class: className = 'p-1' }: Props = $props() let buttonHover = $state(false) let starred = $derived(favoriteManager.isStarred(path, kind)) @@ -28,7 +29,9 @@ onclick={preventDefault(onClick)} onmouseenter={() => (buttonHover = true)} onmouseleave={() => (buttonHover = false)} - class="p-1" + class={className} + data-starred={starred || undefined} + title={starred ? 'Remove from favorites' : 'Add to favorites'} > {#if starred} {#if buttonHover} diff --git a/frontend/src/lib/components/TimeAgo.svelte b/frontend/src/lib/components/TimeAgo.svelte index 6138cdb6da..1e5515f144 100644 --- a/frontend/src/lib/components/TimeAgo.svelte +++ b/frontend/src/lib/components/TimeAgo.svelte @@ -9,7 +9,8 @@ noDate?: boolean isRecent?: boolean noSeconds?: boolean - /** Single-unit form ("2m", "3h", "5d") for rows too narrow for "2 mins ago". */ + /** Single-unit form ("2m", "3h", "5d") for rows too narrow for "2 mins ago". With + * `noSeconds`, the first minute reads "1m" rather than counting seconds. */ compact?: boolean } @@ -71,7 +72,9 @@ const secs = secondsAgo(new Date(dateString)) const left = secs < 60 - ? 1 + ? compact && noSeconds + ? 60 - secs + : 1 : secs < 3600 ? 60 - (secs % 60) : secs < 86_400 @@ -125,7 +128,7 @@ // (PipelineEventLog, PipelineActivityPanel, IndexerMemorySettings): seconds stay // meaningful right up to the minute mark. const secs = secondsAgo(date) - if (secs < 60) return `${secs}s` + if (secs < 60) return noSeconds ? '1m' : `${secs}s` const mins = minutesAgo(date) if (mins < 60) return `${mins}m` const hours = hoursAgo(date) diff --git a/frontend/src/lib/components/UserSettings.svelte b/frontend/src/lib/components/UserSettings.svelte index d5811e737b..306ce3505e 100644 --- a/frontend/src/lib/components/UserSettings.svelte +++ b/frontend/src/lib/components/UserSettings.svelte @@ -6,7 +6,8 @@ import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' import TokensTable from './settings/TokensTable.svelte' - import { createEventDispatcher } from 'svelte' + import { createEventDispatcher, tick } from 'svelte' + import CreateToken from './settings/CreateToken.svelte' import UserInfoSettings from './settings/UserInfoSettings.svelte' import AIUserSettings from './settings/AIUserSettings.svelte' import AiUsagePanel from './workspaceSettings/AiUsagePanel.svelte' @@ -36,14 +37,18 @@ }: Props = $props() let drawer: Drawer | undefined = $state() - let openWithMcpMode = $state(false) + let mcpSection: HTMLDivElement | undefined = $state() + let tokensTable: TokensTable | undefined = $state() let darkVariant = $state<DarkModeVariant>(getDarkModeVariant()) const dispatch = createEventDispatcher() - export function openDrawer(mcpMode: boolean = false) { - openWithMcpMode = mcpMode + export async function openDrawer(mcpMode: boolean = false) { drawer?.openDrawer() + if (mcpMode) { + await tick() + mcpSection?.scrollIntoView({ block: 'start' }) + } } export function closeDrawer() { @@ -120,10 +125,22 @@ {/if} {/if} + {#if showMcpMode} + <div bind:this={mcpSection} class="flex flex-col p-4 border border-border-light rounded-md"> + <h2 class="text-emphasis text-sm font-semibold mb-1"> + Build and run from your AI client + </h2> + <div class="text-xs text-secondary mb-2"> + Connect Claude, ChatGPT or Cursor to Windmill over MCP: build scripts, flows and apps, + run your automations and check their runs, from your chat. + </div> + <CreateToken mcpOnly onTokenCreated={() => tokensTable?.listTokens()} /> + </div> + {/if} + <div class="grow min-h-0"> <TokensTable - {showMcpMode} - {openWithMcpMode} + bind:this={tokensTable} defaultNewTokenLabel={newTokenLabel} defaultNewTokenWorkspace={newTokenWorkspace} {scopes} diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 901bf119cc..d2af0187bc 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -15,6 +15,7 @@ import { sendUserToast } from '$lib/toast' import { canEditItem } from '$lib/editRights' import { userWorkspaces } from '$lib/stores' + import { capabilitiesIn } from '$lib/roleCapabilities' import { Save } from 'lucide-svelte' import VariableForm from './VariableForm.svelte' import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte' @@ -141,8 +142,12 @@ return canEditVariableIn(selected, perms) }) function canEditVariableIn(ws: string, perms: Record<string, boolean>): boolean { - const settings = $userWorkspaces.find((w) => w.id === ws)?.operator_settings - return canEditItem('variable', editPath ?? '', perms, acting.in(ws), settings) + const user = acting.in(ws) + const caps = capabilitiesIn( + user, + $userWorkspaces.find((w) => w.id === ws) + ) + return canEditItem('variable', editPath ?? '', perms, user, caps) } const dirtyWorkspaces = $derived( Object.keys(states).filter((ws) => !draftValuesEqual(states[ws].draft, initialStates[ws])) diff --git a/frontend/src/lib/components/VariableForm.svelte b/frontend/src/lib/components/VariableForm.svelte index 272004396c..e3542b9b36 100644 --- a/frontend/src/lib/components/VariableForm.svelte +++ b/frontend/src/lib/components/VariableForm.svelte @@ -10,7 +10,8 @@ import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' import { Loader2, RotateCcw } from 'lucide-svelte' import autosize from '$lib/autosize' - import { type UserExt } from '$lib/stores' + import { type UserExt, userWorkspaces } from '$lib/stores' + import { capabilitiesIn } from '$lib/roleCapabilities' import { isOwner } from '$lib/utils' import { isEncryptedDraftValue } from '$lib/encryptedDraft' import EncryptedDraftField from './EncryptedDraftField.svelte' @@ -66,6 +67,12 @@ }: Props = $props() let ws = $derived(workspace ?? $operatingWorkspace) + let caps = $derived( + capabilitiesIn( + actingUser ?? undefined, + $userWorkspaces.find((w) => w.id === ws) + ) + ) // 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 @@ -115,9 +122,9 @@ stretch the hit area over the whole row and the alert. The toggle carries its na on:change={(e) => onSecretChange?.(e.detail)} bind:checked={variable.is_secret} disabled={unsecureNeedsLoad || - (edit && (!actingUser || actingUser.operator || isEncryptedDraftValue(variable.value)))} + (edit && (!actingUser || !caps.code || isEncryptedDraftValue(variable.value)))} /> - {#if unsecureNeedsLoad && can_write && !actingUser?.operator} + {#if unsecureNeedsLoad && can_write && caps.code} <span class="text-2xs text-secondary"> Load the secret value or enter a new one to make it non-secret </span> @@ -163,8 +170,8 @@ stretch the hit area over the whole row and the alert. The toggle carries its na > Reset </Button> - {:else if actingUser?.operator} - <div class="p-2 border">Operators cannot load secret value</div> + {:else if !caps.code} + <div class="p-2 border">Your role cannot load secret values</div> {:else} <Button size="xs" variant="default" on:click={() => onLoadSecret?.()}> Load secret value<Tooltip>Will generate an audit log</Tooltip> diff --git a/frontend/src/lib/components/WorkerGroup.svelte b/frontend/src/lib/components/WorkerGroup.svelte index 5e8957c31a..584d3bf2d5 100644 --- a/frontend/src/lib/components/WorkerGroup.svelte +++ b/frontend/src/lib/components/WorkerGroup.svelte @@ -56,6 +56,12 @@ import DedicatedWorkersSelector from './DedicatedWorkersSelector.svelte' import { computeHashedTag } from './dedicated_worker' import { apiBaseUrl } from '$lib/apiBaseUrl.svelte' + import { + devWorkspaceListenSuffix, + devWorkspaceTagSuffix, + loadDevWorkspaceTagSuffix, + withDevSuffix + } from '$lib/devWorkspaceTags' import ObjectStoreConfigSettings, { type ObjectStoreConfig } from './ObjectStoreConfigSettings.svelte' @@ -99,6 +105,7 @@ periodic_script_interval_seconds?: number env_vars_static?: Map<string, string> env_vars_allowlist?: string[] + disable_job_proxy?: boolean additional_python_paths?: string[] pip_local_dependencies?: string[] min_alive_workers_alert_threshold?: number @@ -106,6 +113,7 @@ native_mode?: boolean object_store_cache_config?: ObjectStoreConfig paused?: boolean + serves_dev_workspaces?: boolean } = $state({}) function loadNConfig() { @@ -205,6 +213,7 @@ init_bash?: string env_vars_static?: Map<string, string> env_vars_allowlist?: string[] + disable_job_proxy?: boolean additional_python_paths?: string[] pip_local_dependencies?: string[] min_alive_workers_alert_threshold?: number @@ -214,6 +223,7 @@ native_mode?: boolean object_store_cache_config?: ObjectStoreConfig paused?: boolean + serves_dev_workspaces?: boolean } activeWorkers: number customTags: string[] | undefined @@ -319,7 +329,9 @@ 'priority_tags', 'additional_python_paths', 'pip_local_dependencies', - 'object_store_cache_config' + 'disable_job_proxy', + 'object_store_cache_config', + 'serves_dev_workspaces' ] as const function restartsWorkers(body: typeof nconfig): boolean { if (activeWorkers === 0) return false @@ -362,6 +374,15 @@ let drawer: Drawer | undefined = $state() let vcpus_memory = $derived(computeVCpuAndMemory(workers)) + let reportedTags = $derived( + [ + ...new Set( + workers.flatMap(([_, pings]) => + pings.filter((p) => (p.last_ping ?? Infinity) < 60).flatMap((p) => p.custom_tags ?? []) + ) + ) + ].sort() + ) let selected = $derived( nconfig?.dedicated_worker != undefined || nconfig?.dedicated_workers != undefined ? 'dedicated' @@ -381,6 +402,13 @@ !t.startsWith('flow-') ) ) + loadDevWorkspaceTagSuffix() + let devSuffix = $derived($devWorkspaceListenSuffix) + let servesDev = $derived( + config?.serves_dev_workspaces === true && + config?.dedicated_worker == undefined && + !config?.dedicated_workers?.length + ) let isAutoNativeMode = $derived(name === 'native') let isNativeModeEnabled = $derived(nconfig?.native_mode === true || isAutoNativeMode) $effect(() => { @@ -647,6 +675,63 @@ {/if} </Label> + {#if nconfig !== undefined} + {@const flagged = nconfig.serves_dev_workspaces === true} + {@const draftTags = nconfig.worker_tags ?? []} + {@const handSuffixed = draftTags.filter((t) => t.endsWith(devSuffix))} + <div class="mt-8"></div> + <Label label="Dev workspaces" eeOnly={!hasEnterpriseFeatures}> + <Toggle + size="sm" + options={{ + right: 'Serves dev workspaces and forks', + rightTooltip: + 'Runs jobs from dev workspaces and forks only. The group listens to each of its tags with the dev suffix, and no longer to the tags themselves.' + }} + checked={flagged} + on:change={(ev) => { + if (nconfig !== undefined) { + nconfig.serves_dev_workspaces = ev.detail ? true : undefined + } + }} + disabled={!canEditEEConfig} + /> + <p class="text-2xs text-secondary">Runs jobs from dev workspaces and forks only.</p> + {#if flagged} + <div class="flex flex-col gap-1 mt-1"> + <span class="text-2xs text-secondary">Tags this group listens to:</span> + {#if draftTags.length > 0} + <TagList + tags={[...new Set(draftTags.map((t) => withDevSuffix(t, devSuffix)))]} + class="flex-wrap" + /> + {:else} + <span class="text-2xs text-secondary"> + Add tags above: each is listened to as <code + >{withDevSuffix('tag', devSuffix)}</code + >. + </span> + {/if} + </div> + {#if $devWorkspaceTagSuffix === null} + <Alert size="xs" type="info" title="Dev workspace isolation is off"> + New jobs get the <code>{devSuffix}</code> suffix only while dev workspace isolation is + on (Workers page). Until then this group only runs jobs already queued on suffixed tags, + and never takes jobs of root workspaces. + </Alert> + {/if} + {:else if handSuffixed.length > 0} + <Alert size="xs" type="warning" title="This group would take dev workspace jobs"> + {handSuffixed.join(', ')} + {handSuffixed.length === 1 ? 'is a tag' : 'are tags'} of dev workspace and fork jobs. Listing + {handSuffixed.length === 1 ? 'it' : 'them'} here runs those jobs next to root workspace + jobs. Remove {handSuffixed.length === 1 ? 'it' : 'them'}, or flag this group as + serving dev workspaces instead. + </Alert> + {/if} + </Label> + {/if} + {#if nconfig?.worker_tags !== undefined && nconfig?.worker_tags.length > 0} <div class="mt-8"></div> <Label label="High-priority tags" eeOnly={!hasEnterpriseFeatures}> @@ -827,6 +912,30 @@ disabled={!canEditConfig} /> </Label> + <div class="mt-8"></div> + <Label label="Worker proxy"> + {#snippet header()} + <Tooltip> + {#snippet text()} + Whether jobs get the worker's HTTP_PROXY, HTTPS_PROXY and NO_PROXY. Dependency + installs always go through the proxy, whatever this is set to. Removing the variables + does not stop a job from reaching the proxy if it knows its address. Workers pick the + change up without restarting. + {/snippet} + </Tooltip> + {/snippet} + <Toggle + size="sm" + options={{ right: "Pass the worker's proxy to jobs" }} + checked={nconfig?.disable_job_proxy !== true} + on:change={(ev) => { + if (nconfig !== undefined) { + nconfig.disable_job_proxy = ev.detail ? undefined : true + } + }} + disabled={!canEditConfig} + /> + </Label> {/if} <div class="mt-8"></div> @@ -970,6 +1079,10 @@ <AutoscalingConfigEditor worker_tags={config?.worker_tags} + group_has_tags={nconfig.worker_tags !== undefined + ? nconfig.worker_tags.length > 0 + : (nconfig.dedicated_workers?.length ?? 0) > 0 || nconfig.dedicated_worker !== undefined} + reported_tags={reportedTags} bind:config={nconfig.autoscaling} disabled={!canEditEEConfig} eeOnly={!hasEnterpriseFeatures} @@ -1364,6 +1477,17 @@ {#if isNativeMode} <Badge color="blue" small>Native</Badge> {/if} + {#if servesDev} + <Badge color="violet" small> + Dev + <Tooltip> + {#snippet text()} + Serves dev workspaces and forks only: listens to its tags suffixed with + <code>{devSuffix}</code>, never to the tags of root workspace jobs. + {/snippet} + </Tooltip> + </Badge> + {/if} {#if config?.paused === true} <Badge color="yellow" small> Paused @@ -1410,7 +1534,7 @@ </div> {/if} </div> - {#if !isAgent} + {#if !isAgent || config} <div class="flex gap-4 items-center justify-end flex-row"> {#if canEditConfig} {#if width > 1000} @@ -1521,7 +1645,13 @@ {#if config?.worker_tags && config.worker_tags.length > 0} <div class="flex flex-row items-start gap-2 w-full"> <div class="text-secondary text-xs mt-1">Tags:</div> - <TagList tags={config.worker_tags} maxVisible={25} class="flex-wrap" /> + <TagList + tags={servesDev + ? [...new Set(config.worker_tags.map((t) => withDevSuffix(t, devSuffix)))] + : config.worker_tags} + maxVisible={25} + class="flex-wrap" + /> </div> {:else if config?.dedicated_workers && config.dedicated_workers.length > 0} <div class="flex flex-row items-start gap-2 w-full min-w-0"> @@ -1554,7 +1684,12 @@ <div class="mt-4"> <Alert type="info" title="Agent Worker Group" size="xs"> {#snippet children()} - This group is formed with agent workers, there is no associated config. {#if $superadmin || $devopsRole} + {#if config} + This group is formed with agent workers, which ignore its config except for autoscaling. + {:else} + This group is formed with agent workers, there is no associated config. + {/if} + {#if $superadmin || $devopsRole} To modify the tags, generate a new <a href="https://www.windmill.dev/docs/core_concepts/agent_workers#quickstart" target="_blank" diff --git a/frontend/src/lib/components/WorkerTagSelect.svelte b/frontend/src/lib/components/WorkerTagSelect.svelte index 503c8f7f66..cc6b3ebb95 100644 --- a/frontend/src/lib/components/WorkerTagSelect.svelte +++ b/frontend/src/lib/components/WorkerTagSelect.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import { workerTags, workspaceStore } from '$lib/stores' + import { userWorkspaces, workerTags, workspaceStore } from '$lib/stores' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' import { WorkerService } from '$lib/gen' @@ -10,6 +10,13 @@ import { RotateCw } from 'lucide-svelte' import { sendUserToast } from '$lib/toast' import Popover from './Popover.svelte' + import { + devWorkspaceTagSuffix, + devWorkspaceSkipNonDevForks, + isIsolatedWorkspaceId, + loadDevWorkspaceTagSuffix, + withDevSuffix + } from '$lib/devWorkspaceTags' let { tag = $bindable(), @@ -113,10 +120,16 @@ return } if (open) { - tagsToWorkerExists = await WorkerService.existsWorkersWithTags({ - tags: items.filter((t) => !dynamicTagRegex.test(t)).join(','), + // Probed as jobs of this workspace land: suffixed in a dev workspace. + const probed = items.filter((t) => !dynamicTagRegex.test(t)) + const asRun = (t: string) => (devSuffix ? withDevSuffix(t, devSuffix) : t) + const exists = await WorkerService.existsWorkersWithTags({ + tags: probed.map(asRun).join(','), workspace: effectiveWorkspace }) + tagsToWorkerExists = Object.fromEntries( + probed.filter((t) => asRun(t) in exists).map((t) => [t, exists[asRun(t)]]) + ) lastCheck = Date.now() if (visible) { timeout = setTimeout(loadTagsToWorkerExists, 5000) @@ -145,6 +158,14 @@ }) let open = $state(false) + + loadDevWorkspaceTagSuffix() + let devSuffix = $derived( + $devWorkspaceTagSuffix && + isIsolatedWorkspaceId(effectiveWorkspace, $userWorkspaces, $devWorkspaceSkipNonDevForks) + ? $devWorkspaceTagSuffix + : undefined + ) </script> {#snippet startSnippet({ item })} @@ -186,13 +207,29 @@ {startSnippet} bottomSnippet={refreshAll} /> + {#if devSuffix && tag} + <Popover> + {#snippet text()} + This workspace is a dev workspace or a fork, so its jobs run on worker groups serving dev + workspaces, with every tag suffixed with <code>{devSuffix}</code>. + {/snippet} + <span class="text-2xs text-hint whitespace-nowrap"> + runs as <code>{withDevSuffix(tag, devSuffix)}</code> here + </span> + </Popover> + {/if} </div> {#snippet refreshAll()} <div class="flex items-center justify-between gap-2 border-t border-border-light"> <span class="max-w-64 px-4 py-1 text-2xs text-secondary"> - <code>$workspace</code>, <code>$args[…]</code> and <code>$flow_expr[…]</code> are filled in when - the job runs, and must land on an allowed tag + <code>$workspace</code>, <code>$args[…]</code> and <code>$flow_expr[…]</code> are filled in + when the job runs, and must land on an allowed tag + {#if devSuffix} + <br />In this dev workspace every tag runs suffixed with <code>{devSuffix}</code>, the + default one included: <code>gpu</code> runs as + <code>{withDevSuffix('gpu', devSuffix)}</code> + {/if} </span> <Button iconOnly diff --git a/frontend/src/lib/components/apps/components/helpers/RunnableComponent.svelte b/frontend/src/lib/components/apps/components/helpers/RunnableComponent.svelte index 41a72fd88b..ebea15de26 100644 --- a/frontend/src/lib/components/apps/components/helpers/RunnableComponent.svelte +++ b/frontend/src/lib/components/apps/components/helpers/RunnableComponent.svelte @@ -32,6 +32,8 @@ import { selectId } from '../../editor/appUtils' import { collectOneOfFields } from '../../editor/appUtilsCore' import { userStore } from '$lib/stores' + import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte' + import { appPreviewOnBehalfOf } from '$lib/utils/previewOnBehalfOf' import { get } from 'svelte/store' import RefreshButton from '$lib/components/apps/components/helpers/RefreshButton.svelte' import { ctxRegex } from '../../utils' @@ -128,8 +130,10 @@ app, connectingInput, bgRuns, - recomputeAllContext + recomputeAllContext, + policy } = getContext<AppViewerContext>('AppViewerContext') + const operatingUser = useOperatingUser() const editorContext = getContext<AppEditorContext>('AppEditorContext') const iterContext = getContext<ListContext>('ListWrapperContext') @@ -530,7 +534,8 @@ force_viewer_one_of_fields: !isEditor ? undefined : undefinedIfEmpty(oneOfRunnableInputs), force_viewer_allow_user_resources: !isEditor ? undefined - : undefinedIfEmpty(allowUserResources) + : undefinedIfEmpty(allowUserResources), + on_behalf_of: isEditor ? appPreviewOnBehalfOf(operatingUser.current, policy) : undefined } return requestBody } diff --git a/frontend/src/lib/components/apps/components/helpers/executeRunnable.ts b/frontend/src/lib/components/apps/components/helpers/executeRunnable.ts index a59f10b66b..5f11ac571f 100644 --- a/frontend/src/lib/components/apps/components/helpers/executeRunnable.ts +++ b/frontend/src/lib/components/apps/components/helpers/executeRunnable.ts @@ -1,4 +1,4 @@ -import { AppService, type ExecuteComponentData } from '$lib/gen' +import { AppService, type ExecuteComponentData, type ScriptModule } from '$lib/gen' import { defaultIfEmptyString } from '$lib/utils' import { isRunnableByName, isRunnableByPath, type Runnable } from '../../inputType' import type { InlineScript } from '../../sharedTypes' @@ -12,7 +12,8 @@ export async function executeRunnable( id: string, requestBody: ExecuteComponentData['requestBody'], inlineScriptOverride?: InlineScript, - queryParams?: Record<string, any> + queryParams?: Record<string, any>, + modules?: Record<string, ScriptModule> ) { let appPath = defaultIfEmptyString(path, `u/${username ?? 'unknown'}/newapp`) if (isRunnableByName(runnable)) { @@ -24,13 +25,17 @@ export async function executeRunnable( if (inlineScript.id !== undefined) { requestBody['id'] = inlineScript.id } + // A lock is not cleared when only an imported runnable is edited, so with + // modules the preview resolves dependencies afresh. + const previewModules = inlineScript.id === undefined ? modules : undefined requestBody['raw_code'] = { content: inlineScript.id === undefined ? inlineScript.content : '', language: inlineScript.language ?? '', path: appPath + '/' + id, - lock: inlineScript.id === undefined ? inlineScript.lock : undefined, + lock: inlineScript.id === undefined && !previewModules ? inlineScript.lock : undefined, cache_ttl: inlineScript.cache_ttl, - ...(inlineScript.tag ? { tag: inlineScript.tag } : {}) + ...(inlineScript.tag ? { tag: inlineScript.tag } : {}), + ...(previewModules ? { modules: previewModules } : {}) } } } else if (isRunnableByPath(runnable)) { diff --git a/frontend/src/lib/components/apps/editor/AppEditor.svelte b/frontend/src/lib/components/apps/editor/AppEditor.svelte index ac0d2b1cb6..643cd074d3 100644 --- a/frontend/src/lib/components/apps/editor/AppEditor.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditor.svelte @@ -26,7 +26,7 @@ import { Alert, Button, Tab } from '$lib/components/common' import TabContent from '$lib/components/common/tabs/TabContent.svelte' import Tabs from '$lib/components/common/tabs/TabsV2.svelte' - import { userStore, workspaceStore } from '$lib/stores' + import { roleCapabilities, userStore, workspaceStore } from '$lib/stores' import { classNames, getModifierKey, @@ -883,7 +883,7 @@ /> <!-- {$focusedGrid?.parentComponentId} --> -{#if !$userStore?.operator} +{#if $roleCapabilities.apps === 'full'} {#if $appStore} <AppEditorHeader {ownsPageHeader} diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index 4e620e2058..e5613a71a1 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -307,7 +307,7 @@ summary: $summary, value: structuredClone($state.snapshot($app)), path: path, - policy: policy, + policy: $state.snapshot(policy), custom_path: customPath, labels: $state.snapshot(labels) } @@ -425,7 +425,7 @@ summary: $summary, value: structuredClone($state.snapshot($app)), path: npath, - policy, + policy: $state.snapshot(policy), custom_path: customPath, labels: $state.snapshot(labels) } diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte index 7b25dc234e..6118f6626d 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -1,10 +1,10 @@ <script lang="ts"> - import { Alert } from '$lib/components/common' - import Badge from '$lib/components/common/badge/Badge.svelte' + import { Alert, Section } from '$lib/components/common' import Toggle from '$lib/components/Toggle.svelte' + import Badge from '$lib/components/Badge.svelte' import { enterpriseLicense, userStore } from '$lib/stores' - import { useOperatorBuilderApps } from '$lib/operatorWriteRights' - import { Loader2 } from 'lucide-svelte' + import { useRoleCapabilities } from '$lib/operatorWriteRights' + import { AlertTriangle, Loader2 } from 'lucide-svelte' import Tooltip from '$lib/components/Tooltip.svelte' @@ -24,7 +24,7 @@ type OnBehalfOfChoice } from '$lib/components/OnBehalfOfSelector.svelte' import { canUserBypassRuleKind, protectionRulesState } from '$lib/workspaceProtectionRules.svelte' - import { FRONTEND_SDK_SCOPES } from '$lib/components/raw_apps/sdkScopes' + import { FRONTEND_SDK_SCOPES, sdkScopesToConfirm } from '$lib/components/raw_apps/sdkScopes' import { logFeatureUsage } from '$lib/utils/featureUsage' import { useOperatingUser, @@ -34,7 +34,9 @@ const operatingWorkspaceStore = useOperatingWorkspace() const operatingUser = useOperatingUser() const actingUser = $derived(operatingUser.current) - const operatorBuilderApps = useOperatorBuilderApps() + const roleCaps = useRoleCapabilities() + // The server forces a composed app into sandbox isolation. + const composeOnlyApps = $derived($roleCaps.apps === 'compose') const WM_DEPLOYERS_GROUP = 'wm_deployers' @@ -158,6 +160,17 @@ let savedOnBehalfOf = $derived(savedApp?.policy?.on_behalf_of) let onBehalfOfChoice: OnBehalfOfChoice = $state(undefined) let customOnBehalfOfEmail: string = $state('') + let myOnBehalfOf = $derived(actingUser?.username ? `u/${actingUser.username}` : undefined) + // Without the right to preserve, the backend runs the redeployed app as the deployer whatever + // the policy says, as it does for "me". + let deployOnBehalfOf = $derived.by(() => { + if (!canPreserve || onBehalfOfChoice === 'me') return myOnBehalfOf + if (onBehalfOfChoice === 'custom') return policy.on_behalf_of + return savedOnBehalfOf + }) + let onBehalfOfWillChange = $derived( + !newApp && !!savedOnBehalfOf && !!deployOnBehalfOf && deployOnBehalfOf !== savedOnBehalfOf + ) let dirtyCustomPath = $state(false) let path: Path | undefined = $state(undefined) @@ -242,437 +255,456 @@ }) </script> -{#if !onLatest} - <Alert - title={headUnknown - ? 'Could not check the deployed version. ' - : "You're not on the latest app version. "} - type="warning" - > - {headUnknown - ? "Deploying may overwrite changes made by other users. Press 'Deploy' to see diff." - : "By deploying, you may overwrite changes made by other users. Press 'Deploy' to see diff."} - </Alert> - <div class="py-2"></div> -{/if} -<label for="summary" class="text-emphasis text-xs font-semibold">Summary</label> -<div class="w-full pt-1"> - <!-- svelte-ignore a11y_autofocus --> - <TextInput - inputProps={{ - id: 'summary', - autofocus: true, - placeholder: 'App summary', - onkeydown: (e) => { - e.stopPropagation() - }, - onkeyup: () => { - if (appPath == '' && summary?.length > 0 && !dirtyPath) { - path?.setName( - summary - .toLowerCase() - .replace(/[^a-z0-9_]/g, '_') - .replace(/-+/g, '_') - .replace(/^-|-$/g, '') - ) - } - } - }} - bind:value={summary} - /> -</div> -<div class="pt-3"></div> -<LabelsInput bind:labels class="-mt-4" /> -<div class="py-6"></div> -<label for="deploymentMsg" class="text-emphasis text-xs font-semibold">Deployment message</label> -<div class="w-full pt-1"> - <!-- svelte-ignore a11y_autofocus --> - <TextInput - inputProps={{ - id: 'deploymentMsg', - placeholder: 'Optional deployment message' - }} - bind:value={deploymentMsg} - /> -</div> -<div class="py-6"></div> -<label for="path" class="text-emphasis text-xs font-semibold">Path</label> -<Path - bind:this={path} - bind:dirty={dirtyPath} - bind:error={pathError} - bind:path={newEditedPath} - initialPath={newPath} - namePlaceholder="app" - kind="app" - autofocus={false} - workspaceOverride={operatingWorkspace} -/> +<div class="flex flex-col gap-8"> + {#if !onLatest} + <Alert + title={headUnknown + ? 'Could not check the deployed version. ' + : "You're not on the latest app version. "} + type="warning" + > + {headUnknown + ? "Deploying may overwrite changes made by other users. Press 'Deploy' to see diff." + : "By deploying, you may overwrite changes made by other users. Press 'Deploy' to see diff."} + </Alert> + {/if} -<div class="py-2"></div> -<Alert title="App executed on behalf of you"> - A viewer of the app will execute the runnables of the app on behalf of the publisher (you) - <Tooltip> - It ensures that all required resources/runnable visible for publisher but not for viewer at time - of creating the app would prevent the execution of the app. To guarantee tight security, a - policy is computed at time of deployment of the app which only allow the scripts/flows referred - to in the app to be called on behalf of. Furthermore, static parameters are not overridable. - Hence, users will only be able to use the app as intended by the publisher without risk for - leaking resources not used in the app. - </Tooltip> - {#if canPreserve} - <div class="mt-4"> - Because you are either an admin or part of the {WM_DEPLOYERS_GROUP} group, you can select another - user to run this app on behalf of. Once deployed the app will be run on behalf of - <OnBehalfOfSelector - targetWorkspace={opWs ?? ''} - targetValue={savedOnBehalfOfEmail} - selected={onBehalfOfChoice} - onSelect={(choice, details) => { - onBehalfOfChoice = choice - if (choice === 'me') { - policy.on_behalf_of_email = $userStore?.email - policy.on_behalf_of = `u/${$userStore?.username}` - customOnBehalfOfEmail = '' - preserveOnBehalfOf = false - } else if (choice === 'target') { - policy.on_behalf_of_email = savedOnBehalfOfEmail - policy.on_behalf_of = savedOnBehalfOf - customOnBehalfOfEmail = '' - preserveOnBehalfOf = true - } else if (choice === 'custom' && details) { - policy.on_behalf_of_email = details.email - policy.on_behalf_of = details.permissionedAs - customOnBehalfOfEmail = details.email - preserveOnBehalfOf = true + <div class="flex flex-col gap-6"> + <div class="flex flex-col gap-1"> + <label for="summary" class="text-emphasis text-xs font-semibold">Summary</label> + <!-- svelte-ignore a11y_autofocus --> + <TextInput + inputProps={{ + id: 'summary', + autofocus: true, + placeholder: 'App summary', + onkeydown: (e) => { + e.stopPropagation() + }, + onkeyup: () => { + if (appPath == '' && summary?.length > 0 && !dirtyPath) { + path?.setName( + summary + .toLowerCase() + .replace(/[^a-z0-9_]/g, '_') + .replace(/-+/g, '_') + .replace(/^-|-$/g, '') + ) + } } }} - kind="app" - {canPreserve} - customValue={customOnBehalfOfEmail} - isDeployment={false} + bind:value={summary} + /> + <LabelsInput bind:labels /> + </div> + <div class="flex flex-col gap-1"> + <label for="deploymentMsg" class="text-emphasis text-xs font-semibold"> + Deployment message + </label> + <TextInput + inputProps={{ + id: 'deploymentMsg', + placeholder: 'Optional deployment message' + }} + bind:value={deploymentMsg} /> </div> - {/if} -</Alert> - -<div class="mt-10"></div> - -<div class="flex items-center gap-2"> - <h2>Sandbox isolation</h2> - <Badge color="yellow">Alpha</Badge> -</div> -<div class="my-6"> - <Toggle - options={{ right: "Isolate the app from the viewer's browser session" }} - checked={$operatorBuilderApps || policy.sandbox == true} - on:change={(e) => { - policy.sandbox = e.detail || undefined - // Counted where the toggle is flipped rather than where the policy is - // persisted: a not-yet-deployed app only mutates it locally, and skipping - // those would read as unused in the case where it is picked up front. - logFeatureUsage('app_sandbox', 'toggled', { - key: `${rawApp ? 'raw' : 'low_code'}:${e.detail ? 'on' : 'off'}` - }) - // Frontend API access exists only for a sandboxed app, so turning - // isolation off drops the declared scopes with it rather than leaving - // them set but inert. - if (!e.detail) { - policy.frontend_sdk_scopes = undefined - } - // A not-yet-deployed app has no row to PATCH — `setPublishState` (POST - // /apps/update) would 404. The flag rides along in the `policy` the first - // deploy sends (createApp), so here we only mutate it locally. Persist - // incrementally once the app exists. - if (savedApp && !newApp) { - setPublishState(e.detail ? 'Sandbox isolation enabled' : 'Sandbox isolation disabled') - } - }} - disabled={!savedApp || $operatorBuilderApps} - /> - {#if $operatorBuilderApps} - <div class="text-xs text-secondary mt-1"> - Required for apps built by operators, and cannot be turned off. + <div class="flex flex-col gap-1"> + <label for="path" class="text-emphasis text-xs font-semibold">Path</label> + <Path + bind:this={path} + bind:dirty={dirtyPath} + bind:error={pathError} + bind:path={newEditedPath} + initialPath={newPath} + namePlaceholder="app" + kind="app" + autofocus={false} + workspaceOverride={operatingWorkspace} + /> </div> - {/if} - <div class="text-xs text-secondary mt-1"> - Controls what the app's browser-side code can reach in each viewer's browser — distinct from the - on-behalf-of model above (which sets who its runnables run as). Off by default, the app's code - uses the viewer's own session; enable it to confine the app to a narrowly-scoped token instead, - on every surface (public URL and in-workspace). Leave it off if the app needs full browser - features (IndexedDB, third-party auth/SDKs, OAuth redirects). </div> - {#if newApp} - <div class="text-xs text-tertiary mt-1">Takes effect when you first deploy this app.</div> - {/if} - {#if policy.sandbox == true} - <div class="mt-2"> - <Alert type="warning" title="Alpha feature" size="xs"> - Sandbox isolation is in alpha. After enabling, open the app from its public URL to confirm - it still works, and report any broken behavior. - </Alert> - </div> - {/if} -</div> -{#if rawApp && policy.sandbox == true} - <h2 class="text-xs font-semibold">Frontend API access</h2> - <div class="mb-6 mt-2"> - <div class="text-xs text-secondary mb-3"> - Let the app's frontend code call the Windmill API through the <code>windmill-client</code> - SDK, authenticated as <b>the viewer</b> (unlike runnables, which run on behalf of the - publisher). Each viewer is asked to approve the scopes below before the app runs. Grant only - what the app needs: its code — or an XSS bug in it — can use them as that viewer. Add - <code>windmill-client</code> to the app's dependencies to import it; it configures itself from - the token handed to the bundle. - </div> - {#each FRONTEND_SDK_SCOPES as scope (scope.value)} - <div class="mb-2"> - <Toggle - size="xs" - options={{ right: scope.label }} - checked={policy.frontend_sdk_scopes?.includes(scope.value) ?? false} - on:change={(e) => { - const current: string[] = policy.frontend_sdk_scopes ?? [] - const next = e.detail - ? [...current, scope.value] - : current.filter((s) => s !== scope.value) - // Keep the curated order so the consent banner and the stored - // consent compare stably across deploys. - const ordered = FRONTEND_SDK_SCOPES.map((s) => s.value).filter((s) => next.includes(s)) - policy.frontend_sdk_scopes = ordered.length > 0 ? ordered : undefined - // Same as sandbox: a not-yet-deployed app has no row to PATCH, so the - // scopes ride along in the first deploy's policy instead. - if (savedApp && !newApp) { - setPublishState('Frontend API access updated') + <Section + label="Run on behalf of" + tooltip={`The app's runnables run as this user whoever opens the app, so viewers need no access of their own. To keep that safe, the app can call only the runnables it was deployed with, and viewers cannot override their static inputs. Runnables still get the viewer's email in WM_END_USER_EMAIL${rawApp ? ', and inline ones can be set to read data tables and object storage as the viewer' : ''}.`} + > + <div class="flex flex-col items-start gap-1"> + <div class="flex items-center gap-1.5"> + <OnBehalfOfSelector + targetWorkspace={opWs ?? ''} + targetValue={savedOnBehalfOfEmail} + selected={onBehalfOfChoice} + onSelect={(choice, details) => { + onBehalfOfChoice = choice + if (choice === 'me') { + policy.on_behalf_of_email = $userStore?.email + policy.on_behalf_of = `u/${$userStore?.username}` + customOnBehalfOfEmail = '' + preserveOnBehalfOf = false + } else if (choice === 'target') { + policy.on_behalf_of_email = savedOnBehalfOfEmail + policy.on_behalf_of = savedOnBehalfOf + customOnBehalfOfEmail = '' + preserveOnBehalfOf = true + } else if (choice === 'custom' && details) { + policy.on_behalf_of_email = details.email + policy.on_behalf_of = details.permissionedAs + customOnBehalfOfEmail = details.email + preserveOnBehalfOf = true } }} - disabled={!savedApp} + kind="app" + {canPreserve} + customValue={customOnBehalfOfEmail} + isDeployment={false} + myPermissionedAs={myOnBehalfOf} + readOnlyWithoutPreserve /> - <div class="text-xs text-hint ml-9">{scope.description}</div> + {#if onBehalfOfWillChange} + <AlertTriangle class="w-3.5 h-3.5 text-yellow-500" /> + <span class="text-2xs text-yellow-600 dark:text-yellow-400" + >will change from <strong>{savedOnBehalfOf}</strong> on deploy</span + > + {/if} </div> - {/each} - {#if newApp} - <div class="text-xs text-tertiary mt-1">Takes effect when you first deploy this app.</div> - {/if} - {#if policy.frontend_sdk_scopes?.length} - <div class="mt-2"> - <Alert type="info" title="Redeploy to use the SDK from a sandboxed app" size="xs"> - A sandboxed app calls the API cross-origin, which older <code>windmill-client</code> versions - cannot do. An app bundled before this Windmill version fails with a CORS error until you deploy - it again, which re-bundles it against a current client. - </Alert> - </div> - {/if} - </div> -{/if} - -{#if !hideSecretUrl} - <h2>Access</h2> - - <div class="my-6"> - {#if rulesetsLoaded && !canSetAnonymous && policy.execution_mode != 'anonymous'} - <Alert type="warning" title="Restricted by a workspace protection rule" size="xs"> - Opening this app to anyone with the link is restricted to workspace admins and bypass users - by a workspace protection rule - </Alert> - <div class="mb-2"></div> - {/if} - {#if rulesetsLoaded && !canSetGuest && policy.execution_mode != 'guest' && guestsAvailable} - <Alert type="warning" title="Restricted by a workspace protection rule" size="xs"> - Opening this app to guests is restricted to workspace admins and bypass users by a workspace - protection rule - </Alert> - <div class="mb-2"></div> - {/if} - <div class="flex gap-2 items-center mb-2"> - <ToggleButtonGroup - selected={accessMode} - on:selected={(e) => onAccessModeChange(e.detail)} - disabled={!savedApp} - > - {#snippet children({ item })} - <ToggleButton - label="Members" - value="publisher" - tooltip="Workspace members with read access on this app." - {item} - /> - <ToggleButton - label="Guests" - value="guest" - disabled={policy.execution_mode != 'guest' && (!guestsAvailable || !canSetGuest)} - tooltip={!guestsAvailable - ? 'Not available on Windmill Cloud. Guests require a self-hosted instance or a dedicated Windmill Cloud deployment.' - : "Anyone your identity provider authenticates who has no Windmill account, plus workspace members. No membership, no seat up to the instance's allowance."} - {item} - /> - <ToggleButton - label="Public" - value="anonymous" - disabled={!canSetAnonymous && policy.execution_mode != 'anonymous'} - tooltip="Anyone with the secret URL. No login." - {item} - /> - {/snippet} - </ToggleButtonGroup> - </div> - <div class="text-xs text-secondary mb-3"> - {#if policy.execution_mode == 'anonymous'} - Anyone holding the secret URL below can open this app without signing in. - {:else if policy.execution_mode == 'guest'} - {#if !guestsAvailable} - Guests are not available on Windmill Cloud, so this app still admits members only. They - require a self-hosted instance or a dedicated Windmill Cloud deployment. - {:else if guestUsage && !guestUsage.instance_enabled} - A superadmin has turned guests off for this instance, so this app still admits members - only. - {:else if guestAccessEnabled === undefined} - Checking whether this workspace allows guests… - {:else if guestAccessEnabled === false} - Guests are turned off for this workspace, so this app still admits members only. A - workspace admin can turn them on in the workspace settings. + <div class="text-2xs text-secondary"> + {#if canPreserve} + Viewers run the app's runnables as this user. {:else} - Anyone your identity provider authenticates can open this app without a Windmill account. - They join no workspace. Members of this workspace can open it too. - {#if guestUsage} - {guestUsage.guest_count} of {guestUsage.free_allowance} free guests used across this instance - in the last {guestUsage.window_days} days; beyond that, {guestUsage.metered - ? 'every four guests count as one seat' - : 'new guests are refused until the count drops'}. - {/if} + Only workspace admins and members of {WM_DEPLOYERS_GROUP} can pick another user. {/if} - {:else} - Only workspace members with read access on this app can open it. - {/if} - </div> - {#if !savedApp || newApp} - <ClipboardPanel content={`Deploy this app once to get the public secret URL`} size="md" /> - {:else if secretUrlHref} - <div class="flex justify-end mb-1"> - <Toggle - size="xs" - checked={embedMode} - on:change={(e) => (embedMode = e.detail)} - options={{ left: 'URL', right: 'Embed' }} - /> </div> - <ClipboardPanel - content={embedMode ? toEmbedSnippet(secretUrlHref) : secretUrlHref} - size="md" - /> - {:else}<Loader2 class="animate-spin" /> - {/if} - <div class="text-xs text-secondary mt-1"> - {#if embedMode} - Paste this iframe snippet into another app. - {#if rawApp} - To embed it in a cross-origin isolated page, add <code>wm_coep=on</code> - <Tooltip - >Sets the cross-origin isolation headers (COEP) on the app, which such a page requires - of its iframes. The app then only loads cross-origin resources that send CORS or a - Cross-Origin-Resource-Policy header.</Tooltip - > to the URL. - {/if} - (if requiring login, top-level domain of embedding app must be the same as the one of Windmill) - {:else} - Share this url directly, or switch to <b>Embed</b> to get an iframe snippet. - {/if} </div> + </Section> - {#if embedMode && policy.execution_mode == 'guest' && guestAccessEnabled && guestJwtBase && guestsAvailable} - <div class="mt-4 border-t pt-3 flex flex-col gap-2"> - <div class="text-xs font-semibold text-emphasis"> - Embed for your own authenticated users (guest JWT) - </div> - <div class="text-xs text-secondary"> - To open this app for a user your own product already authenticates, mint a short-lived JWT - in your backend and append it to the app URL as <code>guest.<jwt></code>. Each token - is its own seatless guest, confined to this app — no shared secret and no Windmill - account, unlike the plain secret URL above. - </div> - <div class="text-xs text-secondary"> - Windmill verifies the token against the workspace's guest JWT key (Workspace settings → - Guests) — a PEM public key or a JWKS URL{#if !isCloudHosted()}, or the instance's - configured issuer (<code>JWT_EXT_JWKS_URL</code>) when no workspace key is set{/if}. Set - the <b>public</b> half there; in your backend, sign each token with the matching - <b>private</b> key using RS256/384/512, PS256/384/512 or ES256/384 (symmetric HS* is - refused), carrying <code>email</code>, <code>workspace_id</code> = <code>{opWs}</code>, - <code>app_path</code> = <code>{appPath}</code> and <code>exp</code> (at most 24h ahead). - </div> - <ClipboardPanel - content={toEmbedSnippet(`${guestJwtBase}/guest.YOUR_GUEST_JWT`)} - size="md" - /> - <div class="text-2xs text-secondary"> - Replace <code>YOUR_GUEST_JWT</code> with the token your backend signs per user. Past the instance's - free guest allowance a new guest email is refused (see the count above); guests already seen - in the window keep working. - </div> + <Section label="Sandbox isolation"> + <Toggle + options={{ + right: "Isolate the app from the viewer's browser session", + rightTooltip: + "Controls what the app's browser-side code can reach in each viewer's browser, separately from who its runnables run as. A sandboxed app gets a narrowly scoped token on every surface, public URL and in-workspace. On by default for new apps. Turn it off only if the app needs IndexedDB, third-party sign-in or OAuth redirects." + }} + checked={composeOnlyApps || policy.sandbox == true} + on:change={(e) => { + policy.sandbox = e.detail || undefined + // Counted where the toggle is flipped rather than where the policy is + // persisted: a not-yet-deployed app only mutates it locally, and skipping + // those would read as unused in the case where it is picked up front. + logFeatureUsage('app_sandbox', 'toggled', { + key: `${rawApp ? 'raw' : 'low_code'}:${e.detail ? 'on' : 'off'}` + }) + // Frontend API access exists only for a sandboxed app, so turning + // isolation off drops the declared scopes with it rather than leaving + // them set but inert. + if (!e.detail) { + policy.frontend_sdk_scopes = undefined + } + // A not-yet-deployed app has no row to PATCH — `setPublishState` (POST + // /apps/update) would 404. The flag rides along in the `policy` the first + // deploy sends (createApp), so here we only mutate it locally. Persist + // incrementally once the app exists. + if (savedApp && !newApp) { + setPublishState(e.detail ? 'Sandbox isolation enabled' : 'Sandbox isolation disabled') + } + }} + disabled={!savedApp || composeOnlyApps} + /> + {#if composeOnlyApps || newApp} + <div class="text-2xs text-secondary mt-1"> + {#if composeOnlyApps} + Required for apps composed by your role, and cannot be turned off. + {/if} + {#if newApp} + Takes effect when you first deploy this app. + {/if} </div> {/if} - - <div class="mt-4"> - {#if !(actingUser?.is_admin || actingUser?.is_super_admin)} - <Alert type="warning" title="Admin only" size="xs"> - Custom path can only be set by workspace admins + {#if !composeOnlyApps && policy.sandbox != true} + <div class="mt-3"> + <Alert type="warning" title="Not isolated" size="xs"> + The app's frontend code runs with each viewer's full Windmill permissions. A future + version will ask viewers to approve this. Keep isolation off only if the app needs it. </Alert> - <div class="mb-2"></div> - {/if} - <!-- svelte-ignore block_empty --> - {#if !$enterpriseLicense} - <EEOnly /> - {/if} - <Toggle - on:change={({ detail }) => { - customPath = detail ? '' : undefined - if (customPath === undefined) { - customPathError = '' - } - }} - checked={customPath !== undefined} - options={{ - right: 'Use a custom URL' - }} - disabled={!$enterpriseLicense || !(actingUser?.is_admin || actingUser?.is_super_admin)} - /> + </div> + {/if} - {#if customPath !== undefined} - <div class="text-secondary text-sm flex items-center gap-1 w-full justify-between"> - <div>Custom path</div> - </div> - <input - disabled={!(actingUser?.is_admin || actingUser?.is_super_admin)} - type="text" - autocomplete="off" - bind:value={customPath} - class={customPathError === '' - ? '' - : 'border border-red-700 bg-red-100 border-opacity-30 focus:border-red-700 focus:border-opacity-30 focus-visible:ring-red-700 focus-visible:ring-opacity-25 focus-visible:border-red-700'} - oninput={() => { - dirtyCustomPath = true - }} - /> - <div class="text-secondary text-sm flex items-center gap-1 mt-2 w-full justify-between"> - <div>Custom public URL</div> - </div> - <ClipboardPanel - content={embedMode ? toEmbedSnippet(fullCustomUrl) : fullCustomUrl} - size="md" - /> + {#if rawApp && policy.sandbox == true} + <div class="mt-6"> + <Section + label="Frontend API access" + small + collapsable + tooltip="Each viewer approves these scopes before the app runs. The app's code, or an XSS bug in it, can use them as that viewer." + > + {#snippet badge()} + {#if sdkScopesToConfirm(policy.frontend_sdk_scopes ?? []).length} + <div class="flex grow min-w-0 flex-wrap gap-1 ps-2"> + {#each FRONTEND_SDK_SCOPES.filter( (s) => policy.frontend_sdk_scopes.includes(s.value) ) as scope (scope.value)} + <Badge twBgColor="bg-surface-sunken" twTextColor="text-primary" + >{scope.label}</Badge + > + {/each} + </div> + {/if} + {/snippet} + <div class="text-xs text-secondary mb-3"> + Let the app's frontend call the Windmill API as <b>the viewer</b> through the + <code>windmill-client</code> SDK, which it lists as a dependency. Grant only what the app + needs. + </div> + <div class="flex flex-col gap-2"> + {#each FRONTEND_SDK_SCOPES as scope (scope.value)} + <div> + <Toggle + size="xs" + options={{ right: scope.label }} + checked={policy.frontend_sdk_scopes?.includes(scope.value) ?? false} + on:change={(e) => { + const current: string[] = policy.frontend_sdk_scopes ?? [] + const next = e.detail + ? [...current, scope.value] + : current.filter((s) => s !== scope.value) + // Keep the curated order so the consent banner and the stored + // consent compare stably across deploys. A leftover implicit scope is + // not in the list, so it drops here rather than keep minting a token + // for an app whose toggles all read off. + const ordered = FRONTEND_SDK_SCOPES.map((s) => s.value).filter((s) => + next.includes(s) + ) + policy.frontend_sdk_scopes = ordered.length > 0 ? ordered : undefined + // Same as sandbox: a not-yet-deployed app has no row to PATCH, so the + // scopes ride along in the first deploy's policy instead. + if (savedApp && !newApp) { + setPublishState('Frontend API access updated') + } + }} + disabled={!savedApp} + /> + <div class="text-2xs text-hint ml-9">{scope.description}</div> + </div> + {/each} + </div> + {#if sdkScopesToConfirm(policy.frontend_sdk_scopes ?? []).length} + <div class="mt-3"> + <Alert type="info" title="Redeploy to use the SDK from a sandboxed app" size="xs"> + A sandboxed app calls the API cross-origin, which older <code>windmill-client</code> + versions cannot do. An app bundled before this Windmill version fails with a CORS error + until you deploy it again, which re-bundles it against a current client. + </Alert> + </div> + {/if} + </Section> + </div> + {/if} + </Section> - <div class="text-red-600 dark:text-red-400 text-2xs mt-1.5" - >{dirtyCustomPath ? customPathError : ''} + {#if !hideSecretUrl} + <Section label="Access"> + <div class="flex flex-col gap-2"> + {#if rulesetsLoaded && !canSetAnonymous && policy.execution_mode != 'anonymous'} + <Alert type="warning" title="Restricted by a workspace protection rule" size="xs"> + Opening this app to anyone with the link is restricted to workspace admins and bypass + users by a workspace protection rule + </Alert> + {/if} + {#if rulesetsLoaded && !canSetGuest && policy.execution_mode != 'guest' && guestsAvailable} + <Alert type="warning" title="Restricted by a workspace protection rule" size="xs"> + Opening this app to guests is restricted to workspace admins and bypass users by a + workspace protection rule + </Alert> + {/if} + <div> + <ToggleButtonGroup + selected={accessMode} + on:selected={(e) => onAccessModeChange(e.detail)} + disabled={!savedApp} + > + {#snippet children({ item })} + <ToggleButton + label="Members" + value="publisher" + tooltip="Workspace members with read access on this app." + {item} + /> + <ToggleButton + label="Guests" + value="guest" + disabled={policy.execution_mode != 'guest' && (!guestsAvailable || !canSetGuest)} + tooltip={!guestsAvailable + ? 'Not available on Windmill Cloud. Guests require a self-hosted instance or a dedicated Windmill Cloud deployment.' + : "Anyone your identity provider authenticates who has no Windmill account, plus workspace members. No membership, no seat up to the instance's allowance."} + {item} + /> + <ToggleButton + label="Public" + value="anonymous" + disabled={!canSetAnonymous && policy.execution_mode != 'anonymous'} + tooltip="Anyone with the secret URL. No login." + {item} + /> + {/snippet} + </ToggleButtonGroup> + </div> + <div class="text-xs text-secondary"> + {#if policy.execution_mode == 'anonymous'} + Anyone holding the secret URL below can open this app without signing in. + {:else if policy.execution_mode == 'guest'} + {#if !guestsAvailable} + Guests are not available on Windmill Cloud, so this app still admits members only. + They require a self-hosted instance or a dedicated Windmill Cloud deployment. + {:else if guestUsage && !guestUsage.instance_enabled} + A superadmin has turned guests off for this instance, so this app still admits members + only. + {:else if guestAccessEnabled === undefined} + Checking whether this workspace allows guests… + {:else if guestAccessEnabled === false} + Guests are turned off for this workspace, so this app still admits members only. A + workspace admin can turn them on in the workspace settings. + {:else} + Anyone your identity provider authenticates can open this app without a Windmill + account. They join no workspace. Members of this workspace can open it too. + {#if guestUsage} + {guestUsage.guest_count} of {guestUsage.free_allowance} free guests used across this + instance in the last {guestUsage.window_days} days; beyond that, {guestUsage.metered + ? 'every four guests count as one seat' + : 'new guests are refused until the count drops'}. + {/if} + {/if} + {:else} + Only workspace members with read access on this app can open it. + {/if} + </div> + </div> + + <div class="flex flex-col gap-1 mt-6"> + {#if !savedApp || newApp} + <ClipboardPanel content={`Deploy this app once to get the public secret URL`} size="md" /> + {:else if secretUrlHref} + <div class="flex justify-end"> + <Toggle + size="xs" + checked={embedMode} + on:change={(e) => (embedMode = e.detail)} + options={{ left: 'URL', right: 'Embed' }} + /> + </div> + <ClipboardPanel + content={embedMode ? toEmbedSnippet(secretUrlHref) : secretUrlHref} + size="md" + /> + {:else}<Loader2 class="animate-spin" /> + {/if} + <div class="text-2xs text-secondary"> + {#if embedMode} + Paste this iframe snippet into another app. + {#if rawApp} + To embed it in a cross-origin isolated page, add <code>wm_coep=on</code> + <Tooltip + >Sets the cross-origin isolation headers (COEP) on the app, which such a page + requires of its iframes. The app then only loads cross-origin resources that send + CORS or a Cross-Origin-Resource-Policy header.</Tooltip + > to the URL. + {/if} + (if requiring login, top-level domain of embedding app must be the same as the one of Windmill) + {:else} + Share this URL directly, or switch to <b>Embed</b> to get an iframe snippet. Only the latest + deployed version is served. + {/if} + </div> + </div> + + {#if embedMode && policy.execution_mode == 'guest' && guestAccessEnabled && guestJwtBase && guestsAvailable} + <div class="mt-6 flex flex-col gap-2"> + <div class="text-xs font-semibold text-emphasis"> + Embed for your own authenticated users (guest JWT) + </div> + <div class="text-xs text-secondary"> + To open this app for a user your own product already authenticates, mint a short-lived + JWT in your backend and append it to the app URL as <code>guest.<jwt></code>. Each + token is its own seatless guest, confined to this app. Unlike the plain secret URL + above, there is no shared secret and no Windmill account. + </div> + <div class="text-xs text-secondary"> + Windmill verifies the token against the workspace's guest JWT key (Workspace settings → + Guests): a PEM public key or a JWKS URL{#if !isCloudHosted()}, or the instance's + configured issuer (<code>JWT_EXT_JWKS_URL</code>) when no workspace key is set{/if}. + Set the <b>public</b> half there; in your backend, sign each token with the matching + <b>private</b> key using RS256/384/512, PS256/384/512 or ES256/384 (symmetric HS* is + refused), carrying <code>email</code>, <code>workspace_id</code> = <code>{opWs}</code>, + <code>app_path</code> = <code>{appPath}</code> and <code>exp</code> (at most 24h ahead). + </div> + <ClipboardPanel + content={toEmbedSnippet(`${guestJwtBase}/guest.YOUR_GUEST_JWT`)} + size="md" + /> + <div class="text-2xs text-secondary"> + Replace <code>YOUR_GUEST_JWT</code> with the token your backend signs per user. Past the + instance's free guest allowance a new guest email is refused (see the count above); guests + already seen in the window keep working. + </div> </div> {/if} - </div> - </div> - <Alert type="info" title="Only latest deployed app is publicly available"> - You will still need to deploy the app to make visible the latest changes - </Alert> - <a - href="https://www.windmill.dev/docs/advanced/external_auth_with_jwt#embed-public-apps-using-your-own-authentification" - class="mt-4 text-2xs">Embed this app in your own product to be used by your own users</a - > -{/if} + <div class="flex flex-col gap-2 mt-6"> + {#if !(actingUser?.is_admin || actingUser?.is_super_admin)} + <Alert type="warning" title="Admin only" size="xs"> + Custom path can only be set by workspace admins + </Alert> + {/if} + <div class="flex items-center gap-2"> + <Toggle + on:change={({ detail }) => { + customPath = detail ? '' : undefined + if (customPath === undefined) { + customPathError = '' + } + }} + checked={customPath !== undefined} + options={{ + right: 'Use a custom URL' + }} + disabled={!$enterpriseLicense || !(actingUser?.is_admin || actingUser?.is_super_admin)} + /> + {#if !$enterpriseLicense} + <EEOnly /> + {/if} + </div> + + {#if customPath !== undefined} + <div class="flex flex-col gap-1"> + <label for="customPath" class="text-emphasis text-xs font-semibold">Custom path</label> + <TextInput + inputProps={{ + id: 'customPath', + autocomplete: 'off', + disabled: !(actingUser?.is_admin || actingUser?.is_super_admin), + oninput: () => { + dirtyCustomPath = true + } + }} + bind:value={customPath} + error={dirtyCustomPath ? customPathError : undefined} + /> + {#if dirtyCustomPath && customPathError} + <div class="text-2xs text-red-600 dark:text-red-400">{customPathError}</div> + {/if} + </div> + <div class="flex flex-col gap-1"> + <div class="text-emphasis text-xs font-semibold">Custom public URL</div> + <ClipboardPanel + content={embedMode ? toEmbedSnippet(fullCustomUrl) : fullCustomUrl} + size="md" + /> + </div> + {/if} + </div> + + <a + href="https://www.windmill.dev/docs/advanced/external_auth_with_jwt#embed-public-apps-using-your-own-authentification" + class="block mt-6 text-2xs" + >Embed this app in your own product to be used by your own users</a + > + </Section> + {/if} +</div> diff --git a/frontend/src/lib/components/apps/editor/AppJsonEditor.svelte b/frontend/src/lib/components/apps/editor/AppJsonEditor.svelte index e60a149cfa..8078e6cc7f 100644 --- a/frontend/src/lib/components/apps/editor/AppJsonEditor.svelte +++ b/frontend/src/lib/components/apps/editor/AppJsonEditor.svelte @@ -8,7 +8,7 @@ import { UserDraft } from '$lib/userDraft.svelte' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { sendUserToast } from '$lib/toast' - import { userStore, workspaceStore } from '$lib/stores' + import { roleCapabilities, workspaceStore } from '$lib/stores' import { createEventDispatcher } from 'svelte' import { Globe, Loader2, Save } from 'lucide-svelte' import { deployRawAppValue } from '$lib/rawAppDeploy' @@ -111,7 +111,7 @@ {/if} {#snippet actions()} - {#if !$userStore?.operator} + {#if $roleCapabilities.apps === 'full'} <Button on:click={saveApp} startIcon={{ icon: isDraftOnly ? Save : Globe }} diff --git a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte index 3384ad3e87..d86713253a 100644 --- a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte +++ b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte @@ -18,13 +18,14 @@ import type { Item } from '$lib/utils' import { Button } from '$lib/components/common' import { AppService, OpenAPI } from '$lib/gen' - import { userStore, type UserExt } from '$lib/stores' + import { userStore, workspaceStore, type UserExt } from '$lib/stores' import { getUserExt } from '$lib/user' import { ExternalLink, Pen } from 'lucide-svelte' import { page } from '$app/state' import { isMenuHidden } from '$lib/components/sessions/sessionMode.svelte' import { setOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' import { useEditRights } from '$lib/operatorWriteRights' + import { recentItems } from '$lib/components/home/recentItems.svelte' let { workspace, @@ -83,6 +84,8 @@ * where the lite payload carries it; the flat field beside it is the apps *list* row's * spelling (ListableApp, which is what AppRow reads). */ let executionMode = $state<string | undefined>(undefined) + /** From the lite read too: a sandboxed low-code app never fills `app`. */ + let summary = $state<string | undefined>(undefined) let editHref = $derived(`/${isRawApp ? 'apps_raw' : 'apps'}/edit/${path}?nodraft=true`) let refresh: (() => void) | undefined @@ -123,8 +126,8 @@ // Viewer side — used for the inline renderings (raw, and unsandboxed low-code); // the sandboxed low-code case loads inside the opaque /app_embed iframe instead. - // getAppByPath returns bundle_secret + runnables for raw apps, which - // PublicApp -> RawAppPreview needs. + // getAppLiteByPath returns the runnables (with their ids) that + // PublicApp -> RawAppPreview needs for raw apps. async function loadApp() { // Kept local and handed to PublicApp rather than written to `userStore`: this is the // membership in `workspace`, and a session preview tab shows a workspace the rest of @@ -134,8 +137,10 @@ // populated `userStore` for the workspace the page *is* on. appUser = await getUserExt(workspace) try { - const loaded: any = await AppService.getAppByPath({ workspace, path }) - // Raw apps need the bundle secret to load their bundle. getAppByPath + // The lite value: its inline scripts carry the ids of their deployed rows, the only + // code a deployed app runs (with its lock, and a raw app's imported runnables). + const loaded: any = await AppService.getAppLiteByPath({ workspace, path }) + // Raw apps need the bundle secret to load their bundle. getAppLiteByPath // doesn't compute it (unlike the public handlers), so fetch it here — the // same call the previous raw viewer used — and hand it to PublicApp -> // RawAppPreview via bundle_secret. @@ -174,8 +179,19 @@ appPerms = { path: lite?.path ?? path, extraPerms: lite?.extra_perms ?? {} } isRawApp = !!lite?.raw_app executionMode = lite?.policy?.execution_mode ?? lite?.execution_mode + summary = lite?.summary || undefined + // Only the route's own page is a visit; a session's preview tab opens it on its own. + if (ownsPageHeader) { + recentItems.record(workspace, { + kind: isRawApp ? 'raw_app' : 'app', + path: appPerms.path, + summary, + via: 'view' + }) + } } catch (_) { appPerms = undefined + summary = undefined } } @@ -248,7 +264,7 @@ <PublicApp {app} {workspace} - user={appUser ?? $userStore} + user={appUser ?? (workspace === $workspaceStore ? $userStore : undefined)} {notExists} {noPermission} jwtError={false} @@ -265,7 +281,7 @@ no item, and the breadcrumb would fall back to the section name "Apps". --> {#if ownsPageHeader} <PageHeaderContent - item={{ kind: 'app', path }} + item={{ kind: 'app', path, summary }} actions={showEdit && !menuHidden ? editAction : undefined} separator="always" /> diff --git a/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte b/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte index eacb3652e1..4c391c0336 100644 --- a/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte +++ b/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte @@ -194,21 +194,38 @@ // Top-level navigation relay (WIN-2006): in-app links to other apps (navbar // "app" items) must navigate the top page — navigating inside the opaque // iframe would load the SPA cookieless. Consumed by PublicApp via gotoFn. - setContext<EmbedNav | undefined>( - EMBED_NAV_CONTEXT_KEY, - isViewer - ? { - navigateTop: (href: string) => { - try { - window.parent.postMessage( - { type: 'wm_embed_navigate', href }, - expectedEmbedderOrigin ?? '*' - ) - } catch (_) {} - } - } - : undefined - ) + function navigateTop(href: string) { + try { + window.parent.postMessage({ type: 'wm_embed_navigate', href }, expectedEmbedderOrigin ?? '*') + } catch (_) {} + } + setContext<EmbedNav | undefined>(EMBED_NAV_CONTEXT_KEY, isViewer ? { navigateTop } : undefined) + + // Plain links (markdown, HTML components) would otherwise load inside the + // cookieless frame, or put an external site in it. Bound on <body> so the link's own + // handlers run first and can cancel it, and before SvelteKit's router on <html>, + // which skips a prevented click. A link to this same page only changes ?query/#hash, + // which the hash relay mirrors, so it stays in the frame. + function relayLinkClick(e: MouseEvent) { + if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) + return + // Attributes, not properties: on an SVG <a> those are SVGAnimatedString. + const a = (e.target as Element | null)?.closest?.('a[href]') + const target = a?.getAttribute('target') + if ( + !a || + a.hasAttribute('download') || + (target && !['_self', '_top', '_parent'].includes(target)) + ) + return + const url = new URL(a.getAttribute('href')!, document.baseURI) + // The embedder only relays http(s); mailto:, tel: and the like work in the frame. + if (url.protocol !== 'http:' && url.protocol !== 'https:') return + const sameOrigin = url.origin === window.location.origin + if (sameOrigin && url.pathname === window.location.pathname) return + e.preventDefault() + navigateTop(sameOrigin ? url.pathname + url.search + url.hash : url.href) + } function installHashRelay() { origPushState = history.pushState origReplaceState = history.replaceState @@ -568,6 +585,7 @@ loadAppPreview().catch(() => {}) window.addEventListener('message', handleViewerMessage) installHashRelay() + document.body.addEventListener('click', relayLinkClick) // Announce readiness so the embedder sends us the token. window.parent.postMessage({ type: 'wm_embed_ready' }, expectedEmbedderOrigin ?? '*') // This page is only ever loaded as the embedder's opaque iframe, which @@ -589,7 +607,10 @@ if (!BROWSER) return window.removeEventListener('message', handleViewerMessage) window.removeEventListener('message', handleEmbedderMessage) - if (isViewer) uninstallHashRelay() + if (isViewer) { + uninstallHashRelay() + document.body.removeEventListener('click', relayLinkClick) + } }) </script> diff --git a/frontend/src/lib/components/apps/editor/inlineScriptsPanel/EmptyInlineScript.svelte b/frontend/src/lib/components/apps/editor/inlineScriptsPanel/EmptyInlineScript.svelte index 1d3d46fc35..ff15d6a4fc 100644 --- a/frontend/src/lib/components/apps/editor/inlineScriptsPanel/EmptyInlineScript.svelte +++ b/frontend/src/lib/components/apps/editor/inlineScriptsPanel/EmptyInlineScript.svelte @@ -15,7 +15,7 @@ import WorkspaceScriptList from '../settingsPanel/mainInput/WorkspaceScriptList.svelte' import RunnableSelector from '../settingsPanel/mainInput/RunnableSelector.svelte' import { defaultScripts } from '$lib/stores' - import { useOperatorBuilderApps } from '$lib/operatorWriteRights' + import { useRoleCapabilities } from '$lib/operatorWriteRights' import DefaultScripts from '$lib/components/DefaultScripts.svelte' import type { Preview } from '$lib/gen' import type { InlineScript } from '../../sharedTypes' @@ -39,7 +39,8 @@ let picker: Drawer | undefined = $state(undefined) const dispatch = createEventDispatcher() - const operatorBuilderApps = useOperatorBuilderApps() + const roleCaps = useRoleCapabilities() + const composeOnlyApps = $derived($roleCaps.apps === 'compose') async function inferInlineScriptSchema( language: Preview['language'], @@ -100,14 +101,14 @@ </script> <Drawer bind:this={picker} size="1000px"> - <DrawerContent title="Script/Flow Picker" on:close={picker.closeDrawer}> + <DrawerContent title="Fork a script" on:close={picker.closeDrawer}> <div> <div class="max-w-6xl"> <Tabs bind:selected={tab}> - <Tab value="workspacescripts" label="Workspace Scripts" icon={Building} /> + <Tab value="workspacescripts" label="Workspace scripts" icon={Building} /> - {#if !$operatorBuilderApps} - <Tab value="hubscripts" label="Hub Scripts" icon={Globe2} /> + {#if !composeOnlyApps} + <Tab value="hubscripts" label="Hub scripts" icon={Globe2} /> {/if} </Tabs> <div class="my-2"></div> @@ -128,13 +129,13 @@ <div class="flex flex-col px-4 gap-2 text-sm" id="app-editor-empty-runnable"> <div class="mt-2 flex justify-between gap-4" id="app-editor-runnable-header"> <div class="font-bold items-baseline truncate"> - {$operatorBuilderApps ? 'Choose a script or flow' : 'Choose a language'} + {composeOnlyApps ? 'Choose a script or flow' : 'Choose a language'} </div> <div class="flex gap-2"> {#if showScriptPicker} <RunnableSelector {unusedInlineScripts} {rawApps} on:pick hideCreateScript /> {/if} - {#if !$operatorBuilderApps} + {#if !composeOnlyApps} <Button on:click={() => picker?.openDrawer()} size="xs" @@ -161,7 +162,7 @@ <!-- Operators with builder rights compose runnables that already exist: the language pickers below all author code, which the backend refuses from them. --> - <div class="flex flex-row w-full gap-8" class:hidden={$operatorBuilderApps}> + <div class="flex flex-row w-full gap-8" class:hidden={composeOnlyApps}> <div id="app-editor-backend-runnables"> <div class="mb-1 text-sm font-semibold flex gap-4">Backend <DefaultScripts /> </div> diff --git a/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptRunnableByPath.svelte b/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptRunnableByPath.svelte index 45dbe82c14..e3552d0e79 100644 --- a/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptRunnableByPath.svelte +++ b/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptRunnableByPath.svelte @@ -2,7 +2,7 @@ import { createBubbler, stopPropagation } from 'svelte/legacy' const bubble = createBubbler() - import { Button, Drawer, DrawerContent } from '$lib/components/common' + import { Alert, Button, Drawer, DrawerContent } from '$lib/components/common' import FlowGraphViewer from '$lib/components/FlowGraphViewer.svelte' import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte' import FlowModuleScript from '$lib/components/flows/content/FlowModuleScript.svelte' @@ -18,7 +18,7 @@ CtxAppInput } from '../../inputType' import type { AppViewerContext } from '../../types' - import { useOperatorBuilderApps } from '$lib/operatorWriteRights' + import { useRoleCapabilities } from '$lib/operatorWriteRights' import { createEventDispatcher } from 'svelte' import { deepEqual } from 'fast-equals' import { computeFields } from './utils' @@ -32,12 +32,16 @@ import Popover from '$lib/components/meltComponents/Popover.svelte' import ScriptEditorDrawer from '$lib/components/flows/content/ScriptEditorDrawer.svelte' import FlowEditorDrawer from '$lib/components/flows/content/FlowEditorDrawer.svelte' - import { FlowService, ScriptService, type OpenFlow } from '$lib/gen' + import { FlowService, ResourceService, ScriptService, type OpenFlow } from '$lib/gen' + import Badge from '$lib/components/common/badge/Badge.svelte' + import { summarizeAgentBrain, type AgentTool } from '$lib/components/flows/agentResourceUtils' + import { agentRunnableSchema } from '$lib/components/raw_apps/agentRunnable' + import { agentMemoryMode } from '$lib/components/flows/agentFormFields' import { replaceScriptPlaceholderWithItsValues } from '$lib/hub' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() - const operatorBuilderApps = useOperatorBuilderApps() + const roleCaps = useRoleCapabilities() interface Props { runnable: RunnableByPath @@ -149,6 +153,47 @@ } } + let agentInfo: + | { + description?: string + rows: { label: string; value: string }[] + tools: string[] + memoryOff: boolean + } + | undefined = $state() + + async function refreshAgent(runnable: RunnableByPath) { + hubFlowPreview = undefined + try { + const agent = await ResourceService.getResource({ + workspace: $operatingWorkspace ?? '', + path: runnable.path + }) + const config = agent.value as any + agentInfo = { + description: agent.description ?? undefined, + rows: summarizeAgentBrain(config), + tools: ((config?.tools ?? []) as AgentTool[]).map( + (t) => t.summary || t.value?.tool_type || t.id + ), + memoryOff: agentMemoryMode(config?.memory) === 'off' + } + const schema = agentRunnableSchema(config?.memory) + if (!deepEqual(runnable.schema, schema)) runnable.schema = schema + // Every input of a turn comes from its caller: a static one would be pinned by the + // deployed policy, answering every viewer's message with the same one. + if ( + Object.values(fields ?? {}).some((f) => f?.type !== 'user') || + !deepEqual(Object.keys(fields ?? {}).sort(), Object.keys(schema.properties).sort()) + ) { + fields = computeFields(schema, true, {}) + } + } catch (e) { + notFound = true + console.error(e) + } + } + async function fork(path: string) { let { content, language, schema } = await getScriptByPath(path) if (!schema || typeof schema == 'string' || Object.keys(schema).length == 0) { @@ -205,6 +250,8 @@ refreshScript(runnable) } else if (runnable.runType == 'flow') { refreshFlow(runnable) + } else if (runnable.runType == 'agent') { + refreshAgent(runnable) } else { hubFlowPreview = undefined } @@ -352,6 +399,17 @@ Details </Button> {/if} + {:else if runnable.runType == 'agent'} + <Button + variant="default" + unifiedSize="md" + startIcon={{ icon: Pen }} + endIcon={{ icon: ExternalLink }} + target="_blank" + href="/agents/edit/{runnable.path}?workspace={$operatingWorkspace}" + > + Edit + </Button> {:else} <Button size="xs" @@ -363,7 +421,7 @@ > Edit </Button> - {#if !$operatorBuilderApps} + {#if $roleCaps.apps !== 'compose'} <Button size="xs" variant="default" @@ -376,45 +434,47 @@ </Button> {/if} {/if} - <Popover - floatingConfig={{ - middleware: [ - autoPlacement({ - allowedPlacements: [ - 'bottom-start', - 'bottom-end', - 'top-start', - 'top-end', - 'top', - 'bottom' - ] - }) - ] - }} - closeButton - contentClasses="block text-primary text-xs p-4 w-[20vh]" - > - {#snippet trigger()} - <Button - nonCaptureEvent={true} - btnClasses={'bg-surface text-primay hover:bg-hover'} - variant="default" - size="xs" - > - Cache - </Button> - {/snippet} - {#snippet content()} - {#if runnable.runType == 'flow' && isHubFlowPath(runnable.path)} - Since this is a reference to a hub flow, cache settings are managed from the flow after - you fork it into your workspace. - {:else} - Since this is a reference to a workspace {runnable.runType}, set the cache in the - {runnable.runType} settings directly by editing it. The cache will be shared by any app or - flow that uses this {runnable.runType}. - {/if} - {/snippet} - </Popover> + {#if runnable.runType != 'agent'} + <Popover + floatingConfig={{ + middleware: [ + autoPlacement({ + allowedPlacements: [ + 'bottom-start', + 'bottom-end', + 'top-start', + 'top-end', + 'top', + 'bottom' + ] + }) + ] + }} + closeButton + contentClasses="block text-primary text-xs p-4 w-[20vh]" + > + {#snippet trigger()} + <Button + nonCaptureEvent={true} + btnClasses={'bg-surface text-primay hover:bg-hover'} + variant="default" + size="xs" + > + Cache + </Button> + {/snippet} + {#snippet content()} + {#if runnable.runType == 'flow' && isHubFlowPath(runnable.path)} + Since this is a reference to a hub flow, cache settings are managed from the flow after + you fork it into your workspace. + {:else} + Since this is a reference to a workspace {runnable.runType}, set the cache in the + {runnable.runType} settings directly by editing it. The cache will be shared by any app or + flow that uses this {runnable.runType}. + {/if} + {/snippet} + </Popover> + {/if} <input onkeydown={stopPropagation(bubble('keydown'))} @@ -454,6 +514,36 @@ {:else} <FlowPathViewer path={runnable.path} fillAvailableHeight /> {/if} + {:else if runnable.runType == 'agent'} + <div class="flex flex-col gap-2 p-2"> + {#if agentInfo?.memoryOff} + <Alert type="info" size="xs" title="Managed memory is off"> + Each call is answered on its own inputs, so a chat would forget every turn. Pass + <code>previous_messages</code> to give it the history. + </Alert> + {/if} + {#if agentInfo?.description} + <p class="text-xs text-secondary">{agentInfo.description}</p> + {/if} + <dl class="flex flex-col gap-1"> + {#each agentInfo?.rows ?? [] as row (row.label)} + <div class="flex items-baseline gap-2 text-2xs"> + <dt class="text-tertiary shrink-0 w-28">{row.label}</dt> + <dd class="text-secondary truncate" title={row.value}>{row.value}</dd> + </div> + {/each} + {#if agentInfo?.tools.length} + <div class="flex items-baseline gap-2 text-2xs"> + <dt class="text-tertiary shrink-0 w-28">Tools</dt> + <dd class="flex flex-wrap gap-1"> + {#each agentInfo.tools as tool, i (i)} + <Badge color="gray">{tool}</Badge> + {/each} + </dd> + </div> + {/if} + </dl> + </div> {:else} Unrecognized runType {runnable.runType} {/if} diff --git a/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/RunnableSelector.svelte b/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/RunnableSelector.svelte index 44f2adb9ac..f741f5511d 100644 --- a/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/RunnableSelector.svelte +++ b/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/RunnableSelector.svelte @@ -2,23 +2,35 @@ import { Button, Drawer, DrawerContent, Tab, Tabs } from '$lib/components/common' import PickHubScript from '$lib/components/flows/pickers/PickHubScript.svelte' import PickHubFlow from '$lib/components/flows/pickers/PickHubFlow.svelte' - import { Building, Globe2, MousePointer, Plus } from 'lucide-svelte' + import { Bot, Building, Globe2, MousePointer, Plus } from 'lucide-svelte' import InlineScriptList from './InlineScriptList.svelte' - import type { InlineScript, Runnable, StaticAppInput } from '$lib/components/apps/inputType' + import type { + InlineScript, + PathRunType, + Runnable, + StaticAppInput + } from '$lib/components/apps/inputType' import WorkspaceScriptList from './WorkspaceScriptList.svelte' import WorkspaceFlowList from './WorkspaceFlowList.svelte' + import WorkspaceAgentList from './WorkspaceAgentList.svelte' import { createEventDispatcher, untrack } from 'svelte' import type { Schema } from '$lib/common' import { emptySchema } from '$lib/utils' import { loadSchema } from '$lib/infer' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' - import { useOperatorBuilderApps } from '$lib/operatorWriteRights' + import { useRoleCapabilities } from '$lib/operatorWriteRights' const operatingWorkspace = useOperatingWorkspace() - const operatorBuilderApps = useOperatorBuilderApps() + const roleCaps = useRoleCapabilities() import { buildPathRunnableSelection } from './runnableSelectorUtils' - type TabType = 'hubscripts' | 'hubflows' | 'workspacescripts' | 'workspaceflows' | 'inlinescripts' + type TabType = + | 'hubscripts' + | 'hubflows' + | 'workspacescripts' + | 'workspaceflows' + | 'workspaceagents' + | 'inlinescripts' interface Props { defaultUserInput?: boolean @@ -47,6 +59,13 @@ ) let filter: string = $state('') let picker: Drawer | undefined = $state() + const selectLabel = $derived( + onlyFlow + ? 'Select a flow' + : rawApps + ? 'Select a script, flow or agent' + : 'Select a script or flow' + ) const dispatch = createEventDispatcher<{ pick: { @@ -57,7 +76,7 @@ async function loadSchemaFromTriggerable( path: string, - runType: 'script' | 'flow' | 'hubscript' + runType: PathRunType ): Promise<{ schema: Schema; summary: string | undefined }> { const schema = await loadSchema($operatingWorkspace!, path, runType) if (!schema.schema.order) { @@ -94,6 +113,20 @@ }) } + async function pickAgent(path: string) { + const selection = buildPathRunnableSelection( + path, + 'agent', + await loadSchemaFromTriggerable(path, 'agent'), + defaultUserInput, + rawApps + ) + dispatch('pick', { + runnable: selection.runnable, + fields: selection.fields + }) + } + async function pickHubScript(path: string) { const selection = buildPathRunnableSelection( path, @@ -154,22 +187,25 @@ </script> <Drawer bind:this={picker} size="1000px"> - <DrawerContent title="Script/Flow Picker" on:close={picker.closeDrawer}> + <DrawerContent title={selectLabel} on:close={picker.closeDrawer}> <div> <div class="max-w-6xl"> <Tabs bind:selected={tab}> {#if !onlyFlow} {#if !rawApps} - <Tab value="inlinescripts" label="Detached Inline Scripts" icon={Building} /> + <Tab value="inlinescripts" label="Detached inline scripts" icon={Building} /> {/if} - <Tab value="workspacescripts" label="Workspace Scripts" icon={Building} /> + <Tab value="workspacescripts" label="Workspace scripts" icon={Building} /> {/if} - <Tab value="workspaceflows" label="Workspace Flows" icon={Building} /> - {#if !$operatorBuilderApps} + <Tab value="workspaceflows" label="Workspace flows" icon={Building} /> + {#if rawApps && !onlyFlow} + <Tab value="workspaceagents" label="AI agents" icon={Bot} /> + {/if} + {#if $roleCaps.apps !== 'compose'} {#if !onlyFlow} - <Tab value="hubscripts" label="Hub Scripts" icon={Globe2} /> + <Tab value="hubscripts" label="Hub scripts" icon={Globe2} /> {/if} - <Tab value="hubflows" label="Hub Flows" icon={Globe2} /> + <Tab value="hubflows" label="Hub flows" icon={Globe2} /> {/if} </Tabs> <div class="my-2"></div> @@ -186,6 +222,8 @@ <WorkspaceScriptList on:pick={(e) => pickScript(e.detail)} /> {:else if tab == 'workspaceflows'} <WorkspaceFlowList on:pick={(e) => pickFlow(e.detail)} /> + {:else if tab == 'workspaceagents'} + <WorkspaceAgentList on:pick={(e) => pickAgent(e.detail)} /> {:else if tab == 'hubscripts'} <PickHubScript bind:filter on:pick={(e) => pickHubScript(e.detail.path)} /> {:else if tab == 'hubflows'} @@ -218,6 +256,6 @@ startIcon={{ icon: MousePointer }} btnClasses="truncate w-full" > - {#if onlyFlow}Select a flow{:else}Select a script or flow{/if} + {selectLabel} </Button> </div> diff --git a/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/WorkspaceAgentList.svelte b/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/WorkspaceAgentList.svelte new file mode 100644 index 0000000000..4335fedaf8 --- /dev/null +++ b/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/WorkspaceAgentList.svelte @@ -0,0 +1,83 @@ +<script lang="ts"> + import { createEventDispatcher, onMount } from 'svelte' + import SearchItems from '$lib/components/SearchItems.svelte' + import NoItemFound from '$lib/components/home/NoItemFound.svelte' + import RowIcon from '$lib/components/common/table/RowIcon.svelte' + import { ResourceService, type ListableResource } from '$lib/gen' + import { emptyString } from '$lib/utils' + import { Skeleton } from '$lib/components/common' + import TextInput from '$lib/components/text_input/TextInput.svelte' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() + + let filter = $state('') + let agents: ListableResource[] | undefined = $state(undefined) + let filteredItems: (ListableResource & { marked?: string })[] = $state([]) + let prefilteredItems = $derived(agents ?? []) + + const dispatch = createEventDispatcher() + + onMount(async () => { + agents = await ResourceService.listResource({ + workspace: $operatingWorkspace!, + resourceType: 'ai_agent', + perPage: 300 + }) + }) +</script> + +<SearchItems + {filter} + items={prefilteredItems} + bind:filteredItems + f={(x) => (emptyString(x.description) ? x.path : x.description + ' (' + x.path + ')')} +/> +<div class="w-full mt-1 mb-4"> + <TextInput + bind:value={filter} + inputProps={{ + placeholder: 'Search workspace AI agents', + // The picker's drawer must not take the keys typed into its search. + onkeydown: (e) => e.stopPropagation() + }} + /> +</div> + +{#if agents} + {#if filteredItems.length == 0} + <NoItemFound /> + {:else} + <ul class="divide-y border rounded-md"> + {#each filteredItems as item (item)} + <li class="flex flex-row w-full"> + <button + class="p-4 gap-4 flex flex-row grow justify-between hover:bg-surface-hover bg-surface transition-all items-center rounded-md" + onclick={() => dispatch('pick', item.path)} + > + <div class="flex items-center gap-4"> + <RowIcon kind="agent" /> + + <div class="w-full text-left font-normal"> + <div class="text-primary flex-wrap text-md font-semibold mb-1"> + {#if item.marked} + {@html item.marked ?? ''} + {:else} + {emptyString(item.description) ? item.path : item.description} + {/if} + </div> + <div class="text-primary text-xs"> + {item.path} + </div> + </div> + </div> + </button> + </li> + {/each} + </ul> + {/if} +{:else} + {#each Array(10).fill(0) as _} + <Skeleton layout={[[4], 0.5]} /> + {/each} +{/if} diff --git a/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/runnableSelectorUtils.ts b/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/runnableSelectorUtils.ts index 44e989e925..66eb1e21cf 100644 --- a/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/runnableSelectorUtils.ts +++ b/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/runnableSelectorUtils.ts @@ -1,5 +1,5 @@ import type { Schema } from '$lib/common' -import type { Runnable, StaticAppInput } from '$lib/components/apps/inputType' +import type { PathRunType, Runnable, StaticAppInput } from '$lib/components/apps/inputType' import { schemaToInputsSpec } from '$lib/components/apps/utils' import { defaultIfEmptyString } from '$lib/utils' @@ -10,7 +10,7 @@ export type LoadedRunnableSchema = { export function buildPathRunnableSelection( path: string, - runType: 'script' | 'flow' | 'hubscript', + runType: PathRunType, loadedSchema: LoadedRunnableSchema, defaultUserInput: boolean, rawApps: boolean diff --git a/frontend/src/lib/components/apps/inputType.ts b/frontend/src/lib/components/apps/inputType.ts index 8501372216..99aa5d8418 100644 --- a/frontend/src/lib/components/apps/inputType.ts +++ b/frontend/src/lib/components/apps/inputType.ts @@ -136,11 +136,14 @@ export type TemplateV2Input = { connections: InputConnectionEval[] } +/** `agent` (a saved AI agent run as one chat turn) exists in raw apps only. */ +export type PathRunType = 'script' | 'flow' | 'hubscript' | 'agent' + export type RunnableByPath = { name: string path: string schema: any - runType: 'script' | 'flow' | 'hubscript' + runType: PathRunType type: 'runnableByPath' | 'path' } diff --git a/frontend/src/lib/components/assets/AssetGraph/PipelineFolderList.svelte b/frontend/src/lib/components/assets/AssetGraph/PipelineFolderList.svelte index 52772ce515..3c7abca060 100644 --- a/frontend/src/lib/components/assets/AssetGraph/PipelineFolderList.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/PipelineFolderList.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import { userStore, workspaceStore } from '$lib/stores' + import { roleCapabilities, workspaceStore } from '$lib/stores' import { goto } from '$app/navigation' import Button from '$lib/components/common/button/Button.svelte' import FolderPicker from '$lib/components/FolderPicker.svelte' @@ -91,8 +91,8 @@ </section> {/if} - {#if !$userStore?.operator} - <!-- Operators only view existing pipelines — opening an arbitrary + {#if $roleCapabilities.code} + <!-- A role without code only views existing pipelines: opening an arbitrary folder is a build-a-new-pipeline affordance. --> <section class="flex flex-col gap-1"> <h3 class="text-sm font-semibold text-emphasis">Pick or create a folder</h3> diff --git a/frontend/src/lib/components/common/badge/Badge.svelte b/frontend/src/lib/components/common/badge/Badge.svelte index 09a56c427a..77d59ce6d4 100644 --- a/frontend/src/lib/components/common/badge/Badge.svelte +++ b/frontend/src/lib/components/common/badge/Badge.svelte @@ -65,7 +65,8 @@ ['dark-indigo']: 'bg-indigo-500 text-indigo-100 dark:bg-indigo-600 dark:text-indigo-200', ['dark-orange']: 'bg-orange-500 text-orange-100 dark:bg-orange-600 dark:text-orange-200', ['dark-violet']: 'bg-violet-500 text-violet-100 dark:bg-violet-600 dark:text-violet-200', - transparent: 'bg-transparent border' + transparent: 'bg-transparent border', + subtle: 'bg-transparent' } const selectedColors: Record<BadgeColor, string> = { @@ -86,7 +87,8 @@ ['dark-orange']: 'bg-orange-700 text-orange-100 dark:bg-orange-800 dark:text-orange-200', ['dark-violet']: 'bg-violet-700 text-violet-100 dark:bg-violet-800 dark:text-violet-200', // Keeps the unselected chip's border, so selecting one does not change its height. - transparent: 'bg-surface-accent-selected text-accent border border-surface-accent-selected' + transparent: 'bg-surface-accent-selected text-accent border border-surface-accent-selected', + subtle: 'bg-surface-accent-selected text-accent' } const hovers: Partial<Record<BadgeColor, string>> = { @@ -106,7 +108,8 @@ ['dark-indigo']: 'hover:bg-indigo-600 dark:hover:bg-indigo-700', ['dark-orange']: 'hover:bg-orange-600 dark:hover:bg-orange-700', ['dark-violet']: 'hover:bg-violet-600 dark:hover:bg-violet-700', - transparent: 'hover:bg-surface-hover dark:hover:border-gray-500' + transparent: 'hover:bg-surface-hover dark:hover:border-gray-500', + subtle: 'hover:bg-surface-hover' } let badgeClass = $derived( diff --git a/frontend/src/lib/components/common/badge/LabelBadge.svelte b/frontend/src/lib/components/common/badge/LabelBadge.svelte new file mode 100644 index 0000000000..698a585e82 --- /dev/null +++ b/frontend/src/lib/components/common/badge/LabelBadge.svelte @@ -0,0 +1,36 @@ +<script lang="ts"> + import type { Snippet } from 'svelte' + import Badge from './Badge.svelte' + import type { BadgeColor, BadgeIconProps } from './model' + + /** + * How a label, an owner or a folder reads wherever an item is tagged with it: the row's own + * labels and Home's quick filters both draw it, so the two cannot drift apart. + */ + interface Props { + icon?: BadgeIconProps['icon'] + color?: BadgeColor + title?: string + /** Makes it a filter: clickable, and marked with × once selected. */ + onclick?: (event: MouseEvent) => void + selected?: boolean + children?: Snippet + } + + let { icon: Icon, color = 'subtle', title, onclick, selected = false, children }: Props = $props() +</script> + +<Badge + {color} + {title} + {onclick} + clickable={!!onclick} + {selected} + class="inline-flex items-center gap-1 px-1.5 align-middle" +> + {#if Icon} + <Icon size={12} class="shrink-0" /> + {/if} + {@render children?.()} + {#if selected}✗{/if} +</Badge> diff --git a/frontend/src/lib/components/common/badge/LabelList.svelte b/frontend/src/lib/components/common/badge/LabelList.svelte new file mode 100644 index 0000000000..8fb15f8186 --- /dev/null +++ b/frontend/src/lib/components/common/badge/LabelList.svelte @@ -0,0 +1,33 @@ +<script lang="ts"> + import LabelBadge from './LabelBadge.svelte' + import type { BadgeColor, BadgeIconProps } from './model' + + interface Props { + labels: string[] | undefined | null + icon: BadgeIconProps['icon'] + /** Names where each label comes from in its tooltip, e.g. "Label". */ + titlePrefix: string + /** Shown as badges; the rest are counted, and named in the count's tooltip. */ + max?: number + color?: BadgeColor + } + + let { labels, icon, titlePrefix, max = 3, color = 'gray' }: Props = $props() +</script> + +{#if labels?.length} + <div class="flex items-center gap-0.5"> + {#each labels.slice(0, max) as label (label)} + <LabelBadge {color} {icon} title="{titlePrefix}: {label}">{label}</LabelBadge> + {/each} + {#if labels.length > max} + <LabelBadge + {color} + title={labels + .slice(max) + .map((l) => `${titlePrefix}: ${l}`) + .join('\n')}>+{labels.length - max}</LabelBadge + > + {/if} + </div> +{/if} diff --git a/frontend/src/lib/components/common/badge/model.ts b/frontend/src/lib/components/common/badge/model.ts index 03adf9f195..4ecfdd8ea4 100644 --- a/frontend/src/lib/components/common/badge/model.ts +++ b/frontend/src/lib/components/common/badge/model.ts @@ -1,6 +1,11 @@ type BaseColor = 'blue' | 'gray' | 'red' | 'green' | 'yellow' | 'indigo' | 'orange' | 'violet' export const ColorModifier = 'dark-' -export type BadgeColor = BaseColor | 'transparent' | `${typeof ColorModifier}${BaseColor}` +/** `transparent` is outlined; `subtle` has neither fill nor border. */ +export type BadgeColor = + | BaseColor + | 'transparent' + | 'subtle' + | `${typeof ColorModifier}${BaseColor}` export interface BadgeIconProps { position?: 'left' | 'right' diff --git a/frontend/src/lib/components/common/contextmenu/ContextMenu.svelte b/frontend/src/lib/components/common/contextmenu/ContextMenu.svelte index 6958de0338..49421e78b3 100644 --- a/frontend/src/lib/components/common/contextmenu/ContextMenu.svelte +++ b/frontend/src/lib/components/common/contextmenu/ContextMenu.svelte @@ -22,7 +22,7 @@ disabled?: boolean onClick?: () => void divider?: boolean - type?: 'action' | 'delete' + type?: 'action' | 'delete' | 'ai' shortcut?: string } @@ -129,7 +129,8 @@ ? CONTEXT_MENU_ITEM_DISABLED_CLASS : menuItem.type === 'delete' ? CONTEXT_MENU_ITEM_DELETE_CLASS - : CONTEXT_MENU_ITEM_HOVER_MELT_CLASS + : CONTEXT_MENU_ITEM_HOVER_MELT_CLASS, + menuItem.type === 'ai' && !menuItem.disabled && 'text-ai' )} use:melt={$item} onclick={() => handleItemClick(menuItem)} diff --git a/frontend/src/lib/components/common/languageIcons/LanguageIcon.svelte b/frontend/src/lib/components/common/languageIcons/LanguageIcon.svelte index 7635b900bb..96eb682456 100644 --- a/frontend/src/lib/components/common/languageIcons/LanguageIcon.svelte +++ b/frontend/src/lib/components/common/languageIcons/LanguageIcon.svelte @@ -16,7 +16,7 @@ import MSSqlServerIcon from '$lib/components/icons/MSSqlServerIcon.svelte' import BunIcon from '$lib/components/icons/BunIcon.svelte' import DenoIcon from '$lib/components/icons/DenoIcon.svelte' - import type { Script } from '$lib/gen' + import { languageLabel } from './languageLabels' import PHPIcon from '$lib/components/icons/PHPIcon.svelte' import RustIcon from '$lib/components/icons/RustIcon.svelte' import AnsibleIcon from '$lib/components/icons/AnsibleIcon.svelte' @@ -52,40 +52,6 @@ let { lang, width = 30, height = 30, scale = 1, size = undefined, ...rest }: Props = $props() - const languageLabel: Record< - Script['language'] | 'bunnative' | 'claudesandbox' | 'pisandbox', - String - > = { - python3: 'Python', - deno: 'TypeScript', - go: 'Go', - bash: 'Bash', - powershell: 'PowerShell', - nativets: 'HTTP', - bunnative: 'HTTP', - graphql: 'GraphQL', - postgresql: 'Postgresql', - bigquery: 'BigQuery', - oracledb: 'Oracle Database', - duckdb: 'DuckDB', - snowflake: 'Snowflake', - mysql: 'MySQL', - mssql: 'MS SQL Server', - bun: 'TypeScript', - php: 'PHP', - rust: 'Rust', - ansible: 'Ansible', - csharp: 'C#', - nu: 'Nu', - java: 'Java', - ruby: 'Ruby', - rlang: 'R', - dbt: 'dbt', - claudesandbox: 'Claude Sandbox', - pisandbox: 'Pi Sandbox' - // for related places search: ADD_NEW_LANG - } - const langToComponent: Record< | SupportedLanguage | 'pgsql' diff --git a/frontend/src/lib/components/common/languageIcons/LanguageTag.svelte b/frontend/src/lib/components/common/languageIcons/LanguageTag.svelte new file mode 100644 index 0000000000..610af5b0e8 --- /dev/null +++ b/frontend/src/lib/components/common/languageIcons/LanguageTag.svelte @@ -0,0 +1,11 @@ +<script lang="ts"> + import { languageLabel, languageShortLabel, type LabelledLanguage } from './languageLabels' + + let { lang }: { lang: LabelledLanguage } = $props() +</script> + +<!-- Quiet on purpose: it sits beside a title, and the title is what the eye is reading. --> +<span + class="shrink-0 text-3xs font-medium text-hint tracking-wide" + title={languageLabel[lang] ?? lang}>{languageShortLabel[lang] ?? lang}</span +> diff --git a/frontend/src/lib/components/common/languageIcons/languageLabels.ts b/frontend/src/lib/components/common/languageIcons/languageLabels.ts new file mode 100644 index 0000000000..142a2790ee --- /dev/null +++ b/frontend/src/lib/components/common/languageIcons/languageLabels.ts @@ -0,0 +1,68 @@ +import type { Script } from '$lib/gen' + +export type LabelledLanguage = Script['language'] | 'bunnative' | 'claudesandbox' | 'pisandbox' + +/** A language's name, as a tooltip or anywhere the full word fits. */ +export const languageLabel: Record<LabelledLanguage, string> = { + python3: 'Python', + deno: 'TypeScript', + go: 'Go', + bash: 'Bash', + powershell: 'PowerShell', + nativets: 'HTTP', + bunnative: 'HTTP', + graphql: 'GraphQL', + postgresql: 'Postgresql', + bigquery: 'BigQuery', + oracledb: 'Oracle Database', + duckdb: 'DuckDB', + snowflake: 'Snowflake', + mysql: 'MySQL', + mssql: 'MS SQL Server', + bun: 'TypeScript', + php: 'PHP', + rust: 'Rust', + ansible: 'Ansible', + csharp: 'C#', + nu: 'Nu', + java: 'Java', + ruby: 'Ruby', + rlang: 'R', + dbt: 'dbt', + claudesandbox: 'Claude Sandbox', + pisandbox: 'Pi Sandbox' + // for related places search: ADD_NEW_LANG +} + +/** A few letters for a list row, where a logo per row is more than the eye needs. Every SQL + * dialect reads "SQL": which one is the tooltip's job (`languageLabel`). */ +export const languageShortLabel: Record<LabelledLanguage, string> = { + python3: 'Py', + deno: 'TS', + bun: 'TS', + go: 'Go', + bash: 'Sh', + powershell: 'PS', + nativets: 'Rest', + bunnative: 'Rest', + graphql: 'GQL', + postgresql: 'SQL', + bigquery: 'SQL', + oracledb: 'SQL', + duckdb: 'SQL', + snowflake: 'SQL', + mysql: 'SQL', + mssql: 'SQL', + php: 'PHP', + rust: 'Rs', + ansible: 'Ans', + csharp: 'C#', + nu: 'Nu', + java: 'Java', + ruby: 'Rb', + rlang: 'R', + dbt: 'dbt', + claudesandbox: 'AI', + pisandbox: 'AI' + // for related places search: ADD_NEW_LANG +} diff --git a/frontend/src/lib/components/common/slack/SlackAppSetup.svelte b/frontend/src/lib/components/common/slack/SlackAppSetup.svelte new file mode 100644 index 0000000000..195893058f --- /dev/null +++ b/frontend/src/lib/components/common/slack/SlackAppSetup.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + import { ExternalLink } from 'lucide-svelte' + import { yaml } from 'svelte-highlight/languages' + import CopyableCodeBlock from '$lib/components/details/CopyableCodeBlock.svelte' + import { slackAppManifest } from './slackAppManifest' +</script> + +<div class="flex flex-col gap-2 text-xs text-primary"> + <span> + Create a Slack app <a href="https://api.slack.com/apps?new_app=1" target="_blank" + >in the Slack API console <ExternalLink size={12} class="inline-block" /></a + >: pick "From a manifest", then YAML, and paste the manifest below. Then copy the Client ID, + Client Secret and Signing Secret here. Socket Mode and token rotation must stay off. More in the + <a href="https://www.windmill.dev/docs/misc/setup_oauth#slack" target="_blank" + >Windmill docs <ExternalLink size={12} class="inline-block" /></a + >. + </span> + <div class="flex flex-col max-h-80 [&>div]:min-h-0"> + <CopyableCodeBlock code={slackAppManifest(window.location.origin)} language={yaml} /> + </div> +</div> diff --git a/frontend/src/lib/components/common/slack/slackAppManifest.ts b/frontend/src/lib/components/common/slack/slackAppManifest.ts new file mode 100644 index 0000000000..deaaebfdd7 --- /dev/null +++ b/frontend/src/lib/components/common/slack/slackAppManifest.ts @@ -0,0 +1,55 @@ +/** Requested on top of the usual ones when a workspace connects with AI agents enabled. */ +export const SLACK_AGENT_SCOPES = ['users:read', 'users:read.email', 'channels:read'] + +/** + * The Slack app manifest Windmill expects, for `baseUrl` (the instance's public URL). It declares + * the agent scopes too: an app may declare more than a connection asks for, never fewer. + */ +export function slackAppManifest(baseUrl: string): string { + return `display_information: + name: Windmill + description: Run Windmill scripts, flows and AI agents from Slack + background_color: '#3b82f6' +features: + app_home: + home_tab_enabled: true + messages_tab_enabled: true + messages_tab_read_only_enabled: false + bot_user: + display_name: Windmill + always_online: true + slash_commands: + - command: /windmill + url: ${baseUrl}/api/oauth/slack_command + description: Trigger the script set in your workspace settings for Slack + usage_hint: the text that will be passed to the script + should_escape: false +oauth_config: + redirect_urls: + - ${baseUrl}/oauth/callback_slack + - ${baseUrl}/oauth/callback_slack/instance + scopes: + bot: + - chat:write + - chat:write.public + - channels:join + - files:write + - commands + - app_mentions:read + - im:history + - im:read +${SLACK_AGENT_SCOPES.map((s) => ` - ${s}`).join('\n')} +settings: + event_subscriptions: + request_url: ${baseUrl}/api/oauth/slack_events + bot_events: + - app_mention + - message.im + interactivity: + is_enabled: true + request_url: ${baseUrl}/api/slack + org_deploy_enabled: false + socket_mode_enabled: false + token_rotation_enabled: false +` +} diff --git a/frontend/src/lib/components/common/table/AgentRow.svelte b/frontend/src/lib/components/common/table/AgentRow.svelte index aca6ae7f9c..20421ac514 100644 --- a/frontend/src/lib/components/common/table/AgentRow.svelte +++ b/frontend/src/lib/components/common/table/AgentRow.svelte @@ -4,15 +4,16 @@ import DraftBadge from '$lib/components/DraftBadge.svelte' import type ShareModal from '$lib/components/ShareModal.svelte' import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte' - import InheritedLabels from '$lib/components/InheritedLabels.svelte' + import RowLabels from './RowLabels.svelte' import type { ListableResource } from '$lib/gen' - import { userStore, workspaceStore } from '$lib/stores' + import { roleCapabilities, userStore, workspaceStore } from '$lib/stores' import { createEventDispatcher } from 'svelte' import { Pen } from 'lucide-svelte' import Button from '../button/Button.svelte' import ChatFlowBadge from '$lib/components/flows/ChatFlowBadge.svelte' 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 Row from './Row.svelte' import { selectMenuItems, type RowSelection } from './rowSelection' @@ -54,8 +55,16 @@ let editHref = $derived(`/agents/edit/${agent.path}`) // A draft-only agent has nothing deployed for its page to run, so it opens in the editor. let rowHref = $derived(agent.draft_only ? editHref : `/agents/get/${agent.path}`) + let history: AgentHistory | undefined = $state(undefined) </script> +<AgentHistory + bind:this={history} + path={agent.path} + canRestore={agent.canWrite} + onRestored={() => dispatch('change')} +/> + {#snippet chatBadge()} <ChatFlowBadge title="Managed memory on: this agent opens as a conversation" /> {/snippet} @@ -69,12 +78,13 @@ path={agent.draft_path ?? agent.path} summary={agent.description} workspaceId={agent.workspace_id ?? $workspaceStore ?? ''} - canFavorite={false} + canFavorite={!agent.draft_only} {depth} {rowSelection} > {#snippet badges()} <SharedBadge canWrite={agent.canWrite} extraPerms={agent.extra_perms} /> + <RowLabels inherited={agent.inherited_labels} /> <DraftBadge is_draft={agent.is_draft} draft_only={agent.draft_only} @@ -84,24 +94,18 @@ path={agent.path} onMigrated={() => dispatch('change')} /> - <InheritedLabels labels={agent.inherited_labels} /> {/snippet} {#snippet actions()} {#if agent.canWrite} <span class="hidden md:inline-flex"> - <Button - variant="subtle" - wrapperClasses="w-20" - unifiedSize="md" - startIcon={{ icon: Pen }} - href={editHref} - > + <Button variant="subtle" unifiedSize="sm" startIcon={{ icon: Pen }} href={editHref}> Edit </Button> </span> {/if} <Dropdown + size="sm" items={async () => [ ...selectMenuItems(rowSelection), ...agentMenuItems({ @@ -110,8 +114,10 @@ canWrite: agent.canWrite, draftOnly: Boolean(agent.draft_only), wsSpecific: agent.ws_specific, + noCode: !$roleCapabilities.code, onPermissions: () => shareModal.openDrawer?.(agent.path, 'resource'), onDeploy: () => deploymentDrawer.openDrawer(agent.path, 'resource'), + onHistory: () => history?.open(), onDelete: (event) => { const { path } = agent if (event?.shiftKey) remove(path) diff --git a/frontend/src/lib/components/common/table/AppRow.svelte b/frontend/src/lib/components/common/table/AppRow.svelte index a318c1f412..812c06284b 100644 --- a/frontend/src/lib/components/common/table/AppRow.svelte +++ b/frontend/src/lib/components/common/table/AppRow.svelte @@ -5,14 +5,15 @@ import DraftBadge from '$lib/components/DraftBadge.svelte' import type ShareModal from '$lib/components/ShareModal.svelte' import { AppService, type ListableApp } from '$lib/gen' - import { userStore, userWorkspaces, workspaceStore } from '$lib/stores' - import { useOperatorBuilderApps } from '$lib/operatorWriteRights' + import { roleCapabilities, userStore, userWorkspaces, workspaceStore } from '$lib/stores' + import { roleCanAuthor } from '$lib/editRights' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { createEventDispatcher } from 'svelte' import Button from '../button/Button.svelte' import Row from './Row.svelte' import { selectMenuItems, type RowSelection } from './rowSelection' - import InheritedLabels from '$lib/components/InheritedLabels.svelte' + import RowLabels from './RowLabels.svelte' + import AskAiRowButton from './AskAiRowButton.svelte' import Badge from '../badge/Badge.svelte' import { ExternalLink, @@ -68,7 +69,9 @@ }: Props = $props() const dispatch = createEventDispatcher() - const operatorBuilderApps = useOperatorBuilderApps() + // 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') let appExport: { open: (path: string, rawApp?: boolean) => void } | undefined = $state(undefined) let appDeploymentHistory: AppDeploymentHistory | undefined = $state(undefined) @@ -127,6 +130,7 @@ <Badge small icon={{ icon: FileJson }}>Raw</Badge> {/if} <SharedBadge canWrite={app.canWrite} extraPerms={app.extra_perms} /> + <RowLabels labels={app.labels} inherited={app.inherited_labels} /> <DraftBadge is_draft={app.is_draft} draft_only={app.draft_only} @@ -137,36 +141,17 @@ path={app.path} onMigrated={() => dispatch('change')} /> - {#if app.labels?.length} - <div class="flex items-center gap-0.5"> - {#each app.labels.slice(0, 3) as label} - <Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge> - {/each} - {#if app.labels.length > 3} - <Badge - color="blue" - small - class="px-1" - title={app.labels - .slice(3) - .map((l) => 'Label: ' + l) - .join('\n')}>+{app.labels.length - 3}</Badge - > - {/if} - </div> - {/if} - <InheritedLabels labels={app.inherited_labels} /> - <div class="w-8 center-center"></div> {/snippet} {#snippet actions()} - <span class="hidden md:inline-flex gap-x-1"> + <span class="hidden md:inline-flex"> + <AskAiRowButton kind="app" path={app.path} summary={app.summary} rawApp={!!app.raw_app} /> {#if showEditButton && app.canWrite} <div> <Button aiId={`edit-app-button-${app.summary?.length > 0 ? app.summary : app.path}`} aiDescription={`Edits the app ${app.summary?.length > 0 ? app.summary : app.path}`} variant="subtle" - wrapperClasses="w-20" + unifiedSize="sm" startIcon={{ icon: Pen }} href="/apps{app.raw_app ? '_raw' : ''}/edit/{app.path}" > @@ -174,11 +159,12 @@ </Button> </div> {/if} - {#if !$userStore?.operator && !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !app.canWrite)} + {#if $roleCapabilities.code && !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !app.canWrite)} <EditInForkButton itemType={app.raw_app ? 'raw_app' : 'app'} path={app.path} /> {/if} </span> <Dropdown + 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 () => { @@ -200,7 +186,7 @@ }) }, disabled: !showEditButton, - hide: $userStore?.operator + hide: !fullApps }, { displayName: 'Delete', @@ -221,12 +207,16 @@ 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. + // 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: $userStore?.operator && !($operatorBuilderApps && app.raw_app) + hide: + !canAuthorApp && + app.draft_users?.length === 1 && + app.draft_users[0]?.username == null }, { - displayName: $userStore?.operator ? 'View JSON' : 'View/Edit JSON', + displayName: fullApps ? 'View/Edit JSON' : 'View JSON', icon: File, action: () => { loadAppJson() @@ -241,7 +231,7 @@ icon: GitFork, href: `/apps${app.raw_app ? '_raw' : ''}/add?template=${path}`, disabled: !showEditButton, - hide: $userStore?.operator + hide: !fullApps }, { displayName: editInForkLabel($workspaceStore, $userWorkspaces), @@ -250,7 +240,7 @@ // menu item's anchor navigates before a delegated onclick can preventDefault it. action: (e) => onEditInForkClick(e, app.raw_app ? 'raw_app' : 'app', path), hide: - $userStore?.operator || + !$roleCapabilities.code || isCloudHosted() || !editInForkAllowed($workspaceStore, $userWorkspaces) }, @@ -261,7 +251,7 @@ moveDrawer.openDrawer(path, summary, 'app') }, disabled: !canEdit, - hide: $userStore?.operator + hide: !fullApps }, ...(isDeployable('app', path, await getDeployUiSettings()) ? [ @@ -271,12 +261,12 @@ action: () => { deploymentDrawer.openDrawer(path, 'app') }, - hide: $userStore?.operator + hide: !fullApps } ] : []), { - displayName: $userStore?.operator ? 'View JSON' : 'View/Edit JSON', + displayName: fullApps ? 'View/Edit JSON' : 'View JSON', icon: FileJson, action: () => { loadAppJson() @@ -286,7 +276,7 @@ displayName: 'Deployments', icon: History, action: () => appDeploymentHistory?.open(), - hide: $userStore?.operator && !($operatorBuilderApps && app.raw_app) + hide: !canAuthorApp }, { displayName: 'Permissions', @@ -294,7 +284,7 @@ action: () => { shareModal.openDrawer && shareModal.openDrawer(path, 'app') }, - hide: $userStore?.operator && !($operatorBuilderApps && app.raw_app) + hide: !canAuthorApp }, { displayName: 'Copy path', @@ -340,7 +330,7 @@ }, type: 'delete', disabled: !canEdit, - hide: $userStore?.operator && !($operatorBuilderApps && app.raw_app) + hide: !canAuthorApp } ] }} diff --git a/frontend/src/lib/components/common/table/AskAiRowButton.svelte b/frontend/src/lib/components/common/table/AskAiRowButton.svelte new file mode 100644 index 0000000000..cc950d6c87 --- /dev/null +++ b/frontend/src/lib/components/common/table/AskAiRowButton.svelte @@ -0,0 +1,30 @@ +<script lang="ts"> + import Button from '../button/Button.svelte' + import { + homeComposerContext, + itemContextElement + } from '$lib/components/sessions/homeComposerContext.svelte' + import type { BulkKind } from '$lib/components/home/homeSelection.svelte' + + let { + kind, + path, + summary, + rawApp + }: { kind: BulkKind; path: string; summary?: string; rawApp?: boolean } = $props() + + let element = $derived(itemContextElement({ kind, path, summary: summary || undefined, rawApp })) +</script> + +{#if element && homeComposerContext.available} + <Button + variant="subtle" + unifiedSize="sm" + btnClasses="!text-ai font-medium" + title="Add to AI session context" + aria-label="Add to AI session context" + onClick={() => element && homeComposerContext.attach(element)} + > + +@ + </Button> +{/if} diff --git a/frontend/src/lib/components/common/table/EditInForkButton.svelte b/frontend/src/lib/components/common/table/EditInForkButton.svelte index fe5c6bc193..22156409b7 100644 --- a/frontend/src/lib/components/common/table/EditInForkButton.svelte +++ b/frontend/src/lib/components/common/table/EditInForkButton.svelte @@ -35,7 +35,7 @@ <Button variant="subtle" wrapperClasses="max-w-56" - unifiedSize="md" + unifiedSize="sm" startIcon={{ icon: Pen }} {href} onClick={(e) => onEditInForkClick(e, itemType, path, { hasHref: true })} diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index 65690121ab..ccbdb9de3e 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -7,7 +7,7 @@ import DraftBadge from '$lib/components/DraftBadge.svelte' import type ShareModal from '$lib/components/ShareModal.svelte' import { FlowService, type Flow } from '$lib/gen' - import { userStore, userWorkspaces, workspaceStore } from '$lib/stores' + import { roleCapabilities, userStore, userWorkspaces, workspaceStore } from '$lib/stores' import { useEditRights } from '$lib/operatorWriteRights' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { createEventDispatcher } from 'svelte' @@ -36,7 +36,8 @@ } from 'lucide-svelte' import FlowHistory from '$lib/components/flows/FlowHistory.svelte' import ChatFlowBadge from '$lib/components/flows/ChatFlowBadge.svelte' - import InheritedLabels from '$lib/components/InheritedLabels.svelte' + import RowLabels from './RowLabels.svelte' + import AskAiRowButton from './AskAiRowButton.svelte' import { getDeployUiSettings } from '$lib/components/home/deploy_ui' import { editInForkAllowed, editInForkLabel, onEditInForkClick } from '$lib/utils/editInFork' import EditInForkButton from './EditInForkButton.svelte' @@ -159,6 +160,7 @@ <Badge color="red" baseClass="border">archived</Badge> {/if} <SharedBadge canWrite={flow.canWrite} extraPerms={flow.extra_perms} /> + <RowLabels labels={flow.labels} inherited={flow.inherited_labels} /> <DraftBadge is_draft={flow.is_draft} draft_only={flow.draft_only} @@ -169,35 +171,15 @@ path={flow.path} onMigrated={() => dispatch('change')} /> - {#if flow.labels?.length} - <div class="flex items-center gap-0.5"> - {#each flow.labels.slice(0, 3) as label} - <Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge> - {/each} - {#if flow.labels.length > 3} - <Badge - color="blue" - small - class="px-1" - title={flow.labels - .slice(3) - .map((l) => 'Label: ' + l) - .join('\n')}>+{flow.labels.length - 3}</Badge - > - {/if} - </div> - {/if} - <InheritedLabels labels={flow.inherited_labels} /> - <div class="w-8 center-center"></div> {/snippet} {#snippet actions()} - <span class="hidden md:inline-flex gap-x-1"> + <span class="hidden md:inline-flex"> + <AskAiRowButton kind="flow" path={flow.path} summary={flow.summary} /> {#if !hideForOperator && showEditButton && flow.canWrite && !flow.archived} <div> <Button variant="subtle" - wrapperClasses="w-20" - unifiedSize="md" + unifiedSize="sm" startIcon={{ icon: Pen }} href="/flows/edit/{flow.path}" aiId={`edit-flow-button-${flow.summary?.length > 0 ? flow.summary : flow.path}`} @@ -207,13 +189,14 @@ </Button> </div> {/if} - <!-- Hidden from every operator, builder rights included. --> - {#if !$userStore?.operator && !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !flow.canWrite)} + <!-- Hidden from every role without code, flow composers included. --> + {#if $roleCapabilities.code && !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !flow.canWrite)} <EditInForkButton itemType="flow" path={flow.path} /> {/if} </span> <Dropdown + 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 () => { @@ -234,7 +217,7 @@ }) }, disabled: !showEditButton, - hide: $userStore?.operator + hide: hideForOperator }, { displayName: 'Delete', @@ -279,7 +262,7 @@ // menu item's anchor navigates before a delegated onclick can preventDefault it. action: (e) => onEditInForkClick(e, 'flow', path), hide: - $userStore?.operator || + !$roleCapabilities.code || isCloudHosted() || !editInForkAllowed($workspaceStore, $userWorkspaces) }, @@ -287,7 +270,7 @@ displayName: 'Audit logs', icon: Eye, href: `/audit_logs?resource=${path}`, - hide: $userStore?.operator + hide: !$roleCapabilities.code || !$roleCapabilities.visibility.audit_logs }, { displayName: 'Move/Rename', @@ -314,7 +297,7 @@ deploymentDrawer.openDrawer(path, 'flow') }, disabled: archived, - hide: $userStore?.operator + hide: $roleCapabilities.flows !== 'full' } ] : []), @@ -333,7 +316,7 @@ scheduleEditor?.openNew(true, path) }, disabled: archived, - hide: hideForOperator + hide: hideForOperator || !$roleCapabilities.manage_schedules }, { displayName: 'Permissions', diff --git a/frontend/src/lib/components/common/table/PipelineRow.svelte b/frontend/src/lib/components/common/table/PipelineRow.svelte index 334c8fb5fd..8a17b18c3c 100644 --- a/frontend/src/lib/components/common/table/PipelineRow.svelte +++ b/frontend/src/lib/components/common/table/PipelineRow.svelte @@ -6,7 +6,7 @@ planPipelineDelete, type PipelineDeletePlan } from '$lib/components/assets/AssetGraph/pipelineDelete' - import { userStore, workspaceStore } from '$lib/stores' + import { roleCapabilities, userStore, workspaceStore } from '$lib/stores' import { sendUserToast } from '$lib/toast' import { Loader2, Trash } from 'lucide-svelte' import Row from './Row.svelte' @@ -78,8 +78,9 @@ {depth} > {#snippet actions()} - {#if !$userStore?.operator} + {#if $roleCapabilities.code} <Dropdown + size="sm" items={[ { displayName: 'Delete', diff --git a/frontend/src/lib/components/common/table/RawAppRow.svelte b/frontend/src/lib/components/common/table/RawAppRow.svelte index be1c9f0150..1740566bdc 100644 --- a/frontend/src/lib/components/common/table/RawAppRow.svelte +++ b/frontend/src/lib/components/common/table/RawAppRow.svelte @@ -47,6 +47,7 @@ {/snippet} {#snippet actions()} <Dropdown + size="sm" items={async () => { let { path } = app diff --git a/frontend/src/lib/components/common/table/Row.svelte b/frontend/src/lib/components/common/table/Row.svelte index 76cd30640a..2d27f89851 100644 --- a/frontend/src/lib/components/common/table/Row.svelte +++ b/frontend/src/lib/components/common/table/Row.svelte @@ -9,6 +9,9 @@ import Tooltip from '../../meltComponents/Tooltip.svelte' import Checkbox from '../checkbox/Checkbox.svelte' import type { RowSelection } from './rowSelection' + import { HOVER_ACTIONS_CLASS, inUnruledList } from './unruledList' + + const unruled = inUnruledList() interface Props { marked: string | undefined @@ -180,8 +183,11 @@ data-row-selection-key={rowSelection?.key} data-row-keyboard-selected={keyboardSelected ? 'true' : undefined} class={twMerge( - 'group/row w-full inline-flex items-center gap-4 first-of-type:!border-t-0 first-of-type:rounded-t-md last-of-type:rounded-b-md [*:not(:last-child)]:border-b px-4 py-3 border-b last:border-b-0', - depth > 0 ? '!rounded-none' : '', + 'group/row w-full inline-flex items-center gap-4 px-4 py-3', + unruled + ? 'rounded-md' + : 'first-of-type:!border-t-0 first-of-type:rounded-t-md last-of-type:rounded-b-md [*:not(:last-child)]:border-b border-b last:border-b-0', + depth > 0 && !unruled ? '!rounded-none' : '', disabled ? 'opacity-25' : 'hover:bg-surface-hover', clickToSelect || inSelectionMode ? 'cursor-pointer select-none' : '', selected || rowSelection?.selected @@ -211,7 +217,7 @@ <!-- The icon slot itself: the kind icon until the row is hovered (or selection mode is on), the checkbox from then on. Both are stacked in a fixed 16px box and swapped with visibility so nothing shifts. --> - <div class="shrink relative w-4 h-4"> + <div class="shrink-0 relative w-4 h-4"> <div class={twMerge( 'absolute inset-0', @@ -255,45 +261,54 @@ {/if} {#if badges} - <div class="hidden lg:flex flex-row gap-4 items-center"> + <div class="hidden lg:flex shrink-0 flex-row gap-4 items-center"> {@render badges?.()} </div> {/if} - {#if canFavorite && (kind == 'app' || kind == 'raw_app' || kind == 'script' || kind == 'flow')} - <div class="center-center h-full text-xs font-semibold text-secondary w-9"> - <Star {kind} {path} {workspaceId} {summary} /> - </div> - {:else} - <div class="w-9"></div> - {/if} - - <div data-row-actions class="flex gap-1 items-center justify-end pr-2"> + <div + data-row-actions + class="flex shrink-0 items-center justify-end {unruled ? HOVER_ACTIONS_CLASS : ''}" + > {@render actions?.()} </div> </div> {#snippet rowContent(withIcon: boolean)} {#if withIcon} - <div class="shrink"> + <div class="shrink-0"> <RowIcon {kind} {triggerKind} /> </div> {/if} <div class="grow min-w-0"> - <div - class={twMerge( - 'text-emphasis flex-wrap text-left text-xs font-semibold', - titleBadge ? 'inline-flex items-center gap-2' : '' - )} - > + <div class="text-emphasis flex items-center gap-1.5 text-left text-xs font-semibold"> {#if customSummary} - {@render customSummary?.()} - {:else if marked} - {@html marked} + <div class="min-w-0">{@render customSummary()}</div> {:else} - {!summary || summary.length == 0 ? displayPath : summary} + <span class="truncate" title={summary || displayPath}> + {#if marked} + {@html marked} + {:else} + {!summary || summary.length == 0 ? displayPath : summary} + {/if} + </span> + {/if} + {#if titleBadge} + <span class="shrink-0">{@render titleBadge()}</span> + {/if} + {#if canFavorite && (kind == 'app' || kind == 'raw_app' || kind == 'script' || kind == 'flow' || kind == 'agent')} + <!-- Beside the title it marks: a star already set stays, an unset one shows on the + row's hover or keyboard focus only, so a list of titles doesn't read as a column of + outlines. Opacity, not visibility: a hidden button drops out of the Tab order. --> + <Star + {kind} + {path} + {workspaceId} + {summary} + size={13} + class="inline-flex shrink-0 p-px text-yellow-500 opacity-0 data-[starred]:opacity-100 group-hover/row:opacity-100 group-has-[:focus-visible]/row:opacity-100" + /> {/if} - {@render titleBadge?.()} </div> <div class="text-hint text-3xs truncate text-left font-normal" title={path}> {#if pathDisplay} diff --git a/frontend/src/lib/components/common/table/RowLabels.svelte b/frontend/src/lib/components/common/table/RowLabels.svelte new file mode 100644 index 0000000000..e07684f7cf --- /dev/null +++ b/frontend/src/lib/components/common/table/RowLabels.svelte @@ -0,0 +1,17 @@ +<script lang="ts"> + import { Folder, Tag } from 'lucide-svelte' + import LabelList from '../badge/LabelList.svelte' + + let { labels, inherited }: { labels?: string[] | null; inherited: string[] | undefined } = + $props() +</script> + +<!-- A row has little room beside its title: one of each kind shows, the rest are counted. --> +<LabelList {labels} icon={Tag} titlePrefix="Label" max={1} color="subtle" /> +<LabelList + labels={inherited} + icon={Folder} + titlePrefix="Label inherited from folder" + max={1} + color="subtle" +/> diff --git a/frontend/src/lib/components/common/table/ScriptRow.svelte b/frontend/src/lib/components/common/table/ScriptRow.svelte index 7ba327f343..9c300b9477 100644 --- a/frontend/src/lib/components/common/table/ScriptRow.svelte +++ b/frontend/src/lib/components/common/table/ScriptRow.svelte @@ -11,6 +11,7 @@ import { disableHubStore, hubBaseUrlStore, + roleCapabilities, userStore, userWorkspaces, workspaceStore @@ -28,7 +29,7 @@ import { isDeployable } from '$lib/utils_deployable' import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte' - import { LanguageIcon } from '../languageIcons' + import LanguageTag from '../languageIcons/LanguageTag.svelte' import { Archive, Calendar, @@ -50,7 +51,8 @@ import WacExportDrawer from '$lib/components/scripts/WacExportDrawer.svelte' import { Drawer, DrawerContent } from '..' import NoMainFuncBadge from '$lib/components/NoMainFuncBadge.svelte' - import InheritedLabels from '$lib/components/InheritedLabels.svelte' + import RowLabels from './RowLabels.svelte' + 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' @@ -207,6 +209,8 @@ > {/if} <SharedBadge canWrite={script.canWrite} extraPerms={script.extra_perms} /> + <RowLabels labels={script.labels} inherited={script.inherited_labels} /> + <!-- Last, so the draft badges line up down the list whatever labels each row has. --> <DraftBadge is_draft={script.is_draft} draft_only={script.draft_only} @@ -217,33 +221,16 @@ path={script.path} onMigrated={() => dispatch('change')} /> - {#if script.labels?.length} - <div class="flex items-center gap-0.5"> - {#each script.labels.slice(0, 3) as label} - <Badge color="blue" small class="px-1" title="Label: {label}">{label}</Badge> - {/each} - {#if script.labels.length > 3} - <Badge - color="blue" - small - class="px-1" - title={script.labels - .slice(3) - .map((l) => 'Label: ' + l) - .join('\n')}>+{script.labels.length - 3}</Badge - > - {/if} - </div> - {/if} - <InheritedLabels labels={script.inherited_labels} /> - <div class="w-8 center-center"> - <LanguageIcon lang={script.language} width={16} height={16} /> - </div> + {/snippet} + + {#snippet titleBadge()} + <LanguageTag lang={script.language} /> {/snippet} {#snippet actions()} - <span class="hidden md:inline-flex gap-x-1"> - {#if !$userStore?.operator} + <span class="hidden md:inline-flex"> + <AskAiRowButton kind="script" path={script.path} summary={script.summary} /> + {#if $roleCapabilities.code} {#if showEditButton} {#if script.use_codebase} <Badge @@ -257,8 +244,7 @@ aiId={`edit-script-button-${script.summary?.length > 0 ? script.summary : script.path}`} aiDescription={`Edits the script ${script.summary?.length > 0 ? script.summary : script.path}`} variant="subtle" - wrapperClasses="w-20" - unifiedSize="md" + unifiedSize="sm" startIcon={{ icon: Pen }} href="/scripts/edit/{script.path}" > @@ -273,6 +259,7 @@ {/if} </span> <Dropdown + 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 () => { @@ -299,7 +286,7 @@ }) }, disabled: !showEditButton, - hide: $userStore?.operator + hide: !$roleCapabilities.code }, { displayName: 'Delete', @@ -352,7 +339,7 @@ icon: GitFork, href: `/scripts/add?template=${script.path}`, disabled: !showEditButton, - hide: $userStore?.operator + hide: !$roleCapabilities.code }, { displayName: editInForkLabel($workspaceStore, $userWorkspaces), @@ -361,7 +348,7 @@ // menu item's anchor navigates before a delegated onclick can preventDefault it. action: (e) => onEditInForkClick(e, 'script', script.path), hide: - $userStore?.operator || + !$roleCapabilities.code || isCloudHosted() || !editInForkAllowed($workspaceStore, $userWorkspaces) }, @@ -372,7 +359,7 @@ moveDrawer.openDrawer(script.path, script.summary, 'script') }, disabled: !owner || script.archived || !canEdit, - hide: $userStore?.operator + hide: !$roleCapabilities.code }, ...(isDeployable('script', script.path, await getDeployUiSettings()) ? [ @@ -383,7 +370,7 @@ deploymentDrawer.openDrawer(script.path, 'script') }, disabled: script.archived, - hide: $userStore?.operator + hide: !$roleCapabilities.code } ] : []), @@ -403,7 +390,7 @@ displayName: 'Audit logs', icon: Eye, href: `/audit_logs?resource=${script.path}`, - hide: $userStore?.operator + hide: !$roleCapabilities.code || !$roleCapabilities.visibility.audit_logs }, { displayName: 'Schedule', @@ -412,7 +399,7 @@ scheduleEditor?.openNew(false, script.path) }, disabled: script.archived, - hide: $userStore?.operator + hide: !$roleCapabilities.code || !$roleCapabilities.manage_schedules }, { displayName: 'Permissions', @@ -421,7 +408,7 @@ shareModal.openDrawer && shareModal.openDrawer(script.path, 'script') }, disabled: script.archived, - hide: $userStore?.operator + hide: !$roleCapabilities.code }, { displayName: 'Copy path', @@ -454,7 +441,7 @@ } }, // Operators can't write scripts, so they have nothing to publish. - hide: $disableHubStore || $userStore?.operator + hide: $disableHubStore || !$roleCapabilities.code }, { displayName: script.archived ? 'Unarchive' : 'Archive', @@ -466,7 +453,7 @@ }, type: 'delete', disabled: !owner || !canEdit, - hide: $userStore?.operator + hide: !$roleCapabilities.code }, ...($userStore?.is_admin || $userStore?.is_super_admin @@ -485,7 +472,7 @@ }, type: dlt, disabled: !canEdit, - hide: $userStore?.operator + hide: !$roleCapabilities.code } ] : []) diff --git a/frontend/src/lib/components/common/table/unruledList.ts b/frontend/src/lib/components/common/table/unruledList.ts new file mode 100644 index 0000000000..9e7ef59caa --- /dev/null +++ b/frontend/src/lib/components/common/table/unruledList.ts @@ -0,0 +1,27 @@ +import { getContext, setContext } from 'svelte' + +const KEY = Symbol('unruledList') + +/** Marks the rows below as one unruled list (Home's items): no line between rows, a rounded + * hover, and row actions shown only on the row in use. Every other list keeps its ruled rows. */ +export function setUnruledList() { + setContext(KEY, true) +} + +export function inUnruledList(): boolean { + return getContext<boolean | undefined>(KEY) ?? false +} + +/** The divider an ordinary list row draws, or none in an unruled list. */ +export function rowRule(unruled: boolean): string { + return unruled ? 'rounded-md' : 'border-b' +} + +/** Row actions in an unruled list: shown for the row hovered, tabbed into, keyboard-selected, + * or with one of its menus open. Opacity rather than visibility, so Tab still reaches them; + * focus counts only when visible, since a menu hands focus back to its trigger on close and a + * mouse user would otherwise keep that row's actions up after leaving it. Needs `group/row` + * on the row. Set per child, not on the container: the menu trigger (`button[data-menu]`) + * stays visible so every row ends on the same control. */ +export const HOVER_ACTIONS_CLASS = + '*:opacity-0 *:transition-opacity *:duration-100 group-hover/row:*:opacity-100 group-has-[:focus-visible]/row:*:opacity-100 group-data-[row-keyboard-selected=true]/row:*:opacity-100 *:has-[[aria-expanded=true]]:opacity-100 [&>button[data-menu]]:opacity-100' diff --git a/frontend/src/lib/components/copilot/AIFormAssistant.svelte b/frontend/src/lib/components/copilot/AIFormAssistant.svelte index 48a46ad182..8b5daefa9d 100644 --- a/frontend/src/lib/components/copilot/AIFormAssistant.svelte +++ b/frontend/src/lib/components/copilot/AIFormAssistant.svelte @@ -55,7 +55,7 @@ {#snippet aiButton(unifiedSize: 'sm' | 'md')} <!-- The fallback is a plain Button rather than AskAiButton, whose own session branch would fire here too and open an empty session. --> - <OpenInSessionButton source={sessionSource} btnProps={{ unifiedSize }}> + <OpenInSessionButton source={sessionSource} btnProps={{ unifiedSize }} deferToHeader> {#snippet fallback()} <Button {unifiedSize} diff --git a/frontend/src/lib/components/copilot/AskAiButton.svelte b/frontend/src/lib/components/copilot/AskAiButton.svelte index 95ff47fb52..4b40c021a5 100644 --- a/frontend/src/lib/components/copilot/AskAiButton.svelte +++ b/frontend/src/lib/components/copilot/AskAiButton.svelte @@ -5,7 +5,7 @@ import { AIBtnClasses } from './chat/AIButtonStyle' import { prefersSessionHandoff } from './chat/global/gate' import { startSessionWithPrompt } from '$lib/components/sessions/sessionSwitch.svelte' - import { userStore } from '$lib/stores' + import { roleCapabilities } from '$lib/stores' interface Props { label?: string initialInput?: string @@ -16,7 +16,7 @@ // The label stays short ("Ask AI") for the search bar's inline row; the hover // text is where "new AI session" fits. - const handsOffToSession = $derived(prefersSessionHandoff($userStore?.operator)) + const handsOffToSession = $derived(prefersSessionHandoff(!$roleCapabilities.code)) export function onClick() { // No item to preview here — this carries a question, not a target — so the diff --git a/frontend/src/lib/components/copilot/ChatModelSettings.svelte b/frontend/src/lib/components/copilot/ChatModelSettings.svelte index a1e677d46b..70745eee1c 100644 --- a/frontend/src/lib/components/copilot/ChatModelSettings.svelte +++ b/frontend/src/lib/components/copilot/ChatModelSettings.svelte @@ -98,7 +98,12 @@ title={config.readOnly ? config.readOnlyReason : config.title} > <span class="flex items-center gap-1 min-w-0"> - <span class="truncate">{config.label}</span> + {#if config.shortLabel} + <span class="truncate hidden xl:inline">{config.label}</span> + <span class="truncate xl:hidden">{config.shortLabel}</span> + {:else} + <span class="truncate">{config.label}</span> + {/if} {#if effortLabel} <span class="shrink-0 text-tertiary">· {effortLabel}</span> {/if} diff --git a/frontend/src/lib/components/copilot/CronGen.svelte b/frontend/src/lib/components/copilot/CronGen.svelte index fb5806234c..5e54136f07 100644 --- a/frontend/src/lib/components/copilot/CronGen.svelte +++ b/frontend/src/lib/components/copilot/CronGen.svelte @@ -82,8 +82,7 @@ <Popover floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}> {#snippet trigger()} <Button - color={genLoading ? 'red' : 'light'} - size="xs" + unifiedSize="md" nonCaptureEvent={!genLoading} startIcon={{ icon: Wand2 }} iconOnly diff --git a/frontend/src/lib/components/copilot/chat/AIChat.svelte b/frontend/src/lib/components/copilot/chat/AIChat.svelte index 301a8dba54..a9312bc5fb 100644 --- a/frontend/src/lib/components/copilot/chat/AIChat.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChat.svelte @@ -10,6 +10,7 @@ import HideButton from '$lib/components/apps/editor/settingsPanel/HideButton.svelte' import { SUPPORTED_CHAT_SCRIPT_LANGUAGES } from './script/core' import { copilotInfo } from '$lib/aiStore' + import type { ComposerSelection } from './composerBox' let { hideHeader = false, @@ -18,9 +19,16 @@ forceDisabledMessage = '', wideLayout = false, emptyHint, + starter = undefined, + viewTransitionNames = false, + onSent = undefined, inputPreface, + actingOn, initialInstructions = undefined, - onDraftChange = undefined + onDraftChange = undefined, + initialAttachments = undefined, + onAttachmentsChange = undefined, + placeholder = undefined }: { hideHeader?: boolean hideModeSelector?: boolean @@ -35,10 +43,19 @@ // in; the narrow global-chat panel leaves it off. wideLayout?: boolean emptyHint?: import('svelte').Snippet + starter?: import('./starter/starterPrompts.svelte').ChatStarter + viewTransitionNames?: boolean + onSent?: () => void inputPreface?: import('svelte').Snippet - // Seed / observe the composer's draft text (forwarded to AIChatDisplay). + /** Forwarded to AIChatDisplay: where the session acts, first among the controls. */ + actingOn?: import('svelte').Snippet + // Seed / observe the composer's draft (forwarded to AIChatDisplay). initialInstructions?: string onDraftChange?: (text: string) => void + initialAttachments?: import('./messageDraft.svelte').DraftAttachments + onAttachmentsChange?: (attachments: import('./messageDraft.svelte').DraftAttachments) => void + /** Composer placeholder for the first message. Falls back to the per-AI-mode wording. */ + placeholder?: string } = $props() const isAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin) @@ -106,8 +123,12 @@ aiChatManager.sendRequest(options) } - export function focusInput() { - aiChatDisplay?.focusInput() + export function focusInput(selection?: ComposerSelection) { + aiChatDisplay?.focusInput(selection) + } + + export async function attachContext(element: import('./context').ContextElement) { + await aiChatDisplay?.attachContext(element) } const historyManager = aiChatManager.historyManager @@ -198,7 +219,14 @@ {hideModeSelector} {wideLayout} {emptyHint} + {starter} + {viewTransitionNames} + {onSent} {inputPreface} + {actingOn} {initialInstructions} {onDraftChange} + {initialAttachments} + {onAttachmentsChange} + {placeholder} ></AIChatDisplay> diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 1060a64110..c5fede3162 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -10,13 +10,9 @@ ArrowDown, AtSign, BookOpen, - ChevronDown, - ChevronsRight, CheckIcon, - ClipboardList, FileText, Folder, - Hand, HistoryIcon, KeyRound, MousePointer2, @@ -31,7 +27,8 @@ import Popover from '$lib/components/meltComponents/Popover.svelte' import DropdownV2 from '$lib/components/DropdownV2.svelte' import { pendingUserAction, pendingUserActionDetail, type DisplayMessage } from './shared' - import { PLAN_MODE_TEXT_COLOR, PLAN_MODE_TRIGGER_CLASS } from './planMode' + 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' @@ -51,8 +48,9 @@ import { getAiChatManager } from './aiChatManagerContext' import ChatTypingIndicator from './ChatTypingIndicator.svelte' import ToolGroupDisplay from './ToolGroupDisplay.svelte' - import { chatItemKey, groupToolRuns } from './toolGroups' + import { chatItemKey, createChatItemGrouper } from './toolGroups' import AIChatInput from './AIChatInput.svelte' + import { STARTER_PLACEHOLDER, type ComposerSelection } from './composerBox' import AttachedFilesBar from './files/AttachedFilesBar.svelte' import QueuedMessageChip from './QueuedMessageChip.svelte' import JobsSegment from './JobsSegment.svelte' @@ -71,6 +69,12 @@ import { sendUserToast } from '$lib/toast' import Alert from '$lib/components/common/alert/Alert.svelte' import { copilotInfo } from '$lib/aiStore' + import SessionGreeting from './starter/SessionGreeting.svelte' + import ComposerSuggestions from './starter/ComposerSuggestions.svelte' + import type { DraftAttachments } from './messageDraft.svelte' + import type { ChatStarter } from './starter/starterPrompts.svelte' + import { isComposerOverlay } from './starter/composerCollapse' + import { smoothHeight } from './starter/smoothHeight' const MAX_YOLO_TOOLTIP_TOOLS = 8 const chatHost = getChatViewHost() @@ -89,80 +93,6 @@ let freeTierUsedPct = $derived(Math.min(100, Math.round((freeTier?.used_ratio ?? 0) * 100))) let showFreeTierUsage = $derived(!!freeTier && !freeTier.exhausted) - // One row per autonomy posture, in picker order, so adding one touches only this - // 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. - type AutonomyAvailability = { - autoAcceptEditsAvailable: boolean - autoAcceptToolConfirmationsAvailable: boolean - planModeAvailable: boolean - } - type AutonomyModeOption = { - mode: AIAutonomyMode - label: string - 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 - } - // The one posture available everywhere, so also the fallback for a mode the - // current AI mode does not offer. - const askPermissionOption: AutonomyModeOption = { - mode: AIAutonomyMode.DEFAULT, - label: 'Ask permission', - icon: Hand, - iconColor: 'text-secondary', - tooltip: (a) => - a.autoAcceptEditsAvailable - ? 'Requires confirmation for edits and tool calls.' - : 'Requires confirmation for tool calls.', - isAvailable: () => true - } - 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, - label: 'Auto-accept edits', - icon: ChevronsRight, - iconColor: 'text-accent', - tooltip: () => - 'Automatically accepts script and flow edits. Tool calls still ask for confirmation.', - isAvailable: (a) => a.autoAcceptEditsAvailable - }, - { - mode: AIAutonomyMode.YOLO, - label: 'Yolo (bypass permissions)', - shortLabel: 'Yolo', - icon: ChevronsRight, - iconColor: 'text-red-500', - tooltip: (a) => - a.autoAcceptEditsAvailable - ? 'Automatically accepts script and flow edits plus tool confirmations.' - : 'Automatically accepts tool confirmations.', - isAvailable: (a) => a.autoAcceptToolConfirmationsAvailable - } - ] - const autonomyModeOption = (mode: AIAutonomyMode) => - autonomyModeOptions.find((o) => o.mode === mode) ?? askPermissionOption - const autonomyModeLabel = (mode: AIAutonomyMode) => { - const option = autonomyModeOption(mode) - return option.shortLabel ?? option.label - } - let { messages, pastChats, @@ -183,10 +113,16 @@ hideModeSelector = false, wideLayout = false, emptyHint, + starter = undefined, + viewTransitionNames = false, + onSent = undefined, inputPreface, + actingOn, footerSettings, initialInstructions = undefined, onDraftChange = undefined, + initialAttachments = undefined, + onAttachmentsChange = undefined, placeholder = undefined, scrollElement = $bindable(), onTranscriptScroll = undefined @@ -215,13 +151,28 @@ // off there. wideLayout?: boolean emptyHint?: Snippet + /** Open an empty chat as the block that starts a session: Home and an empty session + * both mount it, so the two are one entry point rather than look-alikes. Replaces + * `emptyHint`. */ + starter?: ChatStarter + /** Names the composer for the view transition between Home and AI Sessions, so it + * glides between its two places, a send from Home included: that lands it under the + * session's first message. Only one chat on the page may carry the name: a duplicate + * makes the browser skip the transition. */ + viewTransitionNames?: boolean + /** After the composer hands a new turn to the host. */ + onSent?: () => void inputPreface?: Snippet + /** Where the session acts: first among the controls after the attach button. */ + actingOn?: Snippet /** 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 - // Seed / observe the main composer's draft text (see AIChatInput). + // Seed / observe the main composer's draft (see AIChatInput). initialInstructions?: string onDraftChange?: (text: string) => void + initialAttachments?: DraftAttachments + onAttachmentsChange?: (attachments: DraftAttachments) => void /** Composer placeholder. Falls back to the per-AI-mode wording. */ placeholder?: string /** The transcript's scroll container. A host that paginates older messages @@ -238,7 +189,8 @@ const mcpMenu = new McpMenu(aiChatManager, () => assistantSettings?.open('mcp')) let plusMenuOpen = $state(false) let editingMessageIndex = $state<number | null>(null) - const chatItems = $derived(groupToolRuns(messages)) + const groupChatItems = createChatItemGrouper() + const chatItems = $derived(groupChatItems(messages)) // Escape stops the generation when focus is on the chat (or parked on // body), but stays with other widgets (e.g. the session's Monaco editor). @@ -357,18 +309,22 @@ chatHost.sendRequest({ instructions: suggestion }) } - export function focusInput() { + export function focusInput(selection?: ComposerSelection) { + aiChatInput?.focusInput(selection) + } + + /** Mention a workspace item in the draft, as "+" → Mention file does, and hand the user + * the caret after it. Focusing is what opens Home's one-line composer. */ + export async function attachContext(element: ContextElement) { + await mentionWorkspaceItem(element) aiChatInput?.focusInput() } $effect(() => { - if (aiChatInput) { - chatHost.setAiChatInput(aiChatInput) - } - - return () => { - chatHost.setAiChatInput(null) - } + const input = aiChatInput + if (!input) return + chatHost.setAiChatInput(input) + return () => chatHost.releaseAiChatInput(input) }) // Also shown for a run held by another tab, labeled with where it is: the @@ -601,7 +557,7 @@ function mentionWorkspaceItem(element: ContextElement) { void aiChatInput?.addContextToSelection(element) - aiChatInput?.insertMention(element.title) + return aiChatInput?.insertMention(element.title) } function onFolderInputChange(e: Event) { @@ -616,18 +572,12 @@ autoAcceptToolConfirmationsAvailable: chatHost.autoAcceptToolConfirmationsAvailable, planModeAvailable: chatHost.planModeAvailable }) - const availableAutonomyModeOptions = $derived( - autonomyModeOptions.filter((option) => option.isAvailable(autonomyAvailability)) - ) - // Fall back to ask-permission when the persisted mode isn't applicable in the - // current AI mode (e.g. auto-accept edits while in a mode without edits). const effectiveAutonomyMode = $derived( - availableAutonomyModeOptions.some((option) => option.mode === chatHost.autonomyMode) - ? chatHost.autonomyMode - : AIAutonomyMode.DEFAULT + resolveAutonomyMode(chatHost.autonomyMode, autonomyAvailability) + ) + const showAutonomyModeSelector = $derived( + !disabled && availableAutonomyModeOptions(autonomyAvailability).length > 1 ) - const showAutonomyModeSelector = $derived(!disabled && availableAutonomyModeOptions.length > 1) - const effectiveAutonomyModeOption = $derived(autonomyModeOption(effectiveAutonomyMode)) // The typing-dots indicator implies the AI is busy, which is misleading while // the loop is parked on the user; surface a text pill instead so users know to @@ -635,10 +585,55 @@ // A step name hangs its icon in the column's left padding (see AssistantMessage), so a // transcript carrying one widens the padding, on both sides to keep the column centred. const agentGutter = $derived(messages.some((m) => m.role === 'assistant' && m.stepName)) + // 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) + // Home is a page, not a pane: nothing there to centre in. + const centreComposer = $derived(starterLayout && starter?.where === 'session') + + // Home's composer at rest is one line. Focusing the field opens it; a press outside it or + // Escape puts it back, but only with nothing in the draft: leaving it open then means + // clearing the text later cannot fold it up under the caret. Opened by the field rather + // than any focus inside: the one-line row's model button would otherwise unmount under the + // menu it is opening. + let composerEl: HTMLDivElement | undefined = $state() + let composerOpened = $state(false) + const homeComposer = $derived(starterLayout && starter?.where === 'home') + // Anything the user has started (text, an attachment) or is in the middle of (they opened it + // and have not left) keeps it open, and so does a workspace small enough that the composer is + // the page's main way in. + // Until the input is bound, the draft it starts with is the one it is seeded with: reading + // it as empty collapses a remounted composer that holds text for a frame, and the box then + // animates open as if the user had opened it. + const seededDraftEmpty = $derived( + !initialInstructions?.trim() && + !initialAttachments?.pastes.length && + !initialAttachments?.images.length && + !initialAttachments?.files.length && + !initialAttachments?.blobs.length + ) + const composerCollapsed = $derived( + homeComposer && + !disabled && + !(starter?.where === 'home' && starter.fewItems) && + !composerOpened && + (aiChatInput ? aiChatInput.isDraftEmpty() : seededDraftEmpty) + ) + function closeComposer() { + if (aiChatInput?.isDraftEmpty() ?? true) composerOpened = false + } + function onComposerPointerDown(e: PointerEvent) { + if (!homeComposer || !composerOpened || composerEl?.contains(e.target as Node)) return + if (!isComposerOverlay(e.target)) closeComposer() + } + + // Home's composer is one block of the page and takes the page's column as it is. const columnClass = $derived( - wideLayout - ? `w-full max-w-3xl mx-auto ${agentGutter ? 'px-8' : 'px-7'}` - : `w-full max-w-2xl mx-auto ${agentGutter ? 'px-8' : 'px-3'}` + starter?.where === 'home' + ? 'w-full' + : wideLayout + ? `w-full max-w-3xl mx-auto ${agentGutter ? 'px-8' : 'px-7'}` + : `w-full max-w-2xl mx-auto ${agentGutter ? 'px-8' : 'px-3'}` ) const waitingForUserAction = $derived(chatHost.loading && !!pendingUserAction(messages)) @@ -694,6 +689,7 @@ const showFooterLeftControls = $derived( !footerMessageShown && (canAttachFiles || + !!actingOn || attachmentsOffReason !== undefined || showContextPicker || showAutonomyModeSelector || @@ -739,10 +735,12 @@ </div> {/snippet} +<svelte:document onpointerdown={onComposerPointerDown} /> + <!-- tabindex="-1": clicks on non-focusable chat content must move focus into the panel, or the Escape-to-stop focus check would wrongly reject them. --> <div - class="flex flex-col h-full relative outline-none" + class="flex flex-col h-full relative outline-none {starterLayout ? 'justify-center' : ''}" tabindex="-1" bind:this={panelEl} ondragenter={onPanelDragEnter} @@ -851,7 +849,21 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> </div> {/if} {#if messages.length === 0} - {#if emptyHint} + {#if starter} + <!-- The greeting rides on top of the centred composer instead of being centred with + it: it takes the space above, the spacer after the composer the same below. Home + has none: there the composer is one control among the page's. --> + {#if centreComposer} + <div class="flex-1 min-h-0 flex flex-col justify-end"> + <div + class={columnClass} + style:view-transition-name={viewTransitionNames ? 'greeting' : undefined} + > + <SessionGreeting /> + </div> + </div> + {/if} + {:else if emptyHint} {@render emptyHint()} {:else} <span class="text-2xs text-gray-500 dark:text-gray-400 text-center px-2 my-2" @@ -972,12 +984,32 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> <!-- Same horizontal padding as the transcript above: the composer's edges line up with the messages rather than sitting closer to the panel edge. --> - <div class="relative {columnClass} pb-2"> + <!-- The focus and key handlers take events bubbling from the field inside, which is the + interactive element; the group itself is never focused. --> + <!-- svelte-ignore a11y_no_noninteractive_element_interactions --> + <div + data-session-composer + role="group" + aria-label="Message composer" + bind:this={composerEl} + class="relative {columnClass} pb-2" + style:view-transition-name={viewTransitionNames ? 'composer' : undefined} + onfocusin={(e) => { + if (homeComposer && e.target instanceof HTMLTextAreaElement) composerOpened = true + }} + onkeydown={(e) => { + // A prevented Escape closed something inside first, such as the @-mention picker. + if (e.key !== 'Escape' || e.defaultPrevented) return + if (!homeComposer || !composerOpened) return + closeComposer() + if (aiChatInput?.isDraftEmpty() ?? true) (document.activeElement as HTMLElement)?.blur() + }} + > {#if showFlowPendingActionControls} <div class="absolute -top-10 w-full flex flex-row justify-center gap-2"> <Button startIcon={{ icon: CheckIcon }} - size="xs" + unifiedSize="sm" variant="default" btnClasses="bg-green-500 hover:bg-green-600 text-white hover:text-white" onclick={() => { @@ -989,7 +1021,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> <div class="rounded bg-surface"> <Button startIcon={{ icon: XIcon }} - size="xs" + unifiedSize="sm" variant="default" btnClasses="dark:opacity-50 opacity-60 hover:opacity-100" onclick={() => { @@ -1018,354 +1050,363 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> {#if inputPreface} {@render inputPreface()} {/if} - {#if showFreeTierUsage} + {#if showFreeTierUsage && !starterLayout} {@render freeTierUsageBanner()} {/if} <AIChatInput bind:this={aiChatInput} bind:selectedContext {availableContext} - {placeholder} + placeholder={starterLayout ? STARTER_PLACEHOLDER : placeholder} {initialInstructions} {onDraftChange} + {initialAttachments} + {onAttachmentsChange} showContext={chatHost.mode !== AIMode.GLOBAL} {disabled} {pendingQuestionToolCallId} isFirstMessage={messages.length === 0} - /> - <div - class="mt-1 flex flex-row flex-wrap items-center gap-x-1.5 gap-y-1" - class:justify-between={showFooterLeftControls} - class:justify-end={!showFooterLeftControls} + hero={starterLayout} + collapsed={composerCollapsed} + chatIcon={homeComposer} + {onSent} > - {#if showFooterLeftControls} - <div class="flex flex-row items-center gap-x-1.5 min-w-0 flex-wrap"> - {#if showContextPicker && !disabled} - <Popover placement="bottom-start"> - {#snippet trigger()} - <Button - nonCaptureEvent - unifiedSize="2xs" - variant="default" - title="Add context" - iconOnly - startIcon={{ icon: AtSign }} - /> - {/snippet} - {#snippet content({ close })} - {#if chatHost.mode === AIMode.APP} - <AppAvailableContextList - {availableContext} - {selectedContext} - onSelect={(element) => { - void aiChatInput?.addContextToSelection(element) - close() - }} - /> - {:else} - <ChatContextPicker - {availableContext} - {selectedContext} - onSelect={(element) => { - void aiChatInput?.addContextToSelection(element) - aiChatInput?.insertMention(element.title) - close() - aiChatInput?.focusInput() - }} - onSelectWorkspaceItem={(element) => { - void aiChatInput?.addContextToSelection(element) - aiChatInput?.insertMention(element.title) - close() - aiChatInput?.focusInput() - }} - setShowing={(showing) => { - if (!showing) close() - }} - onSelectFile={(name) => { - aiChatInput?.insertFileMention(name) - close() - }} - /> - {/if} - {/snippet} - </Popover> - {/if} - {#if attachmentsOffReason} - <Tooltip small placement="top"> - <Button - nonCaptureEvent - unifiedSize="2xs" - variant="default" - iconOnly - disabled - startIcon={{ icon: Plus }} - /> - {#snippet text()} - <div class="max-w-64 text-xs">{attachmentsOffReason}</div> - {/snippet} - </Tooltip> - {:else if canAttachFiles} - <DropdownV2 - items={() => { - const closeMenu = () => (plusMenuOpen = false) - const inGlobal = chatHost.mode === AIMode.GLOBAL - if (inGlobal) { - skillsMenu.refresh() - mcpMenu.refresh() - } - return [ - { - displayName: 'Attach file or image', - icon: FileText, - action: () => { - plusMenuOpen = false - linkFiles() - } - }, - ...(canLinkFolders - ? [ - { - // A real (live) link needs the File System Access API; without it the - // folder is only snapshotted, so call it "Add folder", not "Link folder". - displayName: canUseFsAccess ? 'Link folder' : 'Add folder', - icon: Folder, - tooltip: canUseFsAccess - ? 'Linked live — the assistant reads the folder’s current files from disk and refreshes each turn.' - : 'Loaded as a snapshot — the folder’s files are copied into your browser (they won’t auto-update). For a live link that refreshes from disk, use a Chromium-based browser (Chrome, Edge).', - action: () => { - plusMenuOpen = false - linkFolder() - } - } - ] - : []), - ...(inGlobal - ? [ - { - displayName: 'Mention file', - icon: AtSign, - customSubmenu: WorkspaceMentionPicker, - customSubmenuProps: { - onSelect: mentionWorkspaceItem, - onAfterClose: () => aiChatInput?.focusInput() - } - } - ] - : []), - ...(inGlobal - ? [ - { - displayName: 'Skills', - icon: BookOpen, - separatorTop: true, - // Getters so the submenus fill in when their fetch lands - // rather than the whole menu waiting on it. - get submenuItems() { - return skillsMenu.items(closeMenu) - } - }, - { - displayName: 'MCP connections', - icon: Plug, - get submenuItems() { - return mcpMenu.items(closeMenu) - } - } - ] - : []) - ] - }} - placement="bottom-start" - fixedHeight={false} - closeOnItemClick={false} - bind:open={plusMenuOpen} - > - {#snippet buttonReplacement()} + {#snippet collapsedControls()} + {#if chatHost.supportsModelSettings} + <AIChatModelSettings promptSettings={false} /> + {/if} + {/snippet} + {#snippet toolbar()} + <div + class="flex flex-row flex-wrap items-center gap-x-1.5 gap-y-1" + class:justify-between={showFooterLeftControls} + class:justify-end={!showFooterLeftControls} + > + {#if showFooterLeftControls} + <div class="flex flex-row items-center gap-x-1.5 min-w-0 flex-wrap"> + {#if showContextPicker && !disabled} + <Popover placement="bottom-start"> + {#snippet trigger()} + <Button + nonCaptureEvent + unifiedSize="2xs" + variant="default" + title="Add context" + iconOnly + startIcon={{ icon: AtSign }} + /> + {/snippet} + {#snippet content({ close })} + {#if chatHost.mode === AIMode.APP} + <AppAvailableContextList + {availableContext} + {selectedContext} + onSelect={(element) => { + void aiChatInput?.addContextToSelection(element) + close() + }} + /> + {:else} + <ChatContextPicker + {availableContext} + {selectedContext} + onSelect={(element) => { + void mentionWorkspaceItem(element) + close() + aiChatInput?.focusInput() + }} + onSelectWorkspaceItem={(element) => { + void mentionWorkspaceItem(element) + close() + aiChatInput?.focusInput() + }} + setShowing={(showing) => { + if (!showing) close() + }} + onSelectFile={(name) => { + aiChatInput?.insertFileMention(name) + close() + }} + /> + {/if} + {/snippet} + </Popover> + {/if} + {#if attachmentsOffReason} <Tooltip small placement="top"> <Button nonCaptureEvent unifiedSize="2xs" - variant="default" + variant="subtle" iconOnly + disabled startIcon={{ icon: Plus }} /> + {#snippet text()} + <div class="max-w-64 text-xs">{attachmentsOffReason}</div> + {/snippet} + </Tooltip> + {:else if canAttachFiles} + <DropdownV2 + items={() => { + const closeMenu = () => (plusMenuOpen = false) + const inGlobal = chatHost.mode === AIMode.GLOBAL + if (inGlobal) { + skillsMenu.refresh() + mcpMenu.refresh() + } + return [ + { + displayName: 'Attach file or image', + icon: FileText, + action: () => { + plusMenuOpen = false + linkFiles() + } + }, + ...(canLinkFolders + ? [ + { + // A real (live) link needs the File System Access API; without it the + // folder is only snapshotted, so call it "Add folder", not "Link folder". + displayName: canUseFsAccess ? 'Link folder' : 'Add folder', + icon: Folder, + tooltip: canUseFsAccess + ? 'Linked live — the assistant reads the folder’s current files from disk and refreshes each turn.' + : 'Loaded as a snapshot — the folder’s files are copied into your browser (they won’t auto-update). For a live link that refreshes from disk, use a Chromium-based browser (Chrome, Edge).', + action: () => { + plusMenuOpen = false + linkFolder() + } + } + ] + : []), + ...(inGlobal + ? [ + { + displayName: 'Mention file', + icon: AtSign, + customSubmenu: WorkspaceMentionPicker, + customSubmenuProps: { + onSelect: mentionWorkspaceItem, + onAfterClose: () => aiChatInput?.focusInput() + } + } + ] + : []), + ...(inGlobal + ? [ + { + displayName: 'Skills', + icon: BookOpen, + separatorTop: true, + // Getters so the submenus fill in when their fetch lands + // rather than the whole menu waiting on it. + get submenuItems() { + return skillsMenu.items(closeMenu) + } + }, + { + displayName: 'MCP connections', + icon: Plug, + get submenuItems() { + return mcpMenu.items(closeMenu) + } + } + ] + : []) + ] + }} + placement="bottom-start" + fixedHeight={false} + closeOnItemClick={false} + bind:open={plusMenuOpen} + > + {#snippet buttonReplacement()} + <Tooltip small placement="top"> + <Button + nonCaptureEvent + unifiedSize="2xs" + variant="subtle" + iconOnly + startIcon={{ icon: Plus }} + /> + {#snippet text()} + <div class="max-w-64 text-xs"> + <p class="font-semibold">Attach files or link a folder</p> + <p class="mt-1"> + Files and images attach to your next message. Images are seen + directly; file contents stay in your browser and are read on demand. + </p> + <p class="mt-1"> + A linked folder is a session-wide resource: the assistant lists, + searches, and reads its files whenever it needs them. + </p> + </div> + {/snippet} + </Tooltip> + {/snippet} + </DropdownV2> + <!-- Fallback file picker (used when the File System Access API is unavailable). + `accept` only steers the picker; the content sniff at attach is authoritative. --> + <input + bind:this={fileInputEl} + type="file" + multiple + accept={chatHost.attachmentAccept} + class="hidden no-default-style" + onchange={onFileInputChange} + /> + <!-- Fallback folder picker (no File System Access API): webkitdirectory selects a + whole folder; its files carry webkitRelativePath and are snapshotted. --> + <input + bind:this={folderInputEl} + type="file" + multiple + webkitdirectory + class="hidden no-default-style" + onchange={onFolderInputChange} + /> + {/if} + {@render actingOn?.()} + {#if showAutonomyModeSelector} + <AutonomyModePicker + mode={chatHost.autonomyMode} + availability={autonomyAvailability} + onChange={chatHost.setAutonomyMode} + /> + {/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" /> {#snippet text()} <div class="max-w-64 text-xs"> - <p class="font-semibold">Attach files or link a folder</p> - <p class="mt-1"> - Files and images attach to your next message. Images are seen directly; - file contents stay in your browser and are read on demand. + <p class="font-semibold"> + {chatHost.autoAcceptEditsAvailable + ? 'Bypass permissions auto-accepts edits and tool usage.' + : 'Bypass permissions auto-accepts tool usage.'} </p> <p class="mt-1"> - A linked folder is a session-wide resource: the assistant lists, searches, - and reads its files whenever it needs them. + {chatHost.autoAcceptEditsAvailable + ? 'This can result in edits being applied or tools being called without user confirmation.' + : 'This can result in tools being called without user confirmation.'} </p> + {#if yoloBypassedTools.length > 0} + <p class="mt-2 font-semibold">Bypassed in current mode:</p> + <ul class="mt-1 list-disc pl-4 space-y-0.5"> + {#each visibleYoloBypassedTools as tool (tool.name)} + <li class="break-words">{tool.label}</li> + {/each} + </ul> + {#if hiddenYoloBypassedToolCount > 0} + <p class="mt-1">+ {hiddenYoloBypassedToolCount} more</p> + {/if} + {:else} + <p class="mt-2">No tools in the current mode require confirmation.</p> + {/if} </div> {/snippet} </Tooltip> - {/snippet} - </DropdownV2> - <!-- Fallback file picker (used when the File System Access API is unavailable). - `accept` only steers the picker; the content sniff at attach is authoritative. --> - <input - bind:this={fileInputEl} - type="file" - multiple - accept={chatHost.attachmentAccept} - class="hidden no-default-style" - onchange={onFileInputChange} - /> - <!-- Fallback folder picker (no File System Access API): webkitdirectory selects a - whole folder; its files carry webkitRelativePath and are snapshotted. --> - <input - bind:this={folderInputEl} - type="file" - multiple - webkitdirectory - class="hidden no-default-style" - onchange={onFolderInputChange} - /> + {/if} + {#if chatHost.mode === AIMode.SCRIPT && hasDiff && !disabled} + <ChatQuickActions {askAi} {diffMode} /> + {/if} + </div> {/if} - {#if showAutonomyModeSelector} - <DropdownV2 - items={() => - availableAutonomyModeOptions.map((option) => ({ - displayName: option.label, - selected: effectiveAutonomyMode === option.mode, - action: () => chatHost.setAutonomyMode(option.mode) - }))} - placement="bottom-start" - fixedHeight={false} - customWidth={240} - > - {#snippet buttonReplacement()} - <Button - nonCaptureEvent - unifiedSize="2xs" - variant="default" - title={effectiveAutonomyModeOption.tooltip(autonomyAvailability)} - btnClasses={effectiveAutonomyModeOption.triggerClass ?? ''} - startIcon={{ - icon: effectiveAutonomyModeOption.icon, - classes: effectiveAutonomyModeOption.iconColor - }} - endIcon={{ icon: ChevronDown }} - > - {autonomyModeLabel(effectiveAutonomyMode)} - </Button> - {/snippet} - </DropdownV2> - {/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" /> - {#snippet text()} - <div class="max-w-64 text-xs"> - <p class="font-semibold"> - {chatHost.autoAcceptEditsAvailable - ? 'Bypass permissions auto-accepts edits and tool usage.' - : 'Bypass permissions auto-accepts tool usage.'} - </p> - <p class="mt-1"> - {chatHost.autoAcceptEditsAvailable - ? 'This can result in edits being applied or tools being called without user confirmation.' - : 'This can result in tools being called without user confirmation.'} - </p> - {#if yoloBypassedTools.length > 0} - <p class="mt-2 font-semibold">Bypassed in current mode:</p> - <ul class="mt-1 list-disc pl-4 space-y-0.5"> - {#each visibleYoloBypassedTools as tool (tool.name)} - <li class="break-words">{tool.label}</li> - {/each} - </ul> - {#if hiddenYoloBypassedToolCount > 0} - <p class="mt-1">+ {hiddenYoloBypassedToolCount} more</p> - {/if} - {:else} - <p class="mt-2">No tools in the current mode require confirmation.</p> - {/if} - </div> - {/snippet} - </Tooltip> - {/if} - {#if chatHost.mode === AIMode.SCRIPT && hasDiff && !disabled} - <ChatQuickActions {askAi} {diffMode} /> - {/if} - </div> - {/if} - {#if footerMessageShown} - <div class="text-primary text-xs my-2 px-2"> - <Markdown md={disabledMessage} /> - </div> - {:else} - <div class="flex flex-row gap-x-1.5 min-w-0 flex-wrap items-center"> - {#if chatHost.mode === AIMode.GLOBAL} - <AttachedFilesBar /> - {/if} - {#if !hideModeSelector} - <ChatMode /> - {/if} - {#if chatHost.mode === AIMode.APP} - <DatatableCreationPolicy /> - {/if} - <ContextUsageIndicator /> - {#if chatHost.supportsModelSettings} - <!-- `promptSettings={false}`: in a session, GLOBAL, the settings modal's + {#if footerMessageShown} + <!-- The workspace picker stays: picking another workspace is how a chat that + cannot run here becomes usable again. --> + <div class="flex flex-row items-center gap-x-1.5 min-w-0 flex-wrap"> + {@render actingOn?.()} + <div class="text-primary text-xs my-2 px-2"> + <Markdown md={disabledMessage} /> + </div> + </div> + {:else} + <div class="flex flex-row gap-x-1.5 min-w-0 flex-wrap items-center"> + {#if chatHost.mode === AIMode.GLOBAL} + <AttachedFilesBar /> + {/if} + {#if !hideModeSelector} + <ChatMode /> + {/if} + {#if chatHost.mode === AIMode.APP} + <DatatableCreationPolicy /> + {/if} + <ContextUsageIndicator /> + {#if chatHost.supportsModelSettings} + <!-- `promptSettings={false}`: in a session, GLOBAL, the settings modal's Instructions section owns the prompt entries. --> - <AIChatModelSettings promptSettings={false} /> - {/if} - {@render footerSettings?.()} - {#if chatHost.mode === AIMode.GLOBAL} - <AssistantSettingsModal bind:this={assistantSettings} /> - {/if} + <AIChatModelSettings promptSettings={false} /> + {/if} + {@render footerSettings?.()} + {#if chatHost.mode === AIMode.GLOBAL} + <AssistantSettingsModal bind:this={assistantSettings} /> + {/if} - {#if chatHost.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)} - {#if appContext.inspectorElement} - <div - class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 text-2xs" - title={appContext.inspectorElement.path} - > - <MousePointer2 class="w-3 h-3" /> - <span class="truncate max-w-[60px]"> - {appContext.inspectorElement.tagName.toLowerCase()}{appContext.inspectorElement - .id - ? `#${appContext.inspectorElement.id}` - : ''}{appContext.inspectorElement.className - ? `.${appContext.inspectorElement.className.split(' ')[0]}` - : ''} - </span> - <button - class="hover:bg-purple-200 dark:hover:bg-purple-800/50 rounded p-0.5 -mr-0.5" - onclick={() => appContext.clearInspector?.()} - title="Clear element selection" - > - <X class="w-2.5 h-2.5" /> - </button> - </div> - {/if} - {#if appContext.codeSelection} - <div - class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 text-2xs" - title={`${appContext.codeSelection.source}: lines ${appContext.codeSelection.startLine}-${appContext.codeSelection.endLine}`} - > - <TextSelect class="w-3 h-3" /> - <span class="truncate max-w-[80px]"> - L{appContext.codeSelection.startLine}-{appContext.codeSelection.endLine} - </span> - <button - class="hover:bg-amber-200 dark:hover:bg-amber-800/50 rounded p-0.5 -mr-0.5" - onclick={() => appContext.clearCodeSelection?.()} - title="Clear code selection" - > - <X class="w-2.5 h-2.5" /> - </button> - </div> - {/if} + {#if chatHost.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)} + {#if appContext.inspectorElement} + <div + class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 text-2xs" + title={appContext.inspectorElement.path} + > + <MousePointer2 class="w-3 h-3" /> + <span class="truncate max-w-[60px]"> + {appContext.inspectorElement.tagName.toLowerCase()}{appContext + .inspectorElement.id + ? `#${appContext.inspectorElement.id}` + : ''}{appContext.inspectorElement.className + ? `.${appContext.inspectorElement.className.split(' ')[0]}` + : ''} + </span> + <button + class="hover:bg-purple-200 dark:hover:bg-purple-800/50 rounded p-0.5 -mr-0.5" + onclick={() => appContext.clearInspector?.()} + title="Clear element selection" + > + <X class="w-2.5 h-2.5" /> + </button> + </div> + {/if} + {#if appContext.codeSelection} + <div + class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 text-2xs" + title={`${appContext.codeSelection.source}: lines ${appContext.codeSelection.startLine}-${appContext.codeSelection.endLine}`} + > + <TextSelect class="w-3 h-3" /> + <span class="truncate max-w-[80px]"> + L{appContext.codeSelection.startLine}-{appContext.codeSelection.endLine} + </span> + <button + class="hover:bg-amber-200 dark:hover:bg-amber-800/50 rounded p-0.5 -mr-0.5" + onclick={() => appContext.clearCodeSelection?.()} + title="Clear code selection" + > + <X class="w-2.5 h-2.5" /> + </button> + </div> + {/if} + {/if} + </div> {/if} </div> + {/snippet} + </AIChatInput> + </div> + <!-- Opens and closes with the box, so the page under it slides rather than jumps. --> + <div use:smoothHeight={{ enabled: starterLayout, key: composerCollapsed }}> + <div> + {#if starterLayout && starter && !disabled && !composerCollapsed && starter.suggestions.length > 0} + <div class="pt-2"> + <ComposerSuggestions + suggestions={starter.suggestions} + wrap={starter.where === 'session'} + onPick={(prompt) => aiChatInput?.setText(prompt)} + /> + </div> {/if} </div> </div> @@ -1386,4 +1427,12 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> </div> {/if} </div> + <!-- Equal to the space above while the session is empty, so the composer sits centred. + Always mounted and shrunk on the first message rather than removed: flex-grow + transitions, so the composer slides down to the bottom instead of jumping. --> + <div + class="min-h-0 basis-0 transition-[flex-grow] duration-[var(--composer-glide-duration)] ease-[var(--composer-glide-easing)] motion-reduce:transition-none {centreComposer + ? 'grow' + : 'grow-0'}" + ></div> </div> diff --git a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte index 41ea788fac..13aaccec94 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte @@ -13,7 +13,18 @@ import { AIMode } from './AIChatManager.svelte' import { CHAT_INPUT_PADDING, getAiChatManager } from './aiChatManagerContext' import { getChatViewHost } from './chatViewHost' - import { composerBoxClass, COMPOSER_FIELD_RESET } from './composerBox' + import { + composerBoxClass, + COMPOSER_FIELD_RESET, + globalComposerSuggestion, + type ComposerSelection + } from './composerBox' + + // The send button sits in the toolbar row, not over the field, so the text needs no + // room reserved on its right, and the row right below it stands in for most of its + // bottom padding. The top padding is the one-line row's, so the text holds still as Home's + // composer opens. + const FIELD_PADDING_WITH_TOOLBAR = '!pl-4 !pr-4 !pt-3 !pb-1' import { formatMention, hasMention, removeMentionFromText } from './mention' import { twMerge } from 'tailwind-merge' import { tick, untrack, type Snippet } from 'svelte' @@ -49,7 +60,7 @@ MAX_BLOB_BYTES, type AttachedBlob } from './blobUtils' - import { MessageDraft } from './messageDraft.svelte' + import { MessageDraft, type DraftAttachments } from './messageDraft.svelte' import { useSessionPermissions } from '$lib/components/sessions/sessionPermissions.svelte' import ExpandableImage, { isImageViewerOpen @@ -89,10 +100,30 @@ // `chatHost.instructions` only carries programmatic prompts). Used by // sessions to persist the typed-but-unsent prompt with the session draft. onDraftChange?: (text: string) => void + /** The rest of the draft — collapsed pastes, images, files — seeded and observed the + * same way. The text alone is not the draft: a collapsed paste is a token in it whose + * content lives here, so a composer remounted elsewhere needs both. */ + initialAttachments?: DraftAttachments + onAttachmentsChange?: (attachments: DraftAttachments) => void // tool_call_id of the askUserQuestion the turn is parked on, when it is. A // plain-text draft sent from here answers it instead of queueing behind a // turn that only the answer can resume. pendingQuestionToolCallId?: string + /** The composer that opens a session (Home, an empty session): `toolbar` is drawn in the + * box's bottom row with the send button at its end, and a usable box is taller. Otherwise + * the controls sit under the box and the send button floats over the field's corner, as a + * chat under way reads more clearly. */ + hero?: boolean + /** With `hero`: one row, the field then `collapsedControls` and the send button, in + * place of the toolbar. */ + collapsed?: boolean + collapsedControls?: Snippet + /** A chat mark before the field, where the box alone doesn't say it is the assistant's. */ + chatIcon?: boolean + /** After a new turn is handed to the host — not an edit, a queued message or an answer. */ + onSent?: () => void + /** The composer's controls. */ + toolbar?: Snippet } let { @@ -116,47 +147,26 @@ loading, onCancel, onDraftChange = undefined, - pendingQuestionToolCallId = undefined + initialAttachments = undefined, + onAttachmentsChange = undefined, + pendingQuestionToolCallId = undefined, + onSent = undefined, + toolbar = undefined, + hero = false, + collapsed = false, + collapsedControls = undefined, + chatIcon = false }: Props = $props() - // GLOBAL-mode suggestion pool. We pick one at mount-time so each new - // session lands on a different prompt; the choice stays stable for - // the lifetime of this input so the placeholder doesn't shuffle as - // the user is reading it. - const GLOBAL_PLACEHOLDER_SUGGESTIONS = [ - 'Write a hello-world flow', - 'Create a script that lists files in an S3 bucket', - 'Build a CRUD app for a customer table', - 'Schedule a daily cleanup of old runs', - 'Wrap an existing script into a flow with retries', - 'Add an HTTP trigger to an existing script', - 'Generate a report from a SQL query and email it', - 'Create a Postgres resource and a script that queries it', - 'Refactor a script to add error handling', - 'List my workspace flows and scripts' - ] - // An operator authors nothing, so nothing above is theirs to ask for; one with the - // workspace's builder right can compose flows out of deployed scripts as well. - const OPERATOR_PLACEHOLDER_SUGGESTIONS = [ - 'List my workspace flows and scripts', - 'Show the runs that failed today and why', - 'Find the script that sends a report and run it', - 'Rerun the last failed run of a flow' - ] - const BUILDER_PLACEHOLDER_SUGGESTIONS = [ - ...OPERATOR_PLACEHOLDER_SUGGESTIONS, - 'Compose a flow that chains two deployed scripts' - ] + const oneLine = $derived(hero && collapsed) + const toolbarInBox = $derived(!!toolbar && hero) + + // Picked at mount-time so each new session lands on a different prompt. const permissions = useSessionPermissions(() => chatHost.operatingWorkspace) const suggestionPick = Math.random() - const globalSuggestion = $derived.by(() => { - const pool = permissions.canOpenEditor('script') - ? GLOBAL_PLACEHOLDER_SUGGESTIONS - : permissions.canOpenEditor('flow') - ? BUILDER_PLACEHOLDER_SUGGESTIONS - : OPERATOR_PLACEHOLDER_SUGGESTIONS - return pool[Math.floor(suggestionPick * pool.length)] - }) + const globalSuggestion = $derived( + globalComposerSuggestion(suggestionPick, permissions.canOpenEditor) + ) // Generate mode-specific placeholder const modePlaceholder = $derived.by(() => { @@ -207,9 +217,10 @@ const draft = new MessageDraft( untrack(() => ({ text: initialInstructions, - pastes: initialPastes ?? [], - images: initialImages ?? [], - files: initialFiles ?? [] + pastes: initialPastes ?? initialAttachments?.pastes ?? [], + images: initialImages ?? initialAttachments?.images ?? [], + files: initialFiles ?? initialAttachments?.files ?? [], + blobs: initialAttachments?.blobs ?? [] })) ) // Report edits, never the mount-time value. The first run carries whatever the @@ -228,6 +239,24 @@ onDraftChange?.(text) }) }) + // Same rule for the rest of the draft. + let attachmentsReported = false + $effect(() => { + // Copied, not passed: the paste lane is edited in place, and a copy reads every item. + const attachments = { + pastes: [...draft.pastes], + images: [...draft.images], + files: [...draft.files], + blobs: [...draft.blobs] + } + untrack(() => { + if (!attachmentsReported) { + attachmentsReported = true + return + } + onAttachmentsChange?.(attachments) + }) + }) // A parked askUserQuestion is a request for input, so a plain-text draft sent // from the composer answers it rather than being queued behind a turn that can @@ -613,10 +642,12 @@ * auto-remove items when the user deletes them. No-op when the same * standalone mention is already present, so picking the same item * again doesn't leave duplicate tokens. */ - export function insertMention(title: string) { + /** Resolves once the caret sits after the mention, so mentions inserted one after another + * land in that order. */ + export async function insertMention(title: string) { if (hasMention(draft.text, title)) return if (contextTextareaComponent) { - void contextTextareaComponent.insertMention(title) + await contextTextareaComponent.insertMention(title) return } const sep = draft.text.length === 0 || /\s$/.test(draft.text) ? '' : ' ' @@ -636,11 +667,26 @@ draft.text = removeMentionFromText(draft.text, title) } - export function focusInput() { + /** Replace the draft's text, keeping its attachments: a starter prompt fills the box, it + * never sends. */ + export function setText(text: string) { + draft.text = text + focusInput() + } + + /** Whether nothing would ship with a send: no text and no attachment. */ + export function isDraftEmpty(): boolean { + return draft.isEmpty + } + + export function focusInput(selection?: ComposerSelection) { if (isContextEnabledMode) { - contextTextareaComponent?.focus() + contextTextareaComponent?.focus(selection) } else { instructionsTextareaComponent?.focus() + if (selection) { + instructionsTextareaComponent?.setSelectionRange(selection.start, selection.end) + } } } @@ -977,6 +1023,7 @@ // textarea still rely on the draft reset alone (no `@`-mention state to // coordinate). contextTextareaComponent?.clearForSend() + onSent?.() } } @@ -1207,7 +1254,23 @@ } </script> -{#snippet sendStopButton()} +{#snippet toolbarRow()} + {#if oneLine} + <div class="flex flex-row items-center gap-1 pr-3 shrink-0"> + {@render collapsedControls?.()} + {@render sendStopButton('sm')} + </div> + {:else if toolbarInBox} + <!-- The send button sets the row's height and the smaller controls centre on it, which + already puts them a few pixels further from the box's bottom edge than the padding. --> + <div class="flex flex-row items-center gap-1.5 px-3 pb-1.5"> + <div class="flex-1 min-w-0">{@render toolbar?.()}</div> + {@render sendStopButton('sm')} + </div> + {/if} +{/snippet} + +{#snippet sendStopButton(size: 'sm' | 'md' = 'md')} <!-- The turn stays `loading` while parked on a question, but a drafted answer is what the button should ship then — otherwise the only pointer action on a typed answer would be Stop. Anything else keeps Stop. --> @@ -1232,9 +1295,11 @@ (onSendRequest !== undefined || chatHost.mode !== AIMode.GLOBAL || selectedContext.length === 0))} + <!-- Accent only once there is something to send, so the button lights up as the draft + becomes sendable; idle and Stop stay quiet. --> <Button - variant="subtle" - unifiedSize="md" + variant={!isLoading && !sendDisabled ? 'accent' : 'subtle'} + unifiedSize={size} iconOnly title={isLoading ? 'Stop' @@ -1402,6 +1467,9 @@ {availableContext} {selectedContext} placeholder={modePlaceholder} + hero={hero && !disabled} + {oneLine} + {chatIcon} onAddContext={(contextElement) => void addContextToSelection(contextElement)} onRemoveContext={(element) => { selectedContext = selectedContext?.filter((c) => !isSameContextElement(c, element)) @@ -1414,13 +1482,21 @@ }} {disabled} {onKeyDown} + className={twMerge( + oneLine ? '!pl-4 !pr-2 !py-3' : toolbarInBox ? FIELD_PADDING_WITH_TOOLBAR : '', + // A set line height, not the font's own: the chat icon is placed against it. + chatIcon && '!pl-2 !leading-5' + )} > {#snippet leading()} {@render badgeRow()} {@render imageChipsRow()} {/snippet} + {#snippet trailing()} + {@render toolbarRow()} + {/snippet} </ContextTextarea> - {#if !bottomRightSnippet} + {#if !bottomRightSnippet && !toolbarInBox} <div class="absolute bottom-1 right-1"> {@render sendStopButton()} </div> @@ -1529,6 +1605,9 @@ </div> </div> {/if} + {#if toolbar && !toolbarInBox} + <div class="mt-1">{@render toolbar()}</div> + {/if} {#if bottomRightSnippet} <div class="absolute bottom-1 right-1"> {@render bottomRightSnippet()} diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 066446c26e..2e998e9cc6 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -25,6 +25,7 @@ import { type ToolCallbacks, type ToolDisplayMessage, type UserQuestionDisplay, + type RunFormAnswer, type RunFormDisplay, type RunFormDraft, type ChatJob, @@ -49,10 +50,14 @@ import { import type { ScriptLintResult } from './shared' import { navigatorTools, prepareNavigatorSystemMessage } from './navigator/core' import { loadApiTools } from './api/apiTools' -import { prepareScriptUserMessage } from './script/core' +import { + prepareScriptUserMessage, + resolveResourceTypeSync, + type ResourceTypeSync +} from './script/core' import { prepareNavigatorUserMessage } from './navigator/core' import { sendUserToast } from '$lib/toast' -import { workspaceAIClients, getStreamedCompletionText } from '../lib' +import { AI_PROVIDERS, workspaceAIClients, getStreamedCompletionText } from '../lib' import { logFeatureUsage } from '$lib/utils/featureUsage' import { modelSupportsVision } from '../modelConfig' import { getEffectiveModelContextWindow } from '../modelConfig' @@ -92,7 +97,7 @@ import { untrack } from 'svelte' import { fromStore, get } from 'svelte/store' import type { ProviderToolSummary } from './agentContext' import { BROWSER } from 'esm-env' -import { workspaceStore, type DBSchemas } from '$lib/stores' +import { roleCapabilities, workspaceStore, type DBSchemas } from '$lib/stores' import { copilotInfo, copilotSessionModel } from '$lib/aiStore' import { copilotWorkspaceRequested, loadCopilot } from '$lib/components/copilot/loadCopilot' import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core' @@ -114,6 +119,7 @@ import { prepareApiSystemMessage, prepareApiUserMessage } from './api/core' import { closeInterruptedToolBatch, runChatLoop, + type ChatLoopConfig, sendsWebSearch, truncateToToolPairedPrefix } from './chatLoop' @@ -168,11 +174,6 @@ import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRout import { appendAttachedFilesRoster } from './files/fileTools' import { ENTER_PLAN_MODE_TOOL, EXIT_PLAN_MODE_TOOL } from './planMode' import { PlanModeController, type PlanModeHost } from './planModeController.svelte' -import { - navigationOperatorBuilderApps, - navigationOperatorBuilderFlows, - navigationOperatorBuilderRights -} from '$lib/operatorWriteRights' // Compaction of the stored history: once the projected request size // (contextTokens — the provider's report when current, a fresh chars/4 @@ -224,7 +225,7 @@ const settledRunForm = (runForm: RunFormDisplay): RunFormDisplay => type PendingRunForm = { /** Absent once the loop is no longer waiting: a card restored from history still mounts * its form and still holds edits, but nothing is left to receive them. */ - resolve?: (args: Record<string, any> | undefined) => void + resolve?: (answer: RunFormAnswer | undefined) => void draft: RunFormDraft submitting: boolean } @@ -246,12 +247,12 @@ const WEB_SEARCH_ERROR_HINT = const REASONING_SUMMARY_WARNED_STORAGE_KEY = 'ai-chat-reasoning-summary-unverified-warned' function providerDisplayName(provider: string): string { - return provider === 'azure_openai' ? 'Azure OpenAI' : 'OpenAI' + return AI_PROVIDERS[provider as keyof typeof AI_PROVIDERS]?.label ?? 'OpenAI' } function reasoningSummaryUnavailableMessage(provider: string): string { const verifyHint = - provider === 'azure_openai' + provider === 'azure_openai' || provider === 'azure_foundry' ? 'To display it, verify your organization with your provider, then reload this page.' : 'To display it, verify your organization in the OpenAI platform settings (Settings > General), then reload this page.' return `This model is reasoning, but your ${providerDisplayName(provider)} organization is not verified to generate reasoning summaries, so its thinking stays hidden. ${verifyHint}` @@ -305,9 +306,7 @@ export function supportsPlanMode(mode: AIMode): boolean { return PLAN_MODES.has(mode) } -const isOperatorBuilder = fromStore(navigationOperatorBuilderRights) -const isOperatorBuilderFlows = fromStore(navigationOperatorBuilderFlows) -const isOperatorBuilderApps = fromStore(navigationOperatorBuilderApps) +const navCapabilities = fromStore(roleCapabilities) export function isAIModeVisible(mode: AIMode): boolean { return mode !== AIMode.GLOBAL || isGlobalAiEnabled() @@ -594,6 +593,23 @@ export class AIChatManager implements ChatViewHost { * chat card holds. Unset outside a session: a chat-bound form has nowhere else to go, * so the card hides the control rather than offering a tab that cannot run. */ openRunForm?: (a: { toolCallId: string; label: string }) => void + /** Show a DEPLOYED run's item on its own page. With `carries`, that page is handed this + * call: it opens its own form on what the call proposed, and the job its Run starts is + * the one the tool follows. Without it the page is only opened to look at — a + * conversational flow's page has no form to confirm on — and the card keeps the run. Set + * only where that page can be shown, a session's preview panel, so the card hides the + * control elsewhere. */ + openRunOnDeployedPage?: (a: { + toolCallId: string + kind: 'script' | 'flow' + path: string + summary: string + carries: boolean + /** Re-point a tab already on that page, and answer `false` rather than opening one. + * For handing a call to a page the reader already has in front of them: a tab showing + * something else, a run included, has no form to confirm this call on. */ + onlyIfAlreadyOpen?: boolean + }) => boolean closeRunForm?: (toolCallId: string) => void /** Hands that tab from the form to the run it just started, in place: the tab keeps its * position in the strip and stays active if it was. */ @@ -603,11 +619,16 @@ export class AIChatManager implements ChatViewHost { workspace: string label: string }) => void - /** Whether the panel holds this call's pending form. Answered off the session's tab list, - * so it stays true while the user is on another tab, and per call rather than "the open - * one". Read from a `$derived` — the reader subscribes to the tab list through the call. - * The card hides its form on it, which is what keeps exactly one mounted per call. */ + /** Whether the panel holds this call's pending form, on either surface. Answered off the + * session's tab list, so it stays true while the user is on another tab, and per call + * rather than "the open one". Read from a `$derived` — the reader subscribes to the tab + * list through the call. The card folds its row on it, and a started job takes over + * whichever surface was waiting. */ isRunFormInPreview?: (toolCallId: string) => boolean + /** Whether the panel holds this call on the chat's own form, as opposed to on the item's + * page. The card's form is exclusive with this one alone: it is the same draft, and two + * mounts of it each reorder the schema SchemaForm edits in place. */ + isRunFormTabOpen?: (toolCallId: string) => boolean openArtifact?: (artifactId: string, name: string, version?: ArtifactVersionTarget) => void closeArtifact?: (artifactId: string) => void #loading = $state<boolean>(false) @@ -1485,23 +1506,22 @@ export class AIChatManager implements ChatViewHost { .map((s) => ({ ...s, kind: 'skill' as const })) ]) - // The flow, app and script builders all write code, which the backend refuses from an - // operator with builder rights: leaving them reachable would only produce work that cannot be - // deployed. + // The flow, app and script builders all write code, which the backend refuses from a role + // that only composes: leaving them reachable would only produce work that cannot be deployed. allowedModes: Record<AIMode, boolean> = $derived({ script: this.flowAiChatHelpers === undefined && this.scriptEditorOptions !== undefined && !this.disabledModes.script && - !isOperatorBuilder.current, + navCapabilities.current.code, flow: this.flowAiChatHelpers !== undefined && !this.disabledModes.flow && - !isOperatorBuilderFlows.current, + navCapabilities.current.flows === 'full', app: this.appAiChatHelpers !== undefined && !this.disabledModes.app && - !isOperatorBuilderApps.current, + navCapabilities.current.apps === 'full', navigator: !this.disabledModes.navigator, ask: !this.disabledModes.ask, API: !this.disabledModes.API, @@ -1726,8 +1746,15 @@ export class AIChatManager implements ChatViewHost { * Returns true on success. Returns false — caller falls back to drop-oldest — * when summarization isn't worthwhile or fails (user abort, empty summary, or * the circuit breaker being tripped). + * + * `inFlightTokens` is the running turn's output, which is not in `messages` but + * rides on every request after it: the tail shrinks by that much, or the next + * tool result re-triggers at once and drop-oldest discards this summary. */ - private summarizeAndCompact = async (contextWindow: number): Promise<boolean> => { + private summarizeAndCompact = async ( + contextWindow: number, + inFlightTokens = 0 + ): Promise<boolean> => { if (this.consecutiveCompactionFailures >= MAX_CONSECUTIVE_COMPACTION_FAILURES) { return false } @@ -1736,13 +1763,14 @@ export class AIChatManager implements ChatViewHost { return false } - const tailBudget = + const historyBudget = contextWindow * COMPACTION_TARGET_RATIO - SUMMARY_OUTPUT_RESERVE_TOKENS - this.estimateOverheadTokens() - if (tailBudget <= 0) { + if (historyBudget <= 0) { return false } + const tailBudget = historyBudget - inFlightTokens const last = this.messages.length - 1 if (last < 1) { @@ -1776,7 +1804,10 @@ export class AIChatManager implements ChatViewHost { const prefix = this.messages.slice(0, keepFrom) const tail = this.messages.slice(keepFrom) - if (prefix.length < MIN_PREFIX_MESSAGES_TO_SUMMARIZE || tail.length === 0) { + // Mid-turn, drop-oldest would cut context the running turn may rely on, so any + // prefix beyond a lone previous summary is worth the round-trip. + const minPrefix = inFlightTokens > 0 ? 2 : MIN_PREFIX_MESSAGES_TO_SUMMARIZE + if (prefix.length < minPrefix || tail.length === 0) { return false } @@ -1809,6 +1840,65 @@ export class AIChatManager implements ChatViewHost { return false } + /** + * Automatic compaction trigger, checked when a turn is sent and again before + * each tool-loop iteration, since one turn's tool results can carry the request + * past the window on their own. Rewrites the stored history (not the turn in + * flight) once `projectedTokens` reaches the trigger ratio of `model`'s window. + * Returns whether it rewrote; callers persist. An unrecognized model still gets + * the conservative assumed window rather than no limit: without one the context + * grows unbounded until the provider (or a proxy in front of it) times out. + * Guessing low only compacts earlier, which is always recoverable. + */ + private compactIfOverTrigger = async ( + projectedTokens: number, + model: ChatLoopConfig['modelProvider'] | undefined, + inFlightTokens = 0 + ): Promise<boolean> => { + const contextWindow = model + ? getEffectiveModelContextWindow( + model.provider, + model.model, + get(copilotInfo).contextWindowPerModel + ) + : undefined + if (contextWindow === undefined || projectedTokens < contextWindow * COMPACTION_TRIGGER_RATIO) { + return false + } + // Preferred path: summarize the older prefix, keep the recent tail. + const summarized = await this.summarizeAndCompact(contextWindow, inFlightTokens) + // A Stop during the in-flight summary aborts this turn's controller; + // summarizeAndCompact then returns false without touching history. Skip + // the drop-oldest fallback — it would destructively compact a conversation + // the user only meant to cancel, and the request can't run on an aborted + // controller anyway. The cancel path rolls the turn back cleanly on its own. + if (this.abortController?.signal.aborted) { + return false + } + if (!summarized) { + // Fallback when summarization isn't worthwhile or fails: drop the + // oldest messages. A report stays meaningful only debited by what + // was dropped; the estimate path needs no bookkeeping — the next + // read re-estimates the compacted history. chars/4 can + // underestimate the freed tokens, which errs toward compacting + // again — never toward overflowing. + const lengthBefore = this.messages.length + const freed = this.compactOldestMessages( + projectedTokens - contextWindow * COMPACTION_TARGET_RATIO + ) + if (this.messages.length === lengthBefore) { + return false + } + if (this.contextUsage !== undefined) { + this.contextUsage = Math.max(0, this.contextUsage - freed) + } + } + // Reconcile file registrations with the compacted transcript — the + // summary message carries the folded-away turns' files forward. + this.#syncMessageFiles() + return true + } + /** * Manual compaction (the `/compact` session command): summarize the ENTIRE * stored history into a single summary message and keep nothing verbatim, so @@ -2053,7 +2143,7 @@ export class AIChatManager implements ChatViewHost { toolId: string, form: RunFormDisplay, opts?: { autoAccepted?: boolean } - ): Promise<Record<string, any> | undefined> => { + ): Promise<RunFormAnswer | undefined> => { // The tool reads the schema before it asks, so a stop during that read drains the // callbacks and settles the card before this runs. Installing one then would park // the turn on a form the settled card no longer renders, leaving nothing able to @@ -2070,15 +2160,32 @@ export class AIChatManager implements ChatViewHost { // Ahead of the wait, not of the stop above: the caller settled this form before // attaching it, so its card renders no fields and nothing here could ever resolve. if (opts?.autoAccepted) { - return Promise.resolve(form.args) + return Promise.resolve({ kind: 'args' as const, args: form.args }) } // Seeded from the caller's copy, before the card renders: the file arguments on // `displayMessages` are redacted, so a draft built from those would open the form on // the marker rather than on the bytes the model proposed. const entry = this.#runFormEntry(toolId, form) - return new Promise((resolve) => { + const answer = new Promise<RunFormAnswer | undefined>((resolve) => { this.#runForms.set(toolId, { ...entry, resolve }) }) + // A tab already on this item's deployed page is where the reader would be sent anyway, + // so the call goes there without waiting to be asked: declining a proposal and having + // the agent propose another leaves that page open, and the second proposal belongs on + // it. Only a tab already there — opening one for every proposed run would take the + // panel away from whatever the reader is doing. After the resolve above is installed, + // since the page reads the call back through `isRunFormPending`. + if (form.kind === 'run' && form.path && !form.conversational) { + this.openRunOnDeployedPage?.({ + toolCallId: toolId, + kind: form.runnableKind === 'flow' ? 'flow' : 'script', + path: form.path, + summary: form.summary || form.path, + carries: true, + onlyIfAlreadyOpen: true + }) + } + return answer } /** @@ -2106,6 +2213,10 @@ export class AIChatManager implements ChatViewHost { markRunFormStarted = (toolId: string) => this.#patchRunForm(toolId, { started: true }) + // Nothing to patch on the card — it already carries the tool's own error — so this exists + // for the surfaces that confirmed the run and would otherwise wait forever for a job. + markRunFormEnded = (toolId: string) => this.closeRunForm?.(toolId) + // A form restored from history has an entry once it mounts, but no resolve: the loop // that opened it is gone. isRunFormPending = (toolId: string): boolean => !!this.#runForms.get(toolId)?.resolve @@ -2139,10 +2250,34 @@ export class AIChatManager implements ChatViewHost { * leaving its submit button spinning on a run that will never start. */ handleRunFormSubmit = (toolId: string, args: Record<string, any>): boolean => { if (!this.isRunFormPending(toolId)) return false - this.#settleRunForm(toolId, args) + this.#settleRunForm(toolId, { kind: 'args', args }) return true } + /** The reader ran the item on its own page; the tool follows that job rather than + * starting one. False when the call is no longer waiting. */ + handleRunFormJob = ( + toolId: string, + jobId: string, + args: Record<string, any>, + schema: Record<string, any> + ): boolean => { + if (!this.isRunFormPending(toolId)) return false + // Snapshotted for the same reason the draft is: these are `$state` proxies off the + // page's own form, which keeps editing them after the run. + this.#settleRunForm(toolId, { + kind: 'job', + jobId, + args: ($state.snapshot(args) ?? {}) as Record<string, any>, + schema: ($state.snapshot(schema) ?? {}) as Record<string, any> + }) + return true + } + + /** What a parked form is waiting on, for a surface opening its own form on the same call. */ + pendingRunFormArgs = (toolId: string): Record<string, any> | undefined => + this.#runForms.get(toolId)?.draft?.args + handleRunFormCancel = (toolId: string) => { // The card's own copy is settled here rather than only in the tool's fn, which a form // restored from history no longer has: Cancel is that card's one way out, and while it @@ -2165,9 +2300,10 @@ export class AIChatManager implements ChatViewHost { */ #settleRunForm = ( toolId: string, - submitted: Record<string, any> | undefined, + answer: RunFormAnswer | undefined, card?: (runForm: RunFormDisplay) => Partial<ToolDisplayMessage> ) => { + const submitted = answer?.args const entry = this.#runForms.get(toolId) // Its draft holds whatever was typed into the form, a minted password included. this.#runForms.delete(toolId) @@ -2181,7 +2317,7 @@ export class AIChatManager implements ChatViewHost { submitted ? { submitted: true } : { canceled: true }, cancelledArgs ? (runForm) => ({ ...card?.(runForm), parameters: cancelledArgs }) : card ) - entry?.resolve?.(submitted) + entry?.resolve?.(answer) } /** @@ -2209,10 +2345,14 @@ export class AIChatManager implements ChatViewHost { ) } - setAiChatInput(aiChatInput: AIChatInput | null) { + setAiChatInput(aiChatInput: AIChatInput) { this.aiChatInput = aiChatInput } + releaseAiChatInput(aiChatInput: AIChatInput) { + if (this.aiChatInput === aiChatInput) this.aiChatInput = null + } + /** Queue the message typed while a turn is streaming. There is only ever * one queued message; pressing Enter again appends the new text as another * line so it all goes out as a single message, and its images accumulate @@ -2706,6 +2846,9 @@ export class AIChatManager implements ChatViewHost { this.systemMessage = { ...target, content: `${target.content}\n\n${section}` } } + /** Resolved per send with the rest of the pre-flight; see `resolveResourceTypeSync`. */ + resourceTypeSync: ResourceTypeSync | undefined = undefined + // Same shape as refreshGlobalSkills. Re-resolved per send rather than once for the // session's life: the operating workspace can change between sends, and a transient // failure resolves fail-open, so the next message re-asks rather than keeping that answer. @@ -3113,7 +3256,8 @@ export class AIChatManager implements ChatViewHost { callbacks, addedMessages, systemMessage: systemMessageOverride, - onWebSearchUnavailable + onWebSearchUnavailable, + onBeforeIteration }: { messages: ChatCompletionMessageParam[] abortController: AbortController @@ -3125,6 +3269,7 @@ export class AIChatManager implements ChatViewHost { addedMessages?: ChatCompletionMessageParam[] systemMessage?: ChatCompletionSystemMessageParam onWebSearchUnavailable?: () => void + onBeforeIteration?: ChatLoopConfig['onBeforeIteration'] }) => { // Fresh batch for this turn — drop any images an aborted prior turn left buffered. this.pendingToolImages.clear() @@ -3212,7 +3357,8 @@ export class AIChatManager implements ChatViewHost { this.contextManager.getSelectedContext(), { workspace: this.operatingWorkspace, - activePreview: this.activePreviewResolver?.() + activePreview: this.activePreviewResolver?.(), + resourceTypeSync: this.resourceTypeSync } ) } @@ -3226,8 +3372,9 @@ export class AIChatManager implements ChatViewHost { console.error('Failed to record AI usage', e) } }, - onBeforeIteration: async (modelProvider) => { + onBeforeIteration: async (modelProvider, lastIterationUsage) => { this.lastIterationModel = modelProvider + await onBeforeIteration?.(modelProvider, lastIterationUsage) } }) if (this.isSessionChat && this.sessionId && result.tokenUsage.total > 0) { @@ -3742,7 +3889,10 @@ export class AIChatManager implements ChatViewHost { this.refreshGlobalSkills(this.operatingWorkspace ?? ''), this.refreshGlobalFolderInstructions(this.operatingWorkspace ?? ''), this.refreshMcpServers(this.operatingWorkspace ?? ''), - this.refreshSessionAccess(this.operatingWorkspace ?? '') + this.refreshSessionAccess(this.operatingWorkspace ?? ''), + resolveResourceTypeSync(this.operatingWorkspace ?? '', this.messages).then((sync) => { + this.resourceTypeSync = sync + }) ]) } // Stop/Escape during the beforeSend pre-flight aborted this send before any @@ -4039,7 +4189,8 @@ export class AIChatManager implements ChatViewHost { workspace: this.operatingWorkspace, activePreview: this.activePreviewResolver?.(), images: sentImages, - files: files + files: files, + resourceTypeSync: this.resourceTypeSync }) break case AIMode.APP: @@ -4073,61 +4224,24 @@ export class AIChatManager implements ChatViewHost { this.currentReasoningActive = false this.resetReasoningTiming() - // Compaction trigger. An unrecognized model still gets the conservative - // assumed window rather than no limit: without one the context grows - // unbounded until the provider (or a proxy in front of it) times out. - // Guessing low only compacts earlier, which is always recoverable. - const contextWindow = model - ? getEffectiveModelContextWindow( - model.provider, - model.model, - get(copilotInfo).contextWindowPerModel - ) - : undefined - if ( - contextWindow !== undefined && - projectedContextTokens >= contextWindow * COMPACTION_TRIGGER_RATIO - ) { - // Preferred path: summarize the older prefix, keep the recent tail. - const summarized = await this.summarizeAndCompact(contextWindow) - // A Stop during the in-flight summary aborts this turn's controller; - // summarizeAndCompact then returns false without touching history. Skip - // the drop-oldest fallback (and its save) — it would destructively - // compact a conversation the user only meant to cancel, and the request - // can't run on an aborted controller anyway. The cancel path below rolls - // the pushed turn back cleanly on its own. - if (!this.abortController?.signal.aborted) { - if (!summarized) { - // Fallback when summarization isn't worthwhile or fails: drop the - // oldest messages. A report stays meaningful only debited by what - // was dropped; the estimate path needs no bookkeeping — the next - // read re-estimates the compacted history. chars/4 can - // underestimate the freed tokens, which errs toward compacting - // again — never toward overflowing. - const freed = this.compactOldestMessages( - projectedContextTokens - contextWindow * COMPACTION_TARGET_RATIO - ) - if (this.contextUsage !== undefined) { - this.contextUsage = Math.max(0, this.contextUsage - freed) - } - } - // Reconcile file registrations with the compacted transcript — the - // summary message carries the folded-away turns' files forward. - this.#syncMessageFiles() - await this.historyManager.saveChat( - this.displayMessages, - this.messages, - this.contextUsage, - this.modifiedItems ? [...this.modifiedItems] : undefined - ) - } + if (await this.compactIfOverTrigger(projectedContextTokens, model)) { + await this.historyManager.saveChat( + this.displayMessages, + this.messages, + this.contextUsage, + this.modifiedItems ? [...this.modifiedItems] : undefined + ) } // Rollback anchors for restoreUnsentTurn: captured after compaction so // they index into the (possibly compacted) stored history. The summary // path shrinks displayMessages too, so the display anchor must also be // read here, not before compaction. + // Mid-turn compaction leaves these stale, but it runs only once the turn has + // tool-paired output, which keeps the turn off the restore path. const modelLenAfterUser = this.messages.length const displayLenAfterUser = this.displayMessages.length + // Set when a mid-turn compaction makes the latest provider report stale. + let usageBeforeCompaction: ChatTokenUsage | null = null const params: { messages: ChatCompletionMessageParam[] @@ -4230,6 +4344,7 @@ export class AIChatManager implements ChatViewHost { requestUserQuestion: this.requestUserQuestion, requestRunArgs: this.requestRunArgs, markRunFormStarted: this.markRunFormStarted, + markRunFormEnded: this.markRunFormEnded, onItemModified: (kind, path) => this.recordModifiedItem(kind, path), onItemDeployed: (kind, from, to) => void this.renameModifiedItem(kind, from, to), onItemDiscarded: (kind, path) => void this.removeModifiedItem(kind, path), @@ -4254,6 +4369,46 @@ export class AIChatManager implements ChatViewHost { addedMessages: collectedMessages, onWebSearchUnavailable: () => { webSearchUnavailable = true + }, + onBeforeIteration: async (iterationModel, lastIterationUsage) => { + // The first iteration sends exactly what the send-time check measured. + if (collectedMessages.length === 0) return + // The last report's prompt covers everything before its assistant message. + // That message counts as its `completion` only on Anthropic, which replays + // its thinking verbatim (`_anthropicContent`); other providers bill reasoning + // the next request does not replay, so it is estimated. The estimate is + // the floor in case a later iteration's report never arrived. A report + // from before a compaction measured the history it replaced. + const lastAssistant = collectedMessages.findLastIndex((m) => m.role === 'assistant') + const replaysOutput = !!(collectedMessages[lastAssistant] as any)?._anthropicContent + const reported = + lastIterationUsage && lastIterationUsage !== usageBeforeCompaction + ? lastIterationUsage.prompt + + (replaysOutput ? lastIterationUsage.completion : 0) + + this.estimateMessagesTokens( + collectedMessages.slice(replaysOutput ? lastAssistant + 1 : lastAssistant) + ) + : 0 + const historyTokens = this.estimateWholeContextTokens() + // Whatever the report sees beyond the stored history is the turn's, replayed + // thinking included, so the summary leaves room for it. + const inFlightTokens = Math.max( + this.estimateMessagesTokens(collectedMessages), + reported - historyTokens + ) + const projected = Math.max(reported, historyTokens + inFlightTokens) + if (await this.compactIfOverTrigger(projected, iterationModel, inFlightTokens)) { + usageBeforeCompaction = lastIterationUsage + // The loop sends from this array, which mirrors the stored history + // plus the turn so far. + params.messages.splice( + 0, + params.messages.length, + ...this.messages, + ...collectedMessages + ) + await checkpointTurn(true) + } } }) stopCheckpoints() @@ -4275,9 +4430,10 @@ export class AIChatManager implements ChatViewHost { // stored history it was sent with (the kept partial tail is a small // undercount the trigger headroom absorbs). Without one, clear the // stale value — readers estimate via contextTokens. - this.contextUsage = result?.lastIterationUsage - ? result.lastIterationUsage.prompt + result.lastIterationUsage.completion - : undefined + this.contextUsage = + result?.lastIterationUsage && result.lastIterationUsage !== usageBeforeCompaction + ? result.lastIterationUsage.prompt + result.lastIterationUsage.completion + : undefined await this.historyManager.saveChat( this.displayMessages, this.messages, @@ -4346,9 +4502,10 @@ export class AIChatManager implements ChatViewHost { // stored — no anchoring or index bookkeeping needed. Without a // report, clear the now-stale value — readers estimate via // contextTokens. - this.contextUsage = result?.lastIterationUsage - ? result.lastIterationUsage.prompt + result.lastIterationUsage.completion - : undefined + this.contextUsage = + result?.lastIterationUsage && result.lastIterationUsage !== usageBeforeCompaction + ? result.lastIterationUsage.prompt + result.lastIterationUsage.completion + : undefined if (this.autoAcceptEditsActive) { this.acceptPendingFlowEdits() } diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index 19d432bd37..1fd92aa67d 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -114,7 +114,16 @@ vi.mock('$lib/stores', () => { // non-membership once this has resolved. usersWorkspaceStore: readable({ workspaces: [] as unknown[] }), NON_MEMBER_USERNAME: 'superadmin', - enterpriseLicense: readable(undefined) + enterpriseLicense: readable(undefined), + // The admin above holds every role capability. + roleCapabilities: readable({ + code: true, + flows: 'full', + apps: 'full', + manage_schedules: true, + manage_triggers: true, + visibility: {} + }) } }) @@ -330,6 +339,54 @@ describe('AIChatManager run form', () => { }) }) + // Only a deployed item has a page with a form to confirm on: a test run executes the + // draft being written, and a chat-enabled flow's page is a conversation. Routing either + // there would park the call on a page that can never answer it. + it('hands a deployed run to a page already open, and nothing else', async () => { + const manager = new AIChatManager() + const routed: any[] = [] + manager.openRunOnDeployedPage = (a) => { + routed.push(a) + return true + } + + void manager.requestRunArgs('call_run', { + path: 'f/a/b', + kind: 'run', + runnableKind: 'flow', + summary: 'Plain flow', + schema: {}, + args: {} + }) + void manager.requestRunArgs('call_test', { + path: 'f/a/b', + kind: 'test', + schema: {}, + args: {} + }) + void manager.requestRunArgs('call_chat', { + path: 'f/a/c', + kind: 'run', + conversational: true, + schema: {}, + args: {} + }) + + expect(routed).toEqual([ + { + toolCallId: 'call_run', + kind: 'flow', + path: 'f/a/b', + summary: 'Plain flow', + carries: true, + // Never opens a tab of its own: the reader did not ask for one here. + onlyIfAlreadyOpen: true + } + ]) + // Routed after the form is installed, so the page finds the call the moment it mounts. + expect(manager.isRunFormPending('call_run')).toBe(true) + }) + // Stopping the turn is the form's other way out, and it settles cards through // settledToolDisplay rather than through #settleRunForm. it('settles a stopped form on what it held too', () => { @@ -3346,6 +3403,186 @@ describe('AIChatManager context compaction', () => { manager.instructions = 'next question' } + it('compacts between tool iterations once tool results carry the turn over the trigger', async () => { + mocks.getCurrentModel.mockReturnValue(gpt4oModel) + mocks.tryGetCurrentModel.mockReturnValue(gpt4oModel) + mocks.getStreamedCompletionText + .mockResolvedValueOnce('<summary>SUMMARY ONE</summary>') + .mockResolvedValueOnce('<summary>SUMMARY TWO</summary>') + const step = (id: string, chars: number) => [ + { + role: 'assistant' as const, + content: '', + tool_calls: [{ id, type: 'function', function: { name: 'x', arguments: '{}' } }] + }, + { role: 'tool' as const, tool_call_id: id, content: 'r'.repeat(chars) } + ] + // ~25k estimated tokens: enough to push a ~60% history past the 0.8 * 128k trigger. + const first = step('t1', 100_000) + // ~70k more: crosses the trigger again within the same turn. + const second = step('t2', 280_000) + let secondIteration: any[] = [] + let thirdIteration: any[] = [] + mocks.runChatLoop.mockImplementation(async (config: any) => { + const push = (ms: any[]) => { + for (const m of ms) { + config.messages.push(m) + config.addedMessages.push(m) + } + } + await config.onBeforeIteration(gpt4oModel, null) + push(first) + await config.onBeforeIteration(gpt4oModel, null) + secondIteration = [...config.messages] + push(second) + await config.onBeforeIteration(gpt4oModel, null) + thirdIteration = [...config.messages] + const reply = { role: 'assistant' as const, content: 'done' } + push([reply]) + return { + addedMessages: [...first, ...second, reply], + tokenUsage: { prompt: 0, completion: 0, total: 0 }, + lastIterationUsage: null, + hitMaxIterations: false + } + }) + const manager = new AIChatManager() + // ~80k estimated tokens over ten messages: under the trigger at send time. + manager.messages = Array.from({ length: 10 }, (_, i) => ({ + role: i % 2 === 0 ? ('user' as const) : ('assistant' as const), + content: `m${i}` + 'x'.repeat(32_000) + })) + manager.displayMessages = manager.messages.map((m, i) => + m.role === 'user' + ? { role: 'user', content: `m${i}`, index: i } + : { role: 'assistant', content: `m${i}` } + ) + manager.instructions = 'next question' + + await manager.sendRequest() + + // Not at send time (under the trigger), only once the tool result landed. + expect(secondIteration[0].content).toContain('SUMMARY ONE') + expect(secondIteration.slice(-2)).toEqual(first) + // The second trigger folds the first summary into a new one instead of + // dropping it to make room for the turn. + expect(mocks.getStreamedCompletionText).toHaveBeenCalledTimes(2) + expect(mocks.getStreamedCompletionText.mock.calls[1][0][0].content).toContain('SUMMARY ONE') + expect(thirdIteration[0].content).toContain('SUMMARY TWO') + expect(thirdIteration.slice(-4)).toEqual([...first, ...second]) + expect(manager.messages[0].content).toContain('SUMMARY TWO') + expect(manager.displayMessages[0].role).toBe('summary') + }) + + it('compacts mid-turn off the provider report, then stops trusting the report it replaced', async () => { + mocks.getCurrentModel.mockReturnValue(gpt4oModel) + mocks.tryGetCurrentModel.mockReturnValue(gpt4oModel) + mocks.getStreamedCompletionText.mockResolvedValue('<summary>SUMMARY ONE</summary>') + const step = (id: string) => [ + { + role: 'assistant' as const, + content: '', + tool_calls: [{ id, type: 'function', function: { name: 'x', arguments: '{}' } }] + }, + { role: 'tool' as const, tool_call_id: id, content: 'r'.repeat(8_000) } + ] + // The provider counts far more than chars/4 sees: over the 0.8 * 128k trigger. + const report = { prompt: 101_000, completion: 1_000, total: 102_000 } + let thirdIteration: any[] = [] + mocks.runChatLoop.mockImplementation(async (config: any) => { + const push = (ms: any[]) => { + for (const m of ms) { + config.messages.push(m) + config.addedMessages.push(m) + } + } + await config.onBeforeIteration(gpt4oModel, null) + push(step('t1')) + await config.onBeforeIteration(gpt4oModel, report) + push(step('t2')) + // The iteration after the compaction reported nothing, so the loop hands the + // pre-compaction report back. + await config.onBeforeIteration(gpt4oModel, report) + thirdIteration = [...config.messages] + const reply = { role: 'assistant' as const, content: 'done' } + push([reply]) + return { + addedMessages: [...step('t1'), ...step('t2'), reply], + tokenUsage: report, + lastIterationUsage: report, + hitMaxIterations: false + } + }) + const manager = new AIChatManager() + // ~75k estimated tokens: under the trigger by the estimate alone. + manager.messages = Array.from({ length: 10 }, (_, i) => ({ + role: i % 2 === 0 ? ('user' as const) : ('assistant' as const), + content: `m${i}` + 'x'.repeat(30_000) + })) + manager.displayMessages = manager.messages.map((m, i) => + m.role === 'user' + ? { role: 'user', content: `m${i}`, index: i } + : { role: 'assistant', content: `m${i}` } + ) + manager.instructions = 'next question' + + await manager.sendRequest() + + expect(mocks.getStreamedCompletionText).toHaveBeenCalledTimes(1) + expect(thirdIteration[0].content).toContain('SUMMARY ONE') + // Nor does the stale report become the stored usage at commit. + expect(manager.contextUsage).toBeUndefined() + }) + + it('counts replayed Anthropic thinking toward the mid-turn projection', async () => { + mocks.getCurrentModel.mockReturnValue(gpt4oModel) + mocks.tryGetCurrentModel.mockReturnValue(gpt4oModel) + mocks.getStreamedCompletionText.mockResolvedValue('<summary>SUMMARY ONE</summary>') + // Under the 0.8 * 128k trigger without the thinking the next request replays. + const report = { prompt: 80_000, completion: 25_000, total: 105_000 } + let historyAfter: any[] = [] + mocks.runChatLoop.mockImplementation(async (config: any) => { + await config.onBeforeIteration(gpt4oModel, null) + const assistant = { + role: 'assistant' as const, + content: '', + tool_calls: [{ id: 't1', type: 'function', function: { name: 'x', arguments: '{}' } }], + _anthropicContent: [{ type: 'thinking', thinking: '...', signature: 'sig' }] + } + const tool = { role: 'tool' as const, tool_call_id: 't1', content: 'r'.repeat(4_000) } + for (const m of [assistant, tool]) { + config.messages.push(m) + config.addedMessages.push(m) + } + await config.onBeforeIteration(gpt4oModel, report) + historyAfter = config.messages.slice(0, -2) + return { + addedMessages: [assistant, tool], + tokenUsage: report, + lastIterationUsage: report, + hitMaxIterations: false + } + }) + const manager = new AIChatManager() + manager.messages = Array.from({ length: 10 }, (_, i) => ({ + role: i % 2 === 0 ? ('user' as const) : ('assistant' as const), + content: `m${i}` + 'x'.repeat(30_000) + })) + manager.displayMessages = manager.messages.map((m, i) => + m.role === 'user' + ? { role: 'user', content: `m${i}`, index: i } + : { role: 'assistant', content: `m${i}` } + ) + manager.instructions = 'next question' + + await manager.sendRequest() + + expect(mocks.getStreamedCompletionText).toHaveBeenCalledTimes(1) + // The kept history leaves room for the replayed thinking within the 0.5 target. + const keptTokens = historyAfter.reduce((acc, m) => acc + String(m.content).length / 4, 0) + expect(keptTokens + report.completion).toBeLessThan(128_000 * 0.5) + }) + it('summarizes the older prefix and keeps the recent tail verbatim', async () => { mocks.getCurrentModel.mockReturnValue(gpt4oModel) mocks.tryGetCurrentModel.mockReturnValue(gpt4oModel) diff --git a/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte b/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte index d11375b89b..5f4b4a5040 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte @@ -240,6 +240,9 @@ const config = $derived<ChatModelSettingsConfig>({ label: providerModel.model, + // "claude-sonnet-4-6" → "sonnet": the family is what tells Claude models apart at a + // glance. Other providers' names have no such word, so they stay whole. + shortLabel: /^claude-([a-z]+)-/.exec(providerModel.model)?.[1], title: 'Model & reasoning settings', badge: freeTier && !freeTier.exhausted ? { text: 'Free', warn: freeRunningLow } : undefined, // Off in a session: the assistant settings modal's Instructions section owns the diff --git a/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte b/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte index 3050dcedf3..ce931213b5 100644 --- a/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte +++ b/frontend/src/lib/components/copilot/chat/AssistantMessage.svelte @@ -98,6 +98,7 @@ }) const candidatePaths = $derived(extractCandidatePaths(message.content)) + const reasoningPlugins = [gfmPlugin(), { renderer: { a: LinkRenderer } }] const rendererPlugin = { renderer: { pre: CodeDisplay, @@ -158,7 +159,7 @@ labelClass="truncate" contentClass="font-main text-secondary {markdownProse.xs}" > - <Markdown md={reasoning} plugins={[gfmPlugin()]} /> + <Markdown md={reasoning} plugins={reasoningPlugins} /> </ChatCollapsibleCard> {/if} diff --git a/frontend/src/lib/components/copilot/chat/AssistantPermissionsSection.svelte b/frontend/src/lib/components/copilot/chat/AssistantPermissionsSection.svelte index ee7b4a689c..c24b7097ee 100644 --- a/frontend/src/lib/components/copilot/chat/AssistantPermissionsSection.svelte +++ b/frontend/src/lib/components/copilot/chat/AssistantPermissionsSection.svelte @@ -3,7 +3,7 @@ The Permissions section of the assistant settings modal: what the user's role lets them create and edit in the chat's workspace, next to what the assistant can make for them from the chat. The assistant acts with the user's own rights, but its write tools go through drafts, so for -operators the second column is narrower. +a restricted role the second column can be narrower. --> <script lang="ts"> import { Badge, Section } from '$lib/components/common' @@ -11,6 +11,7 @@ operators the second column is narrower. import { userWorkspaces } from '$lib/stores' import { useActingUser } from '$lib/actingUser.svelte' import { capitalize } from '$lib/utils/capitalize' + import { capabilitiesIn, isRestricted } from '$lib/roleCapabilities' import { OPERATOR_SETTINGS_LOCATION, permissionRows, roleName } from './assistantPermissions' let { ws }: { ws: string } = $props() @@ -18,9 +19,11 @@ operators the second column is narrower. const acting = useActingUser(() => ws) let user = $derived(acting.current) - let operatorSettings = $derived($userWorkspaces.find((w) => w.id === ws)?.operator_settings) - let role = $derived(user ? roleName(user) : undefined) - let rows = $derived(user ? permissionRows(user, operatorSettings) : []) + let entry = $derived($userWorkspaces.find((w) => w.id === ws)) + let caps = $derived(capabilitiesIn(user, entry)) + let restricted = $derived(isRestricted(caps)) + let role = $derived(user ? roleName(user, entry) : undefined) + let rows = $derived(user ? permissionRows(user, caps) : []) let chatNarrower = $derived(rows.some((r) => r.user && !r.assistant)) </script> @@ -31,7 +34,7 @@ operators the second column is narrower. > {#snippet action()} {#if role} - <Badge color={role === 'operator' ? 'yellow' : 'gray'}>{capitalize(role)}</Badge> + <Badge color={restricted ? 'yellow' : 'gray'}>{capitalize(role)}</Badge> {/if} {/snippet} @@ -76,9 +79,9 @@ operators the second column is narrower. Items in folders you can't read are hidden from you and from the assistant. Someone with access to the folder can run them. </p> - {#if role === 'operator'} + {#if restricted} <p> - A workspace admin can give operators more rights in {OPERATOR_SETTINGS_LOCATION}. + A workspace admin can give your role more rights in {OPERATOR_SETTINGS_LOCATION}. </p> {/if} </div> diff --git a/frontend/src/lib/components/copilot/chat/AutonomyModePicker.svelte b/frontend/src/lib/components/copilot/chat/AutonomyModePicker.svelte new file mode 100644 index 0000000000..f63db32d0e --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/AutonomyModePicker.svelte @@ -0,0 +1,51 @@ +<script lang="ts"> + import { ChevronDown } from 'lucide-svelte' + import { Button } from '$lib/components/common' + import DropdownV2 from '$lib/components/DropdownV2.svelte' + import type { AIAutonomyMode } from './AIChatManager.svelte' + import { + autonomyModeOption, + availableAutonomyModeOptions, + resolveAutonomyMode, + type AutonomyAvailability + } from './autonomyModes' + + let { + mode, + availability, + onChange + }: { + mode: AIAutonomyMode + availability: AutonomyAvailability + onChange: (mode: AIAutonomyMode) => void + } = $props() + + const effective = $derived(resolveAutonomyMode(mode, availability)) + const option = $derived(autonomyModeOption(effective)) +</script> + +<DropdownV2 + items={() => + availableAutonomyModeOptions(availability).map((o) => ({ + displayName: o.label, + selected: effective === o.mode, + action: () => onChange(o.mode) + }))} + placement="bottom-start" + fixedHeight={false} + customWidth={240} +> + {#snippet buttonReplacement()} + <Button + nonCaptureEvent + unifiedSize="2xs" + variant="subtle" + title={option.tooltip(availability)} + btnClasses={option.triggerClass ?? ''} + startIcon={{ icon: option.icon, classes: option.iconColor }} + endIcon={{ icon: ChevronDown }} + > + {option.shortLabel ?? option.label} + </Button> + {/snippet} +</DropdownV2> diff --git a/frontend/src/lib/components/copilot/chat/ComposerPlaceholder.svelte b/frontend/src/lib/components/copilot/chat/ComposerPlaceholder.svelte new file mode 100644 index 0000000000..2aba3d0be4 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/ComposerPlaceholder.svelte @@ -0,0 +1,115 @@ +<script lang="ts"> + import { ArrowUp } from 'lucide-svelte' + import { twMerge } from 'tailwind-merge' + import { Button } from '$lib/components/common' + import autosize from '$lib/autosize' + import { CHAT_INPUT_PADDING } from './aiChatManagerContext' + import type { Snippet } from 'svelte' + import { + composerBoxClass, + COMPOSER_FIELD_RESET, + HERO_BOX_CLASS, + type ComposerSelection + } from './composerBox' + + /** + * The chat composer's box, drawn while the chat code loads. It must import nothing of + * AIChat's: it exists so the page can show and type into the composer before that code + * arrives. The host passes `value` and the last selection to the real composer when it mounts. + */ + let { + value = $bindable(), + placeholder, + disabled = false, + autofocus = false, + onSelectionChange, + onCompositionChange, + toolbar + }: { + value: string + placeholder?: string + disabled?: boolean + autofocus?: boolean + /** The caret while the field has focus, undefined once the user moves to another element. */ + onSelectionChange?: (selection: ComposerSelection | undefined) => void + /** Whether an IME composition is in progress: replacing the field then would lose it. */ + onCompositionChange?: (composing: boolean) => void + /** Draws the box that opens a session (AIChatInput's `hero`), its controls row in the box. */ + toolbar?: Snippet + } = $props() + + function trackSelection(e: Event & { currentTarget: HTMLTextAreaElement }) { + onSelectionChange?.({ + start: e.currentTarget.selectionStart, + end: e.currentTarget.selectionEnd + }) + } +</script> + +{#snippet sendButton(size: 'sm' | 'md')} + <Button + variant="subtle" + unifiedSize={size} + iconOnly + title="Send" + startIcon={{ icon: ArrowUp }} + disabled + /> +{/snippet} + +<div class="relative mt-1"> + <div class={twMerge(composerBoxClass(disabled), toolbar && HERO_BOX_CLASS)}> + <div class="relative w-full"> + <!-- Text metrics match ContextTextarea's `.textarea-input`, so the swap moves nothing. --> + <textarea + {@attach (node) => { + if (autofocus) node.focus() + }} + bind:value + use:autosize={toolbar ? { maxHeight: 160, minHeight: 56 } : { maxHeight: '40vh' }} + rows={1} + {placeholder} + {disabled} + class={twMerge( + 'w-full resize-none overflow-clip whitespace-pre-wrap break-words text-xs leading-[1.72] min-h-[1.9rem]', + COMPOSER_FIELD_RESET, + CHAT_INPUT_PADDING, + toolbar && '!pl-4 !pr-4 !pt-3 !pb-1' + )} + onfocus={trackSelection} + oninput={trackSelection} + onselect={trackSelection} + onkeyup={trackSelection} + onpointerup={trackSelection} + onblur={(e) => { + // Unmounting the focused field blurs it, while still connected; that is the hand-off, + // not the user leaving. The field is detached by the time a microtask runs. A window + // blur leaves the field the active element, so it is not the user leaving either. + const field = e.currentTarget + queueMicrotask(() => { + if (field.isConnected && document.activeElement !== field) { + onSelectionChange?.(undefined) + } + }) + }} + oncompositionstart={() => onCompositionChange?.(true)} + oncompositionend={() => onCompositionChange?.(false)} + onkeydown={(e) => { + // Enter sends in the real composer; here it must not add a newline to the draft. + if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) e.preventDefault() + }} + ></textarea> + {#if !toolbar} + <div class="absolute bottom-1 right-1"> + {@render sendButton('md')} + </div> + {/if} + </div> + {#if toolbar} + <div class="flex flex-row items-center gap-1.5 px-3 pb-1.5"> + <div class="flex-1 min-w-0">{@render toolbar()}</div> + {@render sendButton('sm')} + </div> + {/if} + </div> +</div> diff --git a/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte b/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte index dd3a011da2..8895ad1ed0 100644 --- a/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte +++ b/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte @@ -1,5 +1,12 @@ <script lang="ts"> - import { composerBoxClass, COMPOSER_FIELD_RESET } from './composerBox' + import { + composerBoxClass, + COMPOSER_FIELD_RESET, + HERO_BOX_CLASS, + type ComposerSelection + } from './composerBox' + import { smoothHeight } from './starter/smoothHeight' + import { MessagesSquare } from 'lucide-svelte' import autosize from '$lib/autosize' import { tick, type Snippet } from 'svelte' import type { ContextElement } from './context' @@ -50,6 +57,14 @@ onKeyDown?: (e: KeyboardEvent) => void /** Rendered inside the input box, above the textarea (e.g. context chips). */ leading?: Snippet + /** Rendered inside the input box, below the textarea (the composer's controls). */ + trailing?: Snippet + /** The taller box that opens a session, matching Home's composer. */ + hero?: boolean + /** One row: the field and `trailing` side by side, for Home's composer at rest. */ + oneLine?: boolean + /** The assistant's chat mark before the field. */ + chatIcon?: boolean } let { @@ -66,7 +81,11 @@ onTextFiles, className = '', onKeyDown = undefined, - leading + leading, + trailing, + hero = false, + oneLine = false, + chatIcon = false }: Props = $props() const aiChatManager = getAiChatManager() @@ -761,8 +780,11 @@ prevMentionedTitles = cur }) - export function focus() { + export function focus(selection?: ComposerSelection) { textarea?.focus() + if (selection) { + textarea?.setSelectionRange(selection.start, selection.end) + } } // Wipe after dispatching a send: pre-zero `prevMentionedTitles` so the effect @@ -789,11 +811,35 @@ } </script> -<div class={composerBoxClass(disabled)}> - <!-- Context chips live inside the input box, above the textarea. The snippet +<!-- The box that opens a session grows and shrinks between its one-line and full shapes. --> +<div + class={twMerge(composerBoxClass(disabled), hero && HERO_BOX_CLASS)} + use:smoothHeight={{ enabled: hero, key: oneLine }} +> + <div class={oneLine ? 'flex flex-row items-center' : ''}> + <!-- Context chips live inside the input box, above the textarea. The snippet self-guards (renders nothing when empty) so no blank row appears. --> - {@render leading?.()} - <div class="relative w-full"> + {@render leading?.()} + {#if chatIcon} + <!-- The same mark as the "open in chat" buttons, centred on the field's first text line in + both shapes: the slot is that line (1px border + 12px padding down, 20px tall), so it + relies on the field's `!py-3`/`!pt-3` and the `leading-5` set alongside `chatIcon`. + Centring on the field box instead follows its autosized height, which varies. --> + <div class="flex items-start {oneLine ? 'flex-1 min-w-0' : ''}"> + <span class="flex items-center h-5 mt-[13px] ml-4 shrink-0"> + <MessagesSquare size={16} class="text-ai" /> + </span> + {@render field()} + </div> + {:else} + {@render field()} + {/if} + {@render trailing?.()} + </div> +</div> + +{#snippet field()} + <div class="relative w-full {oneLine ? 'flex-1 min-w-0' : ''}"> <div class={twMerge( 'textarea-input absolute inset-0 overflow-hidden pointer-events-none', @@ -811,7 +857,7 @@ bind:this={textarea} onkeydown={handleKeyDown} bind:value - use:autosize={{ maxHeight: '40vh' }} + use:autosize={hero && !oneLine ? { maxHeight: 160, minHeight: 56 } : { maxHeight: '40vh' }} rows={1} oninput={handleInput} onpaste={handlePaste} @@ -837,7 +883,9 @@ }} {placeholder} class={twMerge( - 'textarea-input resize-none caret-black dark:caret-white overflow-clip', + // `no-default-style`: the global input rule paints `surface-input` with `!important` + // and outranks `!bg-transparent`, so the field showed as a band on a hero box. + 'textarea-input no-default-style block resize-none caret-black dark:caret-white overflow-clip', COMPOSER_FIELD_RESET, CHAT_INPUT_PADDING, className @@ -846,7 +894,7 @@ {disabled} ></textarea> </div> -</div> +{/snippet} {#if showContextTooltip || showCommandTooltip} <Portal target="body"> diff --git a/frontend/src/lib/components/copilot/chat/JobsSegment.svelte b/frontend/src/lib/components/copilot/chat/JobsSegment.svelte index 31fce8fd6e..c56a3f1b05 100644 --- a/frontend/src/lib/components/copilot/chat/JobsSegment.svelte +++ b/frontend/src/lib/components/copilot/chat/JobsSegment.svelte @@ -6,7 +6,6 @@ import SessionStatusPopover from '$lib/components/sessions/SessionStatusPopover.svelte' import { zIndexes } from '$lib/zIndexes' import JobStatusIcon from '$lib/components/runs/JobStatusIcon.svelte' - import FlowStatusWaitingForEvents from '$lib/components/FlowStatusWaitingForEvents.svelte' import { ChevronUp, Hourglass, ThumbsUp, TimerOff } from 'lucide-svelte' import { slide } from 'svelte/transition' import { JobService, type Job } from '$lib/gen' @@ -244,6 +243,11 @@ let approvalOpen = $state(false) let approvalLabel = $state('') let approvalJob = $state<Job | undefined>(undefined) + // Loaded with the job, not with this segment: the approval form reaches the code editor, and + // the sessions page renders this segment on load. + let FlowStatusWaitingForEvents = $state.raw< + typeof import('$lib/components/FlowStatusWaitingForEvents.svelte').default | undefined + >(undefined) async function openApproval(job: ChatJob) { approvalLabel = job.label @@ -252,12 +256,16 @@ try { // The stored snapshot is trimmed; the approval form needs the full job // (flow_status + raw_flow), so fetch it fresh. - const full = await JobService.getJob({ - workspace: job.workspace, - id: job.jobId, - noLogs: true, - noCode: true - }) + const [full, form] = await Promise.all([ + JobService.getJob({ + workspace: job.workspace, + id: job.jobId, + noLogs: true, + noCode: true + }), + import('$lib/components/FlowStatusWaitingForEvents.svelte') + ]) + FlowStatusWaitingForEvents = form.default if (deriveChatJobStatus(full) === 'suspended') { approvalJob = full } else { @@ -383,7 +391,7 @@ bind:open={approvalOpen} minZIndex={zIndexes.monacoEditorSuggestions + 1} > - {#if approvalJob} + {#if approvalJob && FlowStatusWaitingForEvents} <FlowStatusWaitingForEvents job={approvalJob} workspaceId={approvalJob.workspace_id} diff --git a/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte b/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte index 4b3fa0b5be..b58ea838bf 100644 --- a/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte +++ b/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte @@ -223,14 +223,31 @@ const autoTab = $derived(hasOutcome ? 'outcome' : ran ? 'logs' : 'input') const activeTab = $derived(steered && tabs.some((t) => t.value === steered) ? steered : autoTab) + // The panel mounts the chat's own form on this call, so the card must not mount a second + // one: two views binding the one draft would each reorder the schema SchemaForm edits in + // place. Only the form is exclusive. A run shows in both, and collapsing the row or + // closing the tab is the user's own way out of seeing it twice. + const formInPreview = $derived( + pending && (aiChatManager.isRunFormTabOpen?.(message.tool_call_id) ?? false) + ) + // A deployed page holding the call builds its own form from a copy of the proposal, so it + // shares no draft and this card keeps a working one. The row folds instead, and expanding + // it is how the reader runs from the transcript without going to the page. + const carriedByPage = $derived( + pending && !formInPreview && (aiChatManager.isRunFormInPreview?.(message.tool_call_id) ?? false) + ) + // Keyed by call id: a bare flag would carry one card's collapse onto the next message // reusing this instance. Open by default, since the run is what was asked for — // except for an inspection, which is usually a step in the reasoning rather than // the answer, and which pays a fetch for being opened. A run cancelled before it started - // is closed too: the row already says Cancelled, and there is no run to show. + // is closed too: the row already says Cancelled, and there is no run to show. So is a call + // whose page is on screen, which is the copy the reader is looking at. let toggled = $state<{ id: string; open: boolean } | undefined>(undefined) const expanded = $derived( - toggled?.id === message.tool_call_id ? toggled.open : !inspected && (ran || !canceled) + toggled?.id === message.tool_call_id + ? toggled.open + : !inspected && (ran || !canceled) && !carriedByPage ) $effect(() => { @@ -285,14 +302,6 @@ } }) - // The panel mounts the chat's own form on this call, so the card must not mount a second - // one: two views binding the one draft would each reorder the schema SchemaForm edits in - // place. Only the form is exclusive. A run shows in both, and collapsing the row or - // closing the tab is the user's own way out of seeing it twice. - const formInPreview = $derived( - pending && (aiChatManager.isRunFormInPreview?.(message.tool_call_id) ?? false) - ) - // The three moments a run moves the card — a tab arrives, the selection follows it, the body // changes under it — are staggered rather than landing in one frame, which is what makes the // card followable. A tab already on screen when the card mounts never arrived, so a settled @@ -420,21 +429,36 @@ // waiting on one, the run once a job exists. Neither, and there is nothing to open, so // the button is not drawn at all — a form has nowhere to go outside a session, and a // call cancelled before Run never became a run. + // A deployed run can be confirmed on the item's own page instead of on this card: the page + // takes over the call, so Run there is Run here. A test run has no deployed page — it + // executes the draft being written — so it keeps the chat's own form tab. + const asDeployedPage = $derived( + pending && runForm?.kind === 'run' && !!path && !!aiChatManager.openRunOnDeployedPage + ) const previewTarget = $derived( pending - ? aiChatManager.openRunForm - ? ('form' as const) - : undefined + ? asDeployedPage + ? ('deployed' as const) + : aiChatManager.openRunForm + ? ('form' as const) + : undefined : job ? ('run' as const) : undefined ) + // A conversational flow's page has no run form, so opening it is all it can offer: the + // run stays on this card, where there is one to confirm. + const carries = $derived(asDeployedPage && !runForm?.conversational) const previewTitle = $derived( previewTarget === 'form' ? `Open this form in the preview panel: ${path}` - : aiChatManager.openRunInPreview - ? `Open this run in the preview panel: ${path || runnableName}` - : `Open this run in a new tab: ${path || runnableName}` + : previewTarget === 'deployed' + ? carries + ? `Open the deployed ${runnableKind === 'flow' ? 'flow' : 'script'} and confirm this run there: ${path}` + : `Open the deployed ${runnableKind === 'flow' ? 'flow' : 'script'}: ${path}` + : aiChatManager.openRunInPreview + ? `Open this run in the preview panel: ${path || runnableName}` + : `Open this run in a new tab: ${path || runnableName}` ) function openPreview() { @@ -443,6 +467,16 @@ aiChatManager.openRunForm?.({ toolCallId: message.tool_call_id, label }) return } + if (previewTarget === 'deployed') { + aiChatManager.openRunOnDeployedPage?.({ + toolCallId: message.tool_call_id, + kind: runnableKind === 'flow' ? 'flow' : 'script', + path, + summary: label, + carries + }) + return + } if (!job) return // Outside a session there is no panel, so the run opens where the jobs tray sends it. if (aiChatManager.openRunInPreview) { @@ -476,6 +510,7 @@ title={previewTitle} onOpen={openPreview} kindIcon={false} + mode={previewTarget === 'deployed' ? 'view' : 'edit'} /> {/snippet} diff --git a/frontend/src/lib/components/copilot/chat/ToolConfirmationFooter.svelte b/frontend/src/lib/components/copilot/chat/ToolConfirmationFooter.svelte index fcf943cb34..faf0395bc3 100644 --- a/frontend/src/lib/components/copilot/chat/ToolConfirmationFooter.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolConfirmationFooter.svelte @@ -34,10 +34,11 @@ } </script> -<div class={twMerge('flex flex-row items-center justify-end gap-2', className)}> +<!-- font-main: the tool cards around it are font-mono, which reads as bold on a button label. --> +<div class={twMerge('flex flex-row items-center justify-end gap-2 font-main', className)}> <Button variant="default" - size="xs" + unifiedSize="sm" destructive={rejectDestructive} startIcon={rejectIcon ? { icon: rejectIcon } : undefined} on:click={() => respond(false)} @@ -46,7 +47,7 @@ </Button> <Button variant="accent" - size="xs" + unifiedSize="sm" startIcon={confirmIcon ? { icon: confirmIcon } : undefined} on:click={() => respond(true)} > diff --git a/frontend/src/lib/components/copilot/chat/ToolDiffCard.svelte b/frontend/src/lib/components/copilot/chat/ToolDiffCard.svelte index 94e33374c3..eb0fc1194b 100644 --- a/frontend/src/lib/components/copilot/chat/ToolDiffCard.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolDiffCard.svelte @@ -3,6 +3,8 @@ import ChatCollapsibleCard from './ChatCollapsibleCard.svelte' import ToolCodeDiffView from './ToolCodeDiffView.svelte' import ToolPreviewCard from './ToolPreviewCard.svelte' + import ToolConfirmationFooter from './ToolConfirmationFooter.svelte' + import { Check, XCircle } from 'lucide-svelte' import { diffIsWholeFile, diffLineCounts, @@ -21,8 +23,9 @@ const diff = $derived(toolCodeDiff(message)) const isStreaming = $derived(Boolean(message.isStreamingArguments)) const hasDiff = $derived(Boolean(diff && (diff.before !== '' || diff.after !== ''))) + const awaitingConfirmation = $derived(Boolean(message.isLoading && message.needsConfirmation)) // The card body is a bordered box: opened with nothing in it, it draws an empty line. - const hasBody = $derived(hasDiff || Boolean(message.error)) + const hasBody = $derived(hasDiff || Boolean(message.error) || awaitingConfirmation) // Keyed by call id: a bare flag would carry the expansion onto the next message that // reuses this instance. An active or failed call opens by itself, and remains open @@ -45,7 +48,11 @@ toggled = { id: toggleKey, open: true } } }) - const expanded = $derived(hasBody && (toggled?.id === toggleKey ? toggled.open : opensByDefault)) + // The Apply/Reject footer is inside the body, so a pending confirmation keeps it open. + const expanded = $derived( + awaitingConfirmation || + (hasBody && (toggled?.id === toggleKey ? toggled.open : opensByDefault)) + ) const lines = $derived(diff && expanded ? toolDiffLines(diff, isStreaming) : undefined) const counts = $derived( diff && expanded @@ -79,7 +86,7 @@ label={message.content} {expanded} onToggle={() => (toggled = { id: toggleKey, open: !expanded })} - toggleable={hasBody} + toggleable={hasBody && !awaitingConfirmation} shimmer={isRunning} class={message.isQueued && !message.error ? 'opacity-60 hover:opacity-100 transition-opacity' @@ -91,6 +98,16 @@ {#if diff && hasDiff && expanded} <ToolCodeDiffView {diff} diffLines={lines} streaming={isStreaming} /> {/if} + {#if awaitingConfirmation} + <ToolConfirmationFooter + class="px-3 py-2 border-t border-border-light" + toolCallId={message.tool_call_id} + rejectIcon={XCircle} + rejectDestructive + confirmLabel="Apply" + confirmIcon={Check} + /> + {/if} {#if message.error} <div class="px-3 py-2 text-2xs text-red-600 dark:text-red-400 whitespace-pre-wrap break-words {hasDiff diff --git a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index 832a96d6b4..2b09d75332 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -3,7 +3,6 @@ Loader2, ChevronRight, XCircle, - Play, ClipboardList, Check, CircleMinus, @@ -43,6 +42,7 @@ import RunScriptCard from './RunScriptCard.svelte' import ToolDiffCard from './ToolDiffCard.svelte' import { hasToolCodeDiff } from './toolCodeDiff' + import { TOOL_CONFIRM_ACTIONS } from './toolConfirmAction' import WebSearchSourcesDisplay from './WebSearchSourcesDisplay.svelte' import ExpandableImage from '$lib/components/common/image/ExpandableImage.svelte' import McpServerIcon from '$lib/components/mcp/McpServerIcon.svelte' @@ -92,6 +92,8 @@ // The preview pane's renderers, so a model-written link opens in a new tab either way. const planPlugins = [gfmPlugin(), { renderer: { pre: CodeDisplay, a: LinkRenderer } }] + const confirmAction = $derived(TOOL_CONFIRM_ACTIONS[message.confirmAction ?? 'confirm']) + const hasParameters = $derived( message.parameters !== undefined && Object.keys(message.parameters).length > 0 ) @@ -118,7 +120,7 @@ // stream, while it waits on the user, and once it has an error or details to keep in view. // The user's toggle lives in state keyed by call id: the `message` prop is replaced on every // chat update, so an override written onto a derived would be recomputed away. A pending - // confirmation wins over it, since the Run/Reject footer renders inside the card. + // confirmation wins over it, since the confirm/reject footer renders inside the card. let toggled = $state<{ id: string | undefined; open: boolean } | undefined>(undefined) const isExpanded = $derived( Boolean(message.isLoading && message.needsConfirmation) || @@ -285,7 +287,7 @@ </div> <!-- Collapsed: the buttons stand on their own rather than boxing empty space. --> {:else if message.needsConfirmation} - {@render confirmFooter('py-1 font-main')} + {@render confirmFooter('py-1')} {/if} </div> {:else} @@ -373,8 +375,8 @@ toolCallId={message.tool_call_id} rejectIcon={XCircle} rejectDestructive - confirmLabel="Run" - confirmIcon={Play} + confirmLabel={confirmAction.label} + confirmIcon={confirmAction.icon} /> <!-- Logs and Result - hide while streaming --> diff --git a/frontend/src/lib/components/copilot/chat/api/apiTools.ts b/frontend/src/lib/components/copilot/chat/api/apiTools.ts index 1063bd97e3..b0466f77de 100644 --- a/frontend/src/lib/components/copilot/chat/api/apiTools.ts +++ b/frontend/src/lib/components/copilot/chat/api/apiTools.ts @@ -109,6 +109,7 @@ export function createApiTools( def: chatTool, planModeSafe: !!endpoint && ['GET', 'HEAD', 'OPTIONS'].includes(method), requiresConfirmation: needsConfirmation, + confirmAction: method === 'DELETE' ? 'delete' : 'confirm', confirmationMessage: `Run ${toolName}`, showDetails: true, showFade: true, diff --git a/frontend/src/lib/components/copilot/chat/app/core.ts b/frontend/src/lib/components/copilot/chat/app/core.ts index 166df65a16..5a0ff5cc6d 100644 --- a/frontend/src/lib/components/copilot/chat/app/core.ts +++ b/frontend/src/lib/components/copilot/chat/app/core.ts @@ -22,7 +22,7 @@ import { appDatatableRole, sdkDatatableCall } from '$lib/components/raw_apps/dat import { RAW_APP_BASE } from '$system_prompts' // Backend runnable types -export type BackendRunnableType = 'script' | 'flow' | 'hubscript' | 'inline' +export type BackendRunnableType = 'script' | 'flow' | 'hubscript' | 'agent' | 'inline' export interface InlineScript { language: 'bun' | 'python3' @@ -311,9 +311,9 @@ const getSetBackendRunnableSchema = memo(() => ), name: z.string().describe('A short summary/description of what the runnable does'), type: z - .enum(['script', 'flow', 'hubscript', 'inline']) + .enum(['script', 'flow', 'hubscript', 'agent', 'inline']) .describe( - 'The type of runnable: "inline" for custom code, "script" for workspace script, "flow" for workspace flow, "hubscript" for hub script' + 'The type of runnable: "inline" for custom code, "script" for workspace script, "flow" for workspace flow, "hubscript" for hub script, "agent" for a saved AI agent (one chat turn)' ), staticInputs: z .record(z.string(), z.any()) @@ -328,7 +328,7 @@ const getSetBackendRunnableSchema = memo(() => .string() .optional() .describe( - 'Required when type is "script", "flow", or "hubscript". The fully qualified path to the workspace script/flow or hub script.' + 'Required when type is "script", "flow", "hubscript" or "agent". The fully qualified path to the workspace script/flow, hub script or AI agent.' ), viewerDataAccess: z .boolean() @@ -723,6 +723,11 @@ export const getAppTools = memo((): Tool<AppAIChatHelpers>[] => [ } const parsedArgs = getSetBackendRunnableSchema().parse(cleanedArgs) + if (parsedArgs.type === 'agent' && Object.keys(parsedArgs.staticInputs ?? {}).length > 0) { + const errorMsg = `An "agent" runnable takes no staticInputs: each turn's inputs come from the app.` + toolCallbacks.setToolStatus(toolId, { content: errorMsg, error: errorMsg }) + return errorMsg + } toolCallbacks.setToolStatus(toolId, { content: `Setting backend runnable '${parsedArgs.key}'...` }) @@ -1087,6 +1092,16 @@ When creating a backend runnable with \`set_backend_runnable\`: } \`\`\` +5. **For saved AI agents** (type: "agent"), one chat turn per call, taking \`user_message\` and either \`memory_id\` (the conversation id, for an agent with managed memory) or \`previous_messages\` (the history, for one whose memory is off); no staticInputs: + \`\`\` + { + key: "chat", + name: "Chat with the support agent", + type: "agent", + path: "f/support/agent" + } + \`\`\` + ## Instructions 1. Use the smallest context needed. If the target file or runnable is clear, inspect only that item with \`get_frontend_file(path)\` or \`get_backend_runnable(key)\`. diff --git a/frontend/src/lib/components/copilot/chat/assistantPermissions.ts b/frontend/src/lib/components/copilot/chat/assistantPermissions.ts index 4df3f605e2..e77864045f 100644 --- a/frontend/src/lib/components/copilot/chat/assistantPermissions.ts +++ b/frontend/src/lib/components/copilot/chat/assistantPermissions.ts @@ -1,18 +1,17 @@ -import type { OperatorSettings } from '$lib/gen' -import type { UserExt } from '$lib/stores' +import type { RoleCapabilities } from '$lib/gen' +import type { UserExt, UserWorkspace } from '$lib/stores' import { roleCanAuthor, type EditKind } from '$lib/editRights' import { capabilitiesForRole } from './global/sessionAccess' import type { SessionAccess } from './sessionCapabilities' -/** Where a workspace admin changes what operators may do. Named in the prompt and the +/** Where a workspace admin changes what a role may do. Named in the prompt and the * permissions section alike, so the two send the user to the same place. */ export const OPERATOR_SETTINGS_LOCATION = 'Workspace settings → Users → Operator settings' type PermissionRow = { kind: EditKind label: string - /** The draft capability the kind's write tool declares in `requires`, plus the schedule or - * trigger write gate where the kind has one. */ + /** The draft capability the kind's write tool declares in `requires`. */ assistant: (access: SessionAccess) => boolean } @@ -20,49 +19,41 @@ const ROWS: PermissionRow[] = [ { kind: 'script', label: 'Scripts', assistant: (a) => a.has('write_draft') }, { kind: 'flow', label: 'Flows', assistant: (a) => a.has('write_flow_draft') }, { kind: 'raw_app', label: 'Apps', assistant: (a) => a.has('write_app_draft') }, - { - kind: 'schedule', - label: 'Schedules', - assistant: (a) => a.has('write_draft') && a.has('manage_schedules') - }, - { - kind: 'trigger', - label: 'Triggers', - assistant: (a) => a.has('write_draft') && a.has('manage_triggers') - }, - { kind: 'resource', label: 'Resources', assistant: (a) => a.has('write_draft') }, - { kind: 'variable', label: 'Variables', assistant: (a) => a.has('write_draft') } + { kind: 'schedule', label: 'Schedules', assistant: (a) => a.has('write_schedule_draft') }, + { kind: 'trigger', label: 'Triggers', assistant: (a) => a.has('write_trigger_draft') }, + { kind: 'resource', label: 'Resources', assistant: (a) => a.has('write_resource_draft') }, + { kind: 'variable', label: 'Variables', assistant: (a) => a.has('write_variable_draft') } ] -export type RoleName = 'admin' | 'developer' | 'operator' - +/** `admin`, or the member's workspace role. A server that predates roles names none, and its + * role is then `operator` or `developer` from the legacy flag. */ export function roleName( - user: Pick<UserExt, 'operator' | 'is_admin' | 'is_super_admin'> -): RoleName { + user: Pick<UserExt, 'operator' | 'is_admin' | 'is_super_admin'>, + workspace: Pick<UserWorkspace, 'role'> | undefined +): string { if (user.is_admin || user.is_super_admin) return 'admin' - return user.operator ? 'operator' : 'developer' + return workspace?.role ?? (user.operator ? 'operator' : 'developer') } /** * Per item kind, whether the user's role lets them create and edit it themselves, and whether * the chat can for them. Both act with the user's rights, but the user's column follows the * item handlers (`roleCanAuthor`) and the chat's the draft endpoint every write tool goes - * through (`capabilitiesForRole`), which refuses operators more than the handlers do. + * through (`capabilitiesForRole`), which refuses a restricted role more than the handlers do. */ export function permissionRows( - user: Pick<UserExt, 'operator' | 'is_admin' | 'is_super_admin'>, - operatorSettings: OperatorSettings | null | undefined + user: Pick<UserExt, 'is_admin' | 'is_super_admin'>, + caps: RoleCapabilities ): { label: string; user: boolean; assistant: boolean }[] { const access = capabilitiesForRole({ isAdmin: !!user.is_admin || !!user.is_super_admin, - operator: !!user.operator, // Deploy rules gate deploying, not the drafts these rows are about. deployRulesPass: true, - operatorSettings + capabilities: caps }) return ROWS.map((row) => ({ label: row.label, - user: roleCanAuthor(row.kind, user, operatorSettings), + user: roleCanAuthor(row.kind, caps), assistant: row.assistant(access) })) } diff --git a/frontend/src/lib/components/copilot/chat/autonomyModes.ts b/frontend/src/lib/components/copilot/chat/autonomyModes.ts new file mode 100644 index 0000000000..1cc43c1f2d --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/autonomyModes.ts @@ -0,0 +1,90 @@ +import { ChevronsRight, ClipboardList, Hand } from 'lucide-svelte' +import { AIAutonomyMode } from './AIChatManager.svelte' +import { PLAN_MODE_TEXT_COLOR, PLAN_MODE_TRIGGER_CLASS } from './planMode' + +export type AutonomyAvailability = { + autoAcceptEditsAvailable: boolean + autoAcceptToolConfirmationsAvailable: boolean + planModeAvailable: boolean +} + +export type AutonomyModeOption = { + mode: AIAutonomyMode + label: string + 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 +} + +// The one posture available everywhere, so also the fallback for a mode the +// current AI mode does not offer. +const askPermissionOption: AutonomyModeOption = { + mode: AIAutonomyMode.DEFAULT, + label: 'Ask permission', + icon: Hand, + iconColor: 'text-secondary', + tooltip: (a) => + a.autoAcceptEditsAvailable + ? 'Requires confirmation for edits and tool calls.' + : 'Requires confirmation for tool calls.', + isAvailable: () => true +} + +// One row per autonomy posture, in picker order, so adding one touches only this +// 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, + label: 'Auto-accept edits', + icon: ChevronsRight, + iconColor: 'text-accent', + tooltip: () => + 'Automatically accepts script and flow edits. Tool calls still ask for confirmation.', + isAvailable: (a) => a.autoAcceptEditsAvailable + }, + { + mode: AIAutonomyMode.YOLO, + label: 'Yolo (bypass permissions)', + shortLabel: 'Yolo', + icon: ChevronsRight, + iconColor: 'text-red-500', + tooltip: (a) => + a.autoAcceptEditsAvailable + ? 'Automatically accepts script and flow edits plus tool confirmations.' + : 'Automatically accepts tool confirmations.', + isAvailable: (a) => a.autoAcceptToolConfirmationsAvailable + } +] + +export function autonomyModeOption(mode: AIAutonomyMode): AutonomyModeOption { + return autonomyModeOptions.find((o) => o.mode === mode) ?? askPermissionOption +} + +export function availableAutonomyModeOptions(a: AutonomyAvailability): AutonomyModeOption[] { + return autonomyModeOptions.filter((option) => option.isAvailable(a)) +} + +/** Falls back to ask-permission when `mode` isn't applicable in the current AI mode + * (e.g. auto-accept edits while in a mode without edits). */ +export function resolveAutonomyMode(mode: AIAutonomyMode, a: AutonomyAvailability) { + return availableAutonomyModeOptions(a).some((option) => option.mode === mode) + ? mode + : AIAutonomyMode.DEFAULT +} diff --git a/frontend/src/lib/components/copilot/chat/chatLoop.test.ts b/frontend/src/lib/components/copilot/chat/chatLoop.test.ts index 282e7e1aa6..af4b648b61 100644 --- a/frontend/src/lib/components/copilot/chat/chatLoop.test.ts +++ b/frontend/src/lib/components/copilot/chat/chatLoop.test.ts @@ -29,7 +29,9 @@ vi.mock('../lib', () => ({ providerSupportsWebSearch: mocks.providerSupportsWebSearch })) -vi.mock('../reasoningRegistry', () => ({ +vi.mock('../reasoningRegistry', async (importOriginal) => ({ + usesOpenAIResponsesApi: (await importOriginal<typeof import('../reasoningRegistry')>()) + .usesOpenAIResponsesApi, resolveRequestReasoning: mocks.resolveRequestReasoning, resolveEffectiveReasoning: mocks.resolveEffectiveReasoning })) diff --git a/frontend/src/lib/components/copilot/chat/chatLoop.ts b/frontend/src/lib/components/copilot/chat/chatLoop.ts index 1e27becba0..09232debbb 100644 --- a/frontend/src/lib/components/copilot/chat/chatLoop.ts +++ b/frontend/src/lib/components/copilot/chat/chatLoop.ts @@ -10,6 +10,7 @@ import { getCompletion, parseOpenAICompletion, providerSupportsWebSearch } from import { resolveEffectiveReasoning, resolveRequestReasoning, + usesOpenAIResponsesApi, type ReasoningProviderModel } from '../reasoningRegistry' import { getAnthropicCompletion, parseAnthropicCompletion } from './anthropic' @@ -72,8 +73,13 @@ export interface ChatLoopConfig { * lets the caller recover partial output if the loop throws or is aborted. */ addedMessages?: ChatCompletionMessageParam[] - /** Called before each iteration (e.g. to record which model it is about to use). */ - onBeforeIteration?: (modelProvider: ReasoningProviderModel) => Promise<void> + /** Called before each iteration (e.g. to record which model it is about to use), with + * the last usage report that carried a prompt count, which can predate the latest + * iteration. It may rewrite `messages` in place. */ + onBeforeIteration?: ( + modelProvider: ReasoningProviderModel, + lastIterationUsage: ChatTokenUsage | null + ) => Promise<void> /** Fired for each completed provider response, before the loop continues. The * loop can fail or be aborted at any iteration, so spend has to be handed over * as it happens — a callback only at the end would discard everything the @@ -419,7 +425,7 @@ export async function runChatLoop(config: ChatLoopConfig): Promise<ChatLoopResul const webSearch = sendsWebSearch(workspace, modelProvider, config.webSearch ?? true) if (onBeforeIteration) { - await onBeforeIteration(modelProvider) + await onBeforeIteration(modelProvider, lastIterationUsage) } const tools = await Promise.all( config.tools.map(async (t) => (t.schemaFor ? { ...t, def: await t.schemaFor(helpers) } : t)) @@ -427,8 +433,7 @@ export async function runChatLoop(config: ChatLoopConfig): Promise<ChatLoopResul const pendingUserMessage = getPendingUserMessage?.() - const isOpenAI = - modelProvider.provider === 'openai' || modelProvider.provider === 'azure_openai' + const isOpenAI = usesOpenAIResponsesApi(modelProvider.provider, modelProvider.model) const isAnthropic = usesAnthropicMessagesApi(modelProvider.provider, modelProvider.model) // Resolve effort once in chat context (applies the default-on level for // capable models, and the provider-native disable token for an explicit diff --git a/frontend/src/lib/components/copilot/chat/chatViewHost.ts b/frontend/src/lib/components/copilot/chat/chatViewHost.ts index bdacb57615..fc52b67449 100644 --- a/frontend/src/lib/components/copilot/chat/chatViewHost.ts +++ b/frontend/src/lib/components/copilot/chat/chatViewHost.ts @@ -72,7 +72,11 @@ export interface ChatViewHost { /** Resolves to whether the draft was consumed as a turn. */ sendRequest: (options?: ChatSendRequestOptions) => Promise<boolean | undefined> cancel: (reason?: string) => void - setAiChatInput: (aiChatInput: AIChatInput | null) => void + setAiChatInput: (aiChatInput: AIChatInput) => void + /** Unregisters `aiChatInput` if it is still the registered one. A host's chat can remount + * elsewhere (Home hands its session to /sessions), and the new composer may register before + * the old one's teardown runs. */ + releaseAiChatInput: (aiChatInput: AIChatInput) => void readonly queuedMessage: string queuedContext: ContextElement[] | undefined readonly queuedImages: AttachedImage[] @@ -114,6 +118,9 @@ export interface ChatViewHost { // of their own; the chrome they drive hides itself. mode?: AIMode isSessionChat: boolean + /** Someone else's transcript, replayed for a reader who did not write it: markup that + * would execute or render beyond plain text (mermaid diagrams) stays off. */ + untrustedTranscript?: boolean /** Model + reasoning picker. Off where the model is configured elsewhere. */ supportsModelSettings: boolean /** Click a user message to edit and resend it. Needs a host that can rewind diff --git a/frontend/src/lib/components/copilot/chat/composerBox.ts b/frontend/src/lib/components/copilot/chat/composerBox.ts index f85783d988..09dcf04764 100644 --- a/frontend/src/lib/components/copilot/chat/composerBox.ts +++ b/frontend/src/lib/components/copilot/chat/composerBox.ts @@ -22,6 +22,57 @@ export function composerBoxClass(disabled: boolean = false): string { }` } +/** Added to a usable box that opens a session (Home, an empty session). Focus shows as the + * base box's border colour alone: the brand has no focus ring. */ +export const HERO_BOX_CLASS = 'rounded-[10px]' + /** Applied to the field inside the box; without it the field draws a second border. */ export const COMPOSER_FIELD_RESET = '!border-transparent !bg-transparent !shadow-none focus:!border-transparent focus:!ring-0 disabled:cursor-not-allowed disabled:placeholder:text-disabled' + +/** The placeholder of a composer that opens a session. */ +export const STARTER_PLACEHOLDER = 'Ask about this workspace, or describe what to build…' + +/** A caret or selected range in the composer's text. */ +export type ComposerSelection = { start: number; end: number } + +const GLOBAL_PLACEHOLDER_SUGGESTIONS = [ + 'Write a hello-world flow', + 'Create a script that lists files in an S3 bucket', + 'Build a CRUD app for a customer table', + 'Schedule a daily cleanup of old runs', + 'Wrap an existing script into a flow with retries', + 'Add an HTTP trigger to an existing script', + 'Generate a report from a SQL query and email it', + 'Create a Postgres resource and a script that queries it', + 'Refactor a script to add error handling', + 'List my workspace flows and scripts' +] +// An operator authors nothing, so nothing above is theirs to ask for; one with the +// workspace's builder right can compose flows out of deployed scripts as well. +const OPERATOR_PLACEHOLDER_SUGGESTIONS = [ + 'List my workspace flows and scripts', + 'Show the runs that failed today and why', + 'Find the script that sends a report and run it', + 'Rerun the last failed run of a flow' +] +const BUILDER_PLACEHOLDER_SUGGESTIONS = [ + ...OPERATOR_PLACEHOLDER_SUGGESTIONS, + 'Compose a flow that chains two deployed scripts' +] + +/** + * The global-mode composer's placeholder: one suggestion the user may ask for. `pick` is a + * number in [0, 1) the caller draws once, so the text does not shuffle while it is read. + */ +export function globalComposerSuggestion( + pick: number, + canOpenEditor: (kind: string) => boolean +): string { + const pool = canOpenEditor('script') + ? GLOBAL_PLACEHOLDER_SUGGESTIONS + : canOpenEditor('flow') + ? BUILDER_PLACEHOLDER_SUGGESTIONS + : OPERATOR_PLACEHOLDER_SUGGESTIONS + return pool[Math.floor(pick * pool.length)] +} diff --git a/frontend/src/lib/components/copilot/chat/datatableMigrationTools.ts b/frontend/src/lib/components/copilot/chat/datatableMigrationTools.ts index 3a2476b9d0..6e5ec4f8a7 100644 --- a/frontend/src/lib/components/copilot/chat/datatableMigrationTools.ts +++ b/frontend/src/lib/components/copilot/chat/datatableMigrationTools.ts @@ -1,6 +1,12 @@ import { z } from 'zod' import { WorkspaceService } from '$lib/gen' import { withMigrationRole } from '$lib/components/datatableMigrationRole' +import { + migrationScope, + parseMigrationSchemas, + scopesOverlap, + withMigrationSchemas +} from '$lib/components/datatableMigrationSchemas' import { createToolDef, type ToolCallbacks } from './shared' import { NONE, RUN_PREVIEW, type SessionTool } from './sessionCapabilities' @@ -81,6 +87,12 @@ const getCreateMigrationSchema = memo(() => .describe( 'The datatable role the migration runs as, on a datatable that has roles. Omit to run as admin, which only the datatable admins may do.' ), + schemas: z + .array(z.string().regex(/^[A-Za-z0-9_$-]{1,63}$/)) + .optional() + .describe( + 'Every schema the migration changes or depends on (including schemas of referenced tables, types and functions), with every name in `code_up` schema-qualified. A migration then waits only on earlier migrations touching those schemas. Omit only for a change that spans the whole database (an extension, a grant across schemas): every later migration then waits for it.' + ), version: z .number() .optional() @@ -182,7 +194,8 @@ export function getDatatableMigrationTools(): SessionTool<{}>[] { migrations: status.migrations.map((m) => ({ version: m.timestamp, name: m.name, - status: migrationStatus(m.status) + status: migrationStatus(m.status), + schemas: m.schemas ?? 'whole database' })), // Set when the database could not be read, which is why every status is unknown. ...(status.error && { error: status.error }) @@ -200,6 +213,7 @@ export function getDatatableMigrationTools(): SessionTool<{}>[] { requires: RUN_PREVIEW, def: getCreateMigrationToolDef(), requiresConfirmation: true, + confirmAction: (args) => (args?.apply === false ? 'create' : 'apply'), confirmationMessage: (args) => `${args?.version === undefined ? 'Create' : 'Rewrite'}${args?.apply === false ? '' : ' and apply'} data table migration`, queuedLabel: (args) => `Write migration "${args?.name ?? ''}"`, @@ -212,9 +226,16 @@ export function getDatatableMigrationTools(): SessionTool<{}>[] { }) // The role is an annotation in the SQL, read from above everything else, and a rollback // has to run as the role that created what it drops. + // No inference here: the model reads the SQL it wrote better than a pattern does, so + // the scope is what it declares, and the whole database when it declares none. + const declared = parseMigrationSchemas(parsed.code_up) + const scope = parsed.schemas ?? (declared.kind === 'schemas' ? declared.schemas : []) const definition = { name: parsed.name, - code_up: withRole(parsed.code_up, parsed.role), + code_up: withMigrationSchemas( + withRole(parsed.code_up, parsed.role), + scope.map((s) => s.toLowerCase()) + ), ...(parsed.code_down !== undefined && { code_down: withRole(parsed.code_down, parsed.role) }) @@ -271,7 +292,10 @@ export function getDatatableMigrationTools(): SessionTool<{}>[] { workspace, datatableName }) - const earlier = status.migrations.filter((m) => m.timestamp < version) + const ownScope = migrationScope(definition.code_up) + const earlier = status.migrations.filter( + (m) => m.timestamp < version && scopesOverlap(m.schemas, ownScope) + ) // Unreadable is not pending: reporting it so would send the user to apply // migrations that already ran. if (status.error || earlier.some((m) => m.status === 'unknown')) { @@ -318,6 +342,7 @@ export function getDatatableMigrationTools(): SessionTool<{}>[] { requires: RUN_PREVIEW, def: getRunMigrationsToolDef(), requiresConfirmation: true, + confirmAction: 'apply', confirmationMessage: 'Apply pending data table migrations', queuedLabel: (args) => `Apply migrations on "${args?.datatable_name ?? 'datatable'}"`, showDetails: true, @@ -353,6 +378,7 @@ export function getDatatableMigrationTools(): SessionTool<{}>[] { requires: RUN_PREVIEW, def: getRollbackMigrationToolDef(), requiresConfirmation: true, + confirmAction: 'rollback', confirmationMessage: (args) => `Roll back data table migration ${args?.version ?? ''}`, queuedLabel: (args) => `Roll back migration ${args?.version ?? ''}`, showDetails: true, diff --git a/frontend/src/lib/components/copilot/chat/datatableTools.ts b/frontend/src/lib/components/copilot/chat/datatableTools.ts index e05b2e4325..097d64d06e 100644 --- a/frontend/src/lib/components/copilot/chat/datatableTools.ts +++ b/frontend/src/lib/components/copilot/chat/datatableTools.ts @@ -344,6 +344,7 @@ export function getDatatableTools(): SessionTool<{}>[] { requires: RUN_PREVIEW, def: getExecDatatableSqlToolDef(), requiresConfirmation: true, + confirmAction: 'run', confirmationMessage: 'Execute SQL on datatable', queuedLabel: (args) => `Execute SQL on "${args?.datatable_name ?? 'datatable'}"`, showDetails: true, diff --git a/frontend/src/lib/components/copilot/chat/flow/core.ts b/frontend/src/lib/components/copilot/chat/flow/core.ts index 4adbe1634b..39be790f3e 100644 --- a/frontend/src/lib/components/copilot/chat/flow/core.ts +++ b/frontend/src/lib/components/copilot/chat/flow/core.ts @@ -441,6 +441,7 @@ export const flowTools: Tool<FlowAIChatHelpers>[] = [ return def }, requiresConfirmation: true, + confirmAction: 'run', confirmationMessage: 'Run a test of the current flow', showDetails: true, autoCollapseDetails: false @@ -486,6 +487,7 @@ export const flowTools: Tool<FlowAIChatHelpers>[] = [ }) }, requiresConfirmation: true, + confirmAction: 'run', confirmationMessage: (args) => `Run a test of step "${args?.stepId ?? ''}"`, showDetails: true, autoCollapseDetails: false diff --git a/frontend/src/lib/components/copilot/chat/flow/openFlow.json b/frontend/src/lib/components/copilot/chat/flow/openFlow.json index 11cee5b724..c9d3322bdb 100644 --- a/frontend/src/lib/components/copilot/chat/flow/openFlow.json +++ b/frontend/src/lib/components/copilot/chat/flow/openFlow.json @@ -1 +1 @@ -{"openapi":"3.0.3","info":{"version":"1.825.0","title":"OpenFlow Spec","contact":{"name":"Ruben Fiszel","email":"ruben@windmill.dev","url":"https://windmill.dev"},"license":{"name":"Apache 2.0","url":"https://www.apache.org/licenses/LICENSE-2.0.html"}},"paths":{},"externalDocs":{"description":"documentation portal","url":"https://windmill.dev"},"components":{"schemas":{"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-<type>' (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":"JavaScript expression to return early from the flow"},"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 × 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 × 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 — 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.<step_id>'"}},"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/<flow_path>-<step_id>"},"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"}},"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"}},"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"}},"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"}},"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"}},"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",null]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw",null]}}}}},"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.<its_own_id>' 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"}]},"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"}}},"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).\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 — 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: { <question name>: answer },\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\nthe answers, follow it with a branchone whose branch exprs read\nresults.<id>.output.<question>.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"]}}}} \ No newline at end of file +{"openapi":"3.0.3","info":{"version":"1.830.0","title":"OpenFlow Spec","contact":{"name":"Ruben Fiszel","email":"ruben@windmill.dev","url":"https://windmill.dev"},"license":{"name":"Apache 2.0","url":"https://www.apache.org/licenses/LICENSE-2.0.html"}},"paths":{},"externalDocs":{"description":"documentation portal","url":"https://windmill.dev"},"components":{"schemas":{"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-<type>' (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 × 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 × 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 — 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.<step_id>'"}},"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/<flow_path>-<step_id>"},"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"}},"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"}},"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"}},"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"}},"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"}},"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",null]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw",null]}}}}},"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.<its_own_id>' 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"}]},"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"}}},"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).\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 — 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: { <question name>: answer },\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\nthe answers, follow it with a branchone whose branch exprs read\nresults.<id>.output.<question>.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"]}}}} \ No newline at end of file diff --git a/frontend/src/lib/components/copilot/chat/flow/openFlowZod.gen.ts b/frontend/src/lib/components/copilot/chat/flow/openFlowZod.gen.ts index 88cb8fe875..d83ef70207 100644 --- a/frontend/src/lib/components/copilot/chat/flow/openFlowZod.gen.ts +++ b/frontend/src/lib/components/copilot/chat/flow/openFlowZod.gen.ts @@ -1,6 +1,6 @@ import { z } from "zod" -export const flowModuleValueSchema = z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.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"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable","volume","dbt"]).describe("Type of asset"), "access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Access level for this asset").optional(), "alt_access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in each iteration"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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.<its_own_id>' 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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined").optional(), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n").optional(), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("window"), "context_length": z.number().int().describe("Number of most recent messages to load and store. 0 turns memory off.") }).describe("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"), z.object({ "kind": z.literal("compaction"), "context_window": z.number().int().describe("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").optional() }).describe("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"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("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"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.")]).describe("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`."), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "memory_id": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "previous_messages": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "user_attachments": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "enabled_tools": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_iterations": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("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'.").optional(), "description": z.string().describe("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.").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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.").optional(), "value": z.any().superRefine((x, ctx) => { +export const flowModuleValueSchema = z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.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"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable","volume","dbt"]).describe("Type of asset"), "access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Access level for this asset").optional(), "alt_access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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"), z.object({ "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in each iteration"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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.<its_own_id>' 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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.lazy(() => flowModuleValueSchema), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch")).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined").optional(), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n").optional(), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("window"), "context_length": z.number().int().describe("Number of most recent messages to load and store. 0 turns memory off.") }).describe("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"), z.object({ "kind": z.literal("compaction"), "context_window": z.number().int().describe("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").optional() }).describe("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"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("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"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.")]).describe("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`."), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "memory_id": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "previous_messages": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "user_attachments": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "enabled_tools": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_iterations": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n").optional(), "model": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "reasoning_effort": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("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'.").optional(), "description": z.string().describe("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.").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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.").optional(), "value": z.any().superRefine((x, ctx) => { const schemas = [z.intersection(z.object({ "tool_type": z.literal("flowmodule") }), z.lazy(() => flowModuleValueSchema)).describe("A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module"), z.object({ "tool_type": z.literal("mcp"), "resource_path": z.string().describe("Path to the MCP resource/server configuration"), "include_tools": z.array(z.string()).describe("Whitelist of specific tools to include from this MCP server").optional(), "exclude_tools": z.array(z.string()).describe("Blacklist of tools to exclude from this MCP server").optional() }).describe("Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers"), z.object({ "tool_type": z.literal("websearch") }).describe("A tool implemented as a websearch tool. The AI can call this like any other websearch tool")]; const errors = schemas.reduce<z.ZodError[]>( (errors, schema) => @@ -17,10 +17,10 @@ export const flowModuleValueSchema = z.discriminatedUnion("type", [z.object({ "i message: "Invalid input: Should pass single schema", }); } - }).describe("The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference") }).describe("A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool")).describe("Array of tools the agent can use. The agent decides which tools to call based on the task").optional(), "type": z.literal("aiagent"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)").optional(), "omit_output_from_conversation": z.boolean().describe("If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.").default(false), "agent": z.string().describe("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).\n").optional(), "tool_inputs": z.record(z.string(), z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"))).describe("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 — including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n").optional(), "parallel": z.boolean().describe("If true, the agent can execute multiple tool calls in parallel").optional() }).describe("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"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined"), "state": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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") }).describe("Input parameters for the decision mapped to their values"), "type": z.literal("aidecision"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)").optional() }).describe("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: { <question name>: answer },\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\nthe answers, follow it with a branchone whose branch exprs read\nresults.<id>.output.<question>.choice. Usable as an AI agent tool, where the calling agent\nsupplies the state.\n")]).describe("The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type") + }).describe("The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference") }).describe("A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool")).describe("Array of tools the agent can use. The agent decides which tools to call based on the task").optional(), "type": z.literal("aiagent"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)").optional(), "omit_output_from_conversation": z.boolean().describe("If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.").default(false), "agent": z.string().describe("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").optional(), "tool_inputs": z.record(z.string(), z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"))).describe("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 — including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n").optional(), "parallel": z.boolean().describe("If true, the agent can execute multiple tool calls in parallel").optional() }).describe("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"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined"), "state": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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") }).describe("Input parameters for the decision mapped to their values"), "type": z.literal("aidecision"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)").optional() }).describe("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: { <question name>: answer },\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\nthe answers, follow it with a branchone whose branch exprs read\nresults.<id>.output.<question>.choice. Usable as an AI agent tool, where the calling agent\nsupplies the state.\n")]).describe("The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type") -export const flowModuleSchema = z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.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"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable","volume","dbt"]).describe("Type of asset"), "access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Access level for this asset").optional(), "alt_access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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"), z.object({ "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute in each iteration"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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.<its_own_id>' 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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined").optional(), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n").optional(), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("window"), "context_length": z.number().int().describe("Number of most recent messages to load and store. 0 turns memory off.") }).describe("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"), z.object({ "kind": z.literal("compaction"), "context_window": z.number().int().describe("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").optional() }).describe("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"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("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"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.")]).describe("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`."), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "memory_id": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "previous_messages": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "user_attachments": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "enabled_tools": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_iterations": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("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'.").optional(), "description": z.string().describe("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.").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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.").optional(), "value": z.any().superRefine((x, ctx) => { +export const flowModuleSchema = z.object({ "id": z.string().describe("Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"), "value": z.discriminatedUnion("type", [z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "content": z.string().describe("The script source code. Should export a 'main' function"), "language": z.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"]).describe("Programming language for this script"), "path": z.string().describe("Optional path for saving this script").optional(), "lock": z.string().describe("Lock file content for dependencies").optional(), "type": z.literal("rawscript"), "tag": z.string().describe("Worker group tag for execution routing").optional(), "concurrent_limit": z.number().describe("Maximum concurrent executions of this script").optional(), "concurrency_time_window_s": z.number().describe("Time window for concurrent_limit").optional(), "custom_concurrency_key": z.string().describe("Custom key for grouping concurrent executions").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional(), "assets": z.array(z.object({ "path": z.string().describe("Path to the asset"), "kind": z.enum(["s3object","resource","ducklake","datatable","volume","dbt"]).describe("Type of asset"), "access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Access level for this asset").optional(), "alt_access_type": z.union([z.literal("r"), z.literal("w"), z.literal("rw"), z.literal(null)]).nullable().describe("Alternative access level").optional() })).describe("External resources this script accesses (S3 objects, resources, etc.)").optional() }).describe("Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments"), "path": z.string().describe("Path to the script in the workspace (e.g., 'f/scripts/send_email')"), "hash": z.string().describe("Optional specific version hash of the script to use").optional(), "type": z.literal("script"), "tag_override": z.string().describe("Override the script's default worker group tag").optional(), "is_trigger": z.boolean().describe("If true, this script is a trigger that can start the flow").optional() }).describe("Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code"), z.object({ "input_transforms": z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs")).describe("Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments"), "path": z.string().describe("Path to the flow in the workspace (e.g., 'f/flows/process_user')"), "type": z.literal("flow") }).describe("Reference to an existing flow by path. Use this to call another flow as a subflow"), z.object({ "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'"), "iterator": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("forloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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"), z.object({ "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute in each iteration"), "skip_failures": z.boolean().describe("If true, iteration failures don't stop the loop. Failed iterations return null"), "type": z.literal("whileloopflow"), "parallel": z.boolean().describe("If true, iterations run concurrently (use with caution in while loops)").optional(), "parallelism": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "squash": z.boolean().optional() }).describe("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.<its_own_id>' 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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch condition").optional(), "expr": z.string().describe("JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"), "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute if this branch's expr is true") })).describe("Array of branches to evaluate in order. The first branch with expr evaluating to true executes"), "default": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute if no branch expressions match"), "type": z.literal("branchone") }).describe("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"), z.object({ "branches": z.array(z.object({ "summary": z.string().describe("Short description of this branch's purpose").optional(), "skip_failure": z.boolean().describe("If true, failure in this branch doesn't fail the entire flow").optional(), "modules": z.array(z.lazy(() => flowModuleSchema)).describe("Steps to execute in this branch") })).describe("Array of branches that all execute (either in parallel or sequentially)"), "type": z.literal("branchall"), "parallel": z.boolean().describe("If true, all branches execute concurrently. If false, they execute sequentially").optional() }).describe("Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently"), z.object({ "type": z.literal("identity"), "flow": z.boolean().describe("If true, marks this as a flow identity (special handling)").optional() }).describe("Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined").optional(), "output_type": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Output format type.\nValid values: 'text' (default) - plain text response, 'image' - image generation\n").optional(), "user_message": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "system_prompt": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("System instructions that guide the AI's behavior, persona, and response style. Optional.").optional(), "streaming": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "memory": z.discriminatedUnion("type", [z.object({ "value": z.discriminatedUnion("kind", [z.object({ "kind": z.literal("off") }).describe("No conversation memory/context"), z.object({ "kind": z.literal("window"), "context_length": z.number().int().describe("Number of most recent messages to load and store. 0 turns memory off.") }).describe("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"), z.object({ "kind": z.literal("compaction"), "context_window": z.number().int().describe("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").optional() }).describe("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"), z.object({ "kind": z.literal("auto"), "context_length": z.number().int().describe("Maximum number of messages to retain in context").optional(), "memory_id": z.string().describe("Identifier for persistent memory across agent invocations").optional() }).describe("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"), z.object({ "kind": z.literal("manual"), "messages": z.array(z.object({ "role": z.enum(["user","assistant","system"]), "content": z.string() }).describe("A single message in conversation history")) }).describe("Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.")]).describe("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`."), "type": z.literal("static") }).describe("Static memory configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined").optional(), "memory_id": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "previous_messages": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "output_schema": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "user_attachments": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "enabled_tools": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_completion_tokens": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "temperature": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "max_iterations": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n").optional(), "model": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional(), "reasoning_effort": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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").optional() }).describe("Input parameters for the AI agent mapped to their values"), "tools": z.array(z.object({ "id": z.string().describe("Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"), "summary": z.string().describe("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'.").optional(), "description": z.string().describe("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.").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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.").optional(), "value": z.any().superRefine((x, ctx) => { const schemas = [z.intersection(z.object({ "tool_type": z.literal("flowmodule") }), z.lazy(() => flowModuleValueSchema)).describe("A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module"), z.object({ "tool_type": z.literal("mcp"), "resource_path": z.string().describe("Path to the MCP resource/server configuration"), "include_tools": z.array(z.string()).describe("Whitelist of specific tools to include from this MCP server").optional(), "exclude_tools": z.array(z.string()).describe("Blacklist of tools to exclude from this MCP server").optional() }).describe("Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers"), z.object({ "tool_type": z.literal("websearch") }).describe("A tool implemented as a websearch tool. The AI can call this like any other websearch tool")]; const errors = schemas.reduce<z.ZodError[]>( (errors, schema) => @@ -37,7 +37,7 @@ export const flowModuleSchema = z.object({ "id": z.string().describe("Unique ide message: "Invalid input: Should pass single schema", }); } - }).describe("The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference") }).describe("A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool")).describe("Array of tools the agent can use. The agent decides which tools to call based on the task").optional(), "type": z.literal("aiagent"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)").optional(), "omit_output_from_conversation": z.boolean().describe("If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.").default(false), "agent": z.string().describe("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).\n").optional(), "tool_inputs": z.record(z.string(), z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"))).describe("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 — including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n").optional(), "parallel": z.boolean().describe("If true, the agent can execute multiple tool calls in parallel").optional() }).describe("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"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined"), "state": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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") }).describe("Input parameters for the decision mapped to their values"), "type": z.literal("aidecision"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)").optional() }).describe("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: { <question name>: answer },\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\nthe answers, follow it with a branchone whose branch exprs read\nresults.<id>.output.<question>.choice. Usable as an AI agent tool, where the calling agent\nsupplies the state.\n")]).describe("The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type"), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch") + }).describe("The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference") }).describe("A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool")).describe("Array of tools the agent can use. The agent decides which tools to call based on the task").optional(), "type": z.literal("aiagent"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)").optional(), "omit_output_from_conversation": z.boolean().describe("If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.").default(false), "agent": z.string().describe("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").optional(), "tool_inputs": z.record(z.string(), z.record(z.string(), z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs"))).describe("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 — including when\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\nback or unlinked.\n").optional(), "parallel": z.boolean().describe("If true, the agent can execute multiple tool calls in parallel").optional() }).describe("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"), z.object({ "input_transforms": z.object({ "provider": z.discriminatedUnion("type", [z.object({ "value": z.object({ "kind": z.enum(["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock","typesafe","cloudflare"]).describe("Supported AI provider types"), "resource": z.string().describe("Resource reference in format '$res:{resource_path}' pointing to provider credentials"), "model": z.string().describe("Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"), "reasoning_effort": z.string().describe("Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.").optional() }).describe("Complete AI provider configuration with resource reference and model selection"), "type": z.literal("static") }).describe("Static provider configuration passed directly to the AI agent"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined"), "state": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").describe("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") }).describe("Input parameters for the decision mapped to their values"), "type": z.literal("aidecision"), "tag": z.string().describe("Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)").optional() }).describe("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: { <question name>: answer },\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\nthe answers, follow it with a branchone whose branch exprs read\nresults.<id>.output.<question>.choice. Usable as an AI agent tool, where the calling agent\nsupplies the state.\n")]).describe("The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type"), "stop_after_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "stop_after_all_iters_if": z.object({ "skip_if_stopped": z.boolean().describe("If true, following steps are skipped when this condition triggers").optional(), "expr": z.string().describe("JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"), "error_message": z.string().nullable().describe("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.").optional(), "error_include_result": z.boolean().describe("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.").optional() }).describe("Early termination condition for a module").optional(), "skip_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.<step_id>'") }).describe("Conditionally skip this step based on previous results or flow inputs").optional(), "sleep": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "cache_ttl": z.number().describe("Cache duration in seconds for this step's results").optional(), "cache_ignore_s3_path": z.boolean().optional(), "timeout": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "delete_after_secs": z.number().int().describe("If set, delete the step's args, result and logs after this many seconds following job completion").optional(), "summary": z.string().describe("Short description of what this step does").optional(), "mock": z.object({ "enabled": z.boolean().describe("If true, return mock value instead of executing").optional(), "return_value": z.any().describe("Value to return when mocked").optional() }).describe("Mock configuration for testing without executing the actual step").optional(), "suspend": z.object({ "required_events": z.number().int().describe("Number of approvals required before continuing").optional(), "timeout": z.number().int().describe("Timeout in seconds before auto-continuing or canceling").optional(), "resume_form": z.object({ "schema": z.record(z.string(), z.any()).describe("JSON Schema for the resume form").optional() }).describe("Form schema for collecting input when resuming").optional(), "user_auth_required": z.boolean().describe("If true, only authenticated users can approve").optional(), "user_groups_required": z.discriminatedUnion("type", [z.object({ "value": z.any().describe("The static value. For resources, use format '$res:path/to/resource'").optional(), "type": z.literal("static") }).describe("Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'"), z.object({ "expr": z.string().describe("JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"), "type": z.literal("javascript") }).describe("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')"), z.object({ "type": z.literal("ai") }).describe("Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.")]).describe("Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs").optional(), "self_approval_disabled": z.boolean().describe("If true, the user who started the flow cannot approve").optional(), "hide_cancel": z.boolean().describe("If true, hide the cancel button on the approval form").optional(), "continue_on_disapprove_timeout": z.boolean().describe("If true, continue flow on timeout instead of canceling").optional(), "skin": z.enum(["detailed","minimal"]).describe("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").optional() }).describe("Configuration for approval/resume steps that wait for user input").optional(), "priority": z.number().describe("Execution priority for this step (higher numbers run first)").optional(), "continue_on_error": z.boolean().describe("If true, flow continues even if this step fails").optional(), "job_token_scopes": z.array(z.string()).nullable().describe("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).").optional(), "retry": z.object({ "constant": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "seconds": z.number().int().describe("Seconds to wait between retries").optional() }).describe("Retry with constant delay between attempts").optional(), "exponential": z.object({ "attempts": z.number().int().describe("Number of retry attempts").optional(), "multiplier": z.number().int().describe("Multiplier for exponential backoff").optional(), "seconds": z.number().int().gte(1).describe("Base of the exponential (seconds); the n-th retry waits multiplier × seconds^n, where n counts constant retries too").optional(), "random_factor": z.number().int().gte(0).lte(100).describe("Random jitter percentage (0-100) to avoid thundering herd").optional() }).describe("Retry with exponential backoff: the n-th retry waits multiplier × seconds^n").optional(), "retry_if": z.object({ "expr": z.string().describe("JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables") }).describe("Conditional retry based on error or result").optional() }).describe("Retry configuration for failed module executions").optional(), "debouncing": z.object({ "debounce_delay_s": z.number().int().describe("Delay in seconds to debounce this step's executions across flow runs").optional(), "debounce_key": z.string().describe("Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/<flow_path>-<step_id>").optional(), "debounce_args_to_accumulate": z.array(z.string()).describe("Array-type arguments to accumulate across debounced executions").optional(), "max_total_debouncing_time": z.number().int().describe("Maximum total time in seconds before forced execution").optional(), "max_total_debounces_amount": z.number().int().describe("Maximum number of debounces before forced execution").optional() }).describe("Debounce configuration for this step (EE only)").optional() }).describe("A single step in a flow. Can be a script, subflow, loop, or branch") export const flowModulesSchema = z.array(flowModuleSchema) 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 d510b21826..61bdea9dc9 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -241,6 +241,7 @@ vi.mock('$lib/gen', async () => { throw new Error('getResource mock not configured') }), createResource: vi.fn(async () => 'created'), + createResourceType: vi.fn(async () => 'created'), updateResource: vi.fn(async () => 'updated'), deleteResource: vi.fn(async () => 'deleted'), // A workspace with no skills, which is what makes `read_skill` refuse a path @@ -415,6 +416,7 @@ import { import { devopsRole, superadmin, userStore, usersWorkspaceStore } from '$lib/stores' import { processSecretArgs } from '$lib/components/secretArgUtils' import { clearWorkspaceRoleCache } from '$lib/user' +import { resourceTypesStore } from '$lib/components/resourceTypesStore' import { get } from 'svelte/store' import type { Tool, ToolCallbacks } from '../shared' @@ -446,7 +448,7 @@ const toolCallbacks: ToolCallbacks = { removeToolStatus: vi.fn(), // Every host that can run a script mounts the form, so the default answers it with what it // opened with. A test meaning to exercise a host without one overrides this with undefined. - requestRunArgs: async (_toolId, form) => form.args + requestRunArgs: async (_toolId, form) => ({ kind: 'args', args: form.args }) } function getGlobalTool(name: string): Tool<{}> { @@ -1209,6 +1211,28 @@ describe('global AI tools', () => { expect(raw).not.toContain('db_draft_input') }) + // The flow read is rebuilt field by field for the model, so a field missing there is + // silently absent and reads as "shared with nobody". + it('reports who a deployed flow is shared with', async () => { + vi.mocked(FlowService.getFlowByPath).mockResolvedValueOnce({ + path: 'f/flows/shared', + summary: 'Shared flow', + value: { modules: [] }, + schema: {}, + extra_perms: { 'g/finance': false } + } as any) + + const item = JSON.parse( + await callGlobalTool('read_workspace_item', { + type: 'flow', + path: 'f/flows/shared', + version: 'deployed' + }) + ) + + expect(item.extraPerms).toEqual({ 'g/finance': false }) + }) + it('redacts variable draft values when reading workspace items', async () => { await callGlobalTool('write_variable', { path: 'f/secrets/api_key', @@ -2524,6 +2548,38 @@ describe('global AI tools', () => { expect(asPath.inlineScript).toBeUndefined() }) + // A static input is pinned by the deployed policy: carried into an agent runnable, it would + // replace every viewer's message. + it('drops inherited static inputs when a runnable becomes an agent', async () => { + seedBackendDraft( + 'raw_app', + 'u/admin/agent_conv_app', + { summary: 'Converted', files: {}, runnables: {}, data: { tables: [] } }, + { workspace: WORKSPACE } + ) + await callGlobalTool('write_app_runnable', { + path: 'u/admin/agent_conv_app', + key: 'chat', + runnable: { + name: 'Run the flow', + type: 'flow', + path: 'u/admin/hello_flow', + staticInputs: { user_message: 'pinned' } + } + }) + await callGlobalTool('write_app_runnable', { + path: 'u/admin/agent_conv_app', + key: 'chat', + runnable: { name: 'Chat', type: 'agent', path: 'u/admin/assistant' } + }) + + const asAgent = getBackendDraft<any>('raw_app', 'u/admin/agent_conv_app', { + workspace: WORKSPACE + }).runnables.chat + expect(asAgent.runType).toBe('agent') + expect(asAgent.fields).toEqual({}) + }) + it("resets a path runnable's schema when it is retargeted, and keeps it when it is not", async () => { // The editor populates `schema` from the item the runnable points at, and // genWmillTs types `backend.<key>(args)` from it — so it must not outlive the target. @@ -4771,6 +4827,39 @@ describe('global AI tools', () => { ).resolves.toBe(code) }) + // The worker reuses a non-empty step lock, so an edited step keeping its lock deploys + // against the old dependencies. + it('set_flow_module_code drops the lock of the edited step only', async () => { + const step = (id: string) => ({ + id, + value: { + type: 'rawscript', + language: 'python3', + content: 'import requests', + lock: 'requests==2.0', + input_transforms: {} + } + }) + seedBackendDraft('flow', 'f/flows/locked', { + path: 'f/flows/locked', + summary: 'f', + description: '', + value: { modules: [step('edited'), step('untouched')] }, + schema: {} + }) + + await callGlobalTool('set_flow_module_code', { + path: 'f/flows/locked', + module_id: 'edited', + code: 'import yaml' + }) + + const [edited, untouched] = getBackendDraft('flow', 'f/flows/locked').value.modules + expect(edited.value.content).toBe('import yaml') + expect(edited.value).not.toHaveProperty('lock') + expect(untouched.value.lock).toBe('requests==2.0') + }) + it('warns about every empty rawscript body (top-level, nested, preprocessor, failure) and skips populated ones', async () => { const result = JSON.parse( await callGlobalTool('write_flow', { @@ -5200,7 +5289,7 @@ describe('global AI tools', () => { opened = form.args kind = form.kind helperSource = { code: form.code, lang: form.lang } - return { name: 'Grace' } + return { kind: 'args', args: { name: 'Grace' } } } } ) @@ -5241,7 +5330,10 @@ describe('global AI tools', () => { vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce(script) const statuses: any[] = [] - const requestRunArgs = vi.fn(async (_toolId: string, form: any) => form.args) + const requestRunArgs = vi.fn(async (_toolId: string, form: any) => ({ + kind: 'args', + args: form.args + })) const yolo = { ...toolCallbacks, setToolStatus: (_toolId: string, status: any) => statuses.push(status), @@ -5299,7 +5391,7 @@ describe('global AI tools', () => { ...toolCallbacks, setToolStatus: (_toolId: string, status: any) => statuses.push(status), shouldAutoAcceptToolConfirmations: () => true, - requestRunArgs: async (_toolId: string, form: any) => form.args + requestRunArgs: async (_toolId: string, form: any) => ({ kind: 'args', args: form.args }) } ) ) @@ -5330,7 +5422,7 @@ describe('global AI tools', () => { { ...toolCallbacks, shouldAutoAcceptToolConfirmations: () => true, - requestRunArgs: async (_toolId: string, form: any) => form.args + requestRunArgs: async (_toolId: string, form: any) => ({ kind: 'args', args: form.args }) } ) ) @@ -5361,7 +5453,7 @@ describe('global AI tools', () => { { ...toolCallbacks, shouldAutoAcceptToolConfirmations: () => true, - requestRunArgs: async (_toolId: string, form: any) => form.args + requestRunArgs: async (_toolId: string, form: any) => ({ kind: 'args', args: form.args }) } ) ) @@ -5394,6 +5486,124 @@ describe('global AI tools', () => { expect(refused).toContain('cannot show a run form') }) + // A surface that confirmed the run is waiting for the job it was promised, and a refused + // start is the one end it cannot see for itself: the call is neither pending nor cancelled, + // and nothing else tells it to stop offering a Run that would never reach a job. + it('run_script says the form is over when the job is refused', async () => { + vi.mocked(ScriptService.getScriptByPath).mockResolvedValue({ + path: 'f/scripts/refused', + schema: { properties: { name: { type: 'string' } } } + } as any) + vi.mocked(JobService.runScriptByPath).mockRejectedValueOnce(new Error('not permitted')) + const markRunFormEnded = vi.fn() + + await expect( + callGlobalTool( + 'run_script', + { path: 'f/scripts/refused', args: { name: 'Ada' } }, + { ...toolCallbacks, markRunFormEnded } + ) + ).rejects.toThrow('not permitted') + + expect(markRunFormEnded).toHaveBeenCalledWith(expect.any(String)) + }) + + // A chat-enabled flow's deployed page is a conversation with no run form, so it is opened + // to look at and the card keeps the run. The card reads this off the form rather than + // fetching the flow again. + it('run_flow marks a chat-enabled flow as conversational on its form', async () => { + vi.mocked(FlowService.getFlowByPath).mockResolvedValue({ + path: 'f/flows/chat', + schema: { properties: { user_message: { type: 'string' } } }, + value: { modules: [], chat_input_enabled: true } + } as any) + + const statuses: any[] = [] + await withCompletedTestJob(() => + callGlobalTool( + 'run_flow', + { path: 'f/flows/chat', args: { user_message: 'hi' } }, + { ...toolCallbacks, setToolStatus: (_id: string, s: any) => statuses.push(s) } + ) + ) + + expect(statuses.find((s) => s.runForm)?.runForm?.conversational).toBe(true) + }) + + // A run confirmed on the item's own page answers with the job it started, so this tool + // never sees a submission: the card and the model would otherwise be left describing the + // proposal as what ran, which is wrong as soon as the reader edits a field. + it('run_script reports an adopted job with the arguments that page sent', async () => { + vi.mocked(ScriptService.getScriptByPath).mockResolvedValue({ + path: 'f/scripts/adopted', + schema: { properties: { name: { type: 'string' } } } + } as any) + + const statuses: any[] = [] + const result = await withCompletedTestJob(() => + callGlobalTool( + 'run_script', + { path: 'f/scripts/adopted', args: { name: 'Ada' } }, + { + ...toolCallbacks, + setToolStatus: (_id: string, s: any) => statuses.push(s), + requestRunArgs: async () => ({ + kind: 'job', + jobId: 'job-123', + args: { name: 'Grace' }, + schema: { properties: { name: { type: 'string' } } } + }) + } + ) + ) + + // Started by the page, so this tool must not start one of its own. + expect(JobService.runScriptByPath).not.toHaveBeenCalled() + expect(statuses.filter((s) => s.parameters).at(-1)?.parameters).toEqual({ name: 'Grace' }) + expect(result).toContain('Grace') + expect(result).not.toContain('Ada') + }) + + // Redaction only visits the leaves the schema it is given declares, and the page runs the + // deployed version as it is now — which a deploy can have given an argument this call's + // schema never had. Redacting with ours would put the bytes in the persisted card and in + // the model's context. + it('run_script redacts an adopted job against the schema that page ran', async () => { + vi.mocked(ScriptService.getScriptByPath).mockResolvedValue({ + path: 'f/scripts/redeployed', + schema: { properties: { name: { type: 'string' } } } + } as any) + + const statuses: any[] = [] + const result = await withCompletedTestJob(() => + callGlobalTool( + 'run_script', + { path: 'f/scripts/redeployed', args: { name: 'Ada' } }, + { + ...toolCallbacks, + setToolStatus: (_id: string, s: any) => statuses.push(s), + requestRunArgs: async () => ({ + kind: 'job', + jobId: 'job-123', + args: { name: 'Grace', attachment: 'QUJDREVG' }, + // The deployed version the page loaded declares a file this call never saw. + schema: { + properties: { + name: { type: 'string' }, + attachment: { type: 'string', contentEncoding: 'base64' } + } + } + }) + } + ) + ) + + const parameters = statuses.filter((s) => s.parameters).at(-1)?.parameters + expect(parameters?.name).toBe('Grace') + expect(parameters?.attachment).not.toBe('QUJDREVG') + expect(result).not.toContain('QUJDREVG') + }) + // The posture answers wherever it is set, form or no form: what it answers is consent, and a // host without one has nothing left to ask. A secret still becomes a reference first, which // is the only thing the missing form would have done. @@ -5447,7 +5657,7 @@ describe('global AI tools', () => { setToolStatus: (_toolId: string, status: any) => statuses.push(status), // The user pastes into the field the model left small: the model's own // proposal is bounded by what it can emit, this is not. - requestRunArgs: async () => ({ blob: huge }) + requestRunArgs: async () => ({ kind: 'args', args: { blob: huge } }) } ) ) @@ -5507,7 +5717,10 @@ describe('global AI tools', () => { callGlobalTool( 'test_run_script', { path: 'f/scripts/noargs-test', args: { force_delete: true } }, - { ...toolCallbacks, requestRunArgs: async (_toolId, form) => form.args } + { + ...toolCallbacks, + requestRunArgs: async (_toolId, form) => ({ kind: 'args', args: form.args }) + } ) ) @@ -5532,7 +5745,10 @@ describe('global AI tools', () => { callGlobalTool( 'run_script', { path: 'f/scripts/host-literal', args: { token: 'hunter2' } }, - { ...toolCallbacks, requestRunArgs: async (_toolId, form) => form.args } + { + ...toolCallbacks, + requestRunArgs: async (_toolId, form) => ({ kind: 'args', args: form.args }) + } ) ) @@ -5555,7 +5771,7 @@ describe('global AI tools', () => { { ...toolCallbacks, shouldAutoAcceptToolConfirmations: () => true, - requestRunArgs: async (_toolId: string, form: any) => form.args + requestRunArgs: async (_toolId: string, form: any) => ({ kind: 'args', args: form.args }) } ) ) @@ -5659,7 +5875,10 @@ describe('global AI tools', () => { path: 'u/admin/live_flow', args: { name: 'Ada' } }, - { ...toolCallbacks, requestRunArgs: async () => ({ name: 'Grace' }) }, + { + ...toolCallbacks, + requestRunArgs: async () => ({ kind: 'args', args: { name: 'Grace' } }) + }, { testActiveFlow } ) ) @@ -5836,7 +6055,7 @@ describe('global AI tools', () => { ...toolCallbacks, requestRunArgs: async (_toolId, f) => { form = f - return { name: 'Grace' } + return { kind: 'args', args: { name: 'Grace' } } } } ) @@ -5902,7 +6121,7 @@ describe('global AI tools', () => { storagePath: 'u/admin/other_flow', effectivePath: 'u/admin/other_flow' }) - return form.args + return { kind: 'args', args: form.args } } }, { testActiveFlow } @@ -6005,7 +6224,7 @@ describe('global AI tools', () => { ...toolCallbacks, requestRunArgs: async (_toolId, f) => { form = f - return { name: 'Grace' } + return { kind: 'args', args: { name: 'Grace' } } } }, { testActiveFlow } @@ -6237,7 +6456,7 @@ describe('global AI tools', () => { ...toolCallbacks, requestRunArgs: async (_toolId, form) => { opened = form - return form.args + return { kind: 'args', args: form.args } } } ) @@ -6294,9 +6513,12 @@ describe('global AI tools', () => { // string, a list as `[]`. callbacks: { requestRunArgs: async (_toolId: string, form: any) => ({ - ...form.args, - user_message: '', - enabled_tools: form.args.enabled_tools ?? [] + kind: 'args' as const, + args: { + ...form.args, + user_message: '', + enabled_tools: form.args.enabled_tools ?? [] + } }) } }, @@ -6304,7 +6526,7 @@ describe('global AI tools', () => { answer: 'the bypass posture', callbacks: { shouldAutoAcceptToolConfirmations: () => true, - requestRunArgs: async (_toolId: string, form: any) => form.args + requestRunArgs: async (_toolId: string, form: any) => ({ kind: 'args', args: form.args }) } } ])( @@ -6406,7 +6628,7 @@ describe('global AI tools', () => { ...toolCallbacks, requestRunArgs: async (_toolId, form) => { opened = form - return form.args + return { kind: 'args', args: form.args } } } ) @@ -6451,7 +6673,7 @@ describe('global AI tools', () => { ...toolCallbacks, requestRunArgs: async (_toolId, f) => { form = f - return { name: 'Grace' } + return { kind: 'args', args: { name: 'Grace' } } } } ) @@ -6497,7 +6719,10 @@ describe('global AI tools', () => { callGlobalTool( 'test_run_step', { path: 'f/flows/drifted-step', stepId: 'call_script', args: { name: 'Ada' } }, - { ...toolCallbacks, requestRunArgs: async (_toolId, f) => ((form = f), f.args) } + { + ...toolCallbacks, + requestRunArgs: async (_toolId, f) => ((form = f), { kind: 'args', args: f.args }) + } ) ) @@ -6536,7 +6761,10 @@ describe('global AI tools', () => { callGlobalTool( 'test_run_step', { path: 'f/flows/secretful-step', stepId: 'call_secretful', args: {} }, - { ...toolCallbacks, requestRunArgs: async (_toolId, f) => ((form = f), f.args) } + { + ...toolCallbacks, + requestRunArgs: async (_toolId, f) => ((form = f), { kind: 'args', args: f.args }) + } ) ) @@ -6565,7 +6793,10 @@ describe('global AI tools', () => { callGlobalTool( 'test_run_step', { path: 'f/flows/preprocessed', stepId: 'preprocessor', args: { event: 'signup' } }, - { ...toolCallbacks, requestRunArgs: async (_toolId, f) => ((form = f), f.args) } + { + ...toolCallbacks, + requestRunArgs: async (_toolId, f) => ((form = f), { kind: 'args', args: f.args }) + } ) ) @@ -6649,7 +6880,10 @@ describe('global AI tools', () => { reset = form.resetKeys // What the user does with the form: attaches the file no model can produce, // and names a variable for the secret it was not allowed to fill. - return { ...form.args, doc: bytes, token: '$var:u/ada/prod_api_key' } + return { + kind: 'args', + args: { ...form.args, doc: bytes, token: '$var:u/ada/prod_api_key' } + } } } ) @@ -6712,7 +6946,7 @@ describe('global AI tools', () => { isPlanModeActive: () => planning, requestRunArgs: async (_toolId, form) => { planning = true - return form.args + return { kind: 'args', args: form.args } } } ) @@ -6742,7 +6976,7 @@ describe('global AI tools', () => { isPlanModeActive: () => planning, requestRunArgs: async (_toolId, form) => { formOpened = true - return form.args + return { kind: 'args', args: form.args } } } ) @@ -6762,7 +6996,10 @@ describe('global AI tools', () => { callGlobalTool( 'run_script', { path: 'f/scripts/greet', args: { name: 'Ada' } }, - { ...toolCallbacks, requestRunArgs: async () => ({ name: 'Grace' }) } + { + ...toolCallbacks, + requestRunArgs: async () => ({ kind: 'args', args: { name: 'Grace' } }) + } ) ) @@ -6789,7 +7026,10 @@ describe('global AI tools', () => { callGlobalTool( 'run_script', { path: 'f/scripts/greet', args: { name: 'Ada' } }, - { ...toolCallbacks, requestRunArgs: async (_toolId, form) => form.args } + { + ...toolCallbacks, + requestRunArgs: async (_toolId, form) => ({ kind: 'args', args: form.args }) + } ) ) @@ -7223,7 +7463,7 @@ describe('prepareGlobalSystemMessage', () => { }) expect(content).toContain('As a workspace admin you can write to any existing folder.') expect(content).toContain( - 'Folders here include `f/marketing`, `f/data_engineering` (you can also write to others not listed).' + 'Folders here include `f/marketing`, `f/data_engineering` (you can also write to others not listed; list_workspace_items with type "folder" lists them all).' ) expect(content).toContain( 'If the user names a folder, use it; if they explicitly ask for a new folder, create it with `create_folder`; otherwise ask them which folder to use rather than guessing or creating one unprompted.' @@ -8147,3 +8387,27 @@ describe('global prompt identity', () => { expect(get(userStore)?.folders).toEqual(['alice_stuff']) }) }) + +describe('create_resource_type', () => { + // The Add resource type form prefixes custom types with c_ unless an admin turns it off, so a + // hub type synced later under the same name cannot clash with one the chat created. + it.each([ + ['acme_billing', 'c_acme_billing'], + ['c_acme_billing', 'c_acme_billing'] + ])('creates %s as %s', async (given, created) => { + vi.mocked(ResourceService.createResourceType).mockClear() + resourceTypesStore.set(['postgresql']) + + const result = await callGlobalTool('create_resource_type', { + name: given, + description: 'Acme billing', + schema: { type: 'object' } + }) + + expect(vi.mocked(ResourceService.createResourceType).mock.calls[0][0].requestBody.name).toBe( + created + ) + expect(result).toContain(created) + expect(get(resourceTypesStore)).toBeUndefined() + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index c23161c215..66af4ab332 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -7,6 +7,7 @@ import { FlowService, FolderService, GcpTriggerService, + GroupService, HttpTriggerService, JobService, KafkaTriggerService, @@ -46,12 +47,13 @@ import type { ScriptLang } from '$lib/gen/types.gen' import { loadSchemaFromModule, memoryPropertyFor } from '$lib/components/flows/flowInfers' -import { flowLocalAgentSchema } from '$lib/components/flows/agentResourceUtils' +import { linkedAgentStepSchema } from '$lib/components/flows/agentResourceUtils' import { AGENT_FIELDS, initialVisibleAgentFields } from '$lib/components/flows/agentFormFields' import { withAgentDrafts } from '$lib/components/flows/linkedAgentDrafts' import { resolveLinkedAgentTools } from '$lib/components/flows/flowState' import { enabledToolNames, type AgentTool } from '$lib/components/flows/agentToolUtils' import { evalValue } from '$lib/components/flows/utils.svelte' +import { dropStaleStepLocks } from '$lib/components/flows/staleStepLocks' import { quickjsReady } from '$lib/utils/quickjsEval.svelte' import { updateRawAppPolicy } from '$lib/components/raw_apps/rawAppPolicy' import { @@ -77,7 +79,9 @@ import { } from '$lib/components/raw_apps/dataTableRefUtils' import { appSourceToDraftValue } from '$lib/components/raw_apps/rawAppDraftValue' import { conformBuilderAppPolicy } from '$lib/components/raw_apps/builderAppPolicy' -import { operatorBuilderAppsIn } from '$lib/operatorWriteRights' +import { capabilitiesInWorkspace } from '$lib/operatorWriteRights' +import { capabilitiesIn } 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' import { sanitizeAttachmentName, textLineCount, type AttachedTextFile } from '../textFileUtils' @@ -109,7 +113,12 @@ import { createInlineScriptSession, findUnresolvedInlineScriptRefs } from '../flow/inlineScriptsUtils' -import { searchNpmPackagesTool } from '../script/core' +import { + resourceTypeSyncBlock, + searchNpmPackagesTool, + searchResourceTypes, + type ResourceTypeSync +} from '../script/core' import type { McpServer } from './mcpTools' import { logFeatureUsage } from '$lib/utils/featureUsage' import { isSkillEnabled } from '../skills/enabledSkills' @@ -123,6 +132,7 @@ import { import { MAX_SKILL_DESCRIPTION_LENGTH, MAX_SKILL_INSTRUCTIONS_LENGTH } from '../skills/skillMd' import { getDatatablePrompt, + getWorkspaceSettingsPrompt, getDatatableSdkReference, getFlowPrompt, getPipelinePrompt, @@ -152,6 +162,7 @@ import { SPECIAL_MODULE_IDS, type CreatedResourceTriggerKind, type PreviewCardKind, + type RunFormAnswer, type RunFormDisplay, type ToolCallbacks, type ToolCodeDiff, @@ -169,7 +180,7 @@ import { fileTools } from '../files/fileTools' import type { AttachedFilesStore } from '../files/attachedFiles.svelte' import { artifactTools } from '../artifacts/artifactTools' import type { SessionArtifactsStore } from '../artifacts/artifactsState.svelte' -import type { Runnable } from '$lib/components/apps/inputType' +import type { PathRunType, Runnable } from '$lib/components/apps/inputType' import { UserDraft } from '$lib/userDraft.svelte' import { emptySchema } from '$lib/utils' import type { Schema } from '$lib/common' @@ -204,6 +215,7 @@ import { } from '$lib/stores' import { getWorkspaceRole, type RoleLookup } from '$lib/user' import { refreshSuperadmin } from '$lib/refreshUser' +import { resourceTypesStore } from '$lib/components/resourceTypesStore' import { get } from 'svelte/store' import { canonicalDraftSideValue, @@ -211,6 +223,7 @@ import { getDraftDiffValues } from '$lib/utils_draft_deploy' import { changedLineIndices, draftDeployedPatch, windowPatch } from './draftDiff' +import { workspaceSettingsTools } from './workspaceSettingsTools' import { getRun, summarizeRun } from './flowRunTree' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { invalidateWorkspaceComparison } from '$lib/workspaceComparison' @@ -225,12 +238,15 @@ import { WRITE_APP_DRAFT, WRITE_DRAFT, WRITE_FLOW_DRAFT, + WRITE_SCHEDULE_DRAFT, + WRITE_TRIGGER_DRAFT, + WRITE_RESOURCE_DRAFT, + WRITE_VARIABLE_DRAFT, type SessionAccess, type SessionTool, type SessionToolPolicy } from '../sessionCapabilities' import { OPERATOR_SETTINGS_LOCATION } from '../assistantPermissions' -import { capitalize } from '$lib/utils/capitalize' import { buildRunsUrl, buildSchedulesUrl, @@ -311,8 +327,12 @@ const INSTRUCTION_SUBJECTS = [ // datatable SDK reference (the wmill.datatable() runnable API) the same way. // `pipeline` likewise isn't an item type — a data pipeline is a set of // annotated scripts in a folder, so it gets authoring guidance, not a CRUD type. -const INSTRUCTION_SUBJECTS_EXTRA = ['datatable', 'pipeline'] as const -const ALL_INSTRUCTION_SUBJECTS = [...INSTRUCTION_SUBJECTS, ...INSTRUCTION_SUBJECTS_EXTRA] as const +// `workspace_settings` is the settings document an admin reads and changes. +const INSTRUCTION_SUBJECTS_EXTRA = ['datatable', 'pipeline', 'workspace_settings'] as const +export const ALL_INSTRUCTION_SUBJECTS = [ + ...INSTRUCTION_SUBJECTS, + ...INSTRUCTION_SUBJECTS_EXTRA +] as const const MAX_LIST_LIMIT = 100 type ActiveGlobalEditorType = Extract<WorkspaceItemType, 'script' | 'flow' | 'app'> type LiveEditorDraftKind = Parameters<typeof UserDraft.getLiveEditorDraft>[0] @@ -360,16 +380,26 @@ export type GlobalUserMessageOptions = { /** Text files attached to this message; listed by reference below — the model * reads their content on demand via the file tools. */ files?: AttachedTextFile[] + /** From `resolveResourceTypeSync`; absent on a synced instance, which is told nothing. */ + resourceTypeSync?: ResourceTypeSync } const itemTypeSchema = z.enum(ITEM_TYPES) +// Folders and groups only carry permissions, so they are readable but kept out of +// `itemTypeSchema`, which the draft, deploy and delete tools share. +const ACCESS_TYPES = ['folder', 'group'] as const +type AccessType = (typeof ACCESS_TYPES)[number] +const readableTypeSchema = z.enum([...ITEM_TYPES, ...ACCESS_TYPES]) +function isAccessType(type: string): type is AccessType { + return (ACCESS_TYPES as readonly string[]).includes(type) +} const instructionSubjectSchema = z.enum(ALL_INSTRUCTION_SUBJECTS) const triggerKindSchema = z.enum(TRIGGER_KINDS) const scriptLangSchema = z.enum($ScriptLang.enum) const getInstructionsSchema = z.object({ subject: instructionSubjectSchema.describe( - 'What to get authoring instructions for: a workspace item type (script, flow, resource, app), "pipeline" for building a data pipeline (a DAG of annotated scripts wired by storage assets — NOT a flow), or "datatable" for how data tables work (schema changes and migrations, roles and permissions) plus the wmill.datatable() SQL SDK used inside runnables. Schedules, triggers, and variables don\'t need instructions — their tool schemas describe everything.' + 'What to get authoring instructions for: a workspace item type (script, flow, resource, app), "pipeline" for building a data pipeline (a DAG of annotated scripts wired by storage assets — NOT a flow), or "datatable" for how data tables work (schema changes and migrations, roles and permissions) plus the wmill.datatable() SQL SDK used inside runnables, or "workspace_settings" for the keys and rules of the workspace settings (admins only). Schedules, triggers, and variables don\'t need instructions — their tool schemas describe everything.' ), language: scriptLangSchema .optional() @@ -439,10 +469,10 @@ const listPipelinesSchema = z.object({}) const listWorkspaceItemsSchema = z.object({ types: z - .array(itemTypeSchema) + .array(readableTypeSchema) .optional() .describe( - 'Optional item types to list. Defaults to scripts and flows. Pass schedule or trigger explicitly when needed (listing triggers spans 9 kinds and is heavier).' + 'Optional item types to list. Defaults to scripts and flows. Pass schedule or trigger explicitly when needed (listing triggers spans 9 kinds and is heavier). "folder" lists folders with their owners and extraPerms; "group" lists groups with their extraPerms (read one for its members). path_prefix does not apply to these two.' ), query: z.string().optional().describe('Optional case-insensitive path or summary search string.'), path_prefix: z @@ -469,11 +499,11 @@ const listWorkspaceItemsSchema = z.object({ }) const readWorkspaceItemSchema = z.object({ - type: itemTypeSchema, + type: readableTypeSchema, path: z .string() .describe( - 'Workspace path of the item to read, or a hub/<version>/<app>/<name> path from search_hub_scripts to read a hub script.' + 'Workspace path of the item to read, or a hub/<version>/<app>/<name> path from search_hub_scripts to read a hub script. For a folder or group, its name.' ), trigger_kind: triggerKindSchema .optional() @@ -735,6 +765,20 @@ const searchResourceTypesSchema = z.object({ .describe('Max number of resource types to return. Defaults to 5.') }) +const createResourceTypeSchema = z.object({ + name: z + .string() + .describe( + 'Type name with the c_ prefix of custom types, e.g. c_acme_billing (added when missing): 1 to 50 letters, digits, "_" or "-". Resources and resource-typed parameters refer to it by this name.' + ), + description: z.string().describe('What a resource of this type connects to.'), + schema: z + .record(z.string(), z.any()) + .describe( + 'JSON Schema of a resource value of this type: { "type": "object", "properties": {...}, "required": [...] }, with every connection field, secrets included (a resource fills those with "$var:" references).' + ) +}) + const getRunSchema = z.object({ id: z.string().describe('The UUID of the run (job) to inspect.'), step: z @@ -968,7 +1012,7 @@ const runScriptSchema = z.object({ const runScriptToolDef = createToolDef( runScriptSchema, 'run_script', - 'Run a DEPLOYED script for real, under the user\'s own permissions. Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs. For a secret argument prefer `$var:<path>` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call. A required file is the user\'s to attach, so call this even when you cannot supply one rather than asking in chat. Use only when the user names the deployed version ("the deployed X", "in production", "for real").', + 'Run a DEPLOYED script for real, under the user\'s own permissions. Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs. In an AI session the user can open that form on the script\'s own deployed page instead; either way this call waits for them and runs what they confirm. For a secret argument prefer `$var:<path>` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call. A required file is the user\'s to attach, so call this even when you cannot supply one rather than asking in chat. Use only when the user names the deployed version ("the deployed X", "in production", "for real").', { strict: false } ) @@ -1007,7 +1051,7 @@ const runFlowSchema = z.object({ const runFlowToolDef = createToolDef( runFlowSchema, 'run_flow', - 'Run a DEPLOYED flow for real, under the user\'s own permissions. Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs. For a secret argument prefer `$var:<path>` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call. A required file is the user\'s to attach, so call this even when you cannot supply one rather than asking in chat. Use only when the user names the deployed version ("the deployed X", "in production", "for real").', + 'Run a DEPLOYED flow for real, under the user\'s own permissions. Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs. In an AI session the user can open that form on the flow\'s own deployed page instead; either way this call waits for them and runs what they confirm. For a secret argument prefer `$var:<path>` naming an existing workspace variable; a literal is minted into a short-lived secret before the run, but stays in this call. A required file is the user\'s to attach, so call this even when you cannot supply one rather than asking in chat. Use only when the user names the deployed version ("the deployed X", "in production", "for real").', { strict: false } ) @@ -1032,9 +1076,9 @@ const backendRunnableSchema = z .object({ name: z.string().describe('Short summary/description of what the runnable does.'), type: z - .enum(['inline', 'script', 'flow', 'hubscript']) + .enum(['inline', 'script', 'flow', 'hubscript', 'agent']) .describe( - 'Runnable kind: "inline" for custom code stored on the app; "script"/"flow" for a workspace runnable; "hubscript" for a hub script.' + '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.' ), staticInputs: z .record(z.string(), z.any()) @@ -1053,7 +1097,7 @@ const backendRunnableSchema = z .string() .optional() .describe( - 'Required when type is "script", "flow", or "hubscript". Workspace path of the runnable.' + 'Required when type is "script", "flow", "hubscript" or "agent". Workspace path of the runnable.' ), viewerDataAccess: z .boolean() @@ -1370,21 +1414,25 @@ const createFolderSchema = z.object({ // `folders`/`foldersRead` are undefined when the user's role in this workspace could not // be resolved. -type FolderPromptContext = { folders?: string[]; foldersRead?: string[]; isAdmin: boolean } +type FolderPromptContext = { + folders?: string[] + foldersRead?: string[] + groups?: string[] + isAdmin: boolean +} // Renders the folders the current user can act on into the system prompt so the -// model can pick an `f/<folder>/...` path without a discovery round-trip (there -// is no folder-listing tool). For a non-admin, `folders` (from whoami) is exactly -// the writable set, so read-only folders are listed separately as off-limits. -// Admins bypass folder ACLs and can write anywhere, but `folders` only carries -// their explicitly-permissioned subset, so for admins it is offered as a -// non-exhaustive hint alongside permission-agnostic guidance (the complete set -// needs a folder-listing tool — follow-up). +// model can pick an `f/<folder>/...` path without a discovery round-trip. For a +// non-admin, `folders` (from whoami) is exactly the writable set, so read-only +// folders are listed separately as off-limits. Admins bypass folder ACLs and can +// write anywhere, but `folders` only carries their explicitly-permissioned subset, +// so for admins it is a non-exhaustive hint and list_workspace_items has the rest. // Capped so a folder-heavy workspace can't dominate the prompt. function buildFolderGuidance( username: string, ctx: FolderPromptContext | undefined, - canCreateFolder: boolean + canCreateFolder: boolean, + external = false ): string { if (!ctx) return '' const MAX = 40 @@ -1399,7 +1447,7 @@ function buildFolderGuidance( if (ctx.isAdmin) { const known = writable.length > 0 - ? ` Folders here include ${fmt(writable)} (you can also write to others not listed).` + ? ` Folders here include ${fmt(writable)} (you can also write to others not listed; list_workspace_items with type "folder" lists them all).` : '' return `- As a workspace admin you can write to any existing folder.${known} If the user names a folder, use it;${canCreateFolder ? ' if they explicitly ask for a new folder, create it with `create_folder`;' : ''} otherwise ask them which folder to use rather than guessing${canCreateFolder ? ' or creating one unprompted' : ''}.` } @@ -1412,7 +1460,7 @@ function buildFolderGuidance( const lines: string[] = [] if (writable.length > 0) { lines.push( - `- Folders you can write to in this workspace: ${fmt(writable)}. For shared/team work, pick the one whose purpose matches the request; if none clearly fits, ask which folder to use (askUserQuestion) rather than inventing a path.${canCreateFolder ? ' Use `create_folder` only when the user explicitly asks for a new folder.' : ''}` + `- Folders you can write to in this workspace: ${fmt(writable)}. For shared/team work, pick the one whose purpose matches the request; if none clearly fits, ask which folder to use${external ? '' : ' (askUserQuestion)'} rather than inventing a path.${canCreateFolder ? ' Use `create_folder` only when the user explicitly asks for a new folder.' : ''}` ) } else { lines.push( @@ -1431,8 +1479,8 @@ function buildFolderGuidance( return lines.join('\n') } -const listJoin = (items: (string | false)[]) => - new Intl.ListFormat('en', { type: 'conjunction' }).format(items.filter((i): i is string => !!i)) +const listJoin = (items: (string | false)[], type: 'conjunction' | 'disjunction' = 'conjunction') => + new Intl.ListFormat('en', { type }).format(items.filter((i): i is string => !!i)) const buildGlobalSystemPrompt = ( username: string, @@ -1440,32 +1488,89 @@ const buildGlobalSystemPrompt = ( folderCtx?: FolderPromptContext, skills: AiSkillListItem[] = [], mcpServers: McpServer[] = [], - access?: SessionAccess + access?: SessionAccess, + // The prompt as an external MCP client reads it: the same rules, minus the tools and + // surfaces only the Windmill UI has (pages, the run form, questions, the open editor). + external = false ) => { // Each `can*` mirrors the capability the matching tools declare in `requires`, so a // rule cannot outlive the tool it describes. An unresolved profile keeps every block. const canWriteDraft = !access || access.has('write_draft') const canWriteFlowDraft = !access || access.has('write_flow_draft') const canWriteAppDraft = !access || access.has('write_app_draft') - // A builder operator: drafts of the kinds the workspace lets them compose, and nothing else. + const canDraftSchedules = !access || access.has('write_schedule_draft') + const canDraftTriggers = !access || access.has('write_trigger_draft') + const canDraftResources = !access || access.has('write_resource_draft') + const canDraftVariables = !access || access.has('write_variable_draft') + const inlineFlowCode = !access || access.has('write_inline_flow_code') + const inlineAppCode = !access || access.has('write_inline_app_code') + // A role that composes a kind out of deployed runnables, with no inline code of its own. // Blocks about drafting in general follow `canWriteAnyDraft`; blocks naming scripts or the // other kinds keep `canWriteDraft`. - const buildsFlows = canWriteFlowDraft && !canWriteDraft - const buildsApps = canWriteAppDraft && !canWriteDraft - const canWriteAnyDraft = canWriteDraft || canWriteFlowDraft || canWriteAppDraft + const buildsFlows = canWriteFlowDraft && !inlineFlowCode + const buildsApps = canWriteAppDraft && !inlineAppCode + const canWriteAnyDraft = + canWriteDraft || + canWriteFlowDraft || + canWriteAppDraft || + canDraftSchedules || + canDraftTriggers || + canDraftResources || + canDraftVariables + const canDeploy = !access || access.has('deploy') + // 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 = + canDeploy || access.has('manage_schedules') || access.has('manage_triggers') + // These read only where `write_draft` is missing: it implies the other draft rights. + const draftedKinds = [ + canDraftResources && 'resources', + canDraftVariables && 'variables', + canDraftSchedules && 'schedules', + canDraftTriggers && 'triggers' + ].filter((k): k is string => !!k) + const draftedOperatorKinds = listJoin(draftedKinds) + // Resources and variables deploy under the rules; schedules and triggers under the right + // that also lets them be drafted. + const deployableKinds = draftedKinds.filter( + (k) => canDeploy || k === 'schedules' || k === 'triggers' + ) + const refusedKinds = [ + !buildsFlows && 'flows', + 'scripts', + !buildsApps && 'apps', + !canDraftSchedules && 'schedules', + !canDraftTriggers && 'triggers', + !canDraftResources && 'resources', + !canDraftVariables && 'variables' + ].filter((k): k is string => !!k) const refusedCode = ['scripts', !buildsFlows && 'flows', !buildsApps && 'apps'].filter( (k): k is string => !!k ) const canRunPreview = !access || access.has('run_preview') + const canRunFlowPreview = !access || access.has('run_flow_preview') + // A role may code without composing flows, so the flow test tool is named only where it ships. + const testRunTools = canRunFlowPreview + ? 'test_run_script, test_run_flow, or test_run_step' + : 'test_run_script or test_run_step' const canManageSchedules = !access || access.has('manage_schedules') const canManageTriggers = !access || access.has('manage_triggers') - // The deploy tools take their kind as an argument, so `deploy` gates only the folder text. + // The deploy tools take their kind as an argument, so `deploy` gates only the folder and + // resource type text. const canCreateFolder = !access || access.has('deploy') + const canCreateResourceType = !access || access.has('deploy') // Each gated block carries its own leading newline, so dropping one leaves no blank // line behind and a full-access prompt is byte-for-byte the ungated text. const when = (cond: boolean, block: string) => (cond ? block : '') - const folderGuidance = buildFolderGuidance(username, folderCtx, canCreateFolder) + const folderGuidance = buildFolderGuidance(username, folderCtx, canCreateFolder, external) const folderGuidanceBlock = folderGuidance ? `\n${folderGuidance}` : '' + // A grant to `g/<group>` reaches the user only through membership, which no permission + // list shows. Outside the folder guidance, which a role that cannot draft never gets. + const memberOf = (folderCtx?.groups ?? []).filter((g) => g !== 'all') + const groupMembershipRule = + memberOf.length > 0 + ? `- The user is a member of ${memberOf.map((g) => `\`g/${g}\``).join(', ')}, so a grant to one of those groups is a grant to them.\n` + : '' // `previewTools` doubles as "this is a session chat" — sessions are the only // chats that get the preview tool set. The alpha heads-up only makes sense // there, where the chat actively builds the pipeline on the canvas; the @@ -1501,15 +1606,52 @@ const buildGlobalSystemPrompt = ( get(enterpriseLicense) ? 'Enterprise Edition' : 'Community Edition' }. Use that to judge whether a feature is available before promising it.` : '' + // The core rules, placed differently for an MCP client (see `coreRules`). + const draftsOnlyRule = when( + canWriteAnyDraft, + ` +- Draft tools create or update drafts only; they do not deploy or mutate deployed workspace items.` + ) + const findItemsRule = + '- Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind.' + const deployRule = when( + canWriteAnyDraft && canDeployAnyKind, + ` +- Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a draft to the workspace.` + ) + const instructionsRule = + '- Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language.' + const testDraftRule = `- After creating or editing a script${canWriteFlowDraft ? ' or flow' : ''} draft, run ${testRunTools} with representative args before reporting that it works. These tools prefer drafts, so testing does not require deployment.` + const testRunList = canRunFlowPreview + ? 'test_run_script, test_run_flow, test_run_step' + : 'test_run_script, test_run_step' + const runRule = `${when( + canRunPreview, + ` +- Default to ${testRunTools} for any run request, an existing script included; they prefer drafts and need no deployment. Use run_script or run_flow only when the user names the deployed version ("the deployed X", "in production", "for real") — a bare "run X" is not that. For those two, read the item with read_workspace_item version: "deployed" first so the arguments match the deployed schema. ${ + external + ? `${testRunList}, run_script and run_flow run with exactly the arguments you send, so fill in every argument the schema asks for. test_run_step takes the step's own inputs, not the flow's.` + : `${testRunList}, run_script and run_flow all show the user an argument form prefilled with what you sent, so fill in every argument you can infer rather than asking for it in chat. test_run_step's form is the step's own inputs, not the flow's.` + }` + )}${when( + !canRunPreview, + ` +- run_script and run_flow are how you run anything here: they run the DEPLOYED item under this user's own permissions, and show them an argument form prefilled with what you sent. Read the item with read_workspace_item version: "deployed" first so the arguments match the deployed schema, and fill in every argument you can infer rather than asking for it in chat.` + )}` - return `You are Windmill's global workspace assistant. + const intro = `${external ? 'You are working in a Windmill workspace through its MCP server.' : "You are Windmill's global workspace assistant."}${when( + external, + ` + +The user sees no Windmill page next to this conversation. Tool results carry links instead: a run's page, a script, flow or agent draft's url (its editor), an app draft's preview_url (the app editor with its live preview, which these instructions call "the preview"), a deployed item's url. Whenever the user will want to see what you built or ran, or has to do a step in Windmill themselves, open the link in a browser they can see from this conversation if you have one, and otherwise give them the link to open.` + )} 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 ${buildsFlows ? '' : 'flows, '}scripts, ${buildsApps ? '' : 'apps, '}schedules, triggers, resources or variables 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 ${refusedCode.length > 1 ? `${refusedCode.slice(0, -1).join(', ')} and ${refusedCode.at(-1)}` : refusedCode[0]} outside this chat too, so for those suggest asking a workspace admin, who grants operators more in ${OPERATOR_SETTINGS_LOCATION}, rather than creating them in the editor. ${capitalize(listJoin(['resources', 'variables', canManageSchedules && 'schedules', canManageTriggers && 'triggers']))} they can still edit themselves on their pages, so when open_page lists that page, open it for them instead.${when( + : `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( @@ -1518,8 +1660,24 @@ ${ )}${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` + : '' + }.` )}` -}${when( +}` + const paths = `${when( canWriteAnyDraft, ` @@ -1535,20 +1693,33 @@ Path conventions: )}${when( canCreateFolder && !canWriteAnyDraft, '\n- You can create a shared folder with `create_folder` when the user explicitly asks for one. You cannot create anything inside it here, so do not offer to.' - )} + )}` + // An MCP client may keep only the first 4096 characters of its instructions, so there + // these rules come before the path conventions and are left out of the later list. + const coreRules = `${draftsOnlyRule} +${findItemsRule}${deployRule}${when(canWriteDraft, `\n${instructionsRule}`)}${when( + canRunPreview && canWriteDraft, + `\n${testDraftRule}` + )}${runRule}` -Rules:${when( + return `${intro}${when(external, `\n\nRules:${coreRules}`)}${paths} + +${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, + " 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( + !external, + ` +- If the user message includes an ACTIVE EDITOR section, treat it as the currently open item and use it for references like "this", "current", or "open editor".` + )}${activePreviewRule}${when(!external, deployRule)}${when( canWriteAnyDraft, ` -- Draft tools create or update drafts only; they do not deploy or mutate deployed workspace items.` - )} -- Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind. -- 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. -- If the user message includes an ACTIVE EDITOR section, treat it as the currently open item and use it for references like "this", "current", or "open editor".${activePreviewRule}${when( - canWriteAnyDraft, - ` -- Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a draft to the workspace. -- To undo something you created or changed in this chat, use discard_local_draft: everything you write is a draft until it is explicitly deployed, so "delete it" / "never mind" / "remove that" about your own work means discarding the draft (it also clears the matching open editor draft). 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.` +- To undo something you created or changed in this chat, use discard_local_draft: everything you write is a draft until it is explicitly deployed, so "delete it" / "never mind" / "remove that" about your own work means discarding the draft (it also clears the matching open editor draft).${when( + canDeployAnyKind, + ' 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, ` @@ -1565,20 +1736,25 @@ Rules:${when( }, here or in the editor, so never ask them to create or edit one: suggest asking a workspace admin.` )} - Use diff to review changes — before deploying, or when the user asks what changed. It is read-only: without arguments it lists every draft in the workspace with its change status; with type+path it returns that item's unified diff (for multi-file apps, pass file to read one file's diff). In a fork, pass against="parent_workspace" to compare the deployed fork with its parent workspace instead. Pass search to grep changed lines across all diffs.${when( + canDraftVariables, + ` +- You can never read a variable's value, secret or not, so never invent one: when editing an existing variable, omit value (and is_secret) from write_variable and pass only the fields you are actually changing. The user can reveal a value in the variable editor; you cannot, so never tell them a value is unreadable in general. "$var:path/to/variable" is how a resource value references a variable — it is never a variable's own value.` + )}${when( canWriteDraft, ` -- You can never read a variable's value, secret or not, so never invent one: when editing an existing variable, omit value (and is_secret) from write_variable and pass only the fields you are actually changing. The user can reveal a value in the variable editor; you cannot, so never tell them a value is unreadable in general. "$var:path/to/variable" is how a resource value references a variable — it is never a variable's own value. -- Use search_resource_types before write_resource, and get_trigger_schema before write_trigger: the trigger config fields differ per kind and are not listed in the write_trigger definition. +- Use search_resource_types before write_resource${ + canDraftTriggers + ? ', and get_trigger_schema before write_trigger: the trigger config fields differ per kind and are not listed in the write_trigger definition.' + : '.' + } - When script or raw app code needs an external npm package you are not fully familiar with, use search_npm_packages to find it and get its documentation and type definitions. Link the package documentation in your answer when you rely on it. - Hub scripts are prebuilt, vetted integrations for third-party services, hosted outside the workspace under \`hub/<version>/<app>/<name>\` paths. Check search_hub_scripts before hand-writing code against a third-party API, even when the user never mentions the hub; read a result with read_workspace_item type "script" and its hub path to get its code, language, and input schema. Use what you find in whichever way fits: reference the hub path directly from a flow module or app runnable when a script already does the job, copy it into a workspace draft and adapt it when it is close (note the source hub path in a comment at the top of the code), or take it as a worked example and write your own. A script that does not do what the user asked is still worth reading when it is the only example of that integration: pass its \`integration\` back to search_hub_scripts to list that integration's other scripts with their descriptions, or use the \`suggested_integrations\` a search hands back when it finds nothing. - Before writing your own code against an integration the hub covers, call get_hub_integration with its slug: it returns the resource type to take, its auth fields and the integration's most-used scripts, which beats inferring them from script bodies. Call it for the integration you are about to write against, whichever it is. A search marks an integration \`documented\` when the hub additionally holds provider knowledge checked against the live API — pagination, enums, error codes and gotchas — so read that closely where it appears rather than trusting your own memory of the API. - If you have a web search tool and the hub does not cover a third-party API, search for the vendor's own API documentation rather than writing its endpoints and auth from memory, and link the page you relied on. Reserve it for external APIs: search_docs answers questions about Windmill itself. -${when(canRunPreview, '- Use get_db_schema with a database resource path to fetch its tables and columns before writing SQL (or a script querying that database).\n')}- Use get_instructions before writing scripts, flows, resources, or apps. For scripts, pass the target language. -${pipelineBullet}` +${when(canCreateResourceType, `- create_resource_type creates a resource type the workspace lacks. ${external ? 'Show the user the schema you propose and create it once they agree.' : 'The user confirms the schema in the call itself, so that is where you propose it rather than asking first in chat.'}\n`)}${when(!canCreateResourceType, '- This user cannot create a resource type here, in chat or on the Resources page, since the workspace refuses their direct deployment: when one is needed, give them its name and JSON Schema to pass to a workspace admin, who can add it.\n')}${when(canRunPreview, '- Use get_db_schema with a database resource path to fetch its tables and columns before writing SQL (or a script querying that database).\n')}${when(!external, `${instructionsRule}\n`)}${pipelineBullet}` )}${when( canRunPreview && canWriteDraft, - ` -- After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step with representative args before reporting that it works. These tools prefer drafts, so testing does not require deployment. + `${when(!external, `\n${testDraftRule}`)} - Do the same for a raw app: run test_run_app_runnable on each backend runnable you wrote or changed before saying the app works. A bundle that compiles proves nothing about whether the runnables run. An inline runnable executes the app's draft code; a path runnable executes the DEPLOYED script/flow it names, so a path runnable aimed at something you have not deployed fails here — that failure is the point: report it and offer to deploy that one target. The app itself does not need deploying to be tested.` )}${when( buildsFlows && (!access || access.has('run_flow_preview')), @@ -1586,23 +1762,21 @@ ${pipelineBullet}` - After creating or editing a flow draft, run test_run_flow with representative args before reporting that it works. It prefers drafts, so testing does not require deployment.` )} - Use list_runs to find recent runs (optionally filtered by path, creator, label, or status), then get_run with a returned id to see what that run was called with, what it returned and what it logged — without starting a new test run. -- get_run also covers what a flow run did per step — statuses and results across the whole execution tree, subflow steps and loop iterations included — and works while the flow is still running. Pass step to read one step's result in full (capped at 12k chars). +- get_run also covers what a flow run did per step — statuses and results across the whole execution tree, subflow steps and loop iterations included — and works while the flow is still running. Pass step to read one step's result in full (capped at 12k chars).${when( + !external, + ` - Use open_page to show a workspace page with filters applied — Runs, Schedules, Variables, Resources, Assets, Audit logs, or Workspace settings on a specific tab (e.g. "open the failed runs of f/foo/bar", "open the schedule for X", "open the git sync settings"). Carry over every filter the user described — Runs takes the page's whole filter set (time window, path, user, folder, label, tag, worker, trigger kind, args/result, ...), so don't drop a criterion just because it wasn't in the request's main clause. Only the pages listed for this user in the tool are available; don't offer pages that aren't listed. Don't use it as a substitute for list_runs when you just need the data yourself. -- Whenever you ask the user to perform a manual step in the UI — fill in a resource's credentials, set a secret variable's value, adjust a schedule or setting — call open_page in the same message, targeted at that item (pass open with its path to land in its editor, or the page's filters otherwise). Never just describe where to click.${when( - canWriteDraft, +- Whenever you ask the user to perform a manual step in the UI — fill in a resource's credentials, set a secret variable's value, adjust a schedule or setting — call open_page in the same message, targeted at that item (pass open with its path to land in its editor, or the page's filters otherwise). Never just describe where to click.` + )}${when( + canWriteDraft && !external, ` - Do not offer or open the Compare & Deploy page for normal draft review. Only use open_page with page "compare" when the user explicitly asks to deploy a forked workspace's changes to its parent workspace.` - )}${when( - canRunPreview, + )}${when(!external, runRule)}${when( + !external, ` -- Default to test_run_script, test_run_flow, or test_run_step for any run request, an existing script included; they prefer drafts and need no deployment. Use run_script or run_flow only when the user names the deployed version ("the deployed X", "in production", "for real") — a bare "run X" is not that. For those two, read the item with read_workspace_item version: "deployed" first so the arguments match the deployed schema. test_run_script, test_run_flow, test_run_step, run_script and run_flow all show the user an argument form prefilled with what you sent, so fill in every argument you can infer rather than asking for it in chat. test_run_step's form is the step's own inputs, not the flow's.` - )}${when( - !canRunPreview, - ` -- run_script and run_flow are how you run anything here: they run the DEPLOYED item under this user's own permissions, and show them an argument form prefilled with what you sent. Read the item with read_workspace_item version: "deployed" first so the arguments match the deployed schema, and fill in every argument you can infer rather than asking for it in chat.` - )} - When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit. Set multiSelect: true only when the answers can genuinely co-apply and the user may pick several (not mutually exclusive). -- When the user asks you to remember a lasting preference, always/never do something, or change/stop a behavior going forward, call update_user_instructions to persist it. It edits only the USER INSTRUCTIONS block (not WORKSPACE INSTRUCTIONS). Keep each instruction concise; do not use it for one-off requests scoped to the current task. +- When the user asks you to remember a lasting preference, always/never do something, or change/stop a behavior going forward, call update_user_instructions to persist it. It edits only the USER INSTRUCTIONS block (not WORKSPACE INSTRUCTIONS). Keep each instruction concise; do not use it for one-off requests scoped to the current task.` + )} - Keep context targeted.${ previewTools ? `${when( @@ -1645,7 +1819,7 @@ Documentation: Flows: - read_workspace_item returns compact flow JSON. Inline script bodies appear as "inline_script.<moduleId>". -- Use read_flow_module_code${when(canWriteDraft, ' and set_flow_module_code')} for inline script bodies.${when( +- Use read_flow_module_code${when(inlineFlowCode && canWriteFlowDraft, ' and set_flow_module_code')} for inline script bodies.${when( canWriteFlowDraft, ` - Use patch_flow_json for structural flow edits and write_flow for full flow rewrites.` @@ -1656,15 +1830,15 @@ Raw apps: - read_workspace_item returns app metadata only. Use read_app_file for file and inline runnable contents.${when( canWriteAppDraft, ` -- A draft app is reachable by nobody; deploying is what exposes its backend runnables. deploy_workspace_item says so when the deploy widens who may open the app: anonymous means anyone with the URL, without logging in; guest means anyone the instance's identity provider authenticates, member of this workspace or not. Relay that in plain words and carry on. This is disclosure, not a gate: do not stop and ask for permission, and do not refuse the deploy. You cannot change who may open an app from chat; it is set on the app's deploy settings. +- A draft app is reachable by nobody; deploying is what exposes its backend runnables. ${canDeployAnyKind ? 'deploy_workspace_item' : 'A deploy'} says so when the deploy widens who may open the app: anonymous means anyone with the URL, without logging in; guest means anyone the instance's identity provider authenticates, member of this workspace or not. Relay that in plain words and carry on. This is disclosure, not a gate: do not stop and ask for permission, and do not refuse the deploy. You cannot change who may open an app from chat; it is set on the app's deploy settings. - Use write_app_file, patch_app_file, and delete_app_file for frontend files. - Use write_app_runnable and delete_app_runnable for backend runnables. - Use init_app for a new app, with framework react19 unless the user named one or points at an app to follow: do not ask about the framework, and write the summary and pick the path (path conventions above) yourself. ${ buildsApps ? 'An app this user builds reads and stores data only through the deployed runnables it calls: do not ask about a data setup or pass init_app a data config.' - : 'When the app has to store data and the user did not say where, settle the data setup with askUserQuestion BEFORE init_app or any SQL (get_instructions subject "app" lists the questions, one askUserQuestion each), then pass the outcome as init_app\'s data.' + : `When the app has to store data and the user did not say where, settle the data setup with ${external ? 'the user' : 'askUserQuestion'} BEFORE init_app or any SQL (get_instructions subject "app" lists the questions${external ? '' : ', one askUserQuestion each'}), then pass the outcome as init_app's data.` } -- Use deploy_workspace_item after explicit user deploy intent; raw app deploy bundles JS/CSS before saving.` +${when(canDeployAnyKind, '- Use deploy_workspace_item after explicit user deploy intent; raw app deploy bundles JS/CSS before saving.')}` )} Data Tables: @@ -1758,6 +1932,7 @@ function serializeWorkspaceItemForRead(item: WorkspaceItem): unknown { path: item.path, summary: item.summary, isSecret: item.isSecret, + extraPerms: item.extraPerms, isDraft: item.isDraft } } @@ -1775,6 +1950,7 @@ function serializeWorkspaceItemForRead(item: WorkspaceItem): unknown { value: summarizeAppValue(item.value as AppDraftValue), rawApp: item.rawApp, executionMode: item.executionMode, + extraPerms: item.extraPerms, isDraft: item.isDraft } } @@ -1788,6 +1964,7 @@ function serializeWorkspaceItemForRead(item: WorkspaceItem): unknown { path: item.path, summary: item.summary, value: editable, + extraPerms: item.extraPerms, isDraft: item.isDraft } } @@ -1831,7 +2008,7 @@ type BackendRunnableInput = z.infer<typeof backendRunnableSchema> type PersistedRunnable = { name: string type: 'inline' | 'path' | 'runnableByName' | 'runnableByPath' - runType?: 'script' | 'flow' | 'hubscript' + runType?: PathRunType path?: string inlineScript?: { language: string; content: string } fields?: Record<string, any> @@ -1864,6 +2041,7 @@ function convertPersistedToBackendRunnable( } else if (persisted.type === 'path' || persisted.type === 'runnableByPath') { if (persisted.runType === 'flow') out.type = 'flow' else if (persisted.runType === 'hubscript') out.type = 'hubscript' + else if (persisted.runType === 'agent') out.type = 'agent' else out.type = 'script' out.path = persisted.path } @@ -1885,14 +2063,19 @@ function buildPersistedRunnable( input: BackendRunnableInput, existing: PersistedRunnable | undefined ): PersistedRunnable { - const fields = input.staticInputs - ? Object.fromEntries( - Object.entries(input.staticInputs).map(([k, v]) => [ - k, - { type: 'static', value: v, fieldType: 'object' } - ]) - ) - : (existing?.fields ?? {}) + // An agent runnable takes every input from the app: a static one inherited from the kind it was + // converted from would be pinned by the deployed policy over every viewer's message. + const fields = + input.type === 'agent' + ? {} + : input.staticInputs + ? Object.fromEntries( + Object.entries(input.staticInputs).map(([k, v]) => [ + k, + { type: 'static', value: v, fieldType: 'object' } + ]) + ) + : (existing?.fields ?? {}) // Converting between kinds must drop the other kind's fields. Every consumer dispatches on // `type` (isRunnableByName / isRunnableByPath), so a leftover does not change which code @@ -1933,7 +2116,15 @@ function buildPersistedRunnable( } if (!input.path) { - throw new Error('path is required when runnable type is "script", "flow", or "hubscript".') + throw new Error( + 'path is required when runnable type is "script", "flow", "hubscript" or "agent".' + ) + } + // A static input is pinned by the deployed policy: every viewer's turn would send the same one. + if (input.type === 'agent' && Object.keys(input.staticInputs ?? {}).length > 0) { + throw new Error( + 'An "agent" runnable takes no staticInputs: each turn\'s inputs come from the app.' + ) } return { ...carried, @@ -2120,7 +2311,7 @@ async function saveAppDraft( path: string, value: AppDraftValue ): Promise<DraftPersistResult> { - if (operatorBuilderAppsIn(workspace)) { + if (capabilitiesInWorkspace(workspace).apps === 'compose') { const role = await roleForWorkspace(workspace) value.policy ??= {} conformBuilderAppPolicy(value.policy, role.kind === 'resolved' ? role.user : undefined) @@ -2310,34 +2501,43 @@ async function readWorkspaceItem( getDraft: !deployedOnly }) const draft = deployedOnly ? undefined : (script.draft as Script | undefined) - return scriptToItem(draft ?? script, true) + return { ...scriptToItem(draft ?? script, true), extraPerms: script.extra_perms } } case 'flow': { // Prefer the DB draft (newer than the deployed version) when one exists, // unless the caller explicitly asked for the deployed state. const flow = await FlowService.getFlowByPath({ workspace, path, getDraft: !deployedOnly }) const draft = deployedOnly ? undefined : (flow.draft as Flow | undefined) - return flowToItem(draft ?? flow, true) + return { ...flowToItem(draft ?? flow, true), extraPerms: flow.extra_perms } } - case 'schedule': - return scheduleToItem(await ScheduleService.getSchedule({ workspace, path }), true) - case 'trigger': + case 'schedule': { + const schedule = await ScheduleService.getSchedule({ workspace, path }) + return { ...scheduleToItem(schedule, true), extraPerms: schedule.extra_perms } + } + case 'trigger': { if (!triggerKind) { throw new Error('trigger_kind is required when type is trigger.') } - return triggerToItem( - triggerKind, - await triggerServices[triggerKind].get({ workspace, path }), - true - ) - case 'resource': - return resourceToItem(await ResourceService.getResource({ workspace, path }), true) - case 'variable': + const trigger = await triggerServices[triggerKind].get({ workspace, path }) + return { + ...triggerToItem(triggerKind, trigger, true), + extraPerms: (trigger as { extra_perms?: Record<string, boolean> }).extra_perms + } + } + case 'resource': { + const resource = await ResourceService.getResource({ workspace, path }) + return { ...resourceToItem(resource, true), extraPerms: resource.extra_perms } + } + case 'variable': { // Never expose the value, even when read directly. Pass decryptSecret=false // to avoid materializing secret values server-side. - return variableToItem( - await VariableService.getVariable({ workspace, path, decryptSecret: false }) - ) + const variable = await VariableService.getVariable({ + workspace, + path, + decryptSecret: false + }) + return { ...variableToItem(variable), extraPerms: variable.extra_perms } + } case 'app': { // Returns lightweight metadata only — file/runnable contents come via read_app_file. const app = await AppService.getAppByPath({ workspace, path }) @@ -2351,6 +2551,7 @@ async function readWorkspaceItem( summary: app.summary, rawApp: false, executionMode, + extraPerms: app.extra_perms, isDraft: false } } @@ -2363,12 +2564,78 @@ async function readWorkspaceItem( value: metadata as unknown as AppDraftValue, rawApp: app.raw_app, executionMode, + extraPerms: app.extra_perms, isDraft: false } } } } +// Accepts the `f/<name>` / `g/<name>` form the model sees in extra_perms keys and paths. +function accessEntityName(type: AccessType, path: string): string { + const prefix = type === 'folder' ? 'f/' : 'g/' + const name = path.startsWith(prefix) ? path.slice(prefix.length) : path + return name.replace(/\/+$/, '') +} + +async function readAccessEntity(type: AccessType, path: string, workspace: string) { + const name = accessEntityName(type, path) + if (type === 'folder') { + const folder = await FolderService.getFolder({ workspace, name }) + return { + type, + name: folder.name, + summary: folder.summary, + owners: folder.owners, + extraPerms: folder.extra_perms, + defaultPermissionedAs: folder.default_permissioned_as + } + } + const group = await GroupService.getGroup({ workspace, name }) + return { + type, + name: group.name, + summary: group.summary, + members: group.members, + extraPerms: group.extra_perms + } +} + +type AccessEntityRow = { + type: AccessType + path: string + summary?: string + owners?: string[] + extraPerms?: Record<string, boolean> +} + +// Group rows omit members: the list endpoint does not return them. +async function listAccessEntities( + types: AccessType[], + workspace: string, + perPage: number, + page?: number +): Promise<AccessEntityRow[]> { + const rows: AccessEntityRow[] = [] + if (types.includes('folder')) { + for (const f of await FolderService.listFolders({ workspace, perPage, page })) { + rows.push({ + type: 'folder', + path: f.name, + summary: f.summary, + owners: f.owners, + extraPerms: f.extra_perms + }) + } + } + if (types.includes('group')) { + for (const g of await GroupService.listGroups({ workspace, perPage, page })) { + rows.push({ type: 'group', path: g.name, summary: g.summary, extraPerms: g.extra_perms }) + } + } + return rows +} + async function listWorkspaceItems( types: WorkspaceItemType[], workspace: string, @@ -2490,14 +2757,15 @@ ${getScriptPrompt(selected)}` async function getFlowInstructions( workspace: string | undefined, - composeOnly: boolean + composeOnly: boolean, + external = false ): Promise<string> { - const aiAgentProviders = formatAiAgentProvidersPrompt( - await getAiAgentProviderCatalog(workspace), - { - canAskUser: true - } - ) + // An external client's catalog is rendered where no workspace is known, so it gets none. + const aiAgentProviders = external + ? '' + : formatAiAgentProvidersPrompt(await getAiAgentProviderCatalog(workspace), { + canAskUser: true + }) return `# Global draft flow instructions - Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata. @@ -2542,7 +2810,11 @@ ${composeOnly ? 'This reference is written for every author. For this user, skip type InstructionSubject = (typeof ALL_INSTRUCTION_SUBJECTS)[number] -function getAppInstructions(language: ScriptLang | undefined, composeOnly: boolean): string { +function getAppInstructions( + language: ScriptLang | undefined, + composeOnly: boolean, + external = false +): string { // getRawAppPrompt swaps in the Python SDK for python3, so the pointer has to swap with // it: telling the model to re-fetch a reference it is already holding wastes a turn, and // naming the wrong SDK contradicts the text right below the sentence. @@ -2557,7 +2829,7 @@ function getAppInstructions(language: ScriptLang | undefined, composeOnly: boole - To create a new app, use \`init_app\` with a path, optional summary, a framework (\`react19\` / \`react18\` / \`svelte5\`)${ composeOnly ? '. "Starting a new app" below decides the framework. No data setup: the app reads and stores data only through the deployed runnables it calls.' - : ' 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 each of its questions with `askUserQuestion`, one question per call with the 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.' + : ` 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. ${external ? 'Ask the user each of its questions, suggested answer first,' : 'Ask each of its questions with \`askUserQuestion\`, one question per call with the 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. ${ composeOnly @@ -2596,7 +2868,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?, 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. +- \`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. - 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. @@ -2626,25 +2898,28 @@ function getPipelineInstructions(): string { return getPipelinePrompt() } -function getInstructions( +export function getInstructions( subject: InstructionSubject, language: ScriptLang | undefined, workspace: string | undefined, - composeOnly: boolean + composeOnly: boolean, + external = false ): string | Promise<string> { switch (subject) { case 'script': return getScriptInstructions(language) case 'flow': - return getFlowInstructions(workspace, composeOnly) + return getFlowInstructions(workspace, composeOnly, external) case 'resource': return getResourceInstructions() case 'app': - return getAppInstructions(language, composeOnly) + return getAppInstructions(language, composeOnly, external) case 'datatable': return getDatatableInstructions(language) case 'pipeline': return getPipelineInstructions() + case 'workspace_settings': + return getWorkspaceSettingsPrompt() } } @@ -2689,13 +2964,19 @@ export function getSessionContextPromptSection( !access || access.has('write_draft') || access.has('write_flow_draft') || - access.has('write_app_draft') + access.has('write_app_draft') || + access.has('write_schedule_draft') || + access.has('write_trigger_draft') || + access.has('write_resource_draft') || + access.has('write_variable_draft') const canRunPreview = !access || access.has('run_preview') || access.has('run_flow_preview') // The kinds no deploy rule reaches, unless the workspace withdrew them from this user. const unruledKinds = [ (!access || access.has('manage_schedules')) && 'schedules', (!access || access.has('manage_triggers')) && 'triggers' ].filter((k): k is string => !!k) + // 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'] .filter(Boolean) .join(', ') @@ -2711,19 +2992,19 @@ export function getSessionContextPromptSection( ) } else if (ctx.parentWorkspaceId && ctx.isDevWorkspace) { lines.push( - `- Operating workspace: "${ctx.workspaceId}" — the user's persistent DEV WORKSPACE, forked from workspace "${ctx.parentWorkspaceId}". deploy_workspace_item 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}".` + `- 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}".` ) } else if (ctx.parentWorkspaceId) { lines.push( - `- Operating workspace: "${ctx.workspaceId}" — an ephemeral STAGED FORK of workspace "${ctx.parentWorkspaceId}", created for session work. deploy_workspace_item publishes into the fork only, and the user reviews & promotes fork changes into "${ctx.parentWorkspaceId}" from the session's deploy panel. Never present a change as live in "${ctx.parentWorkspaceId}".` + `- Operating workspace: "${ctx.workspaceId}" — an ephemeral STAGED FORK of workspace "${ctx.parentWorkspaceId}", created for session work. ${deployer} publishes into the fork only, and the user reviews & promotes fork changes into "${ctx.parentWorkspaceId}" from the session's deploy panel. Never present a change as live in "${ctx.parentWorkspaceId}".` ) } else if (ctx.forkParentUnknown) { lines.push( - `- Operating workspace: "${ctx.workspaceId}" — a fork whose parent workspace is not currently visible to this user. deploy_workspace_item publishes into the fork only; the user promotes changes from the session's deploy panel. Never present a change as live in any other workspace.` + `- Operating workspace: "${ctx.workspaceId}" — a fork whose parent workspace is not currently visible to this user. ${deployer} publishes into the fork only; the user promotes changes from the session's deploy panel. Never present a change as live in any other workspace.` ) } else if (ctx.workspaceId) { lines.push( - `- Operating workspace: "${ctx.workspaceId}" — the live workspace itself, not a fork. deploy_workspace_item publishes directly to everyone in it.` + `- Operating workspace: "${ctx.workspaceId}" — the live workspace itself, not a fork. ${deployer} publishes directly to everyone in it.` ) } else { lines.push( @@ -2880,40 +3161,25 @@ function allowedTriggerKinds(): PageTriggerKind[] { return (Object.keys(TRIGGER_PAGES) as PageTriggerKind[]).filter((k) => ee || !TRIGGER_PAGES[k].ee) } -// Pages an operator may reach: exactly the ones enabled in the workspace's -// operator_settings, the same source the sidebar gates on. Operators are never admins, so -// workspace_settings is excluded whatever the settings say. -function operatorOpenPages(workspaceId: string | undefined): OpenPageName[] { - const settings = get(userWorkspaces).find((w) => w.id === workspaceId)?.operator_settings - return OPEN_PAGE_NAMES.filter( - (p) => - p !== 'workspace_settings' && settings?.[p as keyof NonNullable<typeof settings>] === true +// Pages a role may reach: the ones its visibility shows, the same source the sidebar gates +// on. workspace_settings is admin/superadmin only, and compare deploys code, so it follows +// `code`. +function roleOpenPages(caps: RoleCapabilities, isAdmin: boolean): OpenPageName[] { + return OPEN_PAGE_NAMES.filter((p) => + p === 'workspace_settings' ? isAdmin : p === 'compare' ? caps.code : caps.visibility[p] ) } -// workspace_settings is admin/superadmin only; the rest are open to any non-operator member. -const NON_ADMIN_OPEN_PAGES = new Set<OpenPageName>([ - 'runs', - 'assets', - 'schedules', - 'variables', - 'resources', - 'audit_logs', - 'folders', - 'groups', - 'triggers', - 'compare' -]) - -// What to advertise before the role in `workspaceId` has been resolved. -// `user_workspaces` nulls operator_settings for a workspace the user is not an operator -// in, so a non-null value narrows an operator correctly; the converse does not hold, -// which is why the real role is still fetched. +// What to advertise before the role in `workspaceId` has been resolved: the role the +// workspace list names, which is why the real role is still fetched. function restrictedOpenPages(workspaceId: string | undefined): OpenPageName[] { - if (get(userWorkspaces).find((w) => w.id === workspaceId)?.operator_settings) { - return operatorOpenPages(workspaceId) - } - return OPEN_PAGE_NAMES.filter((p) => NON_ADMIN_OPEN_PAGES.has(p)) + return roleOpenPages( + capabilitiesIn( + undefined, + get(userWorkspaces).find((w) => w.id === workspaceId) + ), + false + ) } // The role to gate `workspaceId` on. `userStore` answers only when it describes that same @@ -2952,6 +3218,7 @@ export type GlobalPromptIdentity = { is_admin?: boolean folders?: string[] folders_read?: string[] + groups?: string[] } // `userWorkspaces` carries the real per-workspace username for every membership at no @@ -2974,7 +3241,8 @@ export async function resolveGlobalPromptIdentity( username: u.username, is_admin: u.is_admin, folders: u.folders, - folders_read: u.folders_read + folders_read: u.folders_read, + groups: u.groups } } // Headless callers (the eval harness) hold no workspace and pass their own identity. @@ -3003,14 +3271,12 @@ async function allowedOpenPages( return { pages: restrictedOpenPages(workspaceId), roleUnverified: false } } const u = role.user - if (u.operator) return { pages: operatorOpenPages(workspaceId), roleUnverified: false } const isAdmin = !!u.is_admin || !!u.is_super_admin || !!get(superadmin) - return { - pages: OPEN_PAGE_NAMES.filter( - (p) => NON_ADMIN_OPEN_PAGES.has(p) || (isAdmin && p === 'workspace_settings') - ), - roleUnverified: false - } + const caps = capabilitiesIn( + { ...u, is_admin: isAdmin }, + get(userWorkspaces).find((w) => w.id === workspaceId) + ) + return { pages: roleOpenPages(caps, isAdmin), roleUnverified: false } } // The Runs page only offers its cross-workspace filter to a superadmin or devops user in @@ -3251,8 +3517,8 @@ function buildOpenPageDefSchema( roleUnverified = false ): z.ZodTypeAny { const full = openPageFullSchema.shape as Record<string, z.ZodTypeAny> - // z.enum() rejects an empty list, and a user with no reachable pages (e.g. an operator - // with every operator_settings flag off) yields exactly that. Fall back to a plain + // z.enum() rejects an empty list, and a user with no reachable pages (e.g. a role with + // every visibility flag off) yields exactly that. Fall back to a plain // string so schema-building can't throw; the handler still fails closed on every page. // The model reads this before it can reach the handler's message, so an unresolved role // must not be described as a denial here either. @@ -3589,19 +3855,19 @@ export const globalTools: SessionTool<{}>[] = [ // Every subject is authoring guidance written around the draft tools by name, and a // tool result is the one place the toolset filter cannot reach. const access = (ctx.helpers as GlobalToolHelpers | undefined)?.access + if (parsed.subject === 'workspace_settings' && access && !access.has('admin')) { + toolCallbacks.setToolStatus(toolId, { content: 'No workspace settings guidance' }) + return 'Only a workspace admin can read or change the workspace settings. Tell the user plainly that you cannot, and suggest asking a workspace admin.' + } const canAuthor = !access || access.has('write_draft') || (parsed.subject === 'flow' && access.has('write_flow_draft')) || - (parsed.subject === 'app' && access.has('write_app_draft')) + (parsed.subject === 'app' && access.has('write_app_draft')) || + (parsed.subject === 'resource' && access.has('write_resource_draft')) if (!canAuthor) { - const composed = [ - access?.has('write_flow_draft') && 'flow', - access?.has('write_app_draft') && 'app' - ].filter((k): k is string => !!k) - const message = composed.length - ? `This session can only compose ${composed.map((k) => `${k}s`).join(' and ')}, so ${composed.join(' and ')} ${composed.length > 1 ? 'are the subjects' : 'is the one subject'} with authoring guidance here. Tell the user plainly that you cannot create or edit this kind of item.` - : 'This session cannot create or edit workspace items, so there is no authoring guidance to give. Tell the user plainly rather than describing how it would be done.' + const message = + 'This session cannot create or edit this kind of item, so there is no authoring guidance to give for it. Tell the user plainly rather than describing how it would be done.' toolCallbacks.setToolStatus(toolId, { content: 'No authoring guidance' }) return message } @@ -3610,9 +3876,17 @@ export const globalTools: SessionTool<{}>[] = [ ? `${parsed.subject} (${parsed.language})` : parsed.subject toolCallbacks.setToolStatus(toolId, { content: `Loaded ${label} instructions` }) - // Past the gate without `write_draft` means a builder: guidance for inline code would - // steer the model at writes the server refuses and tools it was not given. - const composeOnly = !!access && !access.has('write_draft') + // Guidance for inline code would steer a composing role at writes the server refuses + // and tools it was not given. + const composeOnly = + !!access && + !access.has( + parsed.subject === 'flow' + ? 'write_inline_flow_code' + : parsed.subject === 'app' + ? 'write_inline_app_code' + : 'write_draft' + ) return getInstructions(parsed.subject, parsed.language, ctx.workspace, composeOnly) } }, @@ -3774,10 +4048,23 @@ export const globalTools: SessionTool<{}>[] = [ planModeSafe: true, fn: async ({ args, workspace, toolId, toolCallbacks }) => { const parsed = listWorkspaceItemsSchema.parse(args) - const types = getRequestedTypes(parsed.types) + const accessTypes = (parsed.types ?? []).filter(isAccessType) + const itemTypes = (parsed.types ?? []).filter((t): t is WorkspaceItemType => !isAccessType(t)) + // Asking only for folders or groups must not fall back to the default scripts and flows. + const types = + accessTypes.length > 0 && itemTypes.length === 0 ? [] : getRequestedTypes(itemTypes) const limit = parsed.limit ?? 50 toolCallbacks.setToolStatus(toolId, { content: 'Listing workspace items...' }) + const accessRows = ( + await listAccessEntities( + accessTypes, + workspace, + Math.min(limit, MAX_LIST_LIMIT), + parsed.page + ) + ).filter((row) => itemMatches(row, parsed.query)) + const byKey = new Map<string, WorkspaceItem>() const workspaceItems = await listWorkspaceItems( types, @@ -3821,7 +4108,10 @@ export const globalTools: SessionTool<{}>[] = [ // No cross-type truncation: each type is already capped at `limit` rows by // its own list call, and slicing the concatenation would silently drop the // later types' rows while their next page skips past them. - const results = Array.from(byKey.values()).filter((item) => itemMatches(item, parsed.query)) + const results = [ + ...Array.from(byKey.values()).filter((item) => itemMatches(item, parsed.query)), + ...accessRows + ] toolCallbacks.setToolStatus(toolId, { content: `Listed ${results.length} workspace item(s)` @@ -3851,12 +4141,19 @@ export const globalTools: SessionTool<{}>[] = [ def: createToolDef( readWorkspaceItemSchema, 'read_workspace_item', - 'Read one workspace item or draft. Prefers your draft when one exists; pass version: "deployed" to read the deployed state instead.' + 'Read one workspace item or draft. Prefers your draft when one exists; pass version: "deployed" to read the deployed state instead. A deployed item carries extraPerms (a draft does not; pass version: "deployed" for it): who it is shared with beyond its owner, as u/<user> or g/<group> → true (write) or false (read-only). A folder returns its owners and extraPerms; a group returns its members and extraPerms (who may manage it). For a folder or group, path is its name. A g/<group> entry grants every member of that group (g/all is everyone), and workspace admins can read and write every folder and item whatever these lists say.' ), planModeSafe: true, fn: async ({ args, workspace, toolId, toolCallbacks }) => { const parsed = readWorkspaceItemSchema.parse(args) - if (parsed.type === 'trigger' && !parsed.trigger_kind) { + const { type } = parsed + if (isAccessType(type)) { + toolCallbacks.setToolStatus(toolId, { content: `Reading ${type} "${parsed.path}"...` }) + const entity = await readAccessEntity(type, parsed.path, workspace) + toolCallbacks.setToolStatus(toolId, { content: `Read ${type} "${entity.name}"` }) + return JSON.stringify(entity, null, 2) + } + if (type === 'trigger' && !parsed.trigger_kind) { const message = 'trigger_kind is required when type is trigger.' toolCallbacks.setToolStatus(toolId, { content: message, error: message }) return JSON.stringify({ success: false, error: message }) @@ -3864,16 +4161,16 @@ export const globalTools: SessionTool<{}>[] = [ const draft = parsed.version === 'deployed' || isHubPath(parsed.path) ? null - : await getGlobalDraft(workspace, parsed.type, parsed.path, parsed.trigger_kind) + : await getGlobalDraft(workspace, type, parsed.path, parsed.trigger_kind) if (draft) { toolCallbacks.setToolStatus(toolId, { - content: `Read draft ${parsed.type} "${parsed.path}"` + content: `Read draft ${type} "${parsed.path}"` }) // The mode reported is the draft's, which is what deploying it will write. The // deployed app's own mode is a different question, asked with version: // "deployed", and must not be fetched here. const executionMode = - parsed.type === 'app' + type === 'app' ? await describeAppExposure( workspace, (draft.value as AppDraftValue)?.policy?.execution_mode @@ -3883,16 +4180,16 @@ export const globalTools: SessionTool<{}>[] = [ } toolCallbacks.setToolStatus(toolId, { - content: `Reading ${parsed.type} "${parsed.path}"...` + content: `Reading ${type} "${parsed.path}"...` }) const item = await readWorkspaceItem( - parsed.type, + type, parsed.path, workspace, parsed.trigger_kind, parsed.version === 'deployed' ) - toolCallbacks.setToolStatus(toolId, { content: `Read ${parsed.type} "${parsed.path}"` }) + toolCallbacks.setToolStatus(toolId, { content: `Read ${type} "${parsed.path}"` }) return JSON.stringify(serializeWorkspaceItemForRead(item), null, 2) } }, @@ -3905,6 +4202,7 @@ export const globalTools: SessionTool<{}>[] = [ 'Create a new shared folder (addressable as f/<name>/) in the workspace. The current user is added as an owner.' ), requiresConfirmation: true, + confirmAction: 'create', confirmationMessage: 'Create folder', showDetails: true, fn: async ({ args, workspace, toolId, toolCallbacks }) => { @@ -3994,7 +4292,7 @@ export const globalTools: SessionTool<{}>[] = [ } }, { - requires: WRITE_DRAFT, + requires: WRITE_SCHEDULE_DRAFT, def: createToolDef( writeScheduleToolSchema, 'write_schedule', @@ -4020,7 +4318,7 @@ export const globalTools: SessionTool<{}>[] = [ } }, { - requires: WRITE_DRAFT, + requires: WRITE_TRIGGER_DRAFT, def: createToolDef( writeTriggerSchema, 'write_trigger', @@ -4130,6 +4428,8 @@ export const globalTools: SessionTool<{}>[] = [ // No requiresConfirmation, for the reason test_run_script carries. bypassedByAutoAccept: true, confirmationMessage: 'Run a deployed script', + // Both endings start the same way: the arguments are read, then either a form opens + // here or the script's own deployed page does. streamingLabel: 'Preparing the run form...', queuedLabel: (args) => `Run ${args?.path ?? 'a script'}`, showDetails: true, @@ -4337,6 +4637,7 @@ export const globalTools: SessionTool<{}>[] = [ showDetails: true, showFade: true, requiresConfirmation: true, + confirmAction: 'deploy', confirmationMessage: 'Deploy draft to workspace', fn: async (ctx) => { const parsed = deployWorkspaceItemSchema.parse(ctx.args) @@ -4352,13 +4653,16 @@ export const globalTools: SessionTool<{}>[] = [ app: WRITE_APP_DRAFT, resource: WRITE_DRAFT, variable: WRITE_DRAFT, + // Not the per-kind draft rights: `rebaseDraft` has no handler for resources, variables, + // schedules or triggers, so an operator holding only those rights would be offered a + // tool that always throws. schedule: WRITE_DRAFT, trigger: WRITE_DRAFT } satisfies Record<(typeof ITEM_TYPES)[number], SessionToolPolicy>, def: createToolDef( rebaseDraftSchema, 'rebase_draft', - '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.', + '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.', { strict: false } ), showDetails: true, @@ -4416,6 +4720,7 @@ export const globalTools: SessionTool<{}>[] = [ showDetails: true, showFade: true, requiresConfirmation: true, + confirmAction: 'delete', confirmationMessage: 'Delete workspace item', validateBeforeConfirmation: validateDeleteWorkspaceItemTarget, fn: async (ctx) => { @@ -4435,6 +4740,7 @@ export const globalTools: SessionTool<{}>[] = [ showDetails: true, showFade: true, requiresConfirmation: true, + confirmAction: 'discard', confirmationMessage: 'Discard draft', fn: async (ctx) => { const parsed = discardLocalDraftSchema.parse(ctx.args) @@ -4442,7 +4748,7 @@ export const globalTools: SessionTool<{}>[] = [ } }, { - requires: WRITE_DRAFT, + requires: WRITE_RESOURCE_DRAFT, def: createToolDef( writeResourceSchema, 'write_resource', @@ -4458,7 +4764,7 @@ export const globalTools: SessionTool<{}>[] = [ } }, { - requires: WRITE_DRAFT, + requires: WRITE_VARIABLE_DRAFT, def: createToolDef( writeVariableSchema, 'write_variable', @@ -4486,33 +4792,75 @@ export const globalTools: SessionTool<{}>[] = [ toolCallbacks.setToolStatus(toolId, { content: `Searching resource types for "${parsed.query}"...` }) - const results = await ResourceService.queryResourceTypes({ + const { resourceTypes, note } = await searchResourceTypes( + parsed.query, workspace, - text: parsed.query, - limit: parsed.limit ?? 5 - }) + parsed.limit ?? 5 + ) toolCallbacks.setToolStatus(toolId, { - content: `Found ${results.length} resource type(s) for "${parsed.query}"` + content: `Found ${resourceTypes.length} resource type(s) for "${parsed.query}"` }) const listing = JSON.stringify( - results.map((rt) => ({ + resourceTypes.map((rt) => ({ name: rt.name, schema: rt.schema })), null, 2 ) + // On an unsynced instance the dedicated type is only missing until a sync, so its note + // replaces the "no dedicated type" one, which would have the model create a duplicate. + if (note) { + return `${listing}\n\n${note}` + } // The search is semantic and always returns its closest types, so a miss reads // like a hit unless the absence is spelled out. const words = (parsed.query.toLowerCase().match(/[a-z0-9]+/g) ?? []).filter( (w) => w.length >= 3 ) - const named = results.some((rt) => words.some((w) => rt.name.toLowerCase().includes(w))) + const named = resourceTypes.some((rt) => words.some((w) => rt.name.toLowerCase().includes(w))) return named || words.length === 0 ? listing : `${listing}\n\nNo resource type name contains "${parsed.query}": these are only the nearest matches, so no dedicated type exists. Searching again with synonyms will not find one — use a fitting generic type above, or create a custom c_<name> type.` } }, + { + // Not a draft: resource types have no draft kind, so this writes the workspace once the + // user confirms, under the same right as a deploy. + requires: DEPLOY, + def: createToolDef( + createResourceTypeSchema, + 'create_resource_type', + 'Create a resource type in the workspace. Mutates the workspace once the user confirms the schema.', + { strict: false } + ), + showDetails: true, + requiresConfirmation: true, + confirmAction: 'create', + confirmationMessage: 'Create resource type', + fn: async ({ args, workspace, toolId, toolCallbacks }) => { + const parsed = createResourceTypeSchema.parse(args) + // The prefix the Add resource type form adds unless an admin turns it off: a hub type + // of the same name, synced later, would otherwise clash with this one. + const name = parsed.name.startsWith('c_') ? parsed.name : `c_${parsed.name}` + await ResourceService.createResourceType({ + workspace, + requestBody: { + name, + description: parsed.description, + schema: parsed.schema + } + }) + // Forms show a resource picker only for a type in this cached list, so a script + // written against the new type would otherwise get a plain object input. + resourceTypesStore.set(undefined) + toolCallbacks.setToolStatus(toolId, { content: `Created resource type ${name}` }) + return JSON.stringify({ + success: true, + message: `Created resource type ${name}. Refer to it by this name.` + }) + } + }, createDbSchemaTool<{}>({ description: 'Fetch the schema (tables and columns) of a database resource by its path. Supports postgresql, mysql, ms_sql_server, snowflake and bigquery resources.', @@ -4532,7 +4880,7 @@ export const globalTools: SessionTool<{}>[] = [ } }, { - requires: WRITE_DRAFT, + requires: [...WRITE_FLOW_DRAFT, 'write_inline_flow_code'], def: createToolDef( setFlowModuleCodeSchema, 'set_flow_module_code', @@ -4669,6 +5017,7 @@ export const globalTools: SessionTool<{}>[] = [ return testRunAppRunnable(parsed, ctx) }, requiresConfirmation: true, + confirmAction: 'run', confirmationMessage: (args) => `Run the backend runnable "${args?.key ?? ''}" of ${pathLeaf(args?.path, 'the app')}`, queuedLabel: (args) => `Test runnable "${args?.key ?? ''}" of ${args?.path ?? 'the app'}`, @@ -4890,7 +5239,8 @@ export const globalTools: SessionTool<{}>[] = [ // Workspace DuckLake: pipeline storage prerequisite, and declared measures ...getDucklakeTools(), // Read-only tools over files the user attached to the conversation - ...fileTools + ...fileTools, + ...workspaceSettingsTools ] // Tools that only make sense inside an AI session (they drive the session's @@ -5082,11 +5432,14 @@ async function canEditItemPath( if (role.kind === 'not_a_member') return 'denied' if (role.kind !== 'resolved' || !role.user) return 'unverified' const user = role.user - const settings = get(userWorkspaces).find((w) => w.id === workspace)?.operator_settings - if (!roleCanDraft(kind, user, settings)) return 'denied' + const caps = capabilitiesIn( + user, + get(userWorkspaces).find((w) => w.id === workspace) + ) + if (!roleCanDraft(kind, caps)) return 'denied' // Folder and ownership rules answer most calls without a request. An item's own // `extra_perms` can only widen them, so the item is fetched only to overturn a denial. - if (canDraftItem(kind, path, {}, user, settings)) return 'allowed' + if (canDraftItem(kind, path, {}, user, caps)) return 'allowed' try { const extraPerms = kind === 'script' @@ -5094,7 +5447,7 @@ async function canEditItemPath( : kind === 'flow' ? (await FlowService.getFlowByPath({ workspace, path })).extra_perms : (await AppService.getAppByPath({ workspace, path })).extra_perms - return canDraftItem(kind, path, extraPerms, user, settings) ? 'allowed' : 'denied' + return canDraftItem(kind, path, extraPerms, user, caps) ? 'allowed' : 'denied' } catch (e) { // Nothing deployed means no sharing to widen the folder rules with. return (e as { status?: number } | null | undefined)?.status === 404 ? 'denied' : 'unverified' @@ -5656,7 +6009,12 @@ type ScriptDraftArgs = { const SCRIPT_SPEC: WriteSpec<NewScript, ScriptDraftArgs> = { probe: (workspace, path) => ScriptService.existsScriptByPath({ workspace, path }), fetchDeployed: async (workspace, path) => { - const existing = await ScriptService.getScriptByPath({ workspace, path }) + // The deployed lock describes the deployed content, not the draft's; the deploy recomputes it. + const { + lock: _lock, + lock_error_logs: _lockErrorLogs, + ...existing + } = await ScriptService.getScriptByPath({ workspace, path }) return { ...(existing as unknown as NewScript), parent_hash: existing.hash } }, buildDraft: async (base, args, path) => { @@ -5755,6 +6113,8 @@ const FLOW_SPEC: WriteSpec<Flow, FlowDraftArgs> = { } } } + // Also checked at deploy; dropped here too because a test run of the draft reads the locks. + dropStaleStepLocks(value, base?.value) return base ? { ...structuredClone(base), @@ -6201,9 +6561,9 @@ async function agentStepRunForm( ): Promise<{ schema: Record<string, any>; args: Record<string, any> }> { const transforms = (module.value as { input_transforms?: Record<string, any> }).input_transforms // A linked step's brain belongs to the resource rather than to this flow, so only the inputs - // this flow supplies are its to edit. + // this flow supplies, and its overrides, are its to edit. const full = (await loadSchemaFromModule(module, workspace)).schema - const schema = (module.value as { agent?: string }).agent ? flowLocalAgentSchema(full) : full + const schema = (module.value as { agent?: string }).agent ? linkedAgentStepSchema(full) : full const visible = initialVisibleAgentFields(transforms, schema.properties) // What the model asked to set, shown even where the step configures nothing: this form has no @@ -6348,6 +6708,8 @@ type FormRunSpec = { label?: string /** Whether the bypass posture may answer this form with what it opened with. */ autoAcceptable?: boolean + /** Whether this item's deployed page is a conversation rather than a run form. */ + conversational?: boolean background?: boolean detachAfterMs?: number startJob: (submitted: Record<string, any>) => Promise<string> @@ -6431,6 +6793,7 @@ async function runThroughForm(spec: FormRunSpec, ctx: WriteDraftCtx): Promise<st summary: spec.summary || undefined, kind: spec.kind, runnableKind: spec.contextName, + conversational: spec.conversational || undefined, schema: autoAccepted ? undefined : schema, code: autoAccepted ? undefined : spec.code, lang: autoAccepted ? undefined : spec.lang, @@ -6457,10 +6820,10 @@ async function runThroughForm(spec: FormRunSpec, ctx: WriteDraftCtx): Promise<st }) // `form`, not `persisted`: a password field mints from what it opens with. - const submitted = toolCallbacks.requestRunArgs + const answer: RunFormAnswer | undefined = toolCallbacks.requestRunArgs ? await toolCallbacks.requestRunArgs(toolId, form, { autoAccepted }) - : proposed - if (!submitted) { + : { kind: 'args', args: proposed } + if (!answer) { toolCallbacks.setToolStatus(toolId, { content: `Run of "${spec.path}" cancelled by user`, isLoading: false, @@ -6471,8 +6834,17 @@ async function runThroughForm(spec: FormRunSpec, ctx: WriteDraftCtx): Promise<st return runFormCancelled(spec.toolName, noun) } - const blockedBeforeRun = blockedByPlanMode() - if (blockedBeforeRun) return blockedBeforeRun + // A run the reader started on the item's own page. It is already going, at that page's own + // version and with arguments its own form coerced and minted — so there is nothing here to + // gate or process, only a job to follow and its arguments to show. + const adopted = answer.kind === 'job' ? answer : undefined + const submitted = answer.args + + const blockedBeforeRun = adopted ? undefined : blockedByPlanMode() + if (blockedBeforeRun) { + toolCallbacks.markRunFormEnded?.(toolId) + return blockedBeforeRun + } // Every job leaves through here, so this is where a sensitive argument becomes a reference: // the form mints as the user types and the bypass mints in its stead, but a host answering @@ -6480,7 +6852,7 @@ async function runThroughForm(spec: FormRunSpec, ctx: WriteDraftCtx): Promise<st // the two that already minted pay a walk and no round trip. let toRun: Record<string, any> try { - toRun = await processSecretArgs(submitted, schema as any, workspace) + toRun = adopted ? submitted : await processSecretArgs(submitted, schema as any, workspace) } catch (e) { const message = `Failed to store the sensitive arguments of "${spec.path}": ${e}` toolCallbacks.setToolStatus(toolId, { @@ -6489,13 +6861,19 @@ async function runThroughForm(spec: FormRunSpec, ctx: WriteDraftCtx): Promise<st isStreamingArguments: false, error: message }) + toolCallbacks.markRunFormEnded?.(toolId) return message } // The card's details pane must show what ran, not what was proposed. Bytes are marked by // size because the card is persisted; everything else stands as the run page shows it for // the same job. - const forCard = redactFileArgs(toRun, schema as any) + // The page's own, for an adopted run: a deploy between this call reading the schema and the + // reader pressing Run can have added arguments, and redaction only visits the leaves the + // schema it is given declares — so a file argument missing from ours would keep its bytes + // through both the persisted card and the model's result. + const ranAgainst = (adopted?.schema ?? schema) as any + const forCard = redactFileArgs(toRun, ranAgainst) // The transcript is re-cloned into IndexedDB on every save and a form carries whatever was // pasted into it, so past what the pane would render the card reads the arguments off the // job instead. Only once there is a job to read them from: substituting the marker any @@ -6503,11 +6881,26 @@ async function runThroughForm(spec: FormRunSpec, ctx: WriteDraftCtx): Promise<st const oversized = JSON.stringify(forCard).length > MAX_PERSISTED_ARGS_LENGTH if (!oversized) { toolCallbacks.setToolStatus(toolId, { parameters: forCard }) + } else if (adopted) { + // Its job exists already, so the marker can stand in now rather than in `jobStarter`. + toolCallbacks.setToolStatus(toolId, { parameters: { reason: 'WINDMILL_TOO_BIG' } }) } const outcome = await executeTestRun({ jobStarter: async () => { - const jobId = await spec.startJob(toRun) + if (adopted) { + toolCallbacks.markRunFormStarted?.(toolId) + return adopted.jobId + } + let jobId: string + try { + jobId = await spec.startJob(toRun) + } catch (e) { + // The ordinary refusal — gone, renamed, not permitted — and the surface that + // confirmed the run is still waiting for the job it was promised. + toolCallbacks.markRunFormEnded?.(toolId) + throw e + } // The form's own submitted flag flips a round trip earlier, when the user presses // Run; only from here is there a job for a stopped turn to say it left running. toolCallbacks.markRunFormStarted?.(toolId) @@ -6549,7 +6942,7 @@ async function runThroughForm(spec: FormRunSpec, ctx: WriteDraftCtx): Promise<st : '' // Redacted for the model alone: what it proposed is already in its own tool call, but a // secret the user typed into the form would be entering its context here. - const redacted = redactFileArgs(redactSecretArgs(toRun, schema as any), schema as any) + const redacted = redactFileArgs(redactSecretArgs(toRun, ranAgainst), ranAgainst) const submittedJson = JSON.stringify(redacted) const shown = submittedJson.length > MAX_SUBMITTED_ARGS_LENGTH @@ -6561,6 +6954,11 @@ async function runThroughForm(spec: FormRunSpec, ctx: WriteDraftCtx): Promise<st const ran = deepEqual(redacted, proposed) ? 'Ran with the arguments the form opened with, unedited.' : `Ran with arguments: ${shown}` + // The notes below say what THIS form did to the proposal, and an adopted run went through + // the page's form instead — so they describe a submission that never happened. + if (adopted) { + return `The user ran it on the ${spec.contextName}'s own page, at the version that page showed. ${ran}\n${outcome}` + } return `${ran}${cleared}${reset}${stripped}${undeclared}\n${outcome}` } @@ -6617,6 +7015,8 @@ async function runDeployedFlow( schema: (flow.schema as Record<string, any>) ?? {}, summary: flow.summary, kind: 'run', + // Its deployed page is a conversation with no run form, so the card keeps this run. + conversational: flow.value?.chat_input_enabled ?? false, // No code/lang: the dynamic-option pickers come from the deployed flow, not an inline copy. schemaNoun: 'deployed', toolName: 'run_flow', @@ -6631,6 +7031,11 @@ async function runDeployedFlow( workspace, path: args.path, requestBody: submitted, + // A chat-enabled flow is refused without one, and it names a conversation rather + // than being a flow argument, so nothing in `args` could carry it. A fresh id: + // this run is its own conversation on the flow's page, not a turn appended to + // one someone is reading there. + memoryId: chatMemoryId(flow.value), // As the flow's own run page does: the form fills the main input schema, and a // preprocessor would take these arguments for a webhook body and hand the flow // its own output instead. @@ -6811,11 +7216,12 @@ async function initApp( }) // A builder can save no inline runnable, so they start from a static page instead of the tour. - const builder = operatorBuilderAppsIn(workspace) + const builder = capabilitiesInWorkspace(workspace).apps === 'compose' const value: AppDraftValue = { summary, files: { ...(builder ? BUILDER_FRAMEWORK_TEMPLATES : FRAMEWORK_TEMPLATES)[framework] }, runnables: builder ? {} : structuredClone(STARTER_RUNNABLES), + policy: { sandbox: true }, ...(data && { data: { tables: (data.tables ?? []).map((ref) => appTableRef(data.datatable, data.schema, ref)), @@ -8718,8 +9124,19 @@ async function deployDraft( break } case 'resource': { - const requestBody = draft.value as any + let requestBody = draft.value as any if (await ResourceService.existsResource({ workspace, path })) { + // The server resets an agent's identity to whoever deploys, unless told to keep + // it: the identity is the deployed one, never the draft's. + const deployed = await ResourceService.getResource({ workspace, path }) + const obo = (deployed.value as any)?.on_behalf_of + if (deployed.resource_type === 'ai_agent' && obo) { + requestBody = { + ...requestBody, + value: { ...requestBody.value, on_behalf_of: obo }, + preserve_on_behalf_of: true + } + } await ResourceService.updateResource({ workspace, path, requestBody }) } else { await ResourceService.createResource({ workspace, requestBody }) @@ -9022,9 +9439,16 @@ async function undeployedRunnableTargets( ? runnable?.runType : runnable?.type const path = runnable?.path - if ((type !== 'script' && type !== 'flow') || !path) continue + if ((type !== 'script' && type !== 'flow' && type !== 'agent') || !path) continue try { - if (await deployedItemExists(workspace, type, path, undefined)) continue + if (type === 'agent') { + // A saved agent is an `ai_agent` resource; another resource at the path runs nothing. + const found = await ResourceService.getResource({ workspace, path }).catch((e) => { + if ((e as { status?: number } | null | undefined)?.status === 404) return undefined + throw e + }) + if (found?.resource_type === 'ai_agent') continue + } else if (await deployedItemExists(workspace, type, path, undefined)) continue } catch { // The probe is advisory: a lookup failure must never block the write or // the deploy it annotates. @@ -9158,6 +9582,8 @@ export function prepareGlobalSystemMessage( /** Capabilities of the chat's operating workspace; undefined keeps every block. Must * match the profile the toolset was filtered with, or the prompt names withheld tools. */ access?: SessionAccess + /** Render for an external MCP client rather than the Windmill chat. */ + external?: boolean } ): ChatCompletionSystemMessageParam { const user = opts?.user ?? get(userStore) @@ -9166,6 +9592,7 @@ export function prepareGlobalSystemMessage( ? { folders: user.folders, foldersRead: user.folders_read ?? user.folders, + groups: user.groups, isAdmin: user.is_admin ?? false } : undefined @@ -9175,7 +9602,8 @@ export function prepareGlobalSystemMessage( folderCtx, opts?.skills ?? [], opts?.mcpServers ?? [], - opts?.access + opts?.access, + opts?.external ?? false ) if (instructions?.workspace?.trim()) { content = `${content}\n\nWORKSPACE INSTRUCTIONS (configured by a workspace admin, shared by everyone in this workspace — you cannot modify these):\n${instructions.workspace.trim()}` @@ -9290,6 +9718,10 @@ export function prepareGlobalUserMessage( content += '\n' } + if (options.resourceTypeSync) { + content += resourceTypeSyncBlock(options.resourceTypeSync) + } + content += `## INSTRUCTIONS:\n${instructions}` const images = options.images ?? [] diff --git a/frontend/src/lib/components/copilot/chat/global/mcpSessionGen.test.ts b/frontend/src/lib/components/copilot/chat/global/mcpSessionGen.test.ts new file mode 100644 index 0000000000..3de18622a5 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/global/mcpSessionGen.test.ts @@ -0,0 +1,255 @@ +/** + * Generates what the Windmill MCP server's toolset (backend/windmill-api/src/mcp/session) + * shows a model, from the same definitions an AI session uses: tool names, descriptions and + * input schemas, the system prompt, and the `get_instructions` texts. The server implements + * the tools in Rust; everything the model reads comes from here, so the two cannot drift + * apart in wording. + * + * Run `npm run gen:mcp-session` after changing a GLOBAL tool definition, the global system + * prompt, or anything under system_prompts/. Without that env var this is the freshness check. + */ +import { describe, expect, it } from 'vitest' +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { + ALL_INSTRUCTION_SUBJECTS, + getInstructions, + globalToolsFor, + prepareGlobalSystemMessage +} from './core' +import type { ScriptLang } from '$lib/gen' +import { FRAMEWORK_TEMPLATES, STARTER_RUNNABLES } from '$lib/components/raw_apps/templates' + +const OUT_DIR = fileURLToPath( + new URL('../../../../../../../backend/windmill-api/src/mcp/session/gen/', import.meta.url) +) + +// Sentences that describe the chat's run form, which an MCP client does not have. Each +// must still be found in the description it is listed for: a reworded description fails +// generation rather than shipping a promise of a form. +const DESCRIPTION_REWRITES: [tool: string, from: string, to: string][] = [ + [ + 'test_run_script', + ' The user gets an argument form prefilled with `args` and may edit or dismiss it before it runs, so fill in every argument you can infer.', + ' It runs with exactly the `args` you send.' + ], + [ + 'test_run_flow', + ' The user gets an argument form prefilled with `args` and may edit or dismiss it before it runs, so fill in every argument you can infer.', + ' It runs with exactly the `args` you send.' + ], + [ + 'test_run_step', + ' The user gets an argument form prefilled with `args` and may edit or dismiss it before it runs, so fill in every argument you can infer.', + ' It runs with exactly the `args` you send.' + ], + [ + 'run_script', + " Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs. In an AI session the user can open that form on the script's own deployed page instead; either way this call waits for them and runs what they confirm.", + ' It runs with exactly the `args` you send.' + ], + [ + 'run_flow', + " Fill in every argument you can infer: the user gets an argument form prefilled with `args` and decides what runs. In an AI session the user can open that form on the flow's own deployed page instead; either way this call waits for them and runs what they confirm.", + ' It runs with exactly the `args` you send.' + ], + ['create_resource_type', ' once the user confirms the schema', ''], + ['set_workspace_settings', ' The user reviews the changes before they apply.', ''] +] + +// A rewrite only proves its old sentence is still there; this catches one added next to it. +const CHAT_ONLY_PROMISE = /argument form|the user (confirms|reviews|approves)/i + +function toolDefs() { + return globalToolsFor({ sessionPreview: false }).map((tool) => { + const fn = tool.def.function + let description = fn.description ?? '' + for (const [name, from, to] of DESCRIPTION_REWRITES) { + if (name !== fn.name) continue + if (!description.includes(from)) { + throw new Error(`${name}: its description no longer contains "${from}"`) + } + description = description.replace(from, to) + } + if (CHAT_ONLY_PROMISE.test(description)) { + throw new Error(`${fn.name}: its description promises a chat-only confirmation`) + } + return { + name: fn.name, + description, + parameters: fn.parameters, + // The session's own read-only marker: what plan mode lets through. + read_only: tool.planModeSafe === true + } + }) +} + +const WRITABLE = '{{writable_folders}}' +const READONLY = '{{readonly_folders}}' + +function prompt( + user: { is_admin: boolean; folders?: string[]; folders_read?: string[] }, + extra: Parameters<typeof prepareGlobalSystemMessage>[1] = {}, + instructions?: { workspace?: string } +): string { + const content = prepareGlobalSystemMessage(instructions, { + user: { username: '{{username}}', ...user } as any, + external: true, + ...extra + }).content as string + // The builder wraps each folder as `f/<name>`; the server substitutes a whole list. + return content + .replaceAll('`f/' + WRITABLE + '`', WRITABLE) + .replaceAll('`f/' + READONLY + '`', READONLY) +} + +function suffixOf(longer: string, base: string, what: string): string { + if (!longer.startsWith(base)) throw new Error(`${what} is no longer appended to the prompt`) + return longer.slice(base.length) +} + +function promptParts() { + const member = prompt({ is_admin: false, folders: [WRITABLE] }) + const skills = suffixOf( + prompt( + { is_admin: false, folders: [WRITABLE] }, + { skills: [{ path: '{{skill_path}}', name: '', description: '{{skill_description}}' }] } + ), + member, + 'the skills section' + ) + const skillLine = skills.split('\n').pop()! + // The folder guidance differs per workspace, so it is cut out of the prompt: the server + // puts it back for a single workspace, and serves it from `get_workspace_context` otherwise. + const withoutFolders = prompt({ is_admin: false }) + const variants = { + admin: prompt({ is_admin: true, folders: [WRITABLE] }), + admin_no_folders: prompt({ is_admin: true, folders: [] }), + member, + member_no_folders: prompt({ is_admin: false, folders: [] }), + member_readonly: prompt({ + is_admin: false, + folders: [WRITABLE], + folders_read: [WRITABLE, READONLY] + }), + member_no_folders_readonly: prompt({ is_admin: false, folders: [], folders_read: [READONLY] }) + } + let at: number | undefined + const folder_guidance = Object.fromEntries( + Object.entries(variants).map(([name, full]) => { + let i = 0 + while (full[i] === withoutFolders[i]) i++ + const rest = withoutFolders.slice(i) + if ((at !== undefined && at !== i) || !full.endsWith(rest)) { + throw new Error(`the folder guidance (${name}) is no longer one inserted block`) + } + at = i + return [name, full.slice(i, full.length - rest.length)] + }) + ) + return { + base: withoutFolders.slice(0, at) + '{{folder_guidance}}' + withoutFolders.slice(at), + folder_guidance, + skills_header: skills.slice(0, skills.length - skillLine.length), + skill_line: skillLine, + workspace_instructions: suffixOf( + prompt( + { is_admin: false, folders: [WRITABLE] }, + {}, + { workspace: '{{workspace_instructions}}' } + ), + member, + 'the workspace instructions' + ) + } +} + +/** What `get_trigger_schema` and `get_schedule_schema` answer: derived from the generated + * request schemas, so the tools themselves are the only faithful source. */ +async function schemas() { + const tools = globalToolsFor({ sessionPreview: false }) + const tool = (name: string) => tools.find((t) => t.def.function.name === name)! + const run = (name: string, args: unknown) => + tool(name).fn({ + args, + workspace: '', + toolId: '', + toolCallbacks: {} as any, + helpers: {} as any + }) + const kinds: string[] = (tool('get_trigger_schema').def.function.parameters as any).properties + .kind.enum + const trigger: Record<string, string> = {} + for (const kind of kinds) { + trigger[kind] = await run('get_trigger_schema', { kind }) + } + return { trigger, schedule: await run('get_schedule_schema', {}) } +} + +/** Every file to write, keyed by name. */ +async function generate(): Promise<Record<string, string>> { + const tools = toolDefs() + const files: Record<string, string> = { + 'tools.json': JSON.stringify(tools, null, '\t') + '\n', + 'prompt.json': JSON.stringify(promptParts(), null, '\t') + '\n' + } + const languages: (ScriptLang | undefined)[] = [ + undefined, + ...(tools.find((t) => t.name === 'get_instructions')!.parameters as any).properties.language + .enum + ] + // Keyed by subject then language ('' when none was given); identical texts are stored once. + const index: Record<string, Record<string, string>> = {} + const texts: Record<string, string> = {} + for (const subject of ALL_INSTRUCTION_SUBJECTS) { + index[subject] = {} + const byText = new Map<string, string>() + for (const language of languages) { + const text = await getInstructions(subject, language, undefined, false, true) + let id = byText.get(text) + if (!id) { + id = `${subject}${language ? '.' + language : ''}` + byText.set(text, id) + texts[id] = text + } + index[subject][language ?? ''] = id + } + } + // What `init_app` seeds a new raw app with. + files['app_templates.json'] = + JSON.stringify( + { templates: FRAMEWORK_TEMPLATES, starter_runnables: STARTER_RUNNABLES }, + null, + '\t' + ) + '\n' + files['schemas.json'] = JSON.stringify(await schemas(), null, '\t') + '\n' + files['instructions.json'] = JSON.stringify({ index, texts }, null, '\t') + '\n' + return files +} + +function committedFiles(dir = OUT_DIR, prefix = ''): string[] { + if (!existsSync(dir)) return [] + return readdirSync(dir, { withFileTypes: true }).flatMap((e) => + e.isDirectory() ? committedFiles(join(dir, e.name), `${prefix}${e.name}/`) : [prefix + e.name] + ) +} + +describe('MCP session server generated definitions', () => { + it('match the session tools and prompts (npm run gen:mcp-session)', async () => { + const files = await generate() + expect(Object.keys(files)).toContain('tools.json') + if (process.env.WM_GEN_MCP_SESSION) { + rmSync(OUT_DIR, { recursive: true, force: true }) + mkdirSync(OUT_DIR, { recursive: true }) + for (const [name, content] of Object.entries(files)) { + writeFileSync(join(OUT_DIR, name), content) + } + return + } + expect(committedFiles().sort()).toEqual(Object.keys(files).sort()) + for (const [name, content] of Object.entries(files)) { + expect(readFileSync(join(OUT_DIR, name), 'utf8'), name).toBe(content) + } + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/global/mcpTools.ts b/frontend/src/lib/components/copilot/chat/global/mcpTools.ts index 620e5a86ea..e1da0fb624 100644 --- a/frontend/src/lib/components/copilot/chat/global/mcpTools.ts +++ b/frontend/src/lib/components/copilot/chat/global/mcpTools.ts @@ -366,6 +366,7 @@ function createCallTool(servers: McpServer[], mode: 'read' | 'write'): SessionTo ? { planModeSafe: true } : { requiresConfirmation: true, + confirmAction: 'run', confirmationMessage: (args: any) => `Call ${args?.tool ?? ''} on ${args?.server ?? ''}` }), fn: async ({ args, workspace, toolId, toolCallbacks }) => { 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 eca9f96bf9..89a13403f0 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts @@ -10,6 +10,8 @@ vi.mock('$lib/utils_workspace_deploy', () => ({ checkDeployRules: deployRules }) import { capabilitiesForRole, resolveSessionAccess } from './sessionAccess' import { clearWorkspaceRoleCache } from '$lib/user' +import { FULL_CAPABILITIES, legacyOperatorCapabilities } from '$lib/roleCapabilities' +import type { OperatorSettings, RoleCapabilities } from '$lib/gen' type WhoamiOverrides = { is_admin?: boolean; is_super_admin?: boolean; operator?: boolean } @@ -56,7 +58,13 @@ describe('resolveSessionAccess', () => { 'run_preview', 'write_app_draft', 'write_draft', - 'write_flow_draft' + 'write_flow_draft', + 'write_inline_app_code', + 'write_inline_flow_code', + 'write_resource_draft', + 'write_schedule_draft', + 'write_trigger_draft', + 'write_variable_draft' ]) }) @@ -64,29 +72,55 @@ describe('resolveSessionAccess', () => { // so withholding `deploy` from an operator would be stricter than the server. it('leaves an operator the deploy-rule capability, and nothing their token refuses', async () => { const caps = await capabilitiesFor({ operator: true }) - expect([...caps].sort()).toEqual(['deploy', 'manage_schedules', 'manage_triggers']) + expect([...caps].sort()).toEqual([ + 'deploy', + 'manage_schedules', + 'manage_triggers', + 'write_resource_draft', + 'write_schedule_draft', + 'write_trigger_draft', + 'write_variable_draft' + ]) }) it('takes deploy from the rules alone, so a rule blocks an operator too', async () => { deployRules.mockResolvedValue({ ok: false, refusedBy: 'DisableDirectDeployment' }) const caps = await capabilitiesFor({ operator: true }) - expect([...caps].sort()).toEqual(['manage_schedules', 'manage_triggers']) + expect([...caps].sort()).toEqual([ + 'manage_schedules', + 'manage_triggers', + 'write_resource_draft', + 'write_schedule_draft', + 'write_trigger_draft', + 'write_variable_draft' + ]) }) - // Both spellings of `authed.is_admin`, which the draft path honours and the handlers - // refusing `authed.is_operator` do not — the session path never clears that flag. + // Both spellings of `authed.is_admin`: `caller_capabilities` gives an admin every role + // capability, whatever role the membership row names. it.each([{ is_admin: true }, { is_super_admin: true }])( - 'lets an admin who is also an operator draft and deploy, but not preview or manage code (%o)', + 'gives an admin whose role is operator everything (%o)', async (role) => { const caps = await capabilitiesFor({ ...role, operator: true }) expect([...caps].sort()).toEqual([ 'admin', 'deploy', + 'manage_apps', + 'manage_code', + 'manage_flows', 'manage_schedules', 'manage_triggers', + 'run_flow_preview', + 'run_preview', 'write_app_draft', 'write_draft', - 'write_flow_draft' + 'write_flow_draft', + 'write_inline_app_code', + 'write_inline_flow_code', + 'write_resource_draft', + 'write_schedule_draft', + 'write_trigger_draft', + 'write_variable_draft' ]) } ) @@ -107,9 +141,8 @@ describe('resolveSessionAccess', () => { it("gives a builder operator the flow capabilities and nothing a script's handler refuses", () => { const caps = capabilitiesForRole({ isAdmin: false, - operator: true, deployRulesPass: true, - operatorSettings: { builder_flows: true } + capabilities: legacyOperatorCapabilities({ builder_flows: true } as OperatorSettings) }) expect([...caps].sort()).toEqual([ 'deploy', @@ -117,7 +150,11 @@ describe('resolveSessionAccess', () => { 'manage_schedules', 'manage_triggers', 'run_flow_preview', - 'write_flow_draft' + 'write_flow_draft', + 'write_resource_draft', + 'write_schedule_draft', + 'write_trigger_draft', + 'write_variable_draft' ]) }) @@ -125,16 +162,19 @@ describe('resolveSessionAccess', () => { it('gives an app builder operator the app capabilities alone', () => { const caps = capabilitiesForRole({ isAdmin: false, - operator: true, deployRulesPass: true, - operatorSettings: { builder_apps: true } + capabilities: legacyOperatorCapabilities({ builder_apps: true } as OperatorSettings) }) expect([...caps].sort()).toEqual([ 'deploy', 'manage_apps', 'manage_schedules', 'manage_triggers', - 'write_app_draft' + 'write_app_draft', + 'write_resource_draft', + 'write_schedule_draft', + 'write_trigger_draft', + 'write_variable_draft' ]) }) @@ -142,12 +182,27 @@ describe('resolveSessionAccess', () => { it('withholds schedule and trigger writes only where the workspace set them to false', () => { const caps = capabilitiesForRole({ isAdmin: false, - operator: true, deployRulesPass: true, - operatorSettings: { manage_schedules: false } + capabilities: legacyOperatorCapabilities({ manage_schedules: false } as OperatorSettings) }) expect(caps.has('manage_schedules')).toBe(false) + expect(caps.has('write_schedule_draft')).toBe(false) expect(caps.has('manage_triggers')).toBe(true) + expect(caps.has('write_trigger_draft')).toBe(true) + }) + + // A role that codes but builds no apps: scripts and flows, and nothing apps.rs refuses. + it('follows the capabilities of a custom role rather than the operator flag', () => { + const scripter: RoleCapabilities = { ...FULL_CAPABILITIES, apps: 'none' } + const caps = capabilitiesForRole({ + isAdmin: false, + deployRulesPass: true, + capabilities: scripter + }) + expect(caps.has('manage_code')).toBe(true) + expect(caps.has('manage_flows')).toBe(true) + expect(caps.has('manage_apps')).toBe(false) + expect(caps.has('write_app_draft')).toBe(false) }) it('grants everything when the role cannot be resolved', async () => { diff --git a/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts b/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts index 6e65d833d5..8d4527091a 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts @@ -1,20 +1,22 @@ import { get } from 'svelte/store' import { userWorkspaces } from '$lib/stores' -import type { OperatorSettings } from '$lib/gen' +import type { 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' -type OperatorRights = Pick< - NonNullable<OperatorSettings>, - 'builder_flows' | 'builder_apps' | 'manage_schedules' | 'manage_triggers' -> - const ALL_CAPABILITIES: SessionCapability[] = [ 'write_draft', 'write_flow_draft', 'write_app_draft', + 'write_inline_flow_code', + 'write_inline_app_code', + 'write_schedule_draft', + 'write_trigger_draft', + 'write_resource_draft', + 'write_variable_draft', 'run_preview', 'run_flow_preview', 'deploy', @@ -36,48 +38,61 @@ export function fullSessionAccess(): SessionAccess { /** Pure, so the profiles this can produce are enumerable rather than listed by hand. * `isAdmin` is one field because the server's `authed.is_admin` is - * `usr.is_admin || super_admin` (auth.rs), which `whoami` reports as two. */ + * `usr.is_admin || super_admin` (auth.rs), which `whoami` reports as two. An admin holds every + * role capability, as `caller_capabilities` gives them. */ export function capabilitiesForRole(role: { isAdmin: boolean - operator: boolean deployRulesPass: boolean - /** The workspace's `operator_settings`, null for a non-operator. */ - operatorSettings?: OperatorRights | null + capabilities: RoleCapabilities }): SessionAccess { const capabilities = new Set<SessionCapability>() - const user = { operator: role.operator, is_admin: role.isAdmin, is_super_admin: false } - const authorsCode = roleCanAuthor('script', user, role.operatorSettings) - const authorsFlows = roleCanAuthor('flow', user, role.operatorSettings) - // Drafts take the admin precedence `roleCanDraft` encodes; previews and the item handlers - // do not — and on the session path the operator flag is never cleared for an admin. - if (roleCanDraft('script', user, role.operatorSettings)) { + const caps = role.isAdmin ? FULL_CAPABILITIES : role.capabilities + if (roleCanDraft('script', caps)) { capabilities.add('write_draft') } - if (roleCanDraft('flow', user, role.operatorSettings)) { + if (roleCanDraft('flow', caps)) { capabilities.add('write_flow_draft') } - if (roleCanDraft('raw_app', user, role.operatorSettings)) { + if (roleCanDraft('raw_app', caps)) { capabilities.add('write_app_draft') } - if (authorsCode) { + if (caps.flows === 'full') { + capabilities.add('write_inline_flow_code') + } + if (caps.apps === 'full') { + capabilities.add('write_inline_app_code') + } + if (roleCanDraft('schedule', caps)) { + capabilities.add('write_schedule_draft') + } + if (roleCanDraft('trigger', caps)) { + capabilities.add('write_trigger_draft') + } + if (roleCanDraft('resource', caps)) { + capabilities.add('write_resource_draft') + } + if (roleCanDraft('variable', caps)) { + capabilities.add('write_variable_draft') + } + if (roleCanAuthor('script', caps)) { capabilities.add('run_preview') capabilities.add('manage_code') } - if (authorsFlows) { + if (roleCanAuthor('flow', caps)) { capabilities.add('run_flow_preview') capabilities.add('manage_flows') } - if (roleCanAuthor('raw_app', user, role.operatorSettings)) { + if (roleCanAuthor('raw_app', caps)) { capabilities.add('manage_apps') } - if (roleCanAuthor('schedule', user, role.operatorSettings)) { + if (roleCanAuthor('schedule', caps)) { capabilities.add('manage_schedules') } - if (roleCanAuthor('trigger', user, role.operatorSettings)) { + if (roleCanAuthor('trigger', caps)) { capabilities.add('manage_triggers') } - // No operator term: the rules are their own gate, and the handlers that also refuse - // operators say so through `manage_code`. Admins bypass the rules inside the check. + // No role term: the rules are their own gate, and the handlers that also refuse a role + // say so through `manage_code`. Admins bypass the rules inside the check. if (role.deployRulesPass) { capabilities.add('deploy') } @@ -100,8 +115,10 @@ export async function resolveSessionAccess(workspace: string): Promise<SessionAc return capabilitiesForRole({ isAdmin: !!me.is_admin || !!me.is_super_admin, - operator: !!me.operator, deployRulesPass: (await checkDeployRules(workspace, me)).ok, - operatorSettings: get(userWorkspaces).find((w) => w.id === workspace)?.operator_settings + 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 ceb179779f..dda1483c50 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts @@ -29,6 +29,8 @@ import { assembleGlobalSystemMessage, assembleGlobalTools } from './globalAssemb import { filterSessionTools, sessionToolAllowed } from './sessionToolset' import { capabilitiesForRole, fullSessionAccess } from './sessionAccess' import type { SessionAccess, SessionCapability, SessionTool } from '../sessionCapabilities' +import { FULL_CAPABILITIES, legacyOperatorCapabilities } from '$lib/roleCapabilities' +import type { AuthorLevel, OperatorSettings, RoleCapabilities } from '$lib/gen' const ASSEMBLY_OPTS = { previewTools: true, @@ -54,9 +56,13 @@ function sessionReachableTools(): SessionTool<any>[] { } function withheldNames(access: SessionAccess): string[] { + // Shipped, not merely allowed: a kind-taking tool left with no kind is dropped too. + const shipped = new Set( + filterSessionTools(sessionReachableTools(), access).map((t) => t.def.function.name) + ) return sessionReachableTools() - .filter((t) => !sessionToolAllowed(t, access)) .map((t) => t.def.function.name) + .filter((n) => !shipped.has(n)) } /** The tools a session actually ships, through the same assembly production uses. */ @@ -68,31 +74,40 @@ function accessWith(capabilities: SessionCapability[]): SessionAccess { return new Set(capabilities) } +const LEVELS: AuthorLevel[] = ['none', 'compose', 'full'] + +/** Every role a workspace can define, over the capabilities `capabilitiesForRole` reads: `full` + * flows or apps require `code`, as `RoleCapabilities::validate` refuses otherwise. */ +const ROLES: RoleCapabilities[] = [false, true] + .flatMap((code) => + LEVELS.flatMap((flows) => + LEVELS.flatMap((apps) => + [false, true].flatMap((manage_schedules) => + [false, true].map((manage_triggers) => ({ + ...FULL_CAPABILITIES, + code, + flows, + apps, + manage_schedules, + manage_triggers + })) + ) + ) + ) + ) + .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 = [ ...new Map( [false, true].flatMap((isAdmin) => - [false, true].flatMap((operator) => - // `checkDeployRules` bypasses on admin, so an admin has only the one outcome. - (isAdmin ? [true] : [false, true]).flatMap((deployRulesPass) => - // The operator rights a workspace can set: none, the builder right, both locks. - [ - undefined, - { builder_flows: true }, - { builder_apps: true }, - { builder_flows: true, builder_apps: true }, - { manage_schedules: false, manage_triggers: false } - ].map((operatorSettings) => { - const access = capabilitiesForRole({ - isAdmin, - operator, - deployRulesPass, - operatorSettings - }) - return [[...access].sort().join(',') || 'none', access] as const - }) - ) + // `checkDeployRules` bypasses on admin, so an admin has only the one outcome. + (isAdmin ? [true] : [false, true]).flatMap((deployRulesPass) => + ROLES.map((capabilities) => { + const access = capabilitiesForRole({ isAdmin, deployRulesPass, capabilities }) + return [[...access].sort().join(',') || 'none', access] as const + }) ) ) ).entries() @@ -197,6 +212,15 @@ describe('session tool policies', () => { expect((original.def.function.parameters as any).properties.type.enum).toContain('script') }) + it('offers the workspace settings tools to admins only', () => { + const settingsTools = ['get_workspace_settings', 'set_workspace_settings'] + for (const [, access] of REACHABLE_PROFILES) { + const names = shippedSessionTools(access).map((t) => t.def.function.name) + const offered = settingsTools.filter((n) => names.includes(n)) + expect(offered).toEqual(access.has('admin') ? settingsTools : []) + } + }) + // Naming a tool the model was not given produces invented calls. Asserted over the // ASSEMBLED message: the session-state, pipeline and plan-mode sections are each // appended by a different caller, and each can name a withheld tool. @@ -291,19 +315,20 @@ describe('session tool policies', () => { // The operator's own rights outrun the chat's tools: what they schedule in the UI must not // be sent to an admin. it("sends an operator to the UI for what their role allows and the chat's tools do not", () => { - const prompt = (operatorSettings: { manage_schedules?: boolean }): string => + const prompt = (operatorSettings: Partial<OperatorSettings>): string => prepareGlobalSystemMessage(undefined, { user: { username: 'op' }, access: capabilitiesForRole({ isAdmin: false, - operator: true, deployRulesPass: true, - operatorSettings + capabilities: legacyOperatorCapabilities(operatorSettings as OperatorSettings) }) }).content as string + // None of these is sent to the UI: the chat drafts them itself. + expect(prompt({})).not.toContain('they can still edit themselves on their pages') expect(prompt({})).toContain( - 'Resources, variables, schedules, and triggers they can still edit' + 'lets them create and edit resources, variables, schedules, and triggers' ) expect(prompt({})).toContain('schedule a deployed script or flow themselves') expect(prompt({ manage_schedules: false })).not.toContain('schedule a deployed script') diff --git a/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts b/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts index 3d922a3b72..d08fe4fd58 100644 --- a/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts +++ b/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts @@ -151,6 +151,10 @@ export type WorkspaceItem = { * app, not in listings — a listing carrying it would make absence read as * proof of no exposure. */ executionMode?: string + /** Who a deployed item is shared with beyond its path owner, as `u/<user>` / + * `g/<group>` → true for write, false for read-only. A draft has no permissions of + * its own, so a draft read leaves it unset rather than reporting the item unshared. */ + extraPerms?: Record<string, boolean> isDraft: boolean isLiveDraft?: boolean } diff --git a/frontend/src/lib/components/copilot/chat/global/workspaceSettingsTools.ts b/frontend/src/lib/components/copilot/chat/global/workspaceSettingsTools.ts new file mode 100644 index 0000000000..1f37eb9300 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/global/workspaceSettingsTools.ts @@ -0,0 +1,170 @@ +import { z } from 'zod' +import YAML from 'yaml' +import { ApiError, WorkspaceService, type ApplyWorkspaceSettingsDocumentResponse } from '$lib/gen' +import { createToolDef, type ToolCodeDiff } from '../shared' +import type { SessionTool, SessionToolPolicy } from '../sessionCapabilities' +import { toStableYaml } from './draftDiff' + +// Both endpoints call `require_admin`. +const ADMIN: SessionToolPolicy = ['admin'] + +const MAX_READ_CHARS = 20_000 + +type SettingsChange = ApplyWorkspaceSettingsDocumentResponse['changes'][number] + +const getWorkspaceSettingsSchema = z.object({ + keys: z + .array(z.string()) + .optional() + .describe('Only these top-level keys. Omit for the whole document.') +}) + +const setWorkspaceSettingsSchema = z.object({ + yaml: z + .string() + .describe( + 'A YAML mapping of only the top-level keys to change, each given whole (a nested object replaces the stored one). A key set to null clears it.' + ) +}) + +function apiErrorText(e: unknown): string { + if (e instanceof ApiError) { + return typeof e.body === 'string' ? e.body : JSON.stringify(e.body) + } + return e instanceof Error ? e.message : String(e) +} + +function parseSettingsYaml(text: string): Record<string, unknown> { + const doc = YAML.parse(text) + if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) { + throw new Error('The yaml must be a mapping of setting keys to values.') + } + return doc +} + +// An unset value is left out rather than written as null, so a side with nothing set is empty. +function changesSide(changes: SettingsChange[], side: 'before' | 'after'): string { + const set = changes.filter((c) => c[side] != null) + return set.length ? toStableYaml(Object.fromEntries(set.map((c) => [c.key, c[side]]))) : '' +} + +function changesDiff(changes: SettingsChange[]): ToolCodeDiff { + return { + before: changesSide(changes, 'before'), + after: changesSide(changes, 'after'), + lang: 'yaml' + } +} + +// The dry run answers before the confirmation card, and the card shows its changes. Keyed by +// the call's parsed arguments, the one object both hooks are handed. +const dryRuns = new WeakMap<object, SettingsChange[]>() + +export const workspaceSettingsTools: SessionTool<{}>[] = [ + { + requires: ADMIN, + def: createToolDef( + getWorkspaceSettingsSchema, + 'get_workspace_settings', + 'Read the workspace settings as YAML, in the layout of the settings.yaml file the CLI syncs. Secrets read as `__SENSITIVE_AND_UNCHANGED__`.' + ), + planModeSafe: true, + showDetails: true, + fn: async ({ args, workspace, toolId, toolCallbacks }) => { + const parsed = getWorkspaceSettingsSchema.parse(args) + const doc = (await WorkspaceService.getWorkspaceSettingsDocument({ + workspace, + secrets: 'redacted' + })) as Record<string, unknown> + let picked = doc + let note = '' + if (parsed.keys?.length) { + picked = Object.fromEntries(parsed.keys.filter((k) => k in doc).map((k) => [k, doc[k]])) + const unset = parsed.keys.filter((k) => !(k in doc)) + if (unset.length) { + note = `\n# Not in the document (unset, or not a setting): ${unset.join(', ')}` + } + } + let text = YAML.stringify(picked, { aliasDuplicateObjects: false, lineWidth: 0 }) + note + if (text.length > MAX_READ_CHARS) { + text = `${text.slice(0, MAX_READ_CHARS)}\n… truncated at ${MAX_READ_CHARS} chars; read the keys you need with \`keys\`.` + } + toolCallbacks.setToolStatus(toolId, { + content: parsed.keys?.length + ? `Read workspace settings: ${parsed.keys.join(', ')}` + : 'Read workspace settings' + }) + return text + } + }, + { + requires: ADMIN, + def: createToolDef( + setWorkspaceSettingsSchema, + 'set_workspace_settings', + 'Change workspace settings. Keys the yaml omits are left as they are. The user reviews the changes before they apply. Read get_instructions subject "workspace_settings" before your first change.' + ), + requiresConfirmation: true, + confirmAction: 'apply', + confirmationMessage: 'Change workspace settings', + cancellationMessage: 'The user declined: no setting was changed.', + showDetails: true, + validateBeforeConfirmation: async ({ args, workspace }) => { + let changes: SettingsChange[] + let warnings: string[] | undefined + try { + const document = parseSettingsYaml(setWorkspaceSettingsSchema.parse(args).yaml) + ;({ changes, warnings } = await WorkspaceService.applyWorkspaceSettingsDocument({ + workspace, + requestBody: { document, replace: false, dry_run: true } + })) + } catch (e) { + const error = apiErrorText(e) + return { label: `Invalid settings: ${error}`, result: `Nothing was changed: ${error}` } + } + if (changes.length === 0) { + return { + label: 'No settings change', + result: `The settings already hold these values: nothing to change.${ + warnings?.length ? ` Warnings: ${warnings.join(' ')}` : '' + }` + } + } + dryRuns.set(args, changes) + return undefined + }, + onConfirmationRequested: ({ args, toolCallbacks, toolId }) => { + const changes = dryRuns.get(args) + if (changes) { + toolCallbacks.setToolStatus(toolId, { codeDiff: changesDiff(changes) }) + } + }, + fn: async ({ args, workspace, toolId, toolCallbacks }) => { + const document = parseSettingsYaml(setWorkspaceSettingsSchema.parse(args).yaml) + toolCallbacks.setToolStatus(toolId, { content: 'Changing workspace settings...' }) + try { + const { changes, warnings } = await WorkspaceService.applyWorkspaceSettingsDocument({ + workspace, + requestBody: { document, replace: false } + }) + const keys = changes.map((c) => c.key) + toolCallbacks.setToolStatus(toolId, { + content: `Changed workspace settings: ${keys.join(', ')}`, + codeDiff: changesDiff(changes) + }) + return JSON.stringify({ + success: true, + message: `The user approved and these settings are now applied: ${keys.join(', ')}.`, + ...(warnings?.length ? { warnings } : {}) + }) + } catch (e) { + const error = apiErrorText(e) + toolCallbacks.setToolStatus(toolId, { + content: 'Failed to change workspace settings', + error + }) + return JSON.stringify({ success: false, error }) + } + } + } +] diff --git a/frontend/src/lib/components/copilot/chat/messageDraft.svelte.ts b/frontend/src/lib/components/copilot/chat/messageDraft.svelte.ts index b64711dccf..01d815b3ef 100644 --- a/frontend/src/lib/components/copilot/chat/messageDraft.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/messageDraft.svelte.ts @@ -29,6 +29,9 @@ export interface DraftSnapshot { blobs: AttachedBlob[] } +/** Everything a draft carries besides its text. */ +export type DraftAttachments = Omit<DraftSnapshot, 'text'> + export class MessageDraft { text = $state('') pastes = $state<PasteAttachment[]>([]) diff --git a/frontend/src/lib/components/copilot/chat/pipeline/core.ts b/frontend/src/lib/components/copilot/chat/pipeline/core.ts index 8d71b00279..abd403206d 100644 --- a/frontend/src/lib/components/copilot/chat/pipeline/core.ts +++ b/frontend/src/lib/components/copilot/chat/pipeline/core.ts @@ -568,6 +568,7 @@ export const pipelineTools: SessionTool<PipelineToolHelpers>[] = [ requires: RUN_PREVIEW, def: testPipelineNodeToolDef, requiresConfirmation: true, + confirmAction: 'run', confirmationMessage: 'Run pipeline node', showDetails: true, autoCollapseDetails: false, diff --git a/frontend/src/lib/components/copilot/chat/readOnlyChatViewHost.svelte.ts b/frontend/src/lib/components/copilot/chat/readOnlyChatViewHost.svelte.ts new file mode 100644 index 0000000000..d3c58af681 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/readOnlyChatViewHost.svelte.ts @@ -0,0 +1,86 @@ +import { AIAutonomyMode } from './AIChatManager.svelte' +import type { ChatViewHost } from './chatViewHost' +import type { DisplayMessage } from './shared' +import { AttachedFilesStore } from './files/attachedFiles.svelte' +import { SessionArtifactsStore } from './artifacts/artifactsState.svelte' +import type { PersistedArtifact } from './artifacts/artifactsDB' + +const noop = () => {} + +/** + * A host over a finished transcript nobody can continue: every affordance that would send, + * edit, retry or answer is off, so the chat components render it as a record. + */ +export function readOnlyChatViewHost(options: { + messages: DisplayMessage[] + workspace: string + artifacts: PersistedArtifact[] + openArtifact: (artifactId: string) => void +}): ChatViewHost { + const artifacts = new SessionArtifactsStore() + artifacts.artifacts = options.artifacts + return { + displayMessages: options.messages, + messages: [], + contextTokens: 0, + operatingWorkspace: options.workspace, + loading: false, + sendPending: false, + runHeldElsewhere: false, + loadingLabel: undefined, + compacting: false, + currentReply: '', + currentReasoning: '', + currentReasoningActive: false, + reasoningHiddenIndicatorLabel: undefined, + automaticScroll: false, + enableAutomaticScroll: noop, + disableAutomaticScroll: noop, + + instructions: '', + sendInFlight: false, + sendRequest: async () => false, + cancel: noop, + setAiChatInput: noop, + releaseAiChatInput: noop, + queuedMessage: '', + queuedContext: undefined, + queuedImages: [], + queuedFiles: [], + queuedBlobs: [], + queueMessage: noop, + dequeueMessage: noop, + setComposerStaged: noop, + setComposerHasDraft: noop, + clearComposerStaged: noop, + attachmentBytesExcluding: () => 0, + + storedImages: () => undefined, + retryRequest: noop, + restartGeneration: noop, + handleUserQuestionAnswer: () => false, + handleToolConfirmation: noop, + hasPendingRunForm: false, + isRunFormPending: () => false, + + isSessionChat: true, + untrustedTranscript: true, + supportsModelSettings: false, + supportsMessageEditing: false, + supportsMessageAttachments: false, + requiresMessageText: false, + supportsLinkedFolders: false, + attachmentAccept: '', + attachmentsAsBlobs: false, + tools: [], + autonomyMode: AIAutonomyMode.DEFAULT, + setAutonomyMode: noop, + autoAcceptEditsActive: false, + autoAcceptEditsAvailable: false, + autoAcceptToolConfirmationsAvailable: false, + planModeAvailable: false, + attachedFiles: new AttachedFilesStore(), + artifacts, + openArtifact: (artifactId) => options.openArtifact(artifactId) + } +} diff --git a/frontend/src/lib/components/copilot/chat/script/CodeDisplay.svelte b/frontend/src/lib/components/copilot/chat/script/CodeDisplay.svelte index 2be52a32d2..e83c163302 100644 --- a/frontend/src/lib/components/copilot/chat/script/CodeDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/script/CodeDisplay.svelte @@ -3,11 +3,13 @@ import MarkdownCodeBlock from '$lib/components/MarkdownCodeBlock.svelte' import { AIMode } from '../AIChatManager.svelte' import { getAiChatManager } from '../aiChatManagerContext' + import { getChatViewHost } from '../chatViewHost' import { Check, Play } from 'lucide-svelte' // Chat `pre` renderer: the shared MarkdownCodeBlock plus the chat-only Apply // button (wired to the script editor) and mermaid diagrams. const aiChatManager = getAiChatManager() + const chatHost = getChatViewHost() const astNode = getAstNode() let code = $derived(astNode.current.children?.[0]?.children?.[0]?.value) @@ -34,5 +36,5 @@ {showApplyButton} onApplyCode={handleApplyCode} applyButtonIcon={aiChatManager.pendingNewCode ? Check : Play} - renderMermaid + renderMermaid={!chatHost.untrustedTranscript} /> diff --git a/frontend/src/lib/components/copilot/chat/script/core.test.ts b/frontend/src/lib/components/copilot/chat/script/core.test.ts index f235ae3796..2a6c37ca71 100644 --- a/frontend/src/lib/components/copilot/chat/script/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/script/core.test.ts @@ -14,7 +14,50 @@ vi.mock('@codingame/monaco-vscode-standalone-typescript-language-features', () = vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({ default: () => ({}) })) vi.mock('$lib/components/vscode', () => ({})) -import { editCodeToolWithDiff } from './core' +import { ResourceService } from '$lib/gen' +import { + editCodeToolWithDiff, + resolveResourceTypeSync, + resourceTypeSyncBlock, + searchResourceTypes +} from './core' + +describe('searchResourceTypes', () => { + it.each([ + [['ai_skill', 'ai_instruction'], true], + [Array.from({ length: 30 }, (_, i) => `type_${i}`), false] + ])('notes only an instance that never synced with the hub (%#)', async (names, noted) => { + vi.spyOn(ResourceService, 'queryResourceTypes').mockResolvedValue([ + { name: 'ai_skill', score: 0.8 } + ]) + vi.spyOn(ResourceService, 'listResourceTypeNames').mockResolvedValue(names) + + const { note } = await searchResourceTypes('stripe', 'ws', 5) + + expect(note?.includes('never synced') ?? false).toBe(noted) + }) +}) + +// The transition is what keeps the note out of the system prompt: a conversation that was +// told "not synced" has to be told once when that stops being true, and nobody else is. +describe('resolveResourceTypeSync', () => { + const synced = Array.from({ length: 30 }, (_, i) => `type_${i}`) + const told = (state: 'unsynced' | 'now_synced') => ({ + role: 'user', + content: resourceTypeSyncBlock(state) + }) + + it.each([ + ['unsynced instance', ['ai_skill'], [], 'unsynced'], + ['always synced', synced, [], undefined], + ['synced after an unsynced note', synced, [told('unsynced')], 'now_synced'], + ['already told it synced', synced, [told('unsynced'), told('now_synced')], undefined] + ] as const)('%s', async (label, names, history, expected) => { + vi.spyOn(ResourceService, 'listResourceTypeNames').mockResolvedValue([...names]) + + expect(await resolveResourceTypeSync(`ws-${label}`, history)).toBe(expected) + }) +}) describe('editCodeToolWithDiff', () => { it('records the complete script diff after applying replacements', async () => { diff --git a/frontend/src/lib/components/copilot/chat/script/core.ts b/frontend/src/lib/components/copilot/chat/script/core.ts index 92cb889b24..93ad12ff67 100644 --- a/frontend/src/lib/components/copilot/chat/script/core.ts +++ b/frontend/src/lib/components/copilot/chat/script/core.ts @@ -1,4 +1,4 @@ -import { ResourceService, JobService } from '$lib/gen/services.gen' +import { ResourceService, JobService } from '$lib/gen' import type { AIProvider, AIProviderModel, ResourceType, ScriptLang } from '$lib/gen/types.gen' import { capitalize, toCamel } from '$lib/utils' import { compile, phpCompile, pythonCompile } from '../../utils' @@ -19,7 +19,8 @@ import { type ScriptLintResult, formatScriptLintResult, createSearchWorkspaceTool, - createGetRunnableDetailsTool + createGetRunnableDetailsTool, + HUB_SYNC_INSTRUCTIONS } from '../shared' import { createWorkspaceMutationTools } from '../workspaceTools' import { setupTypeAcquisition, type DepsToGet } from '$lib/ata' @@ -41,7 +42,7 @@ const TYPES_CONTEXT_PERCENTAGE = 1 export const DIFF_BASED_EDIT_PROVIDERS: AIProvider[] = [] export function formatResourceTypes( - allResourceTypes: ResourceType[], + allResourceTypes: Pick<ResourceType, 'name' | 'schema'>[], lang: 'python3' | 'php' | 'bun' | 'deno' | 'nativets' | 'bunnative' ) { if (lang === 'python3') { @@ -67,14 +68,75 @@ export function formatResourceTypes( } } -async function getResourceTypes(prompt: string, workspace: string) { - const resourceTypes = await ResourceService.queryResourceTypes({ - workspace: workspace, - text: prompt, - limit: 5 - }) +// The hub publishes hundreds of resource types and an instance seeds only a few of its own, +// so fewer than this means the instance never synced with the hub. +const UNSYNCED_RESOURCE_TYPE_COUNT = 20 - return resourceTypes +/** + * The note on an unsynced instance cannot wait for an empty result: the similarity search + * still returns the nearest of the few types there are (the instance's own `ai_skill` scores + * about 0.8 for "confluence"). + */ +export async function searchResourceTypes( + query: string, + workspace: string, + limit: number +): Promise<{ resourceTypes: Pick<ResourceType, 'name' | 'schema'>[]; note?: string }> { + const [resourceTypes, names] = await Promise.all([ + ResourceService.queryResourceTypes({ workspace, text: query, limit }), + ResourceService.listResourceTypeNames({ workspace }).catch(() => []) + ]) + const count = new Set(names).size + return { + resourceTypes, + note: + count > 0 && count < UNSYNCED_RESOURCE_TYPE_COUNT + ? `Only ${count} resource types exist on this instance, so it has most likely never synced with the Windmill Hub, which has types for most services. ${HUB_SYNC_INSTRUCTIONS}` + : undefined + } +} + +export type ResourceTypeSync = 'unsynced' | 'now_synced' + +const NOT_SYNCED_HEADING = '## RESOURCE TYPES NOT SYNCED' +const SYNCED_HEADING = '## RESOURCE TYPES SYNCED' + +// An instance does not go back to unsynced, so one seen synced is never asked again. +const syncedWorkspaces = new Set<string>() + +/** + * Whether the next user message has to say the instance lacks the hub's types, or that an + * earlier such note no longer holds. It rides in the user message, not the system prompt, + * so a sync during a conversation leaves the cached prefix untouched; `history` is what + * tells a sync that happened mid-conversation from an instance that was always synced. + */ +export async function resolveResourceTypeSync( + workspace: string, + history: readonly { role: string; content?: unknown }[] +): Promise<ResourceTypeSync | undefined> { + if (!syncedWorkspaces.has(workspace)) { + const count = await ResourceService.listResourceTypeNames({ workspace }) + .then((names) => new Set(names).size) + .catch(() => 0) + if (count === 0) { + return undefined + } + if (count < UNSYNCED_RESOURCE_TYPE_COUNT) { + return 'unsynced' + } + syncedWorkspaces.add(workspace) + } + const lastNote = history + .filter((m) => m.role === 'user') + .map((m) => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? ''))) + .findLast((text) => text.includes(NOT_SYNCED_HEADING) || text.includes(SYNCED_HEADING)) + return lastNote?.includes(NOT_SYNCED_HEADING) ? 'now_synced' : undefined +} + +export function resourceTypeSyncBlock(state: ResourceTypeSync): string { + return state === 'unsynced' + ? `${NOT_SYNCED_HEADING}\nThis instance has not synced the Windmill Hub's resource types, so no hub type exists here yet (stripe, slack, postgresql, ...), including the one a hub script takes. ${HUB_SYNC_INSTRUCTIONS}\n\n` + : `${SYNCED_HEADING}\nThe instance now holds the Windmill Hub's resource types: the earlier "not synced" note no longer applies.\n\n` } export const SUPPORTED_CHAT_SCRIPT_LANGUAGES = [ @@ -149,13 +211,14 @@ export async function getFormattedResourceTypes( case 'bunnative': case 'python3': case 'php': { - const resourceTypes = await getResourceTypes(prompt, workspace) + const { resourceTypes, note } = await searchResourceTypes(prompt, workspace, 5) - const intro = `RESOURCE_TYPES:\n` + const found = + resourceTypes.length > 0 + ? `RESOURCE_TYPES:\n${formatResourceTypes(resourceTypes, lang)}` + : `No resource type found for "${prompt}".` - const resourceTypesText = formatResourceTypes(resourceTypes, lang) - - return intro + resourceTypesText + return note ? `${found}\n\n${note}` : found } default: return '' @@ -944,6 +1007,7 @@ export const testRunScriptTool: Tool<ScriptChatHelpers> = { }) }, requiresConfirmation: true, + confirmAction: 'run', confirmationMessage: 'Run a test of the current script', showDetails: true, autoCollapseDetails: false diff --git a/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts b/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts index ba5f4facff..8291a62085 100644 --- a/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts +++ b/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts @@ -9,31 +9,42 @@ import type { Tool } from './shared' * what the model is offered and guarantees nothing. */ export type SessionCapability = - /** Passes drafts.rs `require_can_write_path` — for a draft of any kind, schedules and - * triggers included, though an operator may create those directly. */ + /** Passes drafts.rs `require_can_write_path` for a script draft: the role's `code`. A tool + * writing another kind requires that kind's capability below instead. */ | 'write_draft' - /** Passes `require_can_write_path` for a flow draft, which an operator also passes where the - * workspace grants `builder_flows`. The server then refuses a draft carrying inline code. */ + /** Passes `require_can_write_path` for a flow draft: `flows` at `compose` or above. At + * `compose` the server then refuses a draft carrying inline code. */ | 'write_flow_draft' - /** The same for a raw app draft, under `builder_apps`. */ + /** The same for a raw app draft, under `apps`. */ | 'write_app_draft' - /** Passes the operator refusal jobs.rs `run_preview_*` makes before starting a job. */ + /** Inline code in a flow draft: `flows` at `full`. At `compose` the server validates the draft + * as a pure composition, even for a role that may write scripts. */ + | 'write_inline_flow_code' + /** Inline and hub runnables in an app draft: `apps` at `full`. */ + | 'write_inline_app_code' + /** Passes `require_can_write_path` for a schedule draft: the role's `manage_schedules`. */ + | 'write_schedule_draft' + /** The same for a trigger draft, read from `manage_triggers`. */ + | 'write_trigger_draft' + /** Passes `require_can_write_path` for a resource draft, which every role does. */ + | 'write_resource_draft' + /** The same for a variable draft. */ + | 'write_variable_draft' + /** Passes the `code` refusal jobs.rs `run_preview_*` makes before starting a job. */ | 'run_preview' - /** Passes `run_preview_flow`'s `check_operator_can_build_flows`: composed flows only, for an - * operator with `builder_flows`. */ + /** Passes `run_preview_flow`'s `check_operator_can_build`: composed flows only at `compose`. */ | 'run_flow_preview' /** Passes `check_deploy_rules`. */ | 'deploy' - /** Passes the operator refusal the script handlers make before creating or + /** Passes the `code` refusal the script handlers make before creating or * deleting one. The same condition as `run_preview` today, but another handler family's * check, so each follows its own gate if the two ever diverge. */ | 'manage_code' - /** Passes the flow handlers' `check_operator_can_build_flows`. */ + /** Passes the flow handlers' `check_operator_can_build`. */ | 'manage_flows' - /** Passes the raw app handlers' `check_operator_can_build`, read from `builder_apps`. */ + /** Passes the raw app handlers' `check_operator_can_build`, read from `apps`. */ | 'manage_apps' - /** Passes `gate_operator_writes` on the schedule router: withheld only from an operator whose - * workspace set `operator_settings.manage_schedules` to false. */ + /** Passes `gate_operator_writes` on the schedule router: the role's `manage_schedules`. */ | 'manage_schedules' /** The same gate on the trigger routers, read from `manage_triggers`. */ | 'manage_triggers' @@ -55,6 +66,10 @@ export const NONE: SessionToolPolicy = [] export const WRITE_DRAFT: SessionToolPolicy = ['write_draft'] export const WRITE_FLOW_DRAFT: SessionToolPolicy = ['write_flow_draft'] export const WRITE_APP_DRAFT: SessionToolPolicy = ['write_app_draft'] +export const WRITE_SCHEDULE_DRAFT: SessionToolPolicy = ['write_schedule_draft'] +export const WRITE_TRIGGER_DRAFT: SessionToolPolicy = ['write_trigger_draft'] +export const WRITE_RESOURCE_DRAFT: SessionToolPolicy = ['write_resource_draft'] +export const WRITE_VARIABLE_DRAFT: SessionToolPolicy = ['write_variable_draft'] export const RUN_PREVIEW: SessionToolPolicy = ['run_preview'] export const RUN_FLOW_PREVIEW: SessionToolPolicy = ['run_flow_preview'] export const DEPLOY: SessionToolPolicy = ['deploy'] diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index f346b4297d..71ef91a0b7 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -571,6 +571,10 @@ export type RunFormDisplay = { /** What is being run, for the noun the card says it in. * Absent on cards recorded before flows had a form, which were all scripts. */ runnableKind?: 'script' | 'flow' + /** The deployed page of this item is a conversation, not a run form — a chat-enabled + * flow. Its page can be opened to look at, but it has no form to confirm this call on, so + * the card keeps the run. */ + conversational?: boolean /** Of whatever version is about to run: the deployed script, or the draft a test * previews. Only the rendered form reads it, so it is dropped once one of the flags * below unmounts that form: kept, every settled card would carry a copy of the schema @@ -606,6 +610,16 @@ export type RunFormDisplay = { /** What a run form is being filled with while it waits. Held by the chat manager, not by * the form, so the chat card and the preview pane edit one draft rather than two copies. * The schema rides along because SchemaForm binds and reorders it. */ +/** How a parked run form is answered. `args` for a form the tool then runs itself; `job` for + * a run the reader already started on the item's own page, which the tool follows instead. */ +export type RunFormAnswer = + | { kind: 'args'; args: Record<string, any> } + /** A job another surface already started. Its `args` are what that surface sent, already + * coerced and minted by its own form, so they are reported rather than processed, and + * `schema` is the one it ran against — which redaction needs, since it only visits the + * leaves a schema declares. */ + | { kind: 'job'; jobId: string; args: Record<string, any>; schema: Record<string, any> } + export type RunFormDraft = { args: Record<string, any> schema: Record<string, any> @@ -628,6 +642,16 @@ export type ToolCodeDiff = { beforeLang?: string } +export type ToolConfirmAction = + | 'run' + | 'deploy' + | 'create' + | 'apply' + | 'delete' + | 'discard' + | 'rollback' + | 'confirm' + export type ToolDisplayMessage = { role: 'tool' tool_call_id: string @@ -643,6 +667,8 @@ export type ToolDisplayMessage = { isQueued?: boolean error?: string needsConfirmation?: boolean + /** What the confirm button says the call will do. */ + confirmAction?: ToolConfirmAction showDetails?: boolean autoCollapseDetails?: boolean isStreamingArguments?: boolean @@ -1089,6 +1115,8 @@ export async function processToolCall<T>({ typeof tool?.confirmationMessage === 'function' ? tool.confirmationMessage(args) : tool?.confirmationMessage + const confirmAction = + typeof tool?.confirmAction === 'function' ? tool.confirmAction(args) : tool?.confirmAction // preAction fires at promotion, not stream time, so its "-ing" label covers // only the execution window — queued cards keep their imperative header. @@ -1097,7 +1125,7 @@ export async function processToolCall<T>({ toolCallbacks.setToolStatus(toolCall.id, { ...(requiresConfirmation - ? { content: confirmationContent ?? 'Waiting for confirmation...' } + ? { content: confirmationContent ?? 'Waiting for confirmation...', confirmAction } : {}), parameters: args, isLoading: true, @@ -1273,6 +1301,8 @@ export interface Tool<T> { /** Header shown on the confirmation card before the tool runs. Pass a function * to derive it from the parsed arguments (e.g. name the script being tested). */ confirmationMessage?: string | ((args: any) => string) + /** The verb on the confirm button; `confirm` when omitted. */ + confirmAction?: ToolConfirmAction | ((args: any) => ToolConfirmAction) /** Only when a card gates the call, so `fn` must not rely on it. Not awaited, so it may * not throw, and must be safe for a call the user then declines. */ onConfirmationRequested?: (p: { args: any; toolCallbacks: ToolCallbacks; toolId: string }) => void @@ -1437,9 +1467,14 @@ export interface ToolCallbacks { toolId: string, form: RunFormDisplay, opts?: { autoAccepted?: boolean } - ) => Promise<Record<string, any> | undefined> + ) => Promise<RunFormAnswer | undefined> /** The submitted form's job is queued. Wired alongside requestRunArgs. */ markRunFormStarted?: (toolId: string) => void + /** The submitted form will never produce a job: the server refused it, a sensitive + * argument could not be stored, or plan mode blocked it after the arguments were handed + * over. The mirror of `markRunFormStarted`, and the only way a surface that confirmed the + * run can tell that it is over — the call settles with neither a job nor a cancellation. */ + markRunFormEnded?: (toolId: string) => void /** Records a workspace item the tool call created/edited/deleted, by its * canonical (itemKind, storagePath). Session chats wire this to accumulate the * chat's modified-items mask; the global side-panel chat omits it (no-op). */ @@ -1728,6 +1763,9 @@ function parseResourceTypeSchema(schema: unknown): unknown { } } +export const HUB_SYNC_INSTRUCTIONS = + 'Tell the user: no resource of a missing type can be created until a superadmin syncs resource types from the hub, with "Sync resource types with Hub" in the Add resource dialog of the Resources page.' + export const getHubIntegrationTool = { requires: NONE, def: getHubIntegrationToolDef, diff --git a/frontend/src/lib/components/copilot/chat/skills/skillResources.ts b/frontend/src/lib/components/copilot/chat/skills/skillResources.ts index d28a4d32e8..5fd7cfad37 100644 --- a/frontend/src/lib/components/copilot/chat/skills/skillResources.ts +++ b/frontend/src/lib/components/copilot/chat/skills/skillResources.ts @@ -1,5 +1,6 @@ import { ResourceService } from '$lib/gen' import { canEditItem } from '$lib/editRights' +import { capabilitiesIn } from '$lib/roleCapabilities' import { userWorkspaces, type UserExt } from '$lib/stores' import { get } from 'svelte/store' @@ -57,7 +58,10 @@ export type SkillListing = { skills: SkillResource[]; truncated: boolean } * the shared `canEditItem` rather than reading `extra_perms` alone. */ export async function listSkillResources(workspace: string, user?: UserExt): Promise<SkillListing> { if (!workspace) return { skills: [], truncated: false } - const operatorSettings = get(userWorkspaces).find((w) => w.id === workspace)?.operator_settings + const caps = capabilitiesIn( + user, + get(userWorkspaces).find((w) => w.id === workspace) + ) const rows: SkillResource[] = [] for (let page = 1; page <= MAX_SKILLS_PAGES; page++) { const resources = await ResourceService.listResource({ @@ -72,7 +76,7 @@ export async function listSkillResources(workspace: string, user?: UserExt): Pro name: skillNameFromPath(r.path), description: r.description ?? '', editedAt: r.edited_at, - canWrite: canEditItem('resource', r.path, r.extra_perms, user, operatorSettings) + canWrite: canEditItem('resource', r.path, r.extra_perms, user, caps) })) ) if (resources.length < SKILLS_PAGE_SIZE) return { skills: rows, truncated: false } diff --git a/frontend/src/lib/components/copilot/chat/starter/ComposerSuggestions.svelte b/frontend/src/lib/components/copilot/chat/starter/ComposerSuggestions.svelte new file mode 100644 index 0000000000..6d7dc379a4 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/starter/ComposerSuggestions.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + import { Pencil, Play } from 'lucide-svelte' + import { Button } from '$lib/components/common' + import type { ComposerSuggestion } from './starterPrompts.svelte' + + let { + suggestions, + onPick, + wrap = false + }: { + suggestions: ComposerSuggestion[] + onPick: (prompt: string) => void + /** Wrap onto more lines instead of one row that scrolls sideways. */ + wrap?: boolean + } = $props() +</script> + +<!-- Unwrapped, one row whatever the width: it scrolls sideways and fades out at its end, so the + page under the composer does not move with the window width. --> +<div + class="flex flex-row items-center gap-1.5 {wrap + ? 'flex-wrap' + : 'overflow-x-auto no-scrollbar [mask-image:linear-gradient(90deg,#000_calc(100%-24px),transparent)]'}" +> + {#each suggestions.slice(0, 4) as suggestion (suggestion.label)} + <Button + variant="default" + unifiedSize="sm" + btnClasses="!rounded-full !text-xs !text-secondary !font-normal shrink-0" + title={suggestion.mode === 'use' ? 'Use: look, ask or run' : 'Build: create or change'} + startIcon={{ icon: suggestion.mode === 'use' ? Play : Pencil, classes: 'text-hint' }} + onClick={() => onPick(suggestion.prompt)} + > + {suggestion.label} + </Button> + {/each} +</div> diff --git a/frontend/src/lib/components/copilot/chat/starter/SessionGreeting.svelte b/frontend/src/lib/components/copilot/chat/starter/SessionGreeting.svelte new file mode 100644 index 0000000000..8bf5a0170b --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/starter/SessionGreeting.svelte @@ -0,0 +1,28 @@ +<script lang="ts"> + import { userStore, userWorkspaces, workspaceStore } from '$lib/stores' + import { takeFirstVisit, timeOfDayGreeting } from './greeting' + + // `name` is the full name when the account has one; the greeting uses the first word. + const userName = $derived($userStore?.name?.trim().split(/\s+/)[0] || $userStore?.username) + const workspaceName = $derived( + $userWorkspaces.find((w) => w.id === $workspaceStore)?.name ?? $workspaceStore + ) + // Read once the membership for this workspace has loaded: `userStore` still describes the + // previous workspace for a moment after a switch. + const firstVisit = $derived( + !!$workspaceStore && + $userStore?.workspace_id === $workspaceStore && + takeFirstVisit($workspaceStore, $userStore.created_at) + ) + const greeting = timeOfDayGreeting() +</script> + +<div class="flex items-center justify-center gap-2 mb-4"> + <p class="text-center font-regular text-3xl"> + {#if firstVisit} + Welcome to {workspaceName}{userName ? `, ${userName}` : ''} + {:else} + {greeting}{userName ? `, ${userName}` : ''} + {/if} + </p> +</div> diff --git a/frontend/src/lib/components/copilot/chat/starter/composerCollapse.ts b/frontend/src/lib/components/copilot/chat/starter/composerCollapse.ts new file mode 100644 index 0000000000..d21f04f1ba --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/starter/composerCollapse.ts @@ -0,0 +1,11 @@ +// The layers the composer's controls open render in portals under <body>, outside the +// composer's own element: DropdownV2 menus, melt popovers, and the settings modals. Not +// `[data-menu]`: DropdownV2 puts it on its trigger too, and Home has triggers everywhere. +const OVERLAY = '[data-melt-dropdown-menu], [data-melt-popover-content], [role="dialog"]' + +/** Whether a pointer-down outside the composer still leaves it open: it landed in a menu or + * dialog, or one is open, and the press is closing it rather than leaving the composer. */ +export function isComposerOverlay(target: EventTarget | null): boolean { + if (target instanceof Element && target.closest(OVERLAY)) return true + return document.querySelector(OVERLAY) !== null +} diff --git a/frontend/src/lib/components/copilot/chat/starter/greeting.test.ts b/frontend/src/lib/components/copilot/chat/starter/greeting.test.ts new file mode 100644 index 0000000000..835896cfb8 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/starter/greeting.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const scoped = vi.hoisted(() => ({ key: 'welcomed::ada@example.com' as string | undefined })) +vi.mock('$lib/userScopedStorage', () => ({ scopedKey: () => scoped.key })) + +import { takeFirstVisit } from './greeting' + +const DAY = 24 * 60 * 60 * 1000 +const daysAgo = (n: number) => new Date(Date.now() - n * DAY).toISOString() + +const store = new Map<string, string>() +vi.stubGlobal('localStorage', { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v) +}) + +beforeEach(() => { + store.clear() + scoped.key = 'welcomed::ada@example.com' +}) + +describe('takeFirstVisit', () => { + it('welcomes a new member once per workspace, for the whole page load', () => { + expect(takeFirstVisit('acme', daysAgo(2))).toBe(true) + // Back on Home or the empty session in the same load: still the welcome. + expect(takeFirstVisit('acme', daysAgo(2))).toBe(true) + expect(JSON.parse(store.get('welcomed::ada@example.com')!)).toEqual(['acme']) + }) + + it('greets by time of day for an older member, or a workspace already welcomed', () => { + expect(takeFirstVisit('old-team', daysAgo(45))).toBe(false) + store.set('welcomed::ada@example.com', JSON.stringify(['seen-before'])) + expect(takeFirstVisit('seen-before', daysAgo(1))).toBe(false) + }) + + it('records nothing before the user is known', () => { + scoped.key = undefined + expect(takeFirstVisit('fresh', daysAgo(1))).toBe(false) + expect(store.size).toBe(0) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/starter/greeting.ts b/frontend/src/lib/components/copilot/chat/starter/greeting.ts new file mode 100644 index 0000000000..8e63dc3d5a --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/starter/greeting.ts @@ -0,0 +1,37 @@ +import { scopedKey } from '$lib/userScopedStorage' + +const WELCOMED_KEY = 'session-block-welcomed-workspaces' +/** A membership older than this is not a first visit, whatever this browser remembers: without + * it, everyone already in a workspace would be welcomed to it once on their next visit. */ +const NEW_MEMBER_MS = 30 * 24 * 60 * 60 * 1000 + +// Workspaces welcomed during this page load: the welcome holds for the whole visit (Home, the +// empty session, back), and only a later load greets by time of day. +const welcomedThisLoad = new Set<string>() + +/** Whether this is the user's first visit to `workspace`: a recent member this browser has not + * welcomed there yet. Answering yes records the welcome. */ +export function takeFirstVisit(workspace: string, memberSince: string | undefined): boolean { + if (welcomedThisLoad.has(workspace)) return true + const key = scopedKey(WELCOMED_KEY) + if (!key) return false + let welcomed: string[] = [] + try { + welcomed = JSON.parse(localStorage.getItem(key) ?? '[]') + } catch {} + if (welcomed.includes(workspace)) return false + const joined = memberSince ? Date.parse(memberSince) : NaN + if (!(Date.now() - joined < NEW_MEMBER_MS)) return false + welcomedThisLoad.add(workspace) + try { + localStorage.setItem(key, JSON.stringify([...welcomed, workspace])) + } catch {} + return true +} + +export function timeOfDayGreeting(now = new Date()): string { + const hour = now.getHours() + if (hour >= 5 && hour < 12) return 'Good morning' + if (hour >= 12 && hour < 18) return 'Good afternoon' + return 'Good evening' +} diff --git a/frontend/src/lib/components/copilot/chat/starter/smoothHeight.ts b/frontend/src/lib/components/copilot/chat/starter/smoothHeight.ts new file mode 100644 index 0000000000..57336ef9b8 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/starter/smoothHeight.ts @@ -0,0 +1,63 @@ +type SmoothHeightParams = { + /** Off: the element takes its natural height and nothing is animated. */ + enabled: boolean + /** The state whose change should animate. Height changes at any other time follow the + * content at once: animating every line the user types would clip the caret's line. */ + key: unknown +} + +const DURATION_MS = 200 +const EASING = 'cubic-bezier(0.2, 0.8, 0.2, 1)' + +/** + * Sizes `node` to its first child and animates that height when `key` changes, so a block + * that changes shape grows and shrinks instead of jumping. The child is measured, not the + * node: the node's own height is the one being set. `node` clips while it animates, so a + * focus ring belongs on `node` itself (box-shadow is not clipped by its own overflow). + */ +export function smoothHeight(node: HTMLElement, params: SmoothHeightParams) { + let { enabled, key } = params + let animateUntil = 0 + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)') + + const apply = () => { + const inner = node.firstElementChild as HTMLElement | null + if (!enabled || !inner) { + node.style.height = '' + node.style.overflow = '' + node.style.transition = '' + return + } + const borders = node.offsetHeight - node.clientHeight + const animate = performance.now() < animateUntil && !reducedMotion.matches + node.style.overflow = 'hidden' + node.style.transition = animate ? `height ${DURATION_MS}ms ${EASING}` : 'none' + node.style.height = `${inner.offsetHeight + borders}px` + } + + // Created on first use: most chats mount this disabled and never turn it on. + let ro: ResizeObserver | undefined + const observe = () => { + ro?.disconnect() + const inner = node.firstElementChild + if (!enabled || !inner) return + ro ??= new ResizeObserver(apply) + ro.observe(inner) + } + // No measuring at mount: the content's height is not settled yet (a newly mounted session + // composer reads ~60px too tall), and a view transition captures the box at that height. + // The observer's first callback comes after layout and sets the right one. + observe() + + return { + update(next: SmoothHeightParams) { + if (next.key !== key) animateUntil = performance.now() + DURATION_MS + ;({ enabled, key } = next) + observe() + apply() + }, + destroy() { + ro?.disconnect() + } + } +} diff --git a/frontend/src/lib/components/copilot/chat/starter/starterPrompts.svelte.ts b/frontend/src/lib/components/copilot/chat/starter/starterPrompts.svelte.ts new file mode 100644 index 0000000000..9bb9341a0b --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/starter/starterPrompts.svelte.ts @@ -0,0 +1,152 @@ +import { onboardingProfile, type StarterPrompt } from '$lib/onboardingProfile' +import { roleCapabilities } from '$lib/stores' +import { fromStore } from 'svelte/store' +import { resource } from 'runed' +import { ScriptService } from '$lib/gen' + +/** 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' } + +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 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. */ +export class StarterPrompts { + #invite = $state<StarterPrompt[]>() + #capabilities = fromStore(roleCapabilities) + #workspace: () => string | undefined + #itemCount: { readonly current: number | undefined } + + /** `workspace`: where the session acts. */ + constructor(workspace: () => string | undefined) { + this.#workspace = workspace + void onboardingProfile().then((p) => { + if (p?.starter_prompts?.length) this.#invite = p.starter_prompts + }) + this.#itemCount = resource(workspace, async (ws) => (ws ? workspaceItemCount(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 { + const ws = this.#workspace() + return this.#itemCount.current ?? (ws ? itemCounts.get(ws) : 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)] + }) + + /** Whether the workspace holds fewer than `FEW_ITEMS`; undefined until known. */ + get fewItems(): boolean | undefined { + const count = this.#count + return count === undefined ? undefined : count < FEW_ITEMS + } +} + +const itemCounts = new Map<string, number>() +const itemCountRequests = new Map<string, Promise<number>>() + +/** 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) +} + +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 + try { + // What Home's list counts: library scripts and the user's own drafts included. + const res = await ScriptService.listRunnables({ + workspace, + includeWithoutMain: true, + includeDraftOnly: true, + perPage: FEW_ITEMS + }) + 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) + } + itemCounts.set(workspace, count) + return count +} + +/** What turns an empty chat into the block that starts a session. */ +export type ChatStarter = { suggestions: ComposerSuggestion[] } & ( + | /** An empty session centres the block in the pane under a greeting. */ + { where: 'session' } /** Home shows it alone, collapsed to one line at rest unless `fewItems`. */ + | { where: 'home'; fewItems: boolean } +) diff --git a/frontend/src/lib/components/copilot/chat/toolConfirmAction.ts b/frontend/src/lib/components/copilot/chat/toolConfirmAction.ts new file mode 100644 index 0000000000..52f5d546e8 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/toolConfirmAction.ts @@ -0,0 +1,13 @@ +import { Check, Play, Plus, Save, Trash2, Undo2 } from 'lucide-svelte' +import type { ToolConfirmAction } from './shared' + +export const TOOL_CONFIRM_ACTIONS: Record<ToolConfirmAction, { label: string; icon: any }> = { + run: { label: 'Run', icon: Play }, + deploy: { label: 'Deploy', icon: Save }, + create: { label: 'Create', icon: Plus }, + apply: { label: 'Apply', icon: Check }, + delete: { label: 'Delete', icon: Trash2 }, + discard: { label: 'Discard', icon: Trash2 }, + rollback: { label: 'Roll back', icon: Undo2 }, + confirm: { label: 'Confirm', icon: Check } +} diff --git a/frontend/src/lib/components/copilot/chat/toolGroups.test.ts b/frontend/src/lib/components/copilot/chat/toolGroups.test.ts index 4dd3b376fe..d0f14e1f52 100644 --- a/frontend/src/lib/components/copilot/chat/toolGroups.test.ts +++ b/frontend/src/lib/components/copilot/chat/toolGroups.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest' import type { DisplayMessage } from './shared' -import { groupHeader, groupToolRuns, type GroupState, type ToolGroup } from './toolGroups' +import { + createChatItemGrouper, + groupHeader, + groupToolRuns, + type GroupState, + type ToolGroup +} from './toolGroups' let nextId = 0 function tool( @@ -274,3 +280,37 @@ describe('groupToolRuns', () => { expect(header([mcp('list_issues'), other])).toBe('List issues, get issue') }) }) + +describe('createChatItemGrouper', () => { + it('keeps the history rows while only the live answer changes', () => { + const group = createChatItemGrouper() + const history = [ + assistant('done'), + tool('read_workspace_item'), + tool('list_runs'), + assistant('more') + ] + const first = group([...history, assistant('a', { streaming: true })]) + const second = group([...history, assistant('ab', { streaming: true })]) + expect(second).toHaveLength(first.length) + second.slice(0, -1).forEach((item, i) => expect(item).toBe(first[i])) + expect(second.at(-1)).toMatchObject({ kind: 'message', index: 4, message: { content: 'ab' } }) + expect(shape([...history, assistant('ab', { streaming: true })])).toEqual( + second.map((item) => + item.kind === 'group' ? { [item.groupKind]: item.entries.map((e) => e.index) } : item.index + ) + ) + }) + + it('keeps every row but the one a tool status change replaced', () => { + const group = createChatItemGrouper() + const history = [assistant('done'), tool('run_script'), assistant('more')] + const first = group(history) + const updated = [history[0], { ...history[1], isLoading: false }, history[2]] + const second = group(updated) + expect(second[0]).toBe(first[0]) + expect(second[1]).not.toBe(first[1]) + expect(second[1]).toMatchObject({ message: updated[1] }) + expect(second[2]).toBe(first[2]) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/toolGroups.ts b/frontend/src/lib/components/copilot/chat/toolGroups.ts index f3e3ad0aa9..5c0a8c2e1e 100644 --- a/frontend/src/lib/components/copilot/chat/toolGroups.ts +++ b/frontend/src/lib/components/copilot/chat/toolGroups.ts @@ -295,6 +295,49 @@ export function groupToolRuns(messages: DisplayMessage[]): ChatItem[] { return items } +function sameChatItem(a: ChatItem, b: ChatItem): boolean { + if (a.kind === 'message') { + return b.kind === 'message' && a.message === b.message && a.index === b.index + } + return ( + b.kind === 'group' && + a.groupKind === b.groupKind && + a.key === b.key && + a.target === b.target && + a.entries.length === b.entries.length && + a.entries.every((e, i) => e.message === b.entries[i].message && e.index === b.entries[i].index) + ) +} + +/** `groupToolRuns` for a transcript that is re-sent whole on every streamed chunk and on every + * tool status change: a row whose messages did not change keeps its previous item object. A + * fresh item per row re-renders the whole history each time (every answer re-parsing its + * markdown, every tool row its result), which freezes the tab on long sessions. + * + * A changed message is told apart by identity alone, so a row must be replaced, never mutated + * in place, for its grouping to update. */ +export function createChatItemGrouper(): (messages: DisplayMessage[]) => ChatItem[] { + let settled: DisplayMessage[] = [] + let settledItems: ChatItem[] = [] + return (messages) => { + // The live answer only ever comes last, and it can neither join nor end a group + // differently than the end of the transcript does, so it is grouped on its own and the + // rows before it are not regrouped while it streams. + const last = messages.at(-1) + const live = last?.role === 'assistant' && last.streaming ? last : undefined + const n = live ? messages.length - 1 : messages.length + if (n !== settled.length || settled.some((m, i) => m !== messages[i])) { + const previous = new Map(settledItems.map((item) => [chatItemKey(item), item])) + settled = messages.slice(0, n) + settledItems = groupToolRuns(settled).map((item) => { + const before = previous.get(chatItemKey(item)) + return before && sameChatItem(before, item) ? before : item + }) + } + return live ? [...settledItems, { kind: 'message', message: live, index: n }] : settledItems + } +} + // The server's full resource path: two servers can share a last segment (u/alice/github, // f/team/github), so only the display shortens it. function mcpServerPath(call: ToolDisplayMessage): string | undefined { diff --git a/frontend/src/lib/components/copilot/chat/workspaceTools.ts b/frontend/src/lib/components/copilot/chat/workspaceTools.ts index 18c3687701..7590f3b686 100644 --- a/frontend/src/lib/components/copilot/chat/workspaceTools.ts +++ b/frontend/src/lib/components/copilot/chat/workspaceTools.ts @@ -309,6 +309,7 @@ function setToolError(toolCallbacks: ToolCallbacks, toolId: string, error: unkno const createScheduleTool: Tool<any> = { def: createScheduleToolDef, requiresConfirmation: true, + confirmAction: 'create', confirmationMessage: 'Create schedule', showDetails: true, showFade: true, @@ -418,6 +419,7 @@ async function resolveEmailTriggerAvailability(): Promise<EmailTriggerAvailabili const createTriggerTool: Tool<any> = { def: createTriggerToolDef, requiresConfirmation: true, + confirmAction: 'create', confirmationMessage: 'Create trigger', showDetails: true, showFade: true, diff --git a/frontend/src/lib/components/copilot/chat/workspaceToolsZod.gen.ts b/frontend/src/lib/components/copilot/chat/workspaceToolsZod.gen.ts index 0b393e6969..e4817e190c 100644 --- a/frontend/src/lib/components/copilot/chat/workspaceToolsZod.gen.ts +++ b/frontend/src/lib/components/copilot/chat/workspaceToolsZod.gen.ts @@ -488,7 +488,7 @@ export const variableRequestSchema = z.object({ "value": z.string().describe("The value of the variable"), "is_secret": z.boolean().describe("Whether the variable is a secret"), "description": z.string().describe("The description of the variable"), - "account": z.number().int().describe("The account identifier").optional(), + "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(), "labels": z.array(z.string()).optional(), @@ -501,7 +501,8 @@ export const resourceRequestSchema = z.object({ "description": z.string().describe("The description of the resource").optional(), "resource_type": z.string().describe("The resource_type associated with the resource"), "labels": z.array(z.string()).optional(), - "ws_specific": z.boolean().optional() + "ws_specific": z.boolean().optional(), + "preserve_on_behalf_of": z.boolean().describe("ai_agent only. Keep the identity `value.on_behalf_of` names (`u/<username>`, `g/<group>`) rather than the caller's own. Honoured for admins and members of wm_deployers; otherwise every write of an agent sets it to the caller.").optional() }) export const triggerRequestSchemas = { diff --git a/frontend/src/lib/components/copilot/chatModelSettings.ts b/frontend/src/lib/components/copilot/chatModelSettings.ts index 3fce0270d1..e65a0b5141 100644 --- a/frontend/src/lib/components/copilot/chatModelSettings.ts +++ b/frontend/src/lib/components/copilot/chatModelSettings.ts @@ -41,6 +41,8 @@ export type ChoiceSection = { export type ChatModelSettingsConfig = { /** The trigger's main text: the chosen model, or an invitation to choose one. */ label: string + /** Shown instead of `label` on windows narrower than 1280px. */ + shortLabel?: string title?: string /** Trailing pill on the trigger, e.g. the free-tier grant this chat is spending. */ badge?: { text: string; warn?: boolean } diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index a9a33e9deb..299765605e 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -26,6 +26,7 @@ import { explicitOffToken, requestsReasoning, stripLegacyThinkingSuffix, + usesOpenAIResponsesApi, type ReasoningEffort } from './reasoningRegistry' import { formatResourceTypes } from './utils' @@ -1242,8 +1243,7 @@ export async function getCompletion( reasoningEffort: options?.reasoningEffort }) - // Use Responses API for OpenAI and Azure OpenAI - if ((provider === 'openai' || provider === 'azure_openai') && !options?.forceCompletions) { + if (usesOpenAIResponsesApi(provider, modelProvider.model) && !options?.forceCompletions) { try { const stream = getOpenAIResponsesCompletionStream(messages, abortController, tools, { forceModelProvider: options?.forceModelProvider, @@ -1327,8 +1327,8 @@ export async function getStreamedCompletionText( } catch (error) { // The Responses API stream only fails once iterated, so the fallback to // chat completions for a deployment that doesn't serve it lives here. - const { provider } = getCurrentModel() - if (abortController.signal.aborted || (provider !== 'openai' && provider !== 'azure_openai')) { + const { provider, model } = getCurrentModel() + if (abortController.signal.aborted || !usesOpenAIResponsesApi(provider, model)) { throw error } console.error('Error using Responses API:', error) diff --git a/frontend/src/lib/components/copilot/reasoningRegistry.test.ts b/frontend/src/lib/components/copilot/reasoningRegistry.test.ts index 30543e6c2f..176ab424d1 100644 --- a/frontend/src/lib/components/copilot/reasoningRegistry.test.ts +++ b/frontend/src/lib/components/copilot/reasoningRegistry.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { applyReasoningToConfig, completionsRejectsToolsWithReasoning, + usesOpenAIResponsesApi, explicitOffToken, getReasoningCapability, REASONING_OFF, @@ -301,6 +302,12 @@ describe('supportsReasoning (static registry)', () => { expect(completionsRejectsToolsWithReasoning('azure_openai', model), model).toBe(false) } }) + it('sends Foundry gpt models that refuse tools on Chat Completions to the Responses API', () => { + expect(usesOpenAIResponsesApi('azure_foundry', 'gpt-6.1-sol')).toBe(true) + for (const model of ['gpt-4o', 'DeepSeek-V4-Pro', 'claude-sonnet-5']) { + expect(usesOpenAIResponsesApi('azure_foundry', model), model).toBe(false) + } + }) it('forwards an explicit off as effort none through OpenRouter', () => { expect( resolveRequestReasoning({ diff --git a/frontend/src/lib/components/copilot/reasoningRegistry.ts b/frontend/src/lib/components/copilot/reasoningRegistry.ts index 0cbf935832..1cce6e3b0d 100644 --- a/frontend/src/lib/components/copilot/reasoningRegistry.ts +++ b/frontend/src/lib/components/copilot/reasoningRegistry.ts @@ -263,6 +263,19 @@ export function completionsRejectsToolsWithReasoning(provider: AIProvider, model return findReasoningRule(provider, model).rule?.completionsToolsNeedOff ?? false } +/** + * Whether the chat sends this model to the Responses API, falling back to Chat Completions + * where a deployment does not serve it. On Foundry, only the models Chat Completions refuses + * function tools from while they reason: gpt-6.1-sol and gpt-6-astra cannot turn it off. + */ +export function usesOpenAIResponsesApi(provider: AIProvider, model: string): boolean { + return ( + provider === 'openai' || + provider === 'azure_openai' || + (provider === 'azure_foundry' && completionsRejectsToolsWithReasoning(provider, model)) + ) +} + export type ReasoningCapability = { supported: boolean /** Suggested levels for the UI control. Empty when unsupported. */ diff --git a/frontend/src/lib/components/copilot/workspaceAiHidden.svelte.ts b/frontend/src/lib/components/copilot/workspaceAiHidden.svelte.ts new file mode 100644 index 0000000000..99d0cca9f0 --- /dev/null +++ b/frontend/src/lib/components/copilot/workspaceAiHidden.svelte.ts @@ -0,0 +1,44 @@ +import { resource } from 'runed' +import { fromStore } from 'svelte/store' +import { WorkspaceService } from '$lib/gen' +import { copilotInfo, copilotWorkspace } from '$lib/aiStore' + +/** + * Whether a workspace hid the AI assistant (`ai_config.copilot_disabled`), read for that workspace + * directly: `copilotInfo` holds whichever workspace loaded last, so it answers only when it was + * loaded for this very workspace. `hidden` is `undefined` until a verdict for the workspace asked + * about lands; runed keeps the previous `current` while a new source loads, so each verdict is + * tagged with its workspace. A failed read counts as not hidden, as an unloaded config does + * everywhere else. + */ +export function useWorkspaceAiHidden(workspace: () => string | undefined) { + const loadedInfo = fromStore(copilotInfo) + const loadedWorkspace = fromStore(copilotWorkspace) + const verdict = resource(workspace, async (ws, _prev, { signal }) => { + if (!ws) return { workspace: ws, hidden: false } + let hidden = false + try { + hidden = (await WorkspaceService.getCopilotInfo({ workspace: ws })).copilot_disabled === true + } catch (e) { + console.error(`Failed to read the AI config of workspace ${ws}:`, e) + } + // The generated client can't take an abort signal, so drop a superseded response here. + if (signal.aborted) throw new DOMException('superseded', 'AbortError') + return { workspace: ws, hidden } + }) + const hidden = $derived.by(() => { + const ws = workspace() + const current = verdict.current + if (current && current.workspace === ws) return current.hidden + return ws && loadedWorkspace.current === ws ? loadedInfo.current.workspaceDisabled : undefined + }) + return { + get hidden(): boolean | undefined { + return hidden + }, + /** Whether any verdict has landed yet, for any workspace. */ + get settledOnce(): boolean { + return verdict.current !== undefined + } + } +} diff --git a/frontend/src/lib/components/datatableMigrationSchemas.test.ts b/frontend/src/lib/components/datatableMigrationSchemas.test.ts new file mode 100644 index 0000000000..306206358b --- /dev/null +++ b/frontend/src/lib/components/datatableMigrationSchemas.test.ts @@ -0,0 +1,90 @@ +import { describe, test, expect } from 'vitest' +import { + parseMigrationSchemas, + suggestMigrationSchemas, + withMigrationSchemas +} from './datatableMigrationSchemas' +import { withMigrationRole, parseMigrationRole } from './datatableMigrationRole' + +describe('parseMigrationSchemas', () => { + test('reads the spellings the server accepts, lowercased and deduplicated', () => { + expect(parseMigrationSchemas('-- role a\n-- Schemas: Sales, billing,sales;\nBEGIN;')).toEqual({ + kind: 'schemas', + schemas: ['sales', 'billing'] + }) + expect(parseMigrationSchemas('BEGIN;\n-- schemas sales')).toEqual({ kind: 'none' }) + expect(parseMigrationSchemas('-- schemas: changes to orders\nBEGIN;')).toEqual({ + kind: 'malformed', + line: '-- schemas: changes to orders' + }) + }) + + test('rewriting the scope keeps the role annotation readable', () => { + const sql = withMigrationSchemas(withMigrationRole('BEGIN;\nEND;', 'analyst'), ['sales']) + expect(sql).toBe('-- schemas sales\n-- role analyst\nBEGIN;\nEND;') + expect(parseMigrationRole(sql)).toEqual({ kind: 'role', role: 'analyst' }) + expect(withMigrationSchemas(sql, [])).toBe('-- role analyst\nBEGIN;\nEND;') + }) +}) + +describe('suggestMigrationSchemas', () => { + test('reads the scope of generated table DDL', () => { + const sql = `BEGIN; +CREATE SCHEMA IF NOT EXISTS billing; +CREATE TABLE IF NOT EXISTS "Sales"."orders" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL, + "amount" numeric(10, 2) CHECK (amount > 0), + "customer" int REFERENCES crm.customers(id) ON DELETE CASCADE, + PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX orders_idx ON sales.orders USING btree (id); +ALTER TABLE sales.orders ALTER COLUMN amount TYPE numeric(12, 2); +COMMENT ON TABLE sales.orders IS 'one row per order'; +END;` + expect(suggestMigrationSchemas(sql)).toEqual(['billing', 'sales', 'crm']) + // As the database manager's query builders write it. + expect( + suggestMigrationSchemas( + 'BEGIN;\n\nALTER TABLE "myschema"."users" ADD COLUMN "age" INT;\nDROP TABLE "myschema"."old";\n\nEND;' + ) + ).toEqual(['myschema']) + expect(suggestMigrationSchemas('ALTER SCHEMA "sales" RENAME TO "billing";')).toEqual([ + 'sales', + 'billing' + ]) + }) + + test('falls back to the whole database for anything it cannot read in full', () => { + for (const sql of [ + // Unqualified objects resolve through the search path. + 'CREATE TABLE plain (id int);', + 'ALTER TABLE ONLY plain ADD COLUMN x int;', + 'CREATE INDEX ON plain (id);', + 'CREATE TABLE sales.t (id int REFERENCES plain(id));', + 'CREATE TABLE sales.t (status order_status);', + 'ALTER TABLE sales.t ADD COLUMN x int DEFAULT my_default();', + 'DROP INDEX plain_idx;', + // Qualified, but where the scope is not read. + 'CREATE TABLE sales.t (status billing.order_status);', + // Statements outside the reader. + 'CREATE TABLE sales.copy AS SELECT * FROM customers;', + 'TRUNCATE plain;', + 'GRANT USAGE ON SCHEMA sales, billing TO analyst;', + 'CREATE EXTENSION postgis WITH SCHEMA public;', + 'CREATE POLICY p ON sales.t FOR SELECT USING (true);', + 'CREATE VIEW sales.v AS SELECT o.id FROM sales.orders o;', + // A cascade drops dependents wherever they are. + 'DROP SCHEMA billing CASCADE;', + // Several targets, or a relation reached through another one. + 'DROP SCHEMA sales, billing;', + 'DROP TABLE sales.t, customers;', + 'ALTER TABLE sales.t ATTACH PARTITION plain FOR VALUES IN (1);', + 'ALTER TABLE sales.t INHERIT plain;', + 'CREATE TABLE sales.t OF plain_type;', + // `t.c` names a table and a column, not a schema. + 'COMMENT ON COLUMN t.c IS null;' + ]) { + expect(suggestMigrationSchemas(`CREATE TABLE sales.a (id int);\n${sql}`), sql).toEqual([]) + } + }) +}) diff --git a/frontend/src/lib/components/datatableMigrationSchemas.ts b/frontend/src/lib/components/datatableMigrationSchemas.ts new file mode 100644 index 0000000000..b2e8f4c255 --- /dev/null +++ b/frontend/src/lib/components/datatableMigrationSchemas.ts @@ -0,0 +1,311 @@ +/** + * A migration declares the schemas it touches in its own SQL, as a `-- schemas sales, billing` + * annotation, the same way it declares its role (see `datatableMigrationRole.ts`). A migration that + * declares none applies to the whole database: every later migration waits for it, so the editors + * suggest a scope from the SQL rather than leaving it out. + * + * Mirrors `parse_migration_schemas` in `datatable_migration_scope.rs`: read from the leading comment + * block only, names lowercased, and a leading comment whose first word is `schemas` is an + * annotation attempt, so a malformed one is an error. + */ + +export type MigrationSchemas = + | { kind: 'none' } + | { kind: 'schemas'; schemas: string[] } + | { kind: 'malformed'; line: string } + +const KEYWORD = 'schemas' + +export function isAnnotatableSchemaName(name: string): boolean { + return /^[A-Za-z0-9_$-]{1,63}$/.test(name) +} + +/** The rest of a leading comment line that attempts a scope annotation, or undefined. */ +function schemasAttempt(line: string): string | undefined { + if (!line.startsWith('--')) return undefined + const body = line.slice(2).trimStart() + if (body.slice(0, KEYWORD.length).toLowerCase() !== KEYWORD) return undefined + const after = body.slice(KEYWORD.length) + if (after !== '' && !/^[\s:=]/.test(after)) return undefined + return after +} + +function parseAttempt(after: string): string[] | undefined { + let rest = after.trimStart() + if (rest.startsWith(':') || rest.startsWith('=')) rest = rest.slice(1) + rest = rest.trim() + if (rest.endsWith(';')) rest = rest.slice(0, -1) + const schemas: string[] = [] + for (const piece of rest.split(',')) { + const name = piece.trim() + if (!isAnnotatableSchemaName(name)) return undefined + const lower = name.toLowerCase() + if (!schemas.includes(lower)) schemas.push(lower) + } + return schemas +} + +export function parseMigrationSchemas(sql: string): MigrationSchemas { + for (const raw of sql.split('\n')) { + const line = raw.trim() + if (line === '') continue + if (!line.startsWith('--')) break + const after = schemasAttempt(line) + if (after === undefined) continue + const schemas = parseAttempt(after) + return schemas === undefined ? { kind: 'malformed', line } : { kind: 'schemas', schemas } + } + return { kind: 'none' } +} + +/** + * `sql` declaring `schemas`: any scope annotation attempt in the leading comment block is removed, + * and `-- schemas <names>` is prepended above everything, or nothing when `schemas` is empty. + */ +export function withMigrationSchemas(sql: string, schemas: string[]): string { + for (const s of schemas) { + if (!isAnnotatableSchemaName(s)) throw new Error(`Invalid schema name '${s}'`) + } + const lines = sql.split('\n') + const kept: string[] = [] + let i = 0 + for (; i < lines.length; i++) { + const line = lines[i].trim() + if (line !== '' && !line.startsWith('--')) break + if (schemasAttempt(line) === undefined) kept.push(lines[i]) + } + const rest = [...kept, ...lines.slice(i)] + while (rest.length > 0 && rest[0].trim() === '') rest.shift() + return schemas.length === 0 + ? rest.join('\n') + : [`-- ${KEYWORD} ${schemas.join(', ')}`, ...rest].join('\n') +} + +/** Whether two scopes overlap; `undefined` is the whole database, which overlaps everything. */ +export function scopesOverlap(a: string[] | undefined, b: string[] | undefined): boolean { + if (a === undefined || b === undefined) return true + return a.some((s) => b.includes(s)) +} + +/** The scope a migration's SQL declares, `undefined` for the whole database. */ +export function migrationScope(sql: string): string[] | undefined { + const parsed = parseMigrationSchemas(sql) + return parsed.kind === 'schemas' ? parsed.schemas : undefined +} + +const IDENT = String.raw`(?:"(?:[^"]|"")+"|[A-Za-z_][A-Za-z0-9_$]*)` +const SYSTEM_SCHEMAS = ['pg_catalog', 'information_schema', 'pg_toast', 'pg_temp'] + +function unquote(ident: string): string { + return ident.startsWith('"') ? ident.slice(1, -1).replaceAll('""', '"') : ident +} + +// The statements a scope is suggested for: table, index, schema, sequence and type DDL. Anything +// else (a view, a function, a grant, data, an extension) is outside the reader, and suggests +// nothing. +const STATEMENT_SHAPE = + /^(?:CREATE\s+(?:UNIQUE\s+)?INDEX|DROP\s+INDEX|(?:CREATE|ALTER|DROP)\s+(?:TABLE|SCHEMA|SEQUENCE|TYPE)|COMMENT\s+ON\s+(?:TABLE|SCHEMA))\b/i +const TRANSACTION_CONTROL = /^(?:BEGIN|COMMIT|END|START\s+TRANSACTION)$/i +// Clauses that reach another object without naming it where the reader looks: a query, a copied +// or inherited definition, a sequence named in a string, and a cascade, which drops whatever +// depends on the target wherever it lives. `ON DELETE CASCADE` is a foreign key's action. +const OPAQUE_CLAUSE = + /\b(?:SELECT|FROM|LIKE|INHERITS?|OF|ATTACH|DETACH|EXECUTE|nextval)\b|::\s*regclass|(?<!\bON\s+(?:DELETE|UPDATE)\s+)\bCASCADE\b/i +// Words a definition puts before `(` that call nothing a search path resolves: clause keywords, +// built-in types with a modifier, index methods, and `pg_catalog` functions. +const PAREN_WORDS = new Set( + ( + 'key unique check references foreign primary exclude constraint as with where include in any ' + + 'and or not cast ' + + 'all array identity generated enum default values filter over partition by ' + + 'varchar char character varying numeric decimal timestamp timestamptz time timetz interval ' + + 'bit varbit float double btree hash gist gin brin spgist ' + + 'now gen_random_uuid uuidv4 uuidv7 clock_timestamp statement_timestamp transaction_timestamp ' + + 'lower upper length char_length coalesce nullif greatest least abs round trunc md5 random ' + + 'date_trunc to_char to_jsonb jsonb_build_object json_build_object array_length cardinality' + ).split(' ') +) +// Built-in types: an unqualified type outside this list may be a user type resolved through the +// search path. +const BUILTIN_TYPES = new Set( + ( + 'int int2 int4 int8 integer smallint bigint serial serial2 serial4 serial8 smallserial ' + + 'bigserial real float float4 float8 double numeric decimal money text varchar char character ' + + 'bpchar name bytea boolean bool date time timetz timestamp timestamptz interval uuid json ' + + 'jsonb xml inet cidr macaddr macaddr8 bit varbit tsvector tsquery point line lseg box path ' + + 'polygon circle int4range int8range numrange tsrange tstzrange daterange int4multirange ' + + 'int8multirange nummultirange tsmultirange tstzmultirange datemultirange oid' + ).split(' ') +) +const BUILTIN_COLLATIONS = new Set(['c', 'posix', 'default', 'ucs_basic', 'unicode']) +// Keywords that follow `ON`, `REFERENCES` or an object keyword where a name could: `ON DELETE`, +// `COMMENT ON TABLE`, `ON COMMIT`, and the optional `IF [NOT] EXISTS` / `ONLY` a match can land +// on when the name after them is qualified. +const NOT_A_NAME = new Set([ + 'if', + 'not', + 'exists', + 'only', + 'concurrently', + 'delete', + 'update', + 'commit', + 'table', + 'column', + 'schema', + 'index', + 'type', + 'sequence', + 'constraint' +]) +// Words a column list puts first that open a table constraint rather than a column. +const CONSTRAINT_WORDS = new Set([ + 'constraint', + 'primary', + 'foreign', + 'unique', + 'check', + 'exclude', + 'like' +]) + +/** + * A scope to suggest for `sql`, read conservatively: every statement must be one of the DDL shapes + * above and name every object it reaches with its schema; otherwise this returns `[]`. It is only + * ever offered for the author to accept, never written for them: PostgreSQL reaches objects the + * SQL does not name (a partition, an inheriting table), so only the author can vouch for a scope. + */ +export function suggestMigrationSchemas(sql: string): string[] { + const code = stripLiteralsAndComments(sql) + if (OPAQUE_CLAUSE.test(code)) return [] + const statements = code + .split(';') + .map((s) => s.trim()) + .filter((s) => s !== '' && !TRANSACTION_CONTROL.test(s)) + if (statements.length === 0) return [] + for (const s of statements) { + // `DROP TABLE a, b` and `DROP SCHEMA a, b` name several targets; the reader checks one. + if (/^DROP\b/i.test(s) && splitTopLevel(s).length > 1) return [] + if (!STATEMENT_SHAPE.test(s) || !namesAreQualified(s)) return [] + } + const found = qualifiedSchemas(code) + // Every qualifier has to be a schema the scope declares: one that is not (an alias, a schema the + // reader has no position for) cannot be told apart from a dependency. + for (const m of code.matchAll(new RegExp(String.raw`(${IDENT})\s*\.\s*${IDENT}`, 'g'))) { + const qualifier = unquote(m[1]).toLowerCase() + if (!found.includes(qualifier) && !isSystemSchema(qualifier)) return [] + } + return found +} + +/** Whether a statement of an accepted shape reaches nothing through the search path. */ +function namesAreQualified(s: string): boolean { + const lower = (ident: string) => unquote(ident).toLowerCase() + const unqualifiedAfter = (prefix: string) => + new RegExp(String.raw`${prefix}\s+(${IDENT})(?![A-Za-z0-9_$"]|\s*\.)`, 'gi') + // The object a statement creates, changes or drops, and the table an index or foreign key + // targets. `IF [NOT] EXISTS` and `ONLY` come before the name, not instead of it. + const relation = String.raw`(?:\s+IF\s+(?:NOT\s+)?EXISTS)?(?:\s+ONLY)?` + for (const re of [ + unqualifiedAfter( + String.raw`\b(?:CREATE|ALTER|DROP)\s+(?:UNLOGGED\s+)?(?:TABLE|SEQUENCE|TYPE)${relation}` + ), + unqualifiedAfter(String.raw`\bDROP\s+INDEX(?:\s+CONCURRENTLY)?${relation}`), + unqualifiedAfter(String.raw`\bON${relation}`), + unqualifiedAfter(String.raw`\bREFERENCES`), + unqualifiedAfter(String.raw`\bCOMMENT\s+ON\s+TABLE`) + ]) { + for (const m of s.matchAll(re)) { + if (!NOT_A_NAME.has(lower(m[1]))) return false + } + } + for (const m of s.matchAll(new RegExp(String.raw`::\s*(${IDENT})(?!\s*\.)`, 'g'))) { + if (!BUILTIN_TYPES.has(lower(m[1]))) return false + } + // Calls: a word right before `(` that is not part of a qualified name. + for (const m of s.matchAll(new RegExp(String.raw`(?<![.\w$"])(${IDENT})\s*\(`, 'g'))) { + if (!PAREN_WORDS.has(lower(m[1]))) return false + } + for (const m of s.matchAll(new RegExp(String.raw`\bCOLLATE\s+(${IDENT})(?!\s*\.)`, 'gi'))) { + if (!BUILTIN_COLLATIONS.has(lower(m[1]))) return false + } + // Column types: in a table's column list, after `ADD [COLUMN]`, and after `TYPE`. + const types: string[] = [] + const body = /^CREATE\s+(?:UNLOGGED\s+)?TABLE\b[^(]*\(([\s\S]*)\)/i.exec(s)?.[1] + if (body !== undefined) { + for (const element of splitTopLevel(body)) { + const m = new RegExp(String.raw`^(${IDENT})\s+(${IDENT})(?!\s*\.)`).exec(element.trim()) + if (m && !CONSTRAINT_WORDS.has(lower(m[1]))) types.push(m[2]) + } + } + for (const m of s.matchAll( + new RegExp( + String.raw`\bADD\s+(?:COLUMN\s+)?(?:IF\s+NOT\s+EXISTS\s+)?(${IDENT})\s+(${IDENT})(?!\s*\.)`, + 'gi' + ) + )) { + if (!CONSTRAINT_WORDS.has(lower(m[1]))) types.push(m[2]) + } + for (const m of s.matchAll(new RegExp(String.raw`\bTYPE\s+(${IDENT})(?!\s*\.)`, 'gi'))) { + types.push(m[1]) + } + return types.every((t) => BUILTIN_TYPES.has(lower(t))) +} + +/** `body` split on the commas outside parentheses. */ +function splitTopLevel(body: string): string[] { + const parts: string[] = [] + let depth = 0 + let start = 0 + for (let i = 0; i < body.length; i++) { + if (body[i] === '(') depth++ + else if (body[i] === ')') depth-- + else if (body[i] === ',' && depth === 0) { + parts.push(body.slice(start, i)) + start = i + 1 + } + } + parts.push(body.slice(start)) + return parts +} + +function isSystemSchema(name: string): boolean { + return SYSTEM_SCHEMAS.includes(name) || name.startsWith('pg_temp') +} + +function stripLiteralsAndComments(sql: string): string { + return sql + .replace(/\$([A-Za-z_]*)\$[\s\S]*?\$\1\$/g, ' ') + .replace(/'(?:[^']|'')*'/g, ' ') + .replace(/\/\*[\s\S]*?\*\//g, ' ') + .replace(/--[^\n]*/g, ' ') +} + +function qualifiedSchemas(code: string): string[] { + const found: string[] = [] + const add = (ident: string) => { + const name = unquote(ident).toLowerCase() + if (isAnnotatableSchemaName(name) && !isSystemSchema(name) && !found.includes(name)) { + found.push(name) + } + } + const patterns = [ + new RegExp(String.raw`\bSCHEMA\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?(${IDENT})`, 'gi'), + // The schema a rename creates, and the one `SET SCHEMA` moves an object into. + new RegExp(String.raw`\bALTER\s+SCHEMA\s+${IDENT}\s+RENAME\s+TO\s+(${IDENT})`, 'gi'), + new RegExp( + String.raw`\b(?:TABLE|VIEW|SEQUENCE|TYPE|FUNCTION|PROCEDURE|DOMAIN|INDEX|TRIGGER|COLUMN|REFERENCES|INTO|UPDATE|FROM|JOIN|EXISTS|ONLY)\s+(${IDENT})\s*\.\s*${IDENT}`, + 'gi' + ), + // `ON` also opens a join condition (`ON a.id = b.id`), so only the forms that name a relation + // count: an index, trigger, policy, grant or comment target. + new RegExp( + String.raw`\bON\s+(?:ONLY\s+)?(${IDENT})\s*\.\s*${IDENT}(?=\s*(?:\(|USING\b|FOR\b|TO\b|AS\b|IS\b|;|$))`, + 'gim' + ) + ] + for (const re of patterns) { + for (const m of code.matchAll(re)) add(m[1]) + } + return found +} diff --git a/frontend/src/lib/components/dateTimeInput.test.ts b/frontend/src/lib/components/dateTimeInput.test.ts new file mode 100644 index 0000000000..8a466ee5cf --- /dev/null +++ b/frontend/src/lib/components/dateTimeInput.test.ts @@ -0,0 +1,14 @@ +import { expect, it } from 'vitest' +import { parseDateTime } from './dateTimeInput' + +it('reads a zone-less naive value as its digits, outside UTC too', () => { + const tz = process.env.TZ + process.env.TZ = 'Europe/Paris' + try { + expect(parseDateTime('2027-09-09T10:00', 'naive').getUTCHours()).toBe(10) + expect(parseDateTime('2027-09-09T10:00', 'local').getUTCHours()).toBe(8) + } finally { + if (tz === undefined) delete process.env.TZ + else process.env.TZ = tz + } +}) diff --git a/frontend/src/lib/components/dateTimeInput.ts b/frontend/src/lib/components/dateTimeInput.ts new file mode 100644 index 0000000000..7ed8517321 --- /dev/null +++ b/frontend/src/lib/components/dateTimeInput.ts @@ -0,0 +1,5 @@ +/** A naive value's digits are its time: `Date` would read a zone-less date-time as local time */ +export function parseDateTime(value: string, timezone: 'naive' | 'local'): Date { + const zoneless = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/.test(value) + return new Date(timezone === 'naive' && zoneless ? value.replace(' ', 'T') + 'Z' : value) +} diff --git a/frontend/src/lib/components/dbOps.ts b/frontend/src/lib/components/dbOps.ts index 1ffab78ac4..a926788180 100644 --- a/frontend/src/lib/components/dbOps.ts +++ b/frontend/src/lib/components/dbOps.ts @@ -411,20 +411,20 @@ export function dbSchemaOpsWithPreviewScripts({ ) return } - // The new migration gets the highest timestamp, so any still-pending - // migration is earlier: running only this one applies it out of order. - // Warn like the row-level Run action does (skipped if no confirm hook). - if (confirmRunOutOfOrder) { - const pending = pendingMigrations(status.migrations).length - if (pending > 0 && !(await confirmRunOutOfOrder(pending))) { - throw new MigrationRunCancelled() - } - } // Wrapped before annotating: the annotation must lead, above `BEGIN;`. const codeUp = withMigrationRole( wrapMigration(await expandMarker(workspace, language, content)), migrationRole ) + // The new migration gets the highest timestamp, so any still-pending + // migration is earlier: running only this one applies it out of order. + // Warn like the row-level Run action does (skipped if no confirm hook). + if (confirmRunOutOfOrder) { + const pending = pendingMigrations(status.migrations, codeUp).length + if (pending > 0 && !(await confirmRunOutOfOrder(pending))) { + throw new MigrationRunCancelled() + } + } // Down migrations are only generated for Postgres for now. let codeDown: string | undefined if (downContent && dbType === 'postgresql') { diff --git a/frontend/src/lib/components/details/CopyableCodeBlock.svelte b/frontend/src/lib/components/details/CopyableCodeBlock.svelte index e99089bbbd..1e2145fc8a 100644 --- a/frontend/src/lib/components/details/CopyableCodeBlock.svelte +++ b/frontend/src/lib/components/details/CopyableCodeBlock.svelte @@ -46,7 +46,7 @@ <Highlight {language} {code} - class="{copyOnClick ? 'pointer-events-none' : 'select-text'} {wrap ? 'whitespace-pre-wrap break-all pr-8' : ''}" + class="{copyOnClick ? 'pointer-events-none' : 'select-text'} {wrap ? 'whitespace-pre-wrap [overflow-wrap:anywhere] pr-8' : ''}" /> </div> </div> diff --git a/frontend/src/lib/components/details/DetailPageHeader.svelte b/frontend/src/lib/components/details/DetailPageHeader.svelte index 30b0d5dab7..c8f7ffc0d7 100644 --- a/frontend/src/lib/components/details/DetailPageHeader.svelte +++ b/frontend/src/lib/components/details/DetailPageHeader.svelte @@ -11,7 +11,13 @@ import type { Item } from '$lib/utils' import { Bell, BellOff, Calendar } from 'lucide-svelte' import { toggleWorkspaceErrorHandler } from './errorHandlerToggle' - import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { + operatingWorkspaceContextEntry, + useOperatingWorkspace + } from '$lib/components/operatingWorkspace.svelte' + import OpenInSessionButton, { + type OpenInSessionSource + } from '$lib/components/sessions/OpenInSessionButton.svelte' const operatingWorkspace = useOperatingWorkspace() @@ -42,7 +48,10 @@ const triggerContext = getContext<TriggerContext | undefined>('TriggerContext') const { triggersCount, triggersState } = $state(triggerContext ?? ({} as Partial<TriggerContext>)) // The buttons below render in the page header, away from this page's tree. - const headerContexts = new Map<any, any>([['TriggerContext', triggerContext]]) + const headerContexts = new Map<any, any>([ + ['TriggerContext', triggerContext], + operatingWorkspaceContextEntry() + ]) interface Props { mainButtons?: MainButton[] @@ -104,6 +113,17 @@ // no error handler and says so itself. const renameKind = $derived(itemKind ?? errorHandlerKind) + // The session's preview opens on this same deployed page (`/get/`), not the item's editor: + // someone here came to run the item, not to change it. + const sessionSource: OpenInSessionSource | undefined = $derived( + errorHandlerKind && path + ? { + page: () => `/${errorHandlerKind}s/get/${path}`, + workspaceId: $operatingWorkspace ?? undefined + } + : undefined + ) + // The band folds the buttons once its breadcrumb has given all it can (`actionFolds` below): // first the secondary ones go to their `placement`, then every bar button but the last, which // is what the page is for (Edit), then that one too. Outside the band — a session's preview @@ -277,8 +297,15 @@ unifiedSize={size} /> {/if} - {#each barButtons as btn (btn.label)} + <!-- The session button goes right before the last bar button, the one the page is for (Edit). --> + {#if ownsPageHeader && barButtons.length === 0} + <OpenInSessionButton source={sessionSource} /> + {/if} + {#each barButtons as btn, i (btn.label)} {@const dropdownItems = dropdownItemsOf(btn)} + {#if ownsPageHeader && i === barButtons.length - 1} + <OpenInSessionButton source={sessionSource} /> + {/if} <Button {...btn.buttonProps} startIcon={{ icon: btn.buttonProps.startIcon }} @@ -313,6 +340,7 @@ pathTrigger: onSaved ? pathTrigger : undefined }} {actions} + {sessionSource} actionFolds={3} earlyActionFolds={1} contexts={headerContexts} diff --git a/frontend/src/lib/components/details/DetailPageLayout.svelte b/frontend/src/lib/components/details/DetailPageLayout.svelte index 131d4c9f38..b78b796f9a 100644 --- a/frontend/src/lib/components/details/DetailPageLayout.svelte +++ b/frontend/src/lib/components/details/DetailPageLayout.svelte @@ -129,7 +129,13 @@ {#snippet content()} <div class="grow min-h-0 overflow-y-auto"> - <TabContent value="form" class="flex flex-col flex-1 h-full"> + <!-- The chat holds its open conversation, running turns and draft in component + state, so leaving the tab must not unmount it. --> + <TabContent + value="form" + alwaysMounted={isChatMode} + class="flex flex-col flex-1 h-full" + > {@render form?.({ graphInline: false })} </TabContent> diff --git a/frontend/src/lib/components/details/FlowDetail.svelte b/frontend/src/lib/components/details/FlowDetail.svelte index 7bf8fda492..b700269e93 100644 --- a/frontend/src/lib/components/details/FlowDetail.svelte +++ b/frontend/src/lib/components/details/FlowDetail.svelte @@ -26,9 +26,13 @@ import { Badge as HeaderBadge, Alert } from '$lib/components/common' import MoveDrawer from '$lib/components/MoveDrawer.svelte' import RunForm from '$lib/components/RunForm.svelte' + import type { PendingRun } from '$lib/components/details/pendingRun' + import { processSecretArgs } from '$lib/components/secretArgUtils' + import type { Schema } from '$lib/common' import ShareModal from '$lib/components/ShareModal.svelte' import { enterpriseLicense, userStore, userWorkspaces, workspaceStore } from '$lib/stores' import { useEditRights } from '$lib/operatorWriteRights' + import { PLAN_MODE_MESSAGES } from '$lib/components/copilot/chat/planModeMessages' import { sendUserToast } from '$lib/toast' import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte' import SavedInputsV2 from '$lib/components/SavedInputsV2.svelte' @@ -91,6 +95,7 @@ openEditInFork } from '$lib/utils/editInFork' import { isCloudHosted } from '$lib/cloud' + import { recentItems } from '$lib/components/home/recentItems.svelte' let { path: routePath, @@ -100,6 +105,7 @@ onNavigate = goto, active = true, embedded = false, + pendingRun, onLoadState }: { /** The `[...path]` route segment: the flow's path. */ @@ -115,6 +121,8 @@ /** Rendered inside a page that is not the flow's own (an AI session preview tab), whose * URL this must leave alone and which a cross-workspace link must not navigate away. */ embedded?: boolean + /** A chat tool call confirmed here rather than on its card. See {@link PendingRun}. */ + pendingRun?: PendingRun /** How the load ended, for a host that renders its own state around this page. */ onLoadState?: (state: 'loaded' | 'not_found') => void } = $props() @@ -256,6 +264,15 @@ if (!flow.path.startsWith(`u/${actingUser?.username}`) && flow.path.split('/').length > 2) { invisible_to_owner = flow.visible_to_runner_only } + // Opened from a session's preview tab, it was the session's doing, not a visit. + if (!embedded) { + recentItems.record(workspace!, { + kind: 'flow', + path: flow.path, + summary: flow.summary, + via: 'view' + }) + } intervalId && clearInterval(intervalId) deploymentInProgress = flow.lock_error_logs == '' if (deploymentInProgress) { @@ -289,6 +306,10 @@ invisibleToOwner: boolean | undefined, overrideTag: string | undefined ) { + // Taken before the round trip below: the call this press answers is the one it was + // pressed under, and a call settled meanwhile — declined, then another proposed for + // this same flow — must not be answered with a job it never asked for. + const call = pendingRun loading = true const scheduledFor = scheduledForStr ? new Date(scheduledForStr).toISOString() : undefined try { @@ -301,7 +322,11 @@ tag: overrideTag, skipPreprocessor: true }) - await onNavigate('/run/' + run + '?workspace=' + workspace) + // The call asked for this run; it follows the job rather than starting one, and + // shows it in place of this page — so navigating here as well would leave two + // surfaces open on the same job. + if (!call?.adopt(run, args, flow?.schema ?? {})) + await onNavigate('/run/' + run + '?workspace=' + workspace) } catch (e) { throw e } finally { @@ -314,11 +339,21 @@ conversationId: string, additionalInputs?: Record<string, any> ): Promise<string> { + // A chat flow's inputs reach the job straight from the composer, with no RunForm in + // between to mint a secret as it is typed — so this is the only place a value the + // schema marks `password` can become a reference. Without it the literal is stored in + // the job's arguments, where anyone who can read the run can read it. Idempotent, so a + // reference that was already minted costs a walk and no round trip. + const requestBody = await processSecretArgs( + { user_message: userMessage, ...(additionalInputs ?? {}) }, + flow?.schema as Schema | undefined, + workspace + ) const run = await JobService.runFlowByPath({ workspace: workspace!, path, memoryId: conversationId, - requestBody: { user_message: userMessage, ...(additionalInputs ?? {}) }, + requestBody, skipPreprocessor: true }) return run @@ -343,8 +378,13 @@ console.error('Was not able to transform hash as args', e) } } + // A chat call confirmed here opens its form on what the chat proposed. Read at init like + // the hash above, and for the same reason; the host remounts this page when the call + // changes, so a tab already open on this flow still builds its form from the new one. + if (pendingRun) args = { ...untrack(() => pendingRun.proposedArgs) } let canAuthorFlow = $derived(editRights.roleCanAuthor('flow')) + const caps = $derived(editRights.capabilities) let moveDrawer: MoveDrawer | undefined = $state() let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state() @@ -381,12 +421,7 @@ }) } - if ( - flow && - !actingUser?.operator && - !isCloudHosted() && - editInForkAllowed(workspace, $userWorkspaces) - ) { + if (flow && caps.code && !isCloudHosted() && editInForkAllowed(workspace, $userWorkspaces)) { buttons.push({ label: editInForkLabel(workspace, $userWorkspaces), description: editInForkDescription('flow', workspace, $userWorkspaces), @@ -431,8 +466,8 @@ return buttons } - // Build app makes a low-code app, which no builder right covers. - if (!actingUser?.operator) { + // Build app starts from the full template, not the builder one a compose-only role deploys. + if (caps.apps === 'full') { buttons.push({ label: 'Build app', placement: 'menu', @@ -501,11 +536,8 @@ }) } - // The builder right opens this menu to operators; audit logs stay behind their own setting. - if ( - !actingUser?.operator || - $userWorkspaces.find((w) => w.id === workspace)?.operator_settings?.audit_logs - ) { + // A flow-composing role opens this menu; audit logs stay behind their own visibility flag. + if (caps.visibility.audit_logs) { menuItems.push({ label: 'Audit logs', Icon: Eye, @@ -515,7 +547,7 @@ }) } - if (isDeployable('flow', flow?.path ?? '', deployUiSettings) && !actingUser?.operator) { + if (isDeployable('flow', flow?.path ?? '', deployUiSettings) && caps.flows === 'full') { menuItems.push({ label: 'Deploy to staging/prod', onclick: () => deploymentDrawer?.openDrawer(flow?.path ?? '', 'flow'), @@ -641,7 +673,7 @@ <DetailPageLayout bind:this={detailLayout} bind:selected={rightPaneSelected} - isOperator={actingUser?.operator} + isOperator={!caps.code} forceSmallScreen={chatInputEnabled} isChatMode={chatInputEnabled} flow_json={{ @@ -826,19 +858,31 @@ flow.schema && Object.keys(flow.schema.properties ?? {}).length > 0} <!-- Normal Mode: Form Layout --> <div class="flex flex-col align-left"> - {#if hasSchema || inputSelected} + {#if hasSchema || inputSelected || pendingRun} <div class="flex flex-row justify-between min-h-12" transition:slide={{ duration: 150 }} > - <InputSelectedBadge - onReject={() => { - savedInputsV2?.resetSelected() - }} - {inputSelected} - /> + <div class="flex flex-row items-start gap-2"> + {#if pendingRun} + <InputSelectedBadge + inputSelected="pending_run" + onReject={() => pendingRun.decline()} + /> + {/if} + <InputSelectedBadge + onReject={() => { + savedInputsV2?.resetSelected() + }} + {inputSelected} + /> + </div> - {#if hasSchema} + <!-- Not offered while the form refuses writes: each side of this + toggle mounts what the other unmounts, and a `password` field + mints a variable from the literal it opens on. The form stays on + whichever side it is already showing, read-only. --> + {#if hasSchema && !pendingRun?.planModeActive} <Toggle bind:checked={jsonView} size="xs" @@ -881,7 +925,11 @@ bind:isValid runnable={flow} runAction={runFlow} + runRefusal={pendingRun?.planModeActive + ? PLAN_MODE_MESSAGES.runFormRefused + : undefined} bind:args + schedulable={!pendingRun} bind:this={runForm} {jsonView} actions={promptForAi ? undefined : aiAssistant} diff --git a/frontend/src/lib/components/details/OnBehalfOfBadge.svelte b/frontend/src/lib/components/details/OnBehalfOfBadge.svelte index 13bd94b122..3b098555f1 100644 --- a/frontend/src/lib/components/details/OnBehalfOfBadge.svelte +++ b/frontend/src/lib/components/details/OnBehalfOfBadge.svelte @@ -8,7 +8,7 @@ onBehalfOf: string | undefined /** Address of the account `onBehalfOf` names, derived from it on the read paths. */ onBehalfOfEmail: string | undefined - kind: 'script' | 'flow' + kind: 'script' | 'flow' | 'agent' } let { onBehalfOf, onBehalfOfEmail, kind }: Props = $props() @@ -25,7 +25,12 @@ {#if onBehalfOf} <Tooltip> {#snippet text()} - Every run of this {kind} is permissioned as {detailed}, whoever starts it. + {#if kind === 'agent'} + Runs of this agent from its page and the API are permissioned as {detailed}, whoever + starts them. + {:else} + Every run of this {kind} is permissioned as {detailed}, whoever starts it. + {/if} {/snippet} <Badge color="violet" icon={{ icon: UserCog, position: 'left' }}> <span class="truncate max-w-40">On behalf of {onBehalfOf}</span> diff --git a/frontend/src/lib/components/details/ScriptDetail.svelte b/frontend/src/lib/components/details/ScriptDetail.svelte index 5880a4d03a..618bafe3ab 100644 --- a/frontend/src/lib/components/details/ScriptDetail.svelte +++ b/frontend/src/lib/components/details/ScriptDetail.svelte @@ -45,10 +45,12 @@ } from '$lib/components/common' import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte' import RunForm from '$lib/components/RunForm.svelte' + import type { PendingRun } from '$lib/components/details/pendingRun' import DbtRunGraph from '$lib/components/dbt/DbtRunGraph.svelte' import { goto } from '$lib/navigation' import MoveDrawer from '$lib/components/MoveDrawer.svelte' + import { PLAN_MODE_MESSAGES } from '$lib/components/copilot/chat/planModeMessages' import { sendUserToast } from '$lib/toast' import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte' import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte' @@ -117,6 +119,7 @@ import WacDiagram from '$lib/components/graph/WacDiagram.svelte' import { twMerge } from 'tailwind-merge' import CiTestResults from '$lib/components/CiTestResults.svelte' + import { recentItems } from '$lib/components/home/recentItems.svelte' let { hash: routeHash, @@ -126,6 +129,7 @@ onNavigate = goto, active = true, embedded = false, + pendingRun, onLoadState }: { /** The `[...hash]` route segment: either a script hash or a script path. */ @@ -141,6 +145,8 @@ /** Rendered inside a page that is not the script's own (an AI session preview tab), whose * URL this must leave alone and which a cross-workspace link must not navigate away. */ embedded?: boolean + /** A chat tool call confirmed here rather than on its card. See {@link PendingRun}. */ + pendingRun?: PendingRun /** How the load ended, for a host that renders its own state around this page. * Providing it also suppresses the "could not load" toast: a 404 here is the normal * state of a not-yet-deployed path, which the host explains in place instead. */ @@ -174,7 +180,8 @@ canWrite(script.path, script.extra_perms!, actingUser) ) const editRights = useEditRights() - // The edit actions also need a role that authors scripts, which no operator has. + const caps = $derived(editRights.capabilities) + // The edit actions also need a role that authors scripts. const canEdit = $derived(can_write && editRights.roleCanAuthor('script')) let deploymentInProgress = $state(false) let expandedModuleLocks: Record<string, boolean> = $state({}) @@ -364,6 +371,16 @@ if (!script.path.startsWith(`u/${actingUser?.username}`) && script.path.split('/').length > 2) { invisible_to_owner = script.visible_to_runner_only } + // Opened from a session's preview tab, it was the session's doing, not a visit. An + // archived script has no page for Home's card to open. + if (!embedded && !script.archived) { + recentItems.record(workspace!, { + kind: 'script', + path: script.path, + summary: script.summary, + via: 'view' + }) + } onLoadState?.('loaded') } @@ -380,6 +397,10 @@ invisibleToOwner: boolean | undefined, overrideTag: string | undefined ) { + // Taken before the round trip below: the call this press answers is the one it was + // pressed under, and a call settled meanwhile — declined, then another proposed for + // this same script — must not be answered with a job it never asked for. + const call = pendingRun try { runLoading = true const scheduledFor = scheduledForStr ? new Date(scheduledForStr).toISOString() : undefined @@ -405,7 +426,11 @@ skipPreprocessor: true }) } - await onNavigate('/run/' + run + '?workspace=' + workspace) + // The call asked for this run; it follows the job rather than starting one, and + // shows it in place of this page — so navigating here as well would leave two + // surfaces open on the same job. + if (!call?.adopt(run, args, script?.schema ?? {})) + await onNavigate('/run/' + run + '?workspace=' + workspace) } catch (err) { sendUserToast(`Could not create job: ${err.body}`, true) } finally { @@ -434,6 +459,10 @@ console.error('Was not able to transform hash as args', e) } } + // A chat call confirmed here opens its form on what the chat proposed. Read at init like + // the hash above, and for the same reason; the host remounts this page when the call + // changes, so a tab already open on this script still builds its form from the new one. + if (pendingRun) args = { ...untrack(() => pendingRun.proposedArgs) } // A dbt retry must name the run it resumes, and a job id is not something to // type from memory: picking `retry` fills the field with the run this caller's @@ -528,7 +557,7 @@ ) { const buttons: any = [] - if (!topHash && script && !actingUser?.operator && !script.codebase) { + if (!topHash && script && caps.code && !script.codebase) { buttons.push({ label: 'Fork', description: `Start a new script from a copy of this one`, @@ -544,12 +573,7 @@ }) } - if ( - script && - !actingUser?.operator && - !isCloudHosted() && - editInForkAllowed(workspace, $userWorkspaces) - ) { + if (script && caps.code && !isCloudHosted() && editInForkAllowed(workspace, $userWorkspaces)) { buttons.push({ label: editInForkLabel(workspace, $userWorkspaces), description: editInForkDescription('script', workspace, $userWorkspaces), @@ -599,24 +623,26 @@ }) } - if (!actingUser?.operator) { - buttons.push({ - label: 'Build app', - placement: 'menu', - alwaysPlaced: true, - buttonProps: { - onClick: async () => { - const app = createRawAppFromScript(script.path, script.summary, script.schema) - // The raw app editor reads the payload from sessionStorage on mount. - sessionStorage.setItem('rawAppImport', JSON.stringify(app)) - await onNavigate('/apps_raw/add') - }, - disabled: !showEditButtons, - unifiedSize: 'md', - variant: 'subtle', - startIcon: LayoutDashboard - } - }) + if (caps.code) { + // It starts from the full template, not the builder one a compose-only role deploys. + if (caps.apps === 'full') + buttons.push({ + label: 'Build app', + placement: 'menu', + alwaysPlaced: true, + buttonProps: { + onClick: async () => { + const app = createRawAppFromScript(script.path, script.summary, script.schema) + // The raw app editor reads the payload from sessionStorage on mount. + sessionStorage.setItem('rawAppImport', JSON.stringify(app)) + await onNavigate('/apps_raw/add') + }, + disabled: !showEditButtons, + unifiedSize: 'md', + variant: 'subtle', + startIcon: LayoutDashboard + } + }) if (script?.restart_unless_cancelled ?? false) { buttons.push({ @@ -671,7 +697,7 @@ script: Script | undefined, deployUiSettings: WorkspaceDeployUISettings | undefined ) { - if (!script || actingUser?.operator) return [] + if (!script || !caps.code) return [] const menuItems: any = [] @@ -838,7 +864,7 @@ </DrawerContent> </Drawer> {#key script?.hash} - <DetailPageLayout bind:selected={rightPaneSelected} isOperator={actingUser?.operator}> + <DetailPageLayout bind:selected={rightPaneSelected} isOperator={!caps.code}> {#snippet header({ wide }: { wide: boolean })} <DetailPageHeader {wide} @@ -1031,20 +1057,32 @@ {/if} <div class="flex flex-col align-left"> - {#if (script.schema && Object.keys(script.schema.properties ?? {}).length > 0) || inputSelected} + {#if (script.schema && Object.keys(script.schema.properties ?? {}).length > 0) || inputSelected || pendingRun} {@const hasSchema = script.schema && Object.keys(script.schema.properties ?? {}).length > 0} <div class="flex flex-row justify-between min-h-12" transition:slide={{ duration: 150 }} > - <InputSelectedBadge - onReject={() => { - savedInputsV2?.resetSelected() - }} - {inputSelected} - /> - {#if hasSchema} + <div class="flex flex-row items-start gap-2"> + {#if pendingRun} + <InputSelectedBadge + inputSelected="pending_run" + onReject={() => pendingRun.decline()} + /> + {/if} + <InputSelectedBadge + onReject={() => { + savedInputsV2?.resetSelected() + }} + {inputSelected} + /> + </div> + <!-- Not offered while the form refuses writes: each side of this toggle + mounts what the other unmounts, and a `password` field mints a + variable from the literal it opens on. The form stays on whichever + side it is already showing, read-only. --> + {#if hasSchema && !pendingRun?.planModeActive} <Toggle bind:checked={jsonView} size="xs" @@ -1108,8 +1146,11 @@ bind:isValid runnable={script} runAction={runScript} + runRefusal={pendingRun?.planModeActive + ? PLAN_MODE_MESSAGES.runFormRefused + : undefined} bind:args - schedulable={true} + schedulable={!pendingRun} bind:this={runForm} {jsonView} actions={promptForAi ? undefined : aiAssistant} diff --git a/frontend/src/lib/components/details/pendingRun.ts b/frontend/src/lib/components/details/pendingRun.ts new file mode 100644 index 0000000000..00e75622fc --- /dev/null +++ b/frontend/src/lib/components/details/pendingRun.ts @@ -0,0 +1,27 @@ +/** + * A chat tool call waiting for a run of this item, shown on the item's deployed page. + * + * The page runs it the way it runs anything — its own form, its own version, its own + * arguments — and tells the call which job that was. The tool then follows that job instead + * of starting one, so the chat reports on the run the reader actually started. + */ +export type PendingRun = { + toolCallId: string + /** The chat promised no runs and no writes. A form standing in for its card owes that + * too: a `password` mints as it is typed and the dynamic-select helper runs on mount, + * both before Run is ever pressed. */ + planModeActive: boolean + /** What the chat proposed, for the form to open on. Read when the form is built, so this + * is the live draft of the card's own form rather than a copy to keep in step. */ + readonly proposedArgs: Record<string, any> + /** The job the reader started here answers the call, with the arguments it was started + * with — the card has no other way to show what ran — and the schema it ran against, which + * a deploy can have moved on from the one the call fetched. Both are needed: redaction + * only visits the leaves a schema declares, so a file argument this page has and that one + * does not would reach the transcript as bytes. True when the call took the job: the chat + * then shows that run in place of this page, so the page must not navigate to it as well. + * False when the call settled while the job was starting, and the run is the reader's + * alone. */ + adopt: (jobId: string, args: Record<string, any>, schema: Record<string, any>) => boolean + decline: () => void +} diff --git a/frontend/src/lib/components/filterSearchbarMatch.test.ts b/frontend/src/lib/components/filterSearchbarMatch.test.ts new file mode 100644 index 0000000000..7bc0a5eefa --- /dev/null +++ b/frontend/src/lib/components/filterSearchbarMatch.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest' +import { filterMatchesQuery } from './FilterSearchbar.svelte' + +describe('filterMatchesQuery', () => { + const archived = { type: 'boolean' as const, label: 'Only archived' } + + it('matches each word anywhere in the label or key', () => { + expect(filterMatchesQuery('archived', archived, 'archive')).toBe(true) + expect(filterMatchesQuery('archived', archived, 'archived only')).toBe(true) + expect(filterMatchesQuery('archived', archived, 'archived scripts')).toBe(false) + expect( + filterMatchesQuery('include_library', { type: 'boolean', label: 'Library' }, 'include lib') + ).toBe(true) + }) + + it('reads an escaped space as a word break', () => { + expect(filterMatchesQuery('archived', archived, 'archived\\ only')).toBe(true) + }) +}) diff --git a/frontend/src/lib/components/flows/AgentHistory.svelte b/frontend/src/lib/components/flows/AgentHistory.svelte new file mode 100644 index 0000000000..c6deff1e59 --- /dev/null +++ b/frontend/src/lib/components/flows/AgentHistory.svelte @@ -0,0 +1,34 @@ +<script lang="ts"> + import { Drawer, DrawerContent } from '$lib/components/common' + import ResourceVersionHistory from '$lib/components/ResourceVersionHistory.svelte' + import { workspaceStore } from '$lib/stores' + import { markAgentWritten } from './agentEditorStore.svelte' + + interface Props { + path: string + canRestore: boolean + onRestored?: () => void + } + + let { path, canRestore, onRestored }: Props = $props() + let drawer: Drawer | undefined = $state() + + export function open() { + drawer?.openDrawer() + } +</script> + +<Drawer bind:this={drawer} size="1200px"> + <DrawerContent title="Version history" on:close={() => drawer?.closeDrawer()} noPadding> + <ResourceVersionHistory + {path} + {canRestore} + onRestore={() => { + drawer?.closeDrawer() + // A restore writes the resource as a deploy does, so whatever shows this agent re-reads it. + markAgentWritten($workspaceStore ?? undefined, path) + onRestored?.() + }} + /> + </DrawerContent> +</Drawer> diff --git a/frontend/src/lib/components/flows/FlowEditor.svelte b/frontend/src/lib/components/flows/FlowEditor.svelte index a9c3a40e8a..f735d1579b 100644 --- a/frontend/src/lib/components/flows/FlowEditor.svelte +++ b/frontend/src/lib/components/flows/FlowEditor.svelte @@ -40,14 +40,11 @@ import FlowPanelPlacementPicker from './common/FlowPanelPlacementPicker.svelte' import { prefersSessionHandoff } from '../copilot/chat/global/gate' import { openSourceInSession } from '$lib/components/sessions/sessionSwitch.svelte' - import { - useOperatingUser, - useOperatingWorkspace - } from '$lib/components/operatingWorkspace.svelte' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { useRoleCapabilities } from '$lib/operatorWriteRights' const operatingWorkspace = useOperatingWorkspace() - const operatingUser = useOperatingUser() - const actingUser = $derived(operatingUser.current) + const roleCaps = useRoleCapabilities() const { flowStore, selectionManager, pathStore, opWorkspace } = getContext<FlowEditorContext>('FlowEditorContext') // Flow paths repeat across workspaces, and a session keeps every tab it has visited alive, so two @@ -408,11 +405,7 @@ // contain. Hand it to a session opened on that step rather than the // docked chat, which sessions leave unmounted. Sent on arrival: the // user already said what they wanted in the description field. - if ( - !sessionScopedManager && - sessionOpen && - prefersSessionHandoff(actingUser?.operator) - ) { + if (!sessionScopedManager && sessionOpen && prefersSessionHandoff(!$roleCaps.code)) { void openSourceInSession(sessionOpen, { previewParams: { selected: detail.moduleId }, seedPrompt: stepInstructionsPrompt(detail.moduleId, detail.instructions), diff --git a/frontend/src/lib/components/flows/agentActions.ts b/frontend/src/lib/components/flows/agentActions.ts index daaec527df..f80a6e4343 100644 --- a/frontend/src/lib/components/flows/agentActions.ts +++ b/frontend/src/lib/components/flows/agentActions.ts @@ -1,8 +1,9 @@ -import { FileUp, Shield, Trash } from 'lucide-svelte' +import { Copy, Eye, FileUp, History, List, Shield, Trash } from 'lucide-svelte' import { ResourceService } from '$lib/gen' import { sendUserToast } from '$lib/toast' -import type { Item } from '$lib/utils' +import { copyToClipboard, type Item } from '$lib/utils' import { isDeployable } from '$lib/utils_deployable' +import { goto } from '$lib/navigation' import type { WorkspaceDeployUISettings } from '$lib/gen' /** Deletes a saved agent, saying how it went. Resolves whether it was deleted. */ @@ -24,10 +25,13 @@ 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. */ + noCode?: boolean /** Undefined until loaded, when nothing is offered for deploy. */ deployUiSettings: WorkspaceDeployUISettings | undefined onPermissions: () => void onDeploy: () => void + onHistory: () => void onDelete: (event?: MouseEvent) => void }): Item[] { const deployable = @@ -35,6 +39,28 @@ export function agentMenuItems(agent: { !agent.draftOnly && isDeployable('resource', agent.path, agent.deployUiSettings) return [ + // A draft-only agent has no runs, no audit trail, and only a placeholder path yet. + ...(agent.draftOnly + ? [] + : [ + { + // An agent run is a flow preview, which the runs page hides by default. Chat turns + // are filed under `<path>.chat` (agent_runs.rs) and listed by the agent's own chat. + displayName: 'View runs', + icon: List, + action: () => goto(`/runs/${agent.path}?job_kinds=previews`) + }, + ...(agent.noCode + ? [] + : [ + { + displayName: 'Audit logs', + icon: Eye, + action: () => goto(`/audit_logs?resource=${agent.path}`) + } + ]), + { displayName: 'Copy path', icon: Copy, action: () => copyToClipboard(agent.path) } + ]), { displayName: 'Permissions', icon: Shield, @@ -44,6 +70,9 @@ export function agentMenuItems(agent: { ...(deployable ? [{ displayName: 'Deploy to prod/staging', icon: FileUp, action: agent.onDeploy }] : []), + ...(agent.draftOnly || agent.noCode + ? [] + : [{ displayName: 'Version history', icon: History, action: agent.onHistory }]), { displayName: 'Delete', icon: Trash, diff --git a/frontend/src/lib/components/flows/agentDraft.svelte.ts b/frontend/src/lib/components/flows/agentDraft.svelte.ts index cb6081ef46..aef204784d 100644 --- a/frontend/src/lib/components/flows/agentDraft.svelte.ts +++ b/frontend/src/lib/components/flows/agentDraft.svelte.ts @@ -4,6 +4,7 @@ import { deepEqual } from 'fast-equals' import { ResourceService } from '$lib/gen' import { sendUserToast } from '$lib/toast' import { canEditItem, roleCanAuthor } from '$lib/editRights' +import { capabilitiesIn } from '$lib/roleCapabilities' import { userStore, userWorkspaces } from '$lib/stores' import { getUserExt } from '$lib/user' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' @@ -144,18 +145,29 @@ export function agentDraftDeployRefusal( */ type AgentWriteResult = { ok: true } | { ok: false; error: string; notAnAgent?: true } +/** Who a deploy makes the agent run as. The server resolves it as it does a flow's: without + * `preserve`, or from someone not allowed to keep another identity, it is the deployer. */ +export interface AgentRunAs { + permissionedAs: string | undefined + preserve: boolean +} + async function writeAgentResource( workspace: string, fromPath: string, state: AgentResourceState, - noDeployed: boolean + noDeployed: boolean, + runAs?: AgentRunAs ): Promise<AgentWriteResult> { const body = { path: state.path, - value: state.args, + value: runAs?.permissionedAs + ? { ...state.args, on_behalf_of: runAs.permissionedAs } + : state.args, description: state.description, labels: state.labels, - ws_specific: state.wsSpecific + ws_specific: state.wsSpecific, + preserve_on_behalf_of: runAs?.preserve || undefined } try { if (noDeployed) { @@ -221,9 +233,12 @@ export interface AgentDraftHandle { /** Why this path cannot be edited here, if it cannot. Render it instead of the form. */ readonly refusal: string | undefined readonly sync: TriggerDraftSync + /** Who the deployed agent runs as when run from its page; undefined when nothing is deployed + * or it runs as whoever runs it. */ + readonly onBehalfOf: string | undefined /** Write the current state to the resource and drop the draft. Resolves to the path written, * which differs from the one loaded when the draft renames the agent, or undefined on failure. */ - deploy: () => Promise<string | undefined> + deploy: (runAs?: AgentRunAs) => Promise<string | undefined> } /** @@ -237,6 +252,7 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { let loading = $state(true) let noDeployed = $state(false) let canWriteResource = $state(true) + let onBehalfOf = $state<string | undefined>(undefined) /** Guards the load against a path that changed under a slow response. */ let loadedFor = $state<string | undefined>(undefined) /** Why the loaded path cannot be edited here, if it cannot. Gates the sync for as long as that @@ -324,10 +340,15 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { return } noDeployed = Boolean((r as any).no_deployed) + // Who it runs as is the deploy's to decide, not a setting the form or a draft holds. + const { on_behalf_of, ...args } = (r.value ?? {}) as AIAgentConfig & { + on_behalf_of?: string + } + onBehalfOf = noDeployed ? undefined : on_behalf_of const deployedState: AgentResourceState = { path: r.path, description: r.description ?? '', - args: (r.value ?? {}) as AIAgentConfig, + args, // Only where nothing else answers for the type: with a deployed row both the // create below and the review page's `deployDraft` read it from there, and // carrying it would make every draft the generic resource editor writes — which @@ -339,12 +360,16 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { // Same rule the generic resource editor applies. The backend refuses the write // either way, but without this the editor would invite edits it cannot save and // autosave a draft on every keystroke against a resource the reader cannot deploy. + const writer = user ?? get(userStore) ?? undefined canWriteResource = canEditItem( 'agent', r.path, r.extra_perms, - user ?? get(userStore) ?? undefined, - get(userWorkspaces).find((w) => w.id === ws)?.operator_settings + writer, + capabilitiesIn( + user, + get(userWorkspaces).find((w) => w.id === ws) + ) ) // An agent that exists only as a draft has no deployed value to compare against or // fall back to, and the response's is a synthetic echo of the draft. Leaving the @@ -386,8 +411,11 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { deployed = undefined canWriteResource = roleCanAuthor( 'agent', - get(userStore) ?? undefined, - get(userWorkspaces).find((w) => w.id === ws)?.operator_settings + // Only superadmin carries over: the nav user's admin flag belongs to another workspace. + capabilitiesIn( + { is_super_admin: get(userStore)?.is_super_admin }, + get(userWorkspaces).find((w) => w.id === ws) + ) ) state = { path: name, @@ -414,7 +442,7 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { }) }) - async function deploy(): Promise<string | undefined> { + async function deploy(runAs?: AgentRunAs): Promise<string | undefined> { const ws = opts.workspace() const fromPath = opts.path() const s = state @@ -430,7 +458,7 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { // made during the request as saved, and the banner would clear on a value the server never // received; against the snapshot it stays a draft, which is what it is. const submitted = structuredClone($state.snapshot(s)) as AgentResourceState - const written = await writeAgentResource(ws, fromPath, submitted, noDeployed) + const written = await writeAgentResource(ws, fromPath, submitted, noDeployed, runAs) if (!written.ok) { // A path that is no longer an agent tears this editor down; anything else is a plain error // the user can retry from the form as it stands. @@ -449,6 +477,10 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { const firstSave = noDeployed deployed = submitted noDeployed = false + // What the server resolved, which a request to keep an identity does not decide alone. + void ResourceService.getResource({ workspace: ws, path: submitted.path }) + .then((r) => (onBehalfOf = (r.value as { on_behalf_of?: string } | undefined)?.on_behalf_of)) + .catch(() => {}) const renamed = submitted.path !== fromPath // Only when the form still holds exactly what was sent. `discard` resets the handle's cell to // what it is given, and the apply-effect copies that back over the form: against an edit made @@ -490,6 +522,9 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { get refusal() { return refusal }, + get onBehalfOf() { + return onBehalfOf + }, sync, deploy } diff --git a/frontend/src/lib/components/flows/agentFormFields.ts b/frontend/src/lib/components/flows/agentFormFields.ts index e8c5584407..4a9c16f53a 100644 --- a/frontend/src/lib/components/flows/agentFormFields.ts +++ b/frontend/src/lib/components/flows/agentFormFields.ts @@ -10,15 +10,21 @@ import type { InputTransform, MemoryConfig } from '$lib/gen' * the two cannot cycle. */ -export type AgentFieldGroup = 'model' | 'messages' | 'tools' | 'output' +export type AgentFieldGroup = 'model' | 'messages' | 'tools' | 'output' | 'overrides' /** Groups in the order a run assembles the request: pick a model, build the messages, let it call - * tools, shape what comes back. */ -export const AGENT_FIELD_GROUPS: { id: AgentFieldGroup; label: string }[] = [ + * tools, shape what comes back. Overrides come last: only a step linked to a saved agent has + * them, and they replace what the agent saved rather than add to the request. */ +export const AGENT_FIELD_GROUPS: { id: AgentFieldGroup; label: string; caption?: string }[] = [ { id: 'model', label: 'Model' }, { id: 'messages', label: 'Messages' }, { id: 'tools', label: 'Tools' }, - { id: 'output', label: 'Output' } + { id: 'output', label: 'Output' }, + { + id: 'overrides', + label: 'Agent overrides', + caption: "Replace what is saved on the agent, for this step's runs only." + } ] /** The tool roster, which reads `flowModule.value.tools` rather than an `input_transforms` key. @@ -30,6 +36,17 @@ export const AGENT_TOOLS_ROW = 'tools' export const AGENT_HISTORY_KEYS = ['memory_id', 'previous_messages'] as const export type AgentHistoryKey = (typeof AGENT_HISTORY_KEYS)[number] +/** A linked step's overrides of its saved agent. Never seeded either: a step with no override + * holds no key for one. */ +export const AGENT_OVERRIDE_KEYS = ['model', 'reasoning_effort'] as const +export type AgentOverrideKey = (typeof AGENT_OVERRIDE_KEYS)[number] + +/** The inputs whose row exists exactly while the step holds the key. */ +export const AGENT_UNSEEDED_KEYS: readonly string[] = [ + ...AGENT_HISTORY_KEYS, + ...AGENT_OVERRIDE_KEYS +] + /** The input transforms a one-off test of an agent step runs with: each form key is fed from * `flow_input`, over the step's own transforms. A blank history input is unset, as on the step: * an expression evaluating to nothing reads as an empty memory id, and the step's transform is @@ -256,6 +273,24 @@ export const AGENT_FIELDS: AgentFieldSpec[] = [ implicit: true, defaultHint: 'Default: on', textOnly: true + }, + { + key: 'model', + group: 'overrides', + label: 'Model', + tooltip: + "Runs this step on another model than the one saved on the agent, through the agent's own provider resource. Set it to an expression to choose the model per run: an empty value keeps the saved model.", + implicit: '', + defaultHint: 'Default: the model saved on the agent' + }, + { + key: 'reasoning_effort', + group: 'overrides', + label: 'Reasoning effort', + tooltip: + 'Runs this step at another reasoning effort than the one saved on the agent. Set it to an expression to choose the effort per run. An empty value sends no effort, so the model runs at its default. Overriding the model without it does the same.', + implicit: '', + defaultHint: 'Default: the effort saved on the agent' } ] diff --git a/frontend/src/lib/components/flows/agentResourceUtils.test.ts b/frontend/src/lib/components/flows/agentResourceUtils.test.ts index 96b9cf83ff..0799e4357c 100644 --- a/frontend/src/lib/components/flows/agentResourceUtils.test.ts +++ b/frontend/src/lib/components/flows/agentResourceUtils.test.ts @@ -6,6 +6,7 @@ import { flowLocalInputs, inputTransformsToAgentConfig, nonStaticBrainKeys, + providerWithOverrides, summarizeAgentBrain, toolInputOverrides, transformValuedBrainKeys @@ -193,3 +194,54 @@ describe('toolInputOverrides', () => { }) }) }) + +describe('providerWithOverrides', () => { + const saved = { + kind: 'openai', + resource: '$res:u/a/openai', + model: 'gpt-5', + reasoning_effort: 'high' + } + + it('folds static overrides into a static provider, as the worker reads them', () => { + expect( + providerWithOverrides(saved, { reasoning_effort: { type: 'static', value: 'low' } }) + ).toEqual({ type: 'static', value: { ...saved, reasoning_effort: 'low' } }) + // The saved effort names a level of the saved model, so it does not follow to another one. + expect( + providerWithOverrides(saved, { model: { type: 'static', value: 'gpt-5-mini' } }) + ).toEqual({ + type: 'static', + value: { kind: 'openai', resource: '$res:u/a/openai', model: 'gpt-5-mini' } + }) + expect(providerWithOverrides(saved, { model: { type: 'static', value: '' } })).toEqual({ + type: 'static', + value: saved + }) + }) + + it('keeps an expression, resolving as the linked step did', () => { + const run = (overrides: Record<string, string>, flow_input: Record<string, unknown>) => { + const folded = providerWithOverrides( + saved, + Object.fromEntries( + Object.entries(overrides).map(([key, expr]) => [key, { type: 'javascript', expr }]) + ) + ) as { type: 'javascript'; expr: string } + expect(folded.type).toBe('javascript') + return JSON.parse( + JSON.stringify(new Function('flow_input', `return ${folded.expr}`)(flow_input)) + ) + } + const other = { kind: 'openai', resource: '$res:u/a/openai', model: 'gpt-5-mini' } + const effort = { reasoning_effort: 'flow_input.effort' } + const model = { model: 'flow_input.model' } + + expect(run(effort, { effort: 'low' })).toEqual({ ...saved, reasoning_effort: 'low' }) + // An effort the step sets to nothing is no effort, not the saved one. + expect(run(effort, { effort: '' })).toEqual({ ...other, model: 'gpt-5' }) + expect(run(model, { model: '' })).toEqual(saved) + expect(run(model, { model: 'gpt-5-mini' })).toEqual(other) + expect(run({ ...model, ...effort }, { model: 'gpt-5-mini', effort: '' })).toEqual(other) + }) +}) diff --git a/frontend/src/lib/components/flows/agentResourceUtils.ts b/frontend/src/lib/components/flows/agentResourceUtils.ts index 3ee0eaa77e..054cb96d54 100644 --- a/frontend/src/lib/components/flows/agentResourceUtils.ts +++ b/frontend/src/lib/components/flows/agentResourceUtils.ts @@ -1,6 +1,11 @@ import { deepEqual } from 'fast-equals' import type { InputTransform } from '$lib/gen' -import { AGENT_FIELDS, describeMemoryPolicy } from './agentFormFields' +import { + AGENT_FIELDS, + AGENT_OVERRIDE_KEYS, + describeMemoryPolicy, + type AgentOverrideKey +} from './agentFormFields' // The brain fields stored flat in an `ai_agent` resource value. The flow-local inputs below are // intentionally excluded — they are supplied per-flow. @@ -31,6 +36,13 @@ export const AGENT_FLOW_LOCAL_KEYS = [ 'previous_messages' ] as const +/** Every input a linked step holds itself: the flow-local ones, and its overrides of what the + * agent saved inside `provider`. */ +export const AGENT_LINKED_STEP_KEYS: readonly string[] = [ + ...AGENT_FLOW_LOCAL_KEYS, + ...AGENT_OVERRIDE_KEYS +] + export type AgentTool = Record<string, any> /** @@ -59,7 +71,7 @@ export function nonStaticBrainKeys( } /** - * Keep only the flow-local inputs on a step's input_transforms. Used when linking: the brain comes + * Keep only the inputs a linked step holds on a step's input_transforms. Used when linking: the brain comes * from the resource, so the step must not carry stale `provider`/`system_prompt`/… transforms — at * runtime they'd still be resolved (an unnecessary, possibly failing provider `$res:` fetch) yet * never used, since the linked branch takes the brain from the resource. @@ -68,7 +80,7 @@ export function flowLocalInputs( inputTransforms: Record<string, InputTransform> | undefined ): Record<string, InputTransform> { const out: Record<string, InputTransform> = {} - for (const key of AGENT_FLOW_LOCAL_KEYS) { + for (const key of AGENT_LINKED_STEP_KEYS) { if (inputTransforms?.[key]) { out[key] = inputTransforms[key] } @@ -106,6 +118,11 @@ export interface AIAgentConfig { temperature?: number max_iterations?: number tools?: AgentTool[] + /** Runs from the agent's page take its `on_behalf_of` rather than the caller's identity. */ + run_on_behalf_of?: boolean + /** Anyone in the Slack channels it is shared in can ask it, not only workspace members. Kept + * only with `run_on_behalf_of`: everyone there runs it as that identity. */ + slack_anyone?: boolean } /** Extract the static brain values from a step's input_transforms into a flat agent config. */ @@ -149,6 +166,74 @@ export function flowLocalAgentSchema(schema: any): any { } } +/** + * The schema of a step linked to a saved agent, in a flow: its flow-local inputs, plus the saved + * settings it may override. + */ +export function linkedAgentStepSchema(schema: any): any { + const local = flowLocalAgentSchema(schema) + if (!local?.properties) { + return local + } + return { + ...local, + properties: { + ...local.properties, + model: { + type: 'string', + description: "Replaces the model saved on the agent for this step's runs." + }, + reasoning_effort: { + type: 'string', + description: "Replaces the reasoning effort saved on the agent for this step's runs." + } + }, + order: [...local.order, ...AGENT_OVERRIDE_KEYS] + } +} + +/** + * The `provider` of a step unlinked from its agent: the saved one with the step's overrides folded + * in, since an unlinked step sets model and effort nowhere else. Follows the worker's + * `override_provider_model`, so the step runs on what it ran on while linked. + */ +export function providerWithOverrides( + provider: any, + overrides: Partial<Record<AgentOverrideKey, InputTransform>> | undefined +): InputTransform | undefined { + if (!provider || typeof provider !== 'object') return undefined + const authored = (key: AgentOverrideKey): any => { + const t = overrides?.[key] as any + return t?.type === 'static' || (t?.type === 'javascript' && t.expr) ? t : undefined + } + const text = (t: any): string => (typeof t.value === 'string' ? t.value.trim() : '') + const model = authored('model') + const effort = authored('reasoning_effort') + const { model: savedModel, reasoning_effort: savedEffort, ...rest } = provider + + if (model?.type !== 'javascript' && effort?.type !== 'javascript') { + const runModel = (model && text(model)) || savedModel + const runEffort = effort ? text(effort) : runModel === savedModel ? savedEffort : undefined + return { + type: 'static', + value: { ...rest, model: runModel, ...(runEffort ? { reasoning_effort: runEffort } : {}) } + } + } + const literal = (value: unknown) => (value === undefined ? 'undefined' : JSON.stringify(value)) + const source = (t: any) => (t.type === 'javascript' ? `(${t.expr})` : literal(text(t))) + // One object literal, the shape the flow's expression evaluator and the chat composer both read. + const runModel = model ? `(${source(model)} || ${literal(savedModel)})` : literal(savedModel) + const runEffort = effort + ? `${source(effort)} || undefined` + : `${runModel} === ${literal(savedModel)} ? ${literal(savedEffort)} : undefined` + const fields = [ + ...Object.entries(rest).map(([key, value]) => `${JSON.stringify(key)}: ${literal(value)}`), + `model: ${runModel}`, + `reasoning_effort: ${runEffort}` + ] + return { type: 'javascript', expr: `({ ${fields.join(', ')} })` } +} + /** Read off the form's own registry, so a linked agent's summary cannot name a field differently * from the form that edits it. */ export const AGENT_BRAIN_LABELS: Record<string, string> = Object.fromEntries( @@ -182,7 +267,7 @@ export function summarizeAgentBrain( if (v === undefined || v === null || v === '') continue let value: string if (key === 'provider') { - value = [v.kind, v.model].filter(Boolean).join(' · ') || 'configured' + value = [v.kind, v.model, v.reasoning_effort].filter(Boolean).join(' · ') || 'configured' } else if (key === 'memory') { value = typeof v === 'object' ? describeMemoryPolicy(v) : String(v) } else if (key === 'output_schema') { diff --git a/frontend/src/lib/components/flows/common/FlowCardHeader.svelte b/frontend/src/lib/components/flows/common/FlowCardHeader.svelte index e50fde2cb2..80aea48e4a 100644 --- a/frontend/src/lib/components/flows/common/FlowCardHeader.svelte +++ b/frontend/src/lib/components/flows/common/FlowCardHeader.svelte @@ -20,7 +20,7 @@ import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui' import DropdownV2 from '$lib/components/DropdownV2.svelte' import { hubBaseUrlStore } from '$lib/stores' - import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' + import { useRoleCapabilities } from '$lib/operatorWriteRights' import { DEFAULT_HUB_BASE_URL, PRIVATE_HUB_MIN_VERSION } from '$lib/hub' import { getLatestHashForScript } from '$lib/scripts' import { sendUserToast, type Item } from '$lib/utils' @@ -31,7 +31,8 @@ import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() - const operatorBuilderFlows = useOperatorBuilderFlows() + const roleCaps = useRoleCapabilities() + const composeOnlyFlows = $derived($roleCaps.flows === 'compose') interface Props { flowModuleValue?: FlowModuleValue | undefined @@ -104,7 +105,7 @@ const scriptItems: Item[] = $derived.by(() => { if (flowModuleValue?.type !== 'script') return [] const items: Item[] = [] - if (!isHub && customUi?.scriptEdit != false && !$operatorBuilderFlows) { + if (!isHub && customUi?.scriptEdit != false && !composeOnlyFlows) { items.push({ displayName: "Edit the script's code", icon: Pen, @@ -148,7 +149,7 @@ }) } } - if (customUi?.scriptFork != false && !$operatorBuilderFlows) { + if (customUi?.scriptFork != false && !composeOnlyFlows) { items.push({ displayName: 'Fork into an inline script', icon: GitFork, diff --git a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte index 658a7adb77..cdec419e5a 100644 --- a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte +++ b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte @@ -47,10 +47,11 @@ AGENT_EDITOR_RUN_INPUTS, AGENT_TOOLS_ROW, DEFAULT_AGENT_MEMORY, - keepsManagedMemory + keepsManagedMemory, + agentMemoryMode } from '../agentFormFields' import { toolDisplayName, type AgentTool } from '../agentToolUtils' - import { useAgentDraft } from '../agentDraft.svelte' + import { useAgentDraft, type AgentRunAs } from '../agentDraft.svelte' import { sendUserToast } from '$lib/toast' interface Props { @@ -243,6 +244,23 @@ // apart from the echo of their own write. let lastArgs = $state<string | undefined>(undefined) + // With memory off a run keeps no history of its own, so the form also takes the one to answer + // from; managed memory and an older setting would ignore one. + let formSchema = $derived( + agentMemoryMode((agentValue?.input_transforms?.memory as any)?.value) !== 'off' + ? AGENT_CHAT_SCHEMA + : { + ...AGENT_CHAT_SCHEMA, + properties: { + ...AGENT_CHAT_SCHEMA.properties, + previous_messages: { + type: 'array', + items: { type: 'object' }, + description: 'The conversation so far, as chat messages, for the agent to answer from' + } + } + } + ) // Picked on the first load, after which it is the reader's: following the memory setting live // would pull them out of a chat for switching memory off in the form. let testMode = $state<'form' | 'chat' | undefined>(undefined) @@ -359,8 +377,8 @@ inputs?: Record<string, any> ): Promise<string | undefined> { if (!agentModule) return undefined - // A view runs the deployed agent, which anyone who can read it may do; the editor previews - // the draft. Both run as the caller. + // A view runs the deployed agent, which anyone who can read it may do, as the server picks + // the identity; the editor previews the draft as the one editing it. if (view && workspace) { return await JobService.runAgent({ workspace, @@ -456,12 +474,21 @@ pathErrorValue = error ?? '' } + /** Who the next deploy makes the agent run as, as the "Permissioned as" line picked it. Unset + * while the line was never shown, which keeps the current identity as the line would have + * preselected it: an admin deploying without looking must not take the agent over. */ + let runAs = $state<AgentRunAs | undefined>(undefined) + export function setRunAs(next: AgentRunAs) { + runAs = next + } + export function deploy(): Promise<boolean> { if (pathErrorValue) { sendUserToast(`Cannot deploy the agent: ${pathErrorValue}`, true) return Promise.resolve(false) } - return draft.deploy().then(async (written) => { + const keep = draft.onBehalfOf ? { permissionedAs: draft.onBehalfOf, preserve: true } : undefined + return draft.deploy(runAs ?? keep).then(async (written) => { // The path the write landed on, which a rename moves off the one this editor opened. if (written) await onSaved?.(written) return written !== undefined @@ -594,7 +621,7 @@ <div class="flex-1 min-h-0 overflow-auto p-4 {testMode === 'chat' ? 'hidden' : ''}"> <div class="max-w-3xl mx-auto"> {@render viewForm?.({ - schema: AGENT_CHAT_SCHEMA, + schema: formSchema, run: runOnce, loading: runLoading, actions: onOpenConfig ? configurationButton : undefined diff --git a/frontend/src/lib/components/flows/content/AgentEditorModal.svelte b/frontend/src/lib/components/flows/content/AgentEditorModal.svelte index 30aa98b65e..f3ee578050 100644 --- a/frontend/src/lib/components/flows/content/AgentEditorModal.svelte +++ b/frontend/src/lib/components/flows/content/AgentEditorModal.svelte @@ -56,6 +56,8 @@ onClose?: (deployed: boolean) => void /** Ran after every successful deploy, with the path written. */ onDeployed?: (path: string) => void + /** Open on the settings, as a link to them does. */ + startInSettings?: boolean } let { @@ -64,7 +66,8 @@ onRenamed = undefined, layout = 'modal', onClose = undefined, - onDeployed = undefined + onDeployed = undefined, + startInSettings = false }: Props = $props() // Every target names the surface that opened it, and only a flow step or a resource row can: @@ -182,6 +185,14 @@ else settingsDrawer?.openDrawer() } + let openedSettings = false + $effect(() => { + if (startInSettings && draft?.state && settingsDrawer && !openedSettings) { + openedSettings = true + untrack(openSettings) + } + }) + /** The unsaved edits, in the shape the server builds from a deployed config * (`ai_evals/run.rs` `config_to_draft`), so a draft run's hash can be recognised as equal to * the version it is later deployed as. */ @@ -582,6 +593,7 @@ path={target?.path ?? ''} workspace={ws} bind:error={() => host?.pathError(), (error) => host?.setPathError(error)} + onRunAsChange={(runAs) => host?.setRunAs(runAs)} /> {/if} {/snippet} diff --git a/frontend/src/lib/components/flows/content/AgentResourceBar.svelte b/frontend/src/lib/components/flows/content/AgentResourceBar.svelte index 3359f20080..6d116cb79d 100644 --- a/frontend/src/lib/components/flows/content/AgentResourceBar.svelte +++ b/frontend/src/lib/components/flows/content/AgentResourceBar.svelte @@ -8,8 +8,9 @@ import ResourcePathHint from '$lib/components/ResourcePathHint.svelte' import { ResourceService, type InputTransform, type Resource } from '$lib/gen' import { sendUserToast } from '$lib/toast' - import { userStore, userWorkspaces } from '$lib/stores' + import { userStore, userWorkspaces, workspaceStore } from '$lib/stores' import { canEditItem } from '$lib/editRights' + import { capabilitiesIn } from '$lib/roleCapabilities' import { Bot, ChevronDown, ChevronUp, Save, Unlink, Pencil } from 'lucide-svelte' import { AGENT_BRAIN_KEYS, @@ -18,6 +19,7 @@ flowLocalInputs, inputTransformsToAgentConfig, nonStaticBrainKeys, + providerWithOverrides, summarizeAgentBrain, type AIAgentConfig, type AgentTool @@ -61,7 +63,7 @@ flowPath = '', fromAgentEditor = false, chatInputEnabled = false, - linkedMemory = $bindable() + linkedAgent = $bindable() }: { agent: string | undefined inputTransforms: Record<string, InputTransform> @@ -80,8 +82,9 @@ // is the wrong way in. fromAgentEditor?: boolean chatInputEnabled?: boolean - // The linked agent's memory once its config has loaded, for the step's history inputs. - linkedMemory?: { memory: unknown } | undefined + // The linked agent's memory and provider once its config has loaded, for the step's history + // inputs and its overrides. + linkedAgent?: { memory: unknown; provider?: unknown } | undefined } = $props() let ws = $derived(opWorkspace ?? $operatingWorkspace) @@ -226,7 +229,9 @@ loadedInfo?.ws === ws && loadedInfo?.path === agent ? loadedInfo : undefined ) $effect(() => { - linkedMemory = linkedInfo ? { memory: linkedInfo.config?.memory } : undefined + linkedAgent = linkedInfo + ? { memory: linkedInfo.config?.memory, provider: linkedInfo.config?.provider } + : undefined }) let inheritedTools = $derived(linkedInfo?.tools ?? []) let brainParams = $derived(summarizeAgentBrain(linkedInfo?.config)) @@ -240,7 +245,10 @@ agent, linkedInfo?.extraPerms, $userStore ?? undefined, - $userWorkspaces.find((w) => w.id === ws)?.operator_settings + capabilitiesIn( + ws === $workspaceStore ? $userStore : undefined, + $userWorkspaces.find((w) => w.id === ws) + ) ) ) // The hint flips on the first keystroke in the agent editor, so the badge does not wait for the @@ -512,7 +520,13 @@ local[key] = inputTransforms[key] } } - const forkedInputs = { ...agentConfigToInputTransforms(cfg), ...local } + // An unlinked step sets model and effort inside `provider`, so its overrides move there. + const provider = providerWithOverrides(cfg.provider, inputTransforms) + const forkedInputs = { + ...agentConfigToInputTransforms(cfg), + ...(provider ? { provider } : {}), + ...local + } const forkedTools = cfg.tools ?? [] inputTransforms = forkedInputs for (const tool of forkedTools) { diff --git a/frontend/src/lib/components/flows/content/AgentSettings.svelte b/frontend/src/lib/components/flows/content/AgentSettings.svelte index 449edfccff..9d02183812 100644 --- a/frontend/src/lib/components/flows/content/AgentSettings.svelte +++ b/frontend/src/lib/components/flows/content/AgentSettings.svelte @@ -1,14 +1,17 @@ <script lang="ts"> import { resource } from 'runed' - import { Badge } from '$lib/components/common' + import { Badge, Section } from '$lib/components/common' import Label from '$lib/components/Label.svelte' import LabelsInput from '$lib/components/LabelsInput.svelte' import Path from '$lib/components/Path.svelte' import ResourceDescriptionField from '$lib/components/ResourceDescriptionField.svelte' import ResourcePathHint from '$lib/components/ResourcePathHint.svelte' import Toggle from '$lib/components/Toggle.svelte' + import PermissionedAsLine from '$lib/components/triggers/PermissionedAsLine.svelte' + import AgentSlackSettings from './AgentSlackSettings.svelte' import { WorkspaceService } from '$lib/gen' - import type { AgentDraftHandle } from '../agentDraft.svelte' + import { userStore } from '$lib/stores' + 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 * rather than what it does. */ @@ -19,11 +22,22 @@ workspace: string | undefined /** Why the path cannot be deployed to, if it cannot. */ error?: string + /** Who the next deploy makes the agent run as, as picked here. */ + onRunAsChange?: (runAs: AgentRunAs) => void } - let { draft, path, workspace, error = $bindable() }: Props = $props() + let { draft, path, workspace, error = $bindable(), onRunAsChange = undefined }: Props = $props() let readOnly = $derived(!draft.canWrite) + // As the deploy resolves it: the deployer, unless one who may keep another picked them. + let pickedRunAs = $state<AgentRunAs | undefined>(undefined) + let nextRunAs = $derived( + pickedRunAs?.preserve && pickedRunAs.permissionedAs + ? pickedRunAs.permissionedAs + : $userStore?.username + ? `u/${$userStore.username}` + : undefined + ) // Only a workspace that deploys somewhere has anything to keep an agent out of. const deployTo = resource( () => workspace, @@ -33,47 +47,91 @@ </script> {#if draft.state} - <div class="flex flex-col gap-6"> - <Label label="Path"> - <ResourcePathHint /> - <Path - bind:path={ - () => draft.state?.path, - (v) => { - if (draft.state && v !== undefined) draft.state.path = v - } - } - bind:error - initialPath={draft.noDeployed ? '' : path} - checkInitialPathExistence={draft.noDeployed} - namePlaceholder="agent" - kind="resource" - workspaceOverride={workspace} - autofocus={false} - disabled={readOnly} - /> - </Label> - {#if readOnly} - {#if draft.state.labels?.length} - <div class="-mt-4 inline-flex items-center gap-1"> - {#each draft.state.labels as label (label)} - <Badge color="blue" small>{label}</Badge> - {/each} - </div> - {/if} - {:else} - <LabelsInput bind:labels={draft.state.labels} {workspace} class="-mt-4" /> - {/if} - {#if deployTo.current} - <Label label="Workspace specific" tooltip="Keeps this agent out of deploys to prod/staging."> - <Toggle bind:checked={draft.state.wsSpecific} disabled={readOnly} /> - </Label> - {/if} - <ResourceDescriptionField - bind:description={draft.state.description} - label="Description" - placeholder="Describe what this agent does" - canWrite={!readOnly} - /> + <div class="flex flex-col gap-8"> + <Section label="General"> + <div class="flex flex-col gap-6"> + <Label label="Path"> + <ResourcePathHint /> + <Path + bind:path={ + () => draft.state?.path, + (v) => { + if (draft.state && v !== undefined) draft.state.path = v + } + } + bind:error + initialPath={draft.noDeployed ? '' : path} + checkInitialPathExistence={draft.noDeployed} + namePlaceholder="agent" + kind="resource" + workspaceOverride={workspace} + autofocus={false} + disabled={readOnly} + /> + </Label> + {#if readOnly} + {#if draft.state.labels?.length} + <div class="-mt-4 inline-flex items-center gap-1"> + {#each draft.state.labels as label (label)} + <Badge color="blue" small>{label}</Badge> + {/each} + </div> + {/if} + {:else} + <LabelsInput bind:labels={draft.state.labels} {workspace} class="-mt-4" /> + {/if} + <ResourceDescriptionField + bind:description={draft.state.description} + label="Description" + placeholder="Describe what this agent does" + canWrite={!readOnly} + /> + {#if deployTo.current} + <Label + label="Workspace specific" + tooltip="Keeps this agent out of deploys to prod/staging." + > + <Toggle bind:checked={draft.state.wsSpecific} disabled={readOnly} /> + </Label> + {/if} + </div> + </Section> + <Section label="Permissions"> + <!-- The identity exists only while the toggle is on, resolved on deploy as a flow's + on-behalf-of identity is. --> + <div class="flex flex-col gap-2"> + <Label + label="Run on behalf of" + tooltip="Runs from the agent's page, the API and Slack take the permissions of whoever deploys it, or of the user an admin picks. Otherwise they take the caller's." + > + <Toggle + bind:checked={ + () => draft.state?.args.run_on_behalf_of ?? false, + (v) => { + if (!draft.state) return + draft.state.args.run_on_behalf_of = v || undefined + // Lending the identity to anyone in Slack needs one to lend. + if (!v) draft.state.args.slack_anyone = undefined + } + } + disabled={readOnly} + /> + </Label> + {#if draft.state.args.run_on_behalf_of} + <PermissionedAsLine + permissionedAs={draft.onBehalfOf} + path={draft.state.path} + disabled={readOnly} + onPermissionedAsChange={(permissionedAs, preserve) => { + pickedRunAs = { permissionedAs, preserve } + onRunAsChange?.(pickedRunAs) + }} + /> + {/if} + </div> + </Section> + <Section label="Slack"> + <AgentSlackSettings {draft} {path} {workspace} {nextRunAs} /> + </Section> </div> {/if} diff --git a/frontend/src/lib/components/flows/content/AgentSlackSettings.svelte b/frontend/src/lib/components/flows/content/AgentSlackSettings.svelte new file mode 100644 index 0000000000..5e1e4f020d --- /dev/null +++ b/frontend/src/lib/components/flows/content/AgentSlackSettings.svelte @@ -0,0 +1,258 @@ +<script lang="ts"> + import { Plus, X } from 'lucide-svelte' + import { resource } from 'runed' + import { Button } from '$lib/components/common' + import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' + import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' + import Label from '$lib/components/Label.svelte' + import Select from '$lib/components/select/Select.svelte' + import TextInput from '$lib/components/text_input/TextInput.svelte' + import Toggle from '$lib/components/Toggle.svelte' + import { SlackAgentsService, type SlackAgentChannel } from '$lib/gen' + import { userStore } from '$lib/stores' + import { sendUserToast } from '$lib/toast' + import type { AgentDraftHandle } from '../agentDraft.svelte' + + /** Who can ask the agent in Slack, saved with it, and the channels it is shared in, which take + * effect at once: they belong to one Slack workspace, not to the agent's definition. */ + interface Props { + draft: AgentDraftHandle + /** The deployed path; channels are shared for an agent that exists. */ + path: string + workspace: string | undefined + /** Who the next deploy makes the agent run as, when it runs on behalf of someone. */ + nextRunAs: string | undefined + } + + let { draft, path, workspace, nextRunAs }: Props = $props() + + let readOnly = $derived(!draft.canWrite) + let obo = $derived(!!draft.state?.args.run_on_behalf_of) + let runsAs = $derived( + readOnly + ? (draft.onBehalfOf ?? 'its run-as user') + : !nextRunAs || nextRunAs === `u/${$userStore?.username}` + ? 'you' + : nextRunAs + ) + let isAdmin = $derived(!!$userStore?.is_admin) + + const slack = resource( + () => ({ ws: workspace, path, deployed: !draft.noDeployed }), + async ({ ws, path, deployed }) => + ws && deployed + ? await SlackAgentsService.getAgentSlackChannels({ workspace: ws, path }) + : undefined + ) + let channels = $derived(slack.current?.channels ?? []) + let canPick = $derived( + !readOnly && + !!slack.current?.slack_team_name && + !!slack.current?.bot_name && + slack.current.missing_scopes.length === 0 + ) + + type Choice = { label: string; value: string; name: string } + let adding = $state(false) + let saving = $state(false) + + // Read again whenever the channels change, so a channel just added or removed moves between + // the list and the picker. + const available = resource( + () => ({ ws: adding && canPick ? workspace : undefined, channels }), + async ({ ws, channels }) => (ws ? await listChoices(ws, channels) : []) + ) + + async function listChoices(ws: string, shared: SlackAgentChannel[]): Promise<Choice[]> { + try { + const all: Choice[] = [] + let cursor: string | undefined = undefined + // ponytail: stops at 10 pages of 200 channels; search on the server if a team has more. + for (let page = 0; page < 10; page++) { + const res = await SlackAgentsService.listAvailableSlackChannels({ workspace: ws, cursor }) + for (const c of res.channels) { + if (shared.some((s) => s.channel_id === c.id)) continue + all.push({ value: c.id, name: c.name, label: `#${c.name}` }) + } + cursor = res.next_cursor + if (!cursor) break + } + return all + } catch (e) { + sendUserToast(`Couldn't list the Slack channels: ${e.body ?? e.message}`, true) + return [] + } + } + + async function share(channelId: string, channelName: string, isDefault: boolean) { + if (!workspace) return + saving = true + try { + await SlackAgentsService.shareAgentInSlackChannel({ + workspace, + path, + requestBody: { channel_id: channelId, channel_name: channelName, is_default: isDefault } + }) + adding = false + } catch (e) { + sendUserToast(e.body ?? e.message, true) + } finally { + saving = false + // Also on failure, so a default toggle flipped in vain flips back. + slack.refetch() + } + } + + async function unshare(channel: SlackAgentChannel) { + if (!workspace) return + try { + await SlackAgentsService.unshareAgentFromSlackChannel({ + workspace, + path, + channelId: channel.channel_id + }) + slack.refetch() + } catch (e) { + sendUserToast(e.body ?? e.message, true) + } + } +</script> + +{#if draft.state} + <div class="flex flex-col gap-4"> + <Label label="Who can ask in its channels"> + <ToggleButtonGroup + bind:selected={ + () => (obo && draft.state?.args.slack_anyone ? 'anyone' : 'members'), + (v) => { + if (!draft.state) return + draft.state.args.slack_anyone = v === 'anyone' || undefined + // Anyone there runs it as its identity, so there must be one. + if (v === 'anyone') draft.state.args.run_on_behalf_of = true + } + } + disabled={readOnly} + noWFull + > + {#snippet children({ item })} + <ToggleButton + value="members" + label={obo ? `Workspace members, as ${runsAs}` : 'Workspace members, as themselves'} + tooltip={`Members who can read the agent, matched by their Slack email. ${obo ? `They run it as ${runsAs}, as they would in Windmill.` : 'Each runs it with their own permissions, as they would in Windmill.'}`} + showTooltipIcon + {item} + small + /> + <ToggleButton + value="anyone" + label="Anyone, as {runsAs}" + tooltip={`Everyone in these channels, people without a Windmill account included, runs it as ${runsAs}. ${obo ? '' : 'Turns on Run on behalf of, which also applies to runs from Windmill and the API. '}Applies when you deploy.`} + showTooltipIcon + {item} + small + /> + {/snippet} + </ToggleButtonGroup> + </Label> + <div class="flex flex-col gap-2 rounded-md border p-3"> + <span class="text-xs font-semibold text-emphasis">Channels</span> + <div class="flex flex-col gap-2"> + {#if draft.noDeployed} + <span class="text-2xs text-hint">Deploy the agent to share it in Slack channels.</span> + {:else if slack.loading && !slack.current} + <span class="text-2xs text-hint">Loading…</span> + {:else if !slack.current?.slack_team_name} + <span class="text-2xs text-hint"> + Slack isn't connected to the workspace. + {#if isAdmin} + <a href="/workspace_settings?tab=slack&agents=true">Connect it</a> with AI agents enabled. + {/if} + </span> + {:else} + {#if channels.length === 0 && !adding} + <span class="text-2xs text-hint">Not shared in any channel.</span> + {/if} + {#each channels as channel (channel.channel_id)} + <div class="flex items-center gap-2 w-full"> + <TextInput + class="flex-1" + inputProps={{ readonly: true }} + value="#{channel.channel_name}" + /> + <Toggle + size="xs" + checked={channel.is_default} + disabled={readOnly || (!isAdmin && !channel.is_default) || saving} + on:change={(e) => share(channel.channel_id, channel.channel_name, e.detail)} + options={{ + right: 'Default', + rightTooltip: + 'Answers bot mentions in this channel that name no agent. Admins only.' + }} + /> + {#if !readOnly} + <Button + variant="subtle" + unifiedSize="md" + iconOnly + destructive + startIcon={{ icon: X }} + title="Stop sharing in #{channel.channel_name}" + onClick={() => unshare(channel)} + /> + {/if} + </div> + {/each} + {#if canPick} + {#if adding} + <div class="flex items-center gap-2 w-full"> + <Select + class="flex-1" + items={available.current ?? []} + bind:value={ + () => undefined, + (v) => { + const choice = available.current?.find((c) => c.value === v) + if (choice) share(choice.value, choice.name, false) + } + } + loading={available.loading || saving} + disabled={saving} + placeholder="Pick a public channel" + noItemsMsg="No other public channel" + open + /> + <Button + variant="subtle" + unifiedSize="md" + iconOnly + startIcon={{ icon: X }} + title="Cancel" + onClick={() => (adding = false)} + /> + </div> + {:else} + <div class="flex justify-start"> + <Button + variant="default" + unifiedSize="sm" + startIcon={{ icon: Plus }} + onClick={() => (adding = true)} + > + Share in a channel + </Button> + </div> + {/if} + <span class="text-2xs text-hint" + >Channels are not saved with the agent: changes apply directly.</span + > + {:else if !readOnly} + <span class="text-2xs text-hint"> + Slack can't be reached or was connected without AI agents; the agent's page says why. + </span> + {/if} + {/if} + </div> + </div> + </div> +{/if} diff --git a/frontend/src/lib/components/flows/content/AgentUsePanel.svelte b/frontend/src/lib/components/flows/content/AgentUsePanel.svelte new file mode 100644 index 0000000000..ee5f761b69 --- /dev/null +++ b/frontend/src/lib/components/flows/content/AgentUsePanel.svelte @@ -0,0 +1,317 @@ +<script lang="ts"> + import { Copy } from 'lucide-svelte' + import CollapseLink from '$lib/components/CollapseLink.svelte' + import { bash, markdown, typescript } from 'svelte-highlight/languages' + import { Alert, Badge, Button, CopyButton, Tab, TabContent, Tabs } from '$lib/components/common' + import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte' + import CopyableCodeBlock from '$lib/components/details/CopyableCodeBlock.svelte' + import Label from '$lib/components/Label.svelte' + import TextInput from '$lib/components/text_input/TextInput.svelte' + import Tooltip from '$lib/components/Tooltip.svelte' + import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' + import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' + import UserSettings from '$lib/components/UserSettings.svelte' + import type { SlackAgentChannel } from '$lib/gen' + import { userStore } from '$lib/stores' + import { copyToClipboard, generateRandomString } from '$lib/utils' + import { apiBaseUrl } from '$lib/apiBaseUrl.svelte' + import { + curlExample, + fetchExample, + integrationPrompt, + runUrl, + sdkExample, + sdkReactExample + } from './agentApiExamples' + + /** + * The ways to use an agent outside its page: in the Slack channels it is shared in, set in its + * settings and summed up here, and over the API. + */ + let { + agentPath, + workspace, + isAdmin, + chat, + slackLoading, + slackTeamName, + botName, + missingScopes, + signingSecretSet, + runsAs, + anyone, + canShare, + channels, + section + }: { + agentPath: string + workspace: string + isAdmin: boolean + /** Whether the agent keeps the conversation. Slack is offered only then: a thread is one. */ + chat: boolean + /** Whether the workspace's Slack connection is still being read. */ + slackLoading: boolean + /** Unset when Slack is not connected to the workspace. */ + slackTeamName: string | undefined + /** Unset when Slack can't be reached with the workspace's bot token. */ + botName: string | undefined + /** Agent scopes the connection lacks: Slack was connected without AI agents enabled. */ + missingScopes: string[] + /** Agents answer in Slack only when the instance verifies Slack's request signatures. */ + signingSecretSet: boolean + /** Who the agent runs as; unset unless it runs on behalf of someone. */ + runsAs: string | undefined + /** Whether anyone in its channels can ask it, rather than workspace members only. */ + anyone: boolean + /** Whether the user can change its Slack settings: anyone who can edit it. */ + canShare: boolean + channels: SlackAgentChannel[] + /** Shows only this section, without the panel's own tabs: the page offers them as its tabs. */ + section?: 'api' | 'slack' + } = $props() + + let pickedTab = $state('api') + let tab = $derived(section ?? (chat ? pickedTab : 'api')) + let handle = $derived(`+${agentPath.split('/').pop()}`) + + let userSettings: UserSettings | undefined = $state(undefined) + let token = $state('') + let target = $derived({ + api: `${apiBaseUrl()}/api/w/${workspace}`, + agentPath, + chat, + token + }) + let body = JSON.stringify({ user_message: 'Hello' }, null, 2) + let headers = $derived( + JSON.stringify( + { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + null, + 2 + ) + ) + let apiTab = $state('prompt') + let tsFlavor = $state('react') +</script> + +<UserSettings + bind:this={userSettings} + on:tokenCreated={(e) => (token = e.detail)} + newTokenWorkspace={workspace} + newTokenLabel={`agent-${$userStore?.username ?? 'superadmin'}-${generateRandomString(4)}`} + scopes={[`jobs:run:agents:${agentPath}`]} +/> + +<div class="flex flex-col h-full"> + <Tabs + bind:selected={() => tab, (t) => (pickedTab = t)} + hideTabs={section !== undefined} + wrapperClass="flex-none w-full" + > + <Tab value="api" label="API" /> + {#if chat} + <Tab value="slack" label="Slack" /> + {/if} + + {#snippet content()} + <div class="min-h-0 grow overflow-y-auto"> + <TabContent value="slack" class="flex flex-col gap-6 p-4"> + {#if slackLoading} + <span class="text-xs text-hint">Loading…</span> + {:else if !slackTeamName} + <span class="text-xs text-secondary"> + Connect Slack to the workspace, with AI agents enabled, to ask this agent from Slack, + by its name or as a channel's default. + </span> + {#if isAdmin} + <div> + <Button + variant="default" + unifiedSize="md" + href="/workspace_settings?tab=slack&agents=true" + > + Connect Slack + </Button> + </div> + {:else} + <span class="text-2xs text-hint">A workspace admin can connect it.</span> + {/if} + {:else} + {#if !signingSecretSet} + <Alert type="warning" title="Agents can't answer in Slack yet" size="xs"> + Windmill needs the Slack app's signing secret to tell who sent a message. + {#if isAdmin} + A workspace's own Slack app takes it in the workspace settings, the instance's app + in the instance settings. + {:else} + A workspace or instance admin can set it. + {/if} + </Alert> + {/if} + {#if !botName} + <Alert type="warning" title="Windmill can't reach Slack" size="xs"> + Slack refused the workspace's bot token or couldn't be reached, so agents can't + answer there. + {#if isAdmin} + <a href="/workspace_settings?tab=slack&agents=true">Reconnect Slack</a> to fix it. + {:else} + A workspace admin can reconnect Slack to fix it. + {/if} + </Alert> + {/if} + {#if botName && missingScopes.length > 0} + <Alert type="warning" title="Slack was connected without AI agents" size="xs"> + The connection lacks {missingScopes.join(', ')}, so agents can't check who is + asking. + {#if isAdmin} + <a href="/workspace_settings?tab=slack&agents=true">Disconnect Slack</a>, then + connect it again with AI agents enabled. + {:else} + A workspace admin can reconnect Slack with AI agents enabled. + {/if} + </Alert> + {/if} + + <div class="flex flex-col gap-2"> + <div class="flex items-center justify-between gap-2"> + <span class="text-xs font-semibold text-emphasis">Ask it in Slack</span> + {#if canShare} + <Button + variant="default" + unifiedSize="sm" + href="/agents/edit/{agentPath}?settings=true" + > + Edit in settings + </Button> + {/if} + </div> + {#if channels.length === 0} + <span class="text-2xs text-hint">Not shared in any channel of {slackTeamName}.</span + > + {:else} + <div class="flex items-center gap-2"> + <code + class="text-2xs font-mono text-emphasis bg-surface-secondary rounded px-2 py-1" + >@{botName ?? '<Slack app name>'} {handle} your question</code + > + <CopyButton value={handle} title="Copy {handle}" /> + <Tooltip> + {handle} matches the last part of the agent's path. Use the whole path, + <code>+{agentPath}</code>, when two agents shared in a channel have that name. + Replies in the thread that mention the bot go to the same agent, no name needed. + </Tooltip> + </div> + <div class="flex flex-wrap items-center gap-1"> + <span class="text-2xs text-hint">in</span> + {#each channels as channel (channel.channel_id)} + <Badge small + >#{channel.channel_name}{channel.is_default ? ' · default' : ''}</Badge + > + {/each} + </div> + <span class="text-2xs text-hint"> + {#if anyone} + Anyone in these channels can ask it, with {runsAs}'s permissions. + {:else if runsAs} + Workspace members can ask it, with {runsAs}'s permissions. + {:else} + Workspace members can ask it, with their own permissions. + {/if} + </span> + {/if} + </div> + {/if} + </TabContent> + + <TabContent value="api" class="flex flex-col gap-6 p-4"> + <Label label="Token"> + <div class="flex flex-col gap-2"> + <TextInput + bind:value={token} + inputProps={{ placeholder: 'Paste your token here once created' }} + class="!text-xs !font-normal" + /> + <div> + <Button + variant="default" + unifiedSize="sm" + onClick={() => userSettings?.openDrawer()} + > + Create an agent-specific token + <Tooltip light> + The token can only run this agent. It is safe to share as it cannot be used to + impersonate you. + </Tooltip> + </Button> + </div> + </div> + </Label> + <div class="flex flex-col"> + <Tabs bind:selected={apiTab}> + <Tab value="prompt" label="AI prompt" /> + <Tab value="ts" label="TypeScript" /> + <Tab value="http" label="HTTP" /> + {#snippet content()} + <TabContent value="ts" class="flex flex-col gap-2 mt-2"> + {#if chat} + <ToggleButtonGroup bind:selected={tsFlavor} noWFull> + {#snippet children({ item })} + <ToggleButton value="react" label="React" {item} small /> + <ToggleButton value="any" label="Any framework" {item} small /> + <ToggleButton value="fetch" label="No SDK" {item} small /> + {/snippet} + </ToggleButtonGroup> + {/if} + <CopyableCodeBlock + code={!chat || tsFlavor === 'fetch' + ? fetchExample(target) + : tsFlavor === 'react' + ? sdkReactExample(target) + : sdkExample(target)} + language={typescript} + /> + </TabContent> + <TabContent value="http" class="flex flex-col gap-6 mt-2"> + <Label label="Url"> + <ClipboardPanel + content={runUrl(target) + (chat ? '?memory_id=<conversation id>' : '')} + /> + </Label> + <Label label="Body"><ClipboardPanel content={body} /></Label> + <Label label="Headers"><ClipboardPanel content={headers} /></Label> + <Label label="Curl"> + <span class="text-xs text-secondary"> + Runs the agent and waits for its answer. The TypeScript tab streams it + instead. + </span> + <CopyableCodeBlock code={curlExample(target)} language={bash} /> + </Label> + </TabContent> + <TabContent value="prompt" class="flex flex-col gap-2 mt-2"> + <span class="text-xs text-secondary"> + Share this with your coding assistant. It assumes your token is in <code + >WINDMILL_TOKEN</code + >. + </span> + <div> + <Button + variant="default" + unifiedSize="sm" + startIcon={{ icon: Copy }} + onClick={() => copyToClipboard(integrationPrompt(target))} + > + Copy prompt + </Button> + </div> + <CollapseLink text="Show prompt"> + <CopyableCodeBlock code={integrationPrompt(target)} language={markdown} wrap /> + </CollapseLink> + </TabContent> + {/snippet} + </Tabs> + </div> + </TabContent> + </div> + {/snippet} + </Tabs> +</div> diff --git a/frontend/src/lib/components/flows/content/AiAgentStepInputs.svelte b/frontend/src/lib/components/flows/content/AiAgentStepInputs.svelte index 4e7c7692a5..a123ce9185 100644 --- a/frontend/src/lib/components/flows/content/AiAgentStepInputs.svelte +++ b/frontend/src/lib/components/flows/content/AiAgentStepInputs.svelte @@ -29,7 +29,7 @@ <script lang="ts"> import type { Schema } from '$lib/common' import { deepEqual } from 'fast-equals' - import { type InputTransform } from '$lib/gen' + import { type AIProvider, type InputTransform } from '$lib/gen' import { allTrue, type DynamicInput as DynamicInputTypes } from '$lib/utils' import { getContext, untrack, type Snippet } from 'svelte' import { SvelteSet } from 'svelte/reactivity' @@ -46,6 +46,12 @@ import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import { AlertTriangle, Plus, X } from 'lucide-svelte' + import { resource } from 'runed' + import { aiProviderDetails, fetchAvailableModels } from '$lib/components/copilot/lib' + import { + explicitOffToken, + getReasoningCapability + } from '$lib/components/copilot/reasoningRegistry' import type { PickableProperties } from '../previousResults' import type { FlowCopilotContext } from '$lib/components/copilot/flow' import { enabledToolNames, type AgentTool } from '../agentToolUtils' @@ -53,6 +59,7 @@ AGENT_FIELDS, AGENT_FIELD_BY_KEY, AGENT_HISTORY_KEYS, + AGENT_UNSEEDED_KEYS, AGENT_MEMORY_DOCS_URL, AGENT_TOOLS_ROW, AGENT_FIELD_GROUPS, @@ -114,9 +121,10 @@ onDeleteTool?: (toolId: string) => void /** Where the tool picker's popover belongs, for a surface that is not the flow editor. */ toolPickerPortal?: string - /** A linked agent's memory, once its config has loaded: whether it keeps managed memory decides - * which history inputs the step offers. */ - linkedMemory?: { memory: unknown } | undefined + /** A linked agent's saved config, once it has loaded: whether it keeps managed memory decides + * which history inputs the step offers, and its provider is what the step's overrides + * replace. */ + linkedAgent?: { memory: unknown; provider?: unknown } | undefined /** Set when this agent's config lives in a resource, so `tools` arrives with it rather than * with the step. */ agentLinked?: boolean @@ -150,7 +158,7 @@ onAddTool = undefined, onDeleteTool = undefined, toolPickerPortal = undefined, - linkedMemory = undefined, + linkedAgent = undefined, agentLinked = false }: Props = $props() @@ -195,7 +203,7 @@ ? agentMemoryMode(transform?.value) : undefined } - return linkedMemory ? agentMemoryMode(linkedMemory.memory) : undefined + return linkedAgent ? agentMemoryMode(linkedAgent.memory) : undefined }) // The one-of field rewrites a value that matches none of its options, so a legacy kind the step @@ -212,6 +220,92 @@ return (AGENT_HISTORY_KEYS as readonly string[]).includes(key) } + let savedProvider = $derived( + linkedAgent?.provider as + | { kind?: AIProvider; resource?: string; model?: string; reasoning_effort?: string } + | undefined + ) + + // The models the agent's own provider resource serves, since an override keeps that resource. + const savedProviderModels = resource( + () => ({ + ws, + kind: savedProvider?.kind, + path: savedProvider?.resource?.replace(/^\$res:/, ''), + shown: args?.model != undefined + }), + async ({ ws, kind, path, shown }, _prev, { onCleanup }) => { + if (!shown || !ws || !kind || !path) return [] + const controller = new AbortController() + onCleanup(() => controller.abort()) + try { + return await fetchAvailableModels(path, ws, kind, controller.signal) + } catch { + return aiProviderDetails(kind).defaultModels + } + } + ) + + // The model the effort is placed on: the step's own where it names one, else the agent's. An + // expression is only known once the run is under way, so the agent's model stands in for it: + // its levels are suggestions, and any other value stays typeable. + let modelIsExpression = $derived(args?.model?.type === 'javascript') + let effortModel = $derived.by(() => { + const transform = args?.model + const own = transform?.type === 'static' ? transform.value : undefined + return (typeof own === 'string' && own.trim()) || savedProvider?.model + }) + let effortCapability = $derived( + savedProvider?.kind && effortModel + ? getReasoningCapability(savedProvider.kind, effortModel) + : undefined + ) + let effortChoices = $derived.by(() => { + if (!savedProvider?.kind || !effortModel || !effortCapability?.supported) return undefined + const off = explicitOffToken(savedProvider.kind, effortModel) + return [...(effortCapability.canDisable && off ? [off] : []), ...effortCapability.levels] + }) + + // The override rows offer what the agent's provider has, where that is known. Custom values stay + // typeable: a resource may serve a model its listing omits. Always an enum, even an empty one, so + // the row keeps one shape whatever is known. Held in state of their own, since the field binds + // into the property it renders. + let overrideProperties: Record<string, any> = $state({ model: {}, reasoning_effort: {} }) + $effect(() => { + const model = { + ...schemaProperties.model, + enum: savedProviderModels.current ?? [] + } + const effort = { ...schemaProperties.reasoning_effort, enum: effortChoices ?? [] } + untrack(() => { + if (!deepEqual($state.snapshot(overrideProperties.model), model)) { + overrideProperties.model = model + } + if (!deepEqual($state.snapshot(overrideProperties.reasoning_effort), effort)) { + overrideProperties.reasoning_effort = effort + } + }) + }) + let overrideFieldSchema = $derived({ + ...schema, + properties: { ...schemaProperties, ...overrideProperties } + }) + + function overrideHint(spec: AgentFieldSpec): string | undefined { + if (spec.group !== 'overrides' || !savedProvider) return undefined + if (spec.key !== 'reasoning_effort') + return `Saved on the agent: ${savedProvider.model || 'none'}` + if (!modelIsExpression && effortCapability?.known && !effortCapability.supported) { + return `${effortModel} does not take a reasoning effort` + } + return `Saved on the agent: ${savedProvider.reasoning_effort || 'none'}` + } + + function fieldSchema(key: string) { + if (key === 'memory') return memoryFieldSchema + return key === 'model' || key === 'reasoning_effort' ? overrideFieldSchema : schema + } + // Offer the agent's own tools as the choices for `enabled_tools`, rather than asking for names // to be typed. Written into the schema because that is where `InputTransformForm` reads a // field's shape from; `flowInfers` hands every step its own copy, so this stays this step's. @@ -260,7 +354,7 @@ ? args?.memory?.type === 'static' ? args.memory.value : undefined - : linkedMemory?.memory + : linkedAgent?.memory ) return onThisForm ? `Ignored while memory is set to ${label}.` @@ -272,10 +366,11 @@ scopedFields.some((spec) => spec.key === 'memory_id') ) - // A history input's row follows its key: the remembered `visible` set can outlive a key that a - // save, an undo or the AI chat removed, and a row with no value renders no field. + // A history input's or an override's row follows its key: the remembered `visible` set can + // outlive a key that a save, an undo or the AI chat removed, and a row with no value renders no + // field. function isShown(key: string): boolean { - if (isHistoryKey(key)) { + if (AGENT_UNSEEDED_KEYS.includes(key)) { return args?.[key] != undefined || (key === 'memory_id' && memoryIdOffered) } return visible.has(key) @@ -330,12 +425,13 @@ }) }) - // A history input's row follows its key rather than `visible`, so the run form is told about one - // only while the step holds the key: an inherited memory id has nothing a test run could inherit. + // A history input's or an override's row follows its key rather than `visible`, so the run form + // is told about one only while the step holds the key: an inherited memory id has nothing a test + // run could inherit. $effect(() => { const keys = [ - ...[...visible].filter((key) => !isHistoryKey(key)), - ...AGENT_HISTORY_KEYS.filter((key) => args?.[key] != undefined) + ...[...visible].filter((key) => !AGENT_UNSEEDED_KEYS.includes(key)), + ...AGENT_UNSEEDED_KEYS.filter((key) => args?.[key] != undefined) ] untrack(() => rememberOpenFields(visibilityKey, keys)) }) @@ -372,7 +468,7 @@ function removeField(spec: AgentFieldSpec) { visible.delete(spec.key) if (args) { - if (isHistoryKey(spec.key)) { + if (AGENT_UNSEEDED_KEYS.includes(spec.key)) { delete args[spec.key] } else { // Back to exactly what `flowInfers` seeds, so removing a field leaves no diff behind. @@ -449,27 +545,29 @@ tooltip: string | undefined, removable: AgentFieldSpec | undefined, header: Snippet | undefined = undefined, - collapsed: boolean = false + collapsed: boolean = false, + hint: string | undefined = undefined )} <InputTransformForm {previousModuleId} bind:arg={args[key]} bind:schema={ - () => (key === 'memory' ? memoryFieldSchema : schema), + () => fieldSchema(key), (value) => { - if (key !== 'memory') schema = value + if (fieldSchema(key) === schema) schema = value } } argName={key} {label} headerTooltip={tooltip} + {hint} hideDescription subtleControls {header} indentUnderHeader={false} {collapsed} animateAppear={header != undefined} - argExtra={schemaProperties[key] ?? {}} + argExtra={fieldSchema(key)?.properties?.[key] ?? {}} bind:inputCheck={() => inputCheck[key] ?? false, (value) => (inputCheck[key] = value)} bind:extraLib={() => extraLib ?? 'missing extraLib', (v) => (extraLib = v)} {variableEditor} @@ -558,6 +656,9 @@ {#if rows.length > 0} <div class="w-full flex flex-col"> <h2 class="mb-1 text-2xs font-normal uppercase text-secondary">{group.label}</h2> + {#if group.caption} + <p class="mb-2 text-xs text-secondary">{group.caption}</p> + {/if} <div class="flex flex-col gap-6"> {#each rows as spec (spec.key)} <ResizeTransitionWrapper innerClass="w-full" vertical> @@ -604,7 +705,15 @@ </p> {/if} {:else} - {@render transformField(spec.key, spec.label, spec.tooltip, spec)} + {@render transformField( + spec.key, + spec.label, + spec.tooltip, + spec, + undefined, + false, + overrideHint(spec) + )} {#if isHistoryKey(spec.key) && !historyInputApplies(spec.key, memoryMode)} <p class="mt-1 text-2xs text-hint"> {memoryMode === 'legacy' diff --git a/frontend/src/lib/components/flows/content/DynamicInputHelpBox.svelte b/frontend/src/lib/components/flows/content/DynamicInputHelpBox.svelte index 8ed18d73bf..0a225e1106 100644 --- a/frontend/src/lib/components/flows/content/DynamicInputHelpBox.svelte +++ b/frontend/src/lib/components/flows/content/DynamicInputHelpBox.svelte @@ -3,12 +3,14 @@ import Button from '$lib/components/common/button/Button.svelte' import { ChevronDown } from 'lucide-svelte' + let { hint = undefined }: { hint?: string } = $props() + let opened = $state(false) </script> <!-- Pulled up against the input above: it reads as a footnote on that field rather than as the next thing in the form. --> -<div class="text-2xs flex flex-row-reverse -mt-2"> +<div class="text-2xs flex flex-row-reverse items-start justify-between gap-2 -mt-2"> <Button on:click={() => { opened = !opened @@ -21,6 +23,10 @@ > Help </Button> + {#if hint} + <!-- The 4px a hint keeps from its input; the toggle stays flush against it. --> + <span class="mt-1 text-hint">{hint}</span> + {/if} </div> {#if opened} diff --git a/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte b/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte index ecb3187c5c..8b77a29896 100644 --- a/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte +++ b/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte @@ -8,7 +8,7 @@ import FlowScriptPickerQuick from '../pickers/FlowScriptPickerQuick.svelte' import { defaultScriptLanguages, processInlineLangs } from '$lib/scripts' import { defaultScripts, enterpriseLicense, hubBaseUrlStore } from '$lib/stores' - import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' + import { useRoleCapabilities } from '$lib/operatorWriteRights' import type { SupportedLanguage } from '$lib/common' import { createEventDispatcher, getContext, untrack } from 'svelte' import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui' @@ -35,7 +35,8 @@ } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() - const operatorBuilderFlows = useOperatorBuilderFlows() + const roleCaps = useRoleCapabilities() + const composeOnlyFlows = $derived($roleCaps.flows === 'compose') const operatingUser = useOperatingUser() const actingUser = $derived(operatingUser.current) @@ -158,7 +159,7 @@ selectedKind: 'script' | 'flow' | 'approval' | 'trigger' | 'preprocessor' | 'failure' ) { if ( - !$operatorBuilderFlows && + !composeOnlyFlows && ['script', 'trigger', 'failure', 'approval', 'preprocessor'].includes(selectedKind) ) { if (!selected && preFilter == 'all') { @@ -258,7 +259,7 @@ let showAiRows = $derived( !disableAi && !$copilotInfo.workspaceDisabled && - !$operatorBuilderFlows && + !composeOnlyFlows && funcDesc?.length > 0 && kind != 'failure' && kind != 'preprocessor' && @@ -291,14 +292,14 @@ preFilter === 'all' && !selected && customUi?.aiSandbox != false && - !$operatorBuilderFlows + !composeOnlyFlows ? matchingAiSandboxes : [] ) // Hub runnables carry code the workspace never reviewed, and the backend refuses them in a // flow a builder authors, so the hub browser and its integration filters are not offered. - let showHub = $derived(!$operatorBuilderFlows) + let showHub = $derived(!composeOnlyFlows) // Every result row lives in one keyboard index space, and hovering a row moves that index, so // mouse and keyboard can never highlight two different rows. Offsets follow the render order. diff --git a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte index 1ca2ed94fc..fa64fad44d 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte @@ -33,14 +33,14 @@ import AiAgentStepInputs from './AiAgentStepInputs.svelte' import AgentToolBindings from './AgentToolBindings.svelte' import { getLinkedAgentTools, linkedToolsScope } from '../linkedAgentToolsStore.svelte' - import { flowLocalAgentSchema } from '../agentResourceUtils' + import { linkedAgentStepSchema } from '../agentResourceUtils' import { AI_AGENT_TOOL_AI_KEYS, AI_DECISION_TOOL_AI_KEYS } from '../agentToolUtils' import { setAiDecisionStep } from '../aiDecisionBranching' import DiffEditor from '$lib/components/DiffEditor.svelte' import type { ButtonProp } from '$lib/components/diffEditorTypes' import { loadSchemaFromModule } from '../flowInfers' import { type Job } from '$lib/gen' - import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' + import { useRoleCapabilities } from '$lib/operatorWriteRights' import { checkIfParentLoop } from '../utils.svelte' import { useWorkspaceScriptSettings } from '../useWorkspaceScriptSettings.svelte' import ScriptSettingsBadges from '$lib/components/ScriptSettingsBadges.svelte' @@ -76,7 +76,8 @@ import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() - const operatorBuilderFlows = useOperatorBuilderFlows() + const roleCaps = useRoleCapabilities() + const composeOnlyFlows = $derived($roleCaps.flows === 'compose') const { selectionManager, @@ -244,11 +245,11 @@ !flowModule.value.path?.startsWith('hub/') && flowModule.value.hash == undefined && customUi?.scriptEdit != false && - !$operatorBuilderFlows && + !composeOnlyFlows && $workspaceScriptSettingsDrawer != undefined ) // Same rule as the graph node's Run button (FlowModuleSchemaItem). - let builderCannotTestStep = $derived($operatorBuilderFlows && flowModule.value.type === 'script') + let builderCannotTestStep = $derived(composeOnlyFlows && flowModule.value.type === 'script') let workspaceScriptNoEditReason = $derived( flowModule.value.type !== 'script' || canEditWorkspaceScriptSettings ? undefined @@ -309,7 +310,7 @@ let inputTransformSchemaForm: { setArgs: (nargs: Record<string, any>) => void } | undefined = $state(undefined) // The linked agent's memory, which decides which history inputs the step offers. - let linkedAgentMemory: { memory: unknown } | undefined = $state(undefined) + let linkedAgentConfig: { memory: unknown; provider?: unknown } | undefined = $state(undefined) let reloadError: string | undefined = $state(undefined) async function reload(flowModule: FlowModule) { @@ -1180,7 +1181,7 @@ opWorkspace={opWs} flowPath={$pathStore} fromAgentEditor={agentEditorHost?.() != undefined} - bind:linkedMemory={linkedAgentMemory} + bind:linkedAgent={linkedAgentConfig} chatInputEnabled={flowStore.val.value?.chat_input_enabled ?? false} bind:agent={ () => @@ -1233,7 +1234,7 @@ bind:this={inputTransformSchemaForm} pickableProperties={stepPropPicker.pickableProperties} schema={agentLinked - ? flowLocalAgentSchema( + ? linkedAgentStepSchema( flowStateStore.val[flowModule.id]?.schema ?? {} ) : (flowStateStore.val[flowModule.id]?.schema ?? {})} @@ -1263,7 +1264,7 @@ chatInputEnabled={flowStore.val.value?.chat_input_enabled ?? false} workspace={opWs} visibilityKey={agentFieldsKey} - linkedMemory={agentLinked ? linkedAgentMemory : undefined} + linkedAgent={agentLinked ? linkedAgentConfig : undefined} {agentLinked} tools={agentLinked ? getLinkedAgentTools( @@ -1367,7 +1368,7 @@ mod={flowModule} {noEditor} schema={agentLinked - ? flowLocalAgentSchema(flowStateStore.val[flowModule.id]?.schema ?? {}) + ? linkedAgentStepSchema(flowStateStore.val[flowModule.id]?.schema ?? {}) : (flowStateStore.val[flowModule.id]?.schema ?? {})} bind:testJob bind:testIsLoading diff --git a/frontend/src/lib/components/flows/content/FlowSettings.svelte b/frontend/src/lib/components/flows/content/FlowSettings.svelte index 0ae94b208e..dacb9efd4d 100644 --- a/frontend/src/lib/components/flows/content/FlowSettings.svelte +++ b/frontend/src/lib/components/flows/content/FlowSettings.svelte @@ -32,6 +32,8 @@ type OnBehalfOfChoice } from '$lib/components/OnBehalfOfSelector.svelte' import { modulesWithRetryOrSleep, SAME_WORKER_INCOMPATIBLE_MSG } from '../utils.svelte' + import { earlyReturnCandidates } from '../earlyReturnCandidates' + import Select from '$lib/components/select/Select.svelte' import { useOperatingUser, useOperatingWorkspace @@ -388,23 +390,26 @@ }} /> {#if flowStore.val.value.early_return} - <div - class="max-w-[120px] flex flex-col mt-2 {flowStore.val.value.early_return - ? '' - : 'bg-surface-secondary'}" - transition:slide={{ duration: 120 }} - > - <select - name="oauth_name" - id="oauth_name" + {@const candidates = earlyReturnCandidates(flowStore.val.value.modules ?? [])} + {@const selected = candidates.find((c) => c.id === flowStore.val.value.early_return)} + <div class="mt-2 flex flex-col gap-1 max-w-sm" transition:slide={{ duration: 120 }}> + <Select + items={candidates.map((c) => ({ value: c.id, label: c.id }))} bind:value={flowStore.val.value.early_return} - class="text-xs" - > - <option value={undefined}>Node's id</option> - {#each flowStore.val.value?.modules?.map((x) => x.id) as name} - <option value={name}>{name}</option> - {/each} - </select> + placeholder="Node's id" + size="sm" + /> + {#if !selected} + <span class="text-2xs text-red-600 dark:text-red-400"> + Step {flowStore.val.value.early_return} is not one of the steps above. Pick another + step. + </span> + {:else if !selected.deterministic} + <span class="text-2xs text-secondary"> + This step only runs if its branch is taken. Otherwise, sync calls return the + flow's final result. + </span> + {/if} </div> {/if} </div> diff --git a/frontend/src/lib/components/flows/content/agentApiExamples.ts b/frontend/src/lib/components/flows/content/agentApiExamples.ts new file mode 100644 index 0000000000..52cdae6ddf --- /dev/null +++ b/frontend/src/lib/components/flows/content/agentApiExamples.ts @@ -0,0 +1,228 @@ +/** + * The code an agent's API tab offers to copy. Every example is meant to run as copied once the + * token is filled in, so each one is exercised against a live agent before it changes here. + */ +export type AgentApiTarget = { + /** `https://host/api/w/<workspace>`, base path included. */ + api: string + agentPath: string + /** Whether the agent keeps the conversation: only then is `memory_id` worth sending. */ + chat: boolean + token: string +} + +export function runUrl({ api, agentPath }: AgentApiTarget): string { + return `${api}/jobs/run/agent/${agentPath}` +} + +export function curlExample(t: AgentApiTarget): string { + const conversation = t.chat + ? `# Any string names a conversation: reuse it for follow-ups, pick a new one to start over. +CONVERSATION=$(uuidgen) +` + : '' + return `TOKEN='${t.token}' +${conversation}JOB=$(curl -s -X POST "${runUrl(t)}${t.chat ? '?memory_id=$CONVERSATION' : ''}" \\ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \\ + -d '{"user_message": "Hello"}') + +while true; do + RES=$(curl -s "${t.api}/jobs_u/completed/get_result_maybe/$JOB" -H "Authorization: Bearer $TOKEN") + if [ "$(echo "$RES" | jq .completed)" = "true" ]; then + echo "$RES" | jq -r '.result.output // .result.error.message' + break + fi + sleep 1 +done` +} + +export function fetchExample(t: AgentApiTarget): string { + const usage = t.chat + ? `export async function main() { + // Any string names a conversation: reuse it for follow-ups, pick a new one to start over. + const conversation = crypto.randomUUID(); + await ask('Hello', conversation, (text) => console.log(text)); + return await ask('What did I just say?', conversation, (text) => console.log(text)); +}` + : `export async function main() { + return await ask('Hello', (text) => console.log(text)); +}` + return `const API = '${t.api}'; +const HEADERS = { Authorization: 'Bearer ${t.token}' }; + +${usage} + +/** Runs the agent and streams its answer to \`onText\` as it is written; resolves to the full answer. */ +async function ask(message${t.chat ? ', conversation' : ''}, onText) { + const run = await fetch(\`\${API}/jobs/run/agent/${t.agentPath}${t.chat ? '?memory_id=${conversation}' : ''}\`, { + method: 'POST', + headers: { ...HEADERS, 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_message: message }) + }); + if (!run.ok) throw new Error(await run.text()); + const job = (await run.text()).trim(); + + // The server closes the stream now and then: reconnect from the last offset, never rerun. + let offset = 0; + let pending = ''; + for (;;) { + const res = await fetch( + \`\${API}/jobs_u/getupdate_sse/\${job}?fast=true&only_result=true&stream_offset=\${offset}\`, + { headers: HEADERS } + ); + let buffer = ''; + for await (const chunk of res.body.pipeThrough(new TextDecoderStream())) { + buffer += chunk; + let end; + while ((end = buffer.indexOf('\\n\\n')) >= 0) { + const frame = buffer.slice(0, end); + buffer = buffer.slice(end + 2); + if (!frame.startsWith('data:')) continue; + const update = JSON.parse(frame.slice(5)); + if (update.new_result_stream) { + // One JSON event per line; a chunk can stop mid-line. + pending += update.new_result_stream; + offset = update.stream_offset; + const lines = pending.split('\\n'); + pending = lines.pop(); + for (const line of lines) { + const event = JSON.parse(line); + if (event.type === 'token_delta') onText(event.content); + // Also: reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result + } + } + if (update.completed) { + const result = update.only_result; + if (result?.error) throw new Error(result.error.message); + return result?.output; + } + } + } + } +}` +} + +function sdkOptions(t: AgentApiTarget, indent: string): string { + return `{ + baseUrl: '${t.api.replace(/\/api\/w\/[^/]+$/, '')}', + workspace: '${t.api.split('/').pop()}', + token: TOKEN, + // What the agent's conversations are filed under. With a token, history stays in this + // browser; history: 'server' also needs the token's flow_conversations:write scope. + flowPath: '${t.agentPath}.chat', + // Runs the agent for each turn; the conversation id is its memory. + run: async (args, { conversationId, signal }) => { + const res = await fetch(\`\${API}/jobs/run/agent/${t.agentPath}?memory_id=\${conversationId}\`, { + method: 'POST', + headers: { Authorization: \`Bearer \${TOKEN}\`, 'Content-Type': 'application/json' }, + body: JSON.stringify(args), + signal + }) + if (!res.ok) throw new Error(await res.text()) + return (await res.text()).trim() + } +}`.replaceAll('\n', '\n' + indent) +} + +export function sdkReactExample(t: AgentApiTarget): string { + return `import { useState } from 'react' +// On the Vercel AI SDK or assistant-ui? Use @windmill-labs/chat-sdk/ai-sdk or /assistant-ui instead: +// https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme +import { useWindmillChat } from '@windmill-labs/chat-sdk/react' + +const API = '${t.api}' +const TOKEN = '${t.token}' + +export function AgentChat() { + const chat = useWindmillChat(${sdkOptions(t, ' ')}) + const [draft, setDraft] = useState('') + // Also chat.stop(), and the conversations: chat.conversations, chat.selectConversation(id), chat.newConversation() + return ( + <div> + {chat.messages.map((m) => <p key={m.id}><b>{m.role}</b> {m.content}</p>)} + <form onSubmit={(e) => { e.preventDefault(); chat.sendMessage(draft); setDraft('') }}> + <input value={draft} onChange={(e) => setDraft(e.target.value)} disabled={chat.status === 'submitted' || chat.status === 'streaming'} /> + </form> + </div> + ) +}` +} + +export function sdkExample(t: AgentApiTarget): string { + return `import { createChat } from '@windmill-labs/chat-sdk' + +const API = '${t.api}' +const TOKEN = '${t.token}' + +const chat = createChat(${sdkOptions(t, '')}) + +// Called now and on every change: messages stream in as they are written. This is the Svelte +// store contract, so in Svelte $chat works as is. +chat.subscribe(({ messages, status }) => console.log(status, messages.at(-1)?.content)) // your UI here +await chat.sendMessage('Hello') +// Also chat.stop(), and the conversations: chat.newConversation(), chat.selectConversation(id)` +} + +/** A brief for a coding assistant asked to integrate the agent. It reads the token from `WINDMILL_TOKEN`. */ +export function integrationPrompt(t: AgentApiTarget): string { + const conversation = t.chat + ? ` +## Conversations + +The agent keeps the conversation. Pass \`memory_id\` as a query parameter on every run: any +string names a conversation, a UUID is best. Reuse it for follow-ups; a new one starts over. +Without it each message is answered on its own. + +Windmill also keeps each conversation, as the token's owner's. One token shared by all your +users makes every conversation theirs, so keep your users' conversation ids in your own app. +Reading them back needs a token with the \`flow_conversations:read\` scope (\`:write\` to delete): +- \`GET ${t.api}/flow_conversations/list?flow_path=${t.agentPath}.chat\` (\`page\`, \`per_page\`). + A conversation's \`id\` is the \`memory_id\` when that was a UUID. +- \`GET ${t.api}/flow_conversations/{id}/messages\`: its latest messages, oldest first. +- \`DELETE ${t.api}/flow_conversations/delete/{id}\`. + +In TypeScript or JavaScript, prefer the \`@windmill-labs/chat-sdk\` package (npm, source at +https://github.com/windmill-labs/windmill/tree/main/chat-sdk): it does all of this, streaming included. +\`createChat\` from \`@windmill-labs/chat-sdk\` works with any framework (or none); use the adapter that +matches the project when there is one: \`useWindmillChat\` from \`@windmill-labs/chat-sdk/react\`, the Vercel +AI SDK transport (\`@windmill-labs/chat-sdk/ai-sdk\`) or the assistant-ui runtime +(\`@windmill-labs/chat-sdk/assistant-ui\`). Point it at the +agent with \`flowPath: '${t.agentPath}.chat'\` and a \`run(args, { conversationId, signal })\` option +that POSTs \`args\` to the run endpoint with \`memory_id=conversationId\` and returns the job id. +` + : '' + return `Integrate the Windmill AI agent \`${t.agentPath}\` into this project. + +## Authentication + +Every request sends \`Authorization: Bearer <token>\`. Read the token from the \`WINDMILL_TOKEN\` +environment variable (or the project's secret store under that name), never hardcode it: the agent +runs as the token's owner, with their permissions. Keep it on the server, unless it is an +agent-specific token (scope \`jobs:run:agents:${t.agentPath}\`): that one can only run this agent, so a +browser app may read it from the bundler's public variable (\`VITE_WINDMILL_TOKEN\`, +\`NEXT_PUBLIC_WINDMILL_TOKEN\`, ...). + +## Sending a message + +\`POST ${runUrl(t)}\` with the JSON body \`{"user_message": "..."}\` starts a run and returns its +job id as plain text. An error is a non-2xx status with the reason as text. +${conversation} +## Streaming the answer + +\`GET ${t.api}/jobs_u/getupdate_sse/{job_id}?fast=true&only_result=true\` is a server-sent events +stream of JSON objects: +- \`new_result_stream\`: text to append to a buffer holding one JSON event per line. A chunk can + stop mid-line, so parse complete lines only. Event types: \`token_delta\` (\`content\`: answer text), + \`reasoning_token_delta\`, \`tool_call\`, \`tool_call_arguments\`, \`tool_execution\`, \`tool_result\` + (\`call_id\`, \`function_name\`, \`result\`, \`success\`). +- \`stream_offset\`: how far the stream has been read. The server closes the stream now and then; + reconnect with \`&stream_offset=<last value>\` and never run the agent again for the same message. +- \`completed: true\` ends it. \`only_result.output\` is the full answer, or \`only_result.error.message\` + when the run failed. + +Without streaming, poll \`GET ${t.api}/jobs_u/completed/get_result_maybe/{job_id}\` until +\`completed\` is true; the answer is \`result.output\`. + +To stop a run: \`POST ${t.api}/jobs_u/queue/cancel/{job_id}\` with \`{"reason": "..."}\`. +` +} diff --git a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte index f6cc9af6f5..52098d33ee 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte @@ -8,11 +8,12 @@ import SchemaForm from '$lib/components/SchemaForm.svelte' import GfmMarkdown from '$lib/components/GfmMarkdown.svelte' import { emptyString, DynamicInput } from '$lib/utils' - import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte' + import { useRoleCapabilities } from '$lib/operatorWriteRights' import { tick, untrack } from 'svelte' import type { Chat } from 'windmill-chat' import { saveFlowChatInputs } from './flowChatProps' - import type { FlowModule } from '$lib/gen' + import { ResourceService, type FlowModule } from '$lib/gen' + import { resource } from 'runed' import { useWorkspaceStorageConfigured } from '$lib/components/inputTransformEnv.svelte' import { attachmentsTargetFor, @@ -23,10 +24,12 @@ import FlowChatModelSettings from './FlowChatModelSettings.svelte' import { agentModelGap, + chatLinkedAgentPaths, composerOwnedInputs, resolveAgentModelWiring, showsModelButton, - withoutRejectedEffort + withoutRejectedEffort, + type SavedAgentProviders } from './agentChatInputs' interface Props { @@ -77,14 +80,14 @@ extraSettings = undefined }: Props = $props() - const operatingUser = useOperatingUser() + const roleCaps = useRoleCapabilities() // Derive helperScript for dynamic inputs from schema const dynamicInputHelperScript = $derived( DynamicInput.flowHelperScript( additionalInputsSchema?.['x-windmill-dyn-select-code'], additionalInputsSchema?.['x-windmill-dyn-select-lang'], path, - operatingUser.current?.operator + !$roleCaps.code ) ) @@ -107,7 +110,26 @@ // The model gets its own button, shaped like the copilot's model settings, driven by // whichever provider fields the flow exposes. Attachments are the paperclip's; every other // flow input is asked for in the Configure-inputs modal. - const modelWiring = $derived(resolveAgentModelWiring(flowModules)) + // A step linked to a saved agent takes its provider from the agent, so that is read here. One + // the reader cannot load leaves the wiring unreadable, and the model button away. + const savedProviders = resource( + () => ({ workspace, paths: chatLinkedAgentPaths(flowModules) }), + async ({ workspace, paths }): Promise<SavedAgentProviders> => { + if (!workspace || paths.length === 0) return {} + const entries = await Promise.all( + paths.map(async (path) => { + try { + const value: any = await ResourceService.getResourceValue({ workspace, path }) + return [path, value?.provider] as const + } catch { + return [path, undefined] as const + } + }) + ) + return Object.fromEntries(entries) + } + ) + const modelWiring = $derived(resolveAgentModelWiring(flowModules, savedProviders.current)) // An agent with nothing to call cannot answer, and the composer cannot fix it, so the // chat says what to go and do instead of offering controls that write nowhere. const modelGap = $derived(agentModelGap(modelWiring, subject)) diff --git a/frontend/src/lib/components/flows/conversations/FlowChatModelSettings.svelte b/frontend/src/lib/components/flows/conversations/FlowChatModelSettings.svelte index f27e14749e..f777a4cf49 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChatModelSettings.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChatModelSettings.svelte @@ -25,6 +25,7 @@ import { resource } from 'runed' import { composerDrivenFields, + wiredFieldValue, type AgentModelWiring, type ProviderField } from './agentChatInputs' @@ -40,9 +41,7 @@ let { wiring, values, setValue, workspace }: Props = $props() function fieldValue(field: ProviderField): any { - if (wiring.whole) return values[wiring.whole]?.[field] - const name = wiring.fields[field] - return name ? values[name] : wiring.fixed[field] + return wiredFieldValue(wiring, values, field) } const driven = $derived(composerDrivenFields(wiring)) diff --git a/frontend/src/lib/components/flows/conversations/FlowConversationsSidebar.svelte b/frontend/src/lib/components/flows/conversations/FlowConversationsSidebar.svelte index 8c6a102fef..8ab69377e2 100644 --- a/frontend/src/lib/components/flows/conversations/FlowConversationsSidebar.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowConversationsSidebar.svelte @@ -306,7 +306,7 @@ <div class="p-1"> <Button unifiedSize="md" - startIcon={{ icon: MessageCircle }} + startIcon={{ icon: MessageCircle, classes: 'ml-[2px]' }} onClick={() => (expanded = true)} title="{items.length} conversation{items.length !== 1 ? 's' : ''}{totalUnread > 0 ? `, ${totalUnread} unread` diff --git a/frontend/src/lib/components/flows/conversations/agentChatInputs.test.ts b/frontend/src/lib/components/flows/conversations/agentChatInputs.test.ts index 00561330d0..6855f8bbf7 100644 --- a/frontend/src/lib/components/flows/conversations/agentChatInputs.test.ts +++ b/frontend/src/lib/components/flows/conversations/agentChatInputs.test.ts @@ -6,6 +6,7 @@ import { parseProviderTransform, resolveAgentModelWiring, showsModelButton, + wiredFieldValue, withoutRejectedEffort } from './agentChatInputs' import type { FlowModule } from '$lib/gen' @@ -499,3 +500,84 @@ describe('parseProviderTransform with comments', () => { ).toBeUndefined() }) }) + +describe('a step linked to a saved agent', () => { + const saved = { + 'u/a/agent': { + kind: 'anthropic', + resource: '$res:u/a/claude', + model: 'claude-sonnet-4-5', + reasoning_effort: 'high' + } + } + function linked(input_transforms: Record<string, unknown>): FlowModule { + return { + id: 'a', + value: { type: 'aiagent', agent: '$res:u/a/agent', tools: [], input_transforms } + } as unknown as FlowModule + } + const wired = linked({ + model: { type: 'javascript', expr: 'flow_input.model' }, + reasoning_effort: { type: 'javascript', expr: 'flow_input.effort' } + }) + + it('drives the overrides it wires, on the provider the agent saved', () => { + const wiring = resolveAgentModelWiring([wired], saved) + expect(wiring?.fields).toEqual({ model: 'model', reasoning_effort: 'effort' }) + expect(wiring?.fixed).toEqual({ kind: 'anthropic', resource: '$res:u/a/claude' }) + expect(agentModelWiringInputs(wiring).sort()).toEqual(['effort', 'model']) + }) + + it('shows the saved model for a model input left empty, and no effort for an empty effort', () => { + const wiring = resolveAgentModelWiring([wired], saved)! + expect(wiredFieldValue(wiring, {}, 'model')).toBe('claude-sonnet-4-5') + // Empty is how a model that reasons only on request is turned off. + expect(wiredFieldValue(wiring, { effort: '' }, 'reasoning_effort')).toBe('') + }) + + it('shows the saved effort where the step sets none, only on the saved model', () => { + const wiring = resolveAgentModelWiring( + [linked({ model: { type: 'javascript', expr: 'flow_input.model' } })], + saved + )! + expect(wiredFieldValue(wiring, {}, 'reasoning_effort')).toBe('high') + const none = resolveAgentModelWiring( + [linked({ reasoning_effort: { type: 'static', value: '' } })], + saved + )! + expect(wiredFieldValue(none, {}, 'reasoning_effort')).toBe('') + expect( + wiredFieldValue(wiring, { model: 'claude-haiku-4-5' }, 'reasoning_effort') + ).toBeUndefined() + }) + + it('does not show the saved effort for steps that fix different ones', () => { + const effort = (value: string) => + linked({ + model: { type: 'javascript', expr: 'flow_input.model' }, + reasoning_effort: { type: 'static', value } + }) + const wiring = resolveAgentModelWiring([effort('low'), effort('medium')], saved)! + expect(wiredFieldValue(wiring, {}, 'reasoning_effort')).toBeUndefined() + expect(wiredFieldValue(wiring, {}, 'model')).toBe('claude-sonnet-4-5') + + // Two agents that share a model and saved different efforts are in the same position. + const other = { ...wired, value: { ...wired.value, agent: '$res:u/a/other' } } as FlowModule + const two = resolveAgentModelWiring([wired, other], { + ...saved, + 'u/a/other': { ...saved['u/a/agent'], reasoning_effort: 'low' } + })! + expect(wiredFieldValue(two, {}, 'model')).toBe('claude-sonnet-4-5') + expect(two.saved?.reasoning_effort).toBeUndefined() + }) + + it('cannot be read until the agent has loaded, or with an expression it cannot place', () => { + expect(resolveAgentModelWiring([wired], {})).toBeUndefined() + expect( + resolveAgentModelWiring( + [linked({ model: { type: 'javascript', expr: 'pick(flow_input.model)' } })], + saved + ) + ).toBeUndefined() + }) +}) diff --git a/frontend/src/lib/components/flows/conversations/agentChatInputs.ts b/frontend/src/lib/components/flows/conversations/agentChatInputs.ts index 1c178bde26..6cfe730572 100644 --- a/frontend/src/lib/components/flows/conversations/agentChatInputs.ts +++ b/frontend/src/lib/components/flows/conversations/agentChatInputs.ts @@ -4,6 +4,12 @@ import { carriedReasoning } from '$lib/components/copilot/chatModelSettings' import { parseExpressionAt } from 'acorn' import { agentSteps, flowInputReads } from './agentAttachmentInput' +/** A step names its agent bare or as `$res:<path>`/`res://<path>`, as `linkedAgentDrafts` reads it; + * kept apart from that module, which loads the editor's draft stores. */ +function normalizeAgentRef(agentRef: string): string { + return agentRef.replace(/^\$res:/, '').replace(/^res:\/\//, '') +} + /** Block and line comments removed, so what is left is only what affects the value. */ function withoutComments(source: string): string { return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '') @@ -78,6 +84,82 @@ export type AgentModelWiring = { * which describe what the composer may offer. */ someAgentCannotRun?: boolean + /** + * What a step linked to a saved agent runs on where it overrides nothing: an empty model + * input keeps the saved model, and the saved effort applies only on that model, to a step + * that sets no effort of its own. + */ + saved?: { model?: string; reasoning_effort?: string } +} + +/** The provider saved on each linked agent, by resource path. */ +export type SavedAgentProviders = Record<string, Record<string, any> | undefined> + +/** The saved agents whose provider the wiring needs, as bare resource paths. */ +export function chatLinkedAgentPaths(modules: FlowModule[] | undefined): string[] { + const paths = chatFacingAgents(modules) + .map((module) => (module.value as any).agent as string | undefined) + .filter((agent): agent is string => !!agent) + .map(normalizeAgentRef) + return [...new Set(paths)].sort() +} + +/** + * Read a step linked to a saved agent: the provider is the agent's, except for the model and + * effort the step overrides, each a literal or one flow input. + */ +function parseLinkedStep( + provider: Record<string, any> | undefined, + transforms: Record<string, InputTransform> | undefined +): AgentModelWiring | undefined { + if (!provider || typeof provider !== 'object') return undefined + const fixed: AgentModelWiring['fixed'] = {} + for (const field of PROVIDER_FIELDS) { + if (provider[field] !== undefined) fixed[field] = provider[field] + } + const saved = { model: fixed.model, reasoning_effort: fixed.reasoning_effort } + // Read off `saved` by whoever shows it, since it holds only on the saved model. + delete fixed.reasoning_effort + const fields: AgentModelWiring['fields'] = {} + for (const field of ['model', 'reasoning_effort'] as const) { + const transform = transforms?.[field] as any + if (!transform) continue + if (transform.type === 'static') { + const value = typeof transform.value === 'string' ? transform.value.trim() : '' + // An empty model keeps the saved one; an empty effort is the step's choice of none. + if (value || field === 'reasoning_effort') fixed[field] = value + continue + } + if (transform.type !== 'javascript') return undefined + const name = flowInputName(parseExpression(transform.expr)) + if (!name) return undefined + fields[field] = name + delete fixed[field] + } + return { fields, fixed, saved } +} + +/** + * The value a provider field runs on, given what the composer holds, as the worker's + * `override_provider_model` resolves a linked agent's. + */ +export function wiredFieldValue( + wiring: AgentModelWiring, + values: Record<string, any>, + field: ProviderField +): any { + if (wiring.whole) return values[wiring.whole]?.[field] + const name = wiring.fields[field] + const own = name ? values[name] : wiring.fixed[field] + if (!wiring.saved) return own + if (field === 'model') return own || wiring.saved.model + // An effort the step overrides is the run's whatever it holds: empty there is no effort. + if (field === 'reasoning_effort' && !name && own === undefined) { + return wiredFieldValue(wiring, values, 'model') === wiring.saved.model + ? wiring.saved.reasoning_effort + : own + } + return own } /** The flow input behind `flow_input.x`, `flow_input?.x` or `flow_input['x']`. */ @@ -121,23 +203,8 @@ export function parseProviderTransform( } if (transform?.type !== 'javascript') return undefined - // The author's own text may already be wrapped, so any balanced surround is fine — what - // the span check rejects is an expression with something else beside it. - const source = parenthesised(transform.expr) - let node: any - try { - node = parseExpressionAt(source, 0, { ecmaVersion: 'latest' }) - } catch { - return undefined - } - // A comment beside the expression is not another expression: the author annotating their - // own provider must not cost them the model control. Dropped before the check so what - // remains is only what would change the value — and so a paren inside a comment is not - // counted as one of the wrapping pair. - const before = withoutComments(source.slice(0, node.start)) - const after = withoutComments(source.slice(node.end)) - if (!/^[\s(]*$/.test(before) || !/^[\s)]*$/.test(after)) return undefined - if ((before.match(/\(/g)?.length ?? 0) !== (after.match(/\)/g)?.length ?? 0)) return undefined + const node = parseExpression(transform.expr) + if (!node) return undefined const whole = flowInputName(node) if (whole) return { whole, fields: {}, fixed: {} } @@ -162,6 +229,28 @@ export function parseProviderTransform( return { fields, fixed } } +/** The one expression a transform holds, or undefined where it is anything more than that. */ +function parseExpression(expr: string): any { + // The author's own text may already be wrapped, so any balanced surround is fine — what + // the span check rejects is an expression with something else beside it. + const source = parenthesised(expr) + let node: any + try { + node = parseExpressionAt(source, 0, { ecmaVersion: 'latest' }) + } catch { + return undefined + } + // A comment beside the expression is not another expression: the author annotating their + // own provider must not cost them the model control. Dropped before the check so what + // remains is only what would change the value — and so a paren inside a comment is not + // counted as one of the wrapping pair. + const before = withoutComments(source.slice(0, node.start)) + const after = withoutComments(source.slice(node.end)) + if (!/^[\s(]*$/.test(before) || !/^[\s)]*$/.test(after)) return undefined + if ((before.match(/\(/g)?.length ?? 0) !== (after.match(/\)/g)?.length ?? 0)) return undefined + return node +} + /** How one agent supplies a provider field: from an input, as a literal, or not at all. */ type FieldSupply = | { kind: 'wired'; name: string } @@ -192,12 +281,16 @@ function fieldSupply(wiring: AgentModelWiring, field: ProviderField): FieldSuppl * and field-by-field wiring is ambiguous throughout and yields nothing. */ export function resolveAgentModelWiring( - modules: FlowModule[] | undefined + modules: FlowModule[] | undefined, + savedProviders: SavedAgentProviders = {} ): AgentModelWiring | undefined { const agents = chatFacingAgents(modules) - const parsed = agents.map((agent) => - parseProviderTransform((agent.value as any).input_transforms?.['provider']) - ) + const parsed = agents.map((agent) => { + const value = agent.value as any + return value.agent + ? parseLinkedStep(savedProviders[normalizeAgentRef(value.agent)], value.input_transforms) + : parseProviderTransform(value.input_transforms?.['provider']) + }) if (parsed.length === 0) return undefined // An agent whose provider cannot be read is an agent the composer cannot speak for: // dropping it would let the rest declare a control that governs only some of them. @@ -219,19 +312,37 @@ export function resolveAgentModelWiring( const fields: AgentModelWiring['fields'] = {} const fixed: AgentModelWiring['fixed'] = {} + const disagreed = new Set<ProviderField>() for (const field of PROVIDER_FIELDS) { // Every agent has to supply the field the same way for the composer to speak for // them all. One wired name among agents that otherwise fix it is not agreement: // the control would move that one agent and leave the others where they are. const supplies = new Set(wirings.map((w) => JSON.stringify(fieldSupply(w, field)))) + // Disagreement leaves the field neither editable nor known: a control offered here // would govern one agent while the rest ran on something else. - if (supplies.size > 1) continue + if (supplies.size > 1) { + disagreed.add(field) + continue + } const supply: FieldSupply = JSON.parse([...supplies][0]) if (supply.kind === 'wired') fields[field] = supply.name else if (supply.kind === 'fixed') fixed[field] = supply.value } - return { fields, fixed, someAgentCannotRun } + // An empty input means one thing only where every agent falls back to the same saved value, + // and a saved value must not stand in for a field the agents disagree about. The saved effort + // belongs to the saved model, so it is only kept alongside one. + const common = (field: 'model' | 'reasoning_effort') => { + const values = new Set(wirings.map((w) => w.saved?.[field])) + return values.size === 1 && !disagreed.has(field) ? [...values][0] : undefined + } + const model = common('model') + return { + fields, + fixed, + someAgentCannotRun, + saved: model === undefined ? undefined : { model, reasoning_effort: common('reasoning_effort') } + } } /** @@ -357,11 +468,9 @@ export function withoutRejectedEffort( const effortInput = wiring.fields.reasoning_effort if (!effortInput) return values - const kindInput = wiring.fields.kind - const modelInput = wiring.fields.model const rejected = rejectsEffort( - kindInput ? values[kindInput] : wiring.fixed.kind, - modelInput ? values[modelInput] : wiring.fixed.model, + wiredFieldValue(wiring, values, 'kind'), + wiredFieldValue(wiring, values, 'model'), values[effortInput] ) return rejected ? { ...values, [effortInput]: '' } : values diff --git a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts index d8dee9dcaa..1aba4c2d02 100644 --- a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts +++ b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts @@ -570,7 +570,7 @@ export class FlowChatViewHost implements ChatViewHost, DraftSender<ComposerAttac } // Typed off the interface: a Svelte component's own type resolves differently // across import specifiers, and the two would then not be assignable. - #aiChatInput: Parameters<ChatViewHost['setAiChatInput']>[0] = null + #aiChatInput: Parameters<ChatViewHost['setAiChatInput']>[0] | null = null setAiChatInput: ChatViewHost['setAiChatInput'] = (aiChatInput) => // Called from the composer's mount effect, and taking what a turn handed back reads the // draft it writes: tracked, that would rerun the effect and hand it back again. @@ -578,6 +578,9 @@ export class FlowChatViewHost implements ChatViewHost, DraftSender<ComposerAttac this.#aiChatInput = aiChatInput this.#takeReturned() }) + releaseAiChatInput: ChatViewHost['releaseAiChatInput'] = (aiChatInput) => { + if (this.#aiChatInput === aiChatInput) this.#aiChatInput = null + } /** Whether a composer showing this conversation holds something not sent — text, an * attachment, a file still being read. Its panel stays mounted for as long as one does: diff --git a/frontend/src/lib/components/flows/earlyReturnCandidates.test.ts b/frontend/src/lib/components/flows/earlyReturnCandidates.test.ts new file mode 100644 index 0000000000..598d6fcd9b --- /dev/null +++ b/frontend/src/lib/components/flows/earlyReturnCandidates.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest' +import type { FlowModule } from '$lib/gen' +import { earlyReturnCandidates } from './earlyReturnCandidates' + +function identity(id: string): FlowModule { + return { id, value: { type: 'identity' } } +} + +describe('earlyReturnCandidates', () => { + it('offers every step reached through branches, but not the steps inside loops', () => { + const modules: FlowModule[] = [ + identity('a'), + { + id: 'b', + value: { + type: 'branchone', + default: [identity('c')], + branches: [ + { + expr: 'true', + modules: [ + identity('d'), + { + id: 'e', + value: { + type: 'branchone', + default: [], + branches: [{ expr: 'true', modules: [identity('f')] }] + } + }, + { + id: 'g', + value: { type: 'branchall', branches: [{ modules: [identity('h')] }] } + }, + { + id: 'i', + value: { + type: 'forloopflow', + iterator: { type: 'javascript', expr: '[]' }, + skip_failures: false, + modules: [identity('j')] + } + } + ] + } + ] + } + }, + { id: 'k', value: { type: 'branchall', branches: [{ modules: [identity('l')] }] } }, + { + id: 'm', + value: { + type: 'forloopflow', + iterator: { type: 'javascript', expr: '[]' }, + skip_failures: false, + modules: [identity('n')] + } + } + ] + + expect(earlyReturnCandidates(modules)).toEqual([ + { id: 'a', deterministic: true }, + { id: 'b', deterministic: true }, + { id: 'c', deterministic: false }, + { id: 'd', deterministic: false }, + { id: 'e', deterministic: false }, + { id: 'f', deterministic: false }, + { id: 'g', deterministic: false }, + { id: 'h', deterministic: false }, + { id: 'i', deterministic: false }, + { id: 'k', deterministic: true }, + { id: 'l', deterministic: true }, + { id: 'm', deterministic: true } + ]) + }) +}) diff --git a/frontend/src/lib/components/flows/earlyReturnCandidates.ts b/frontend/src/lib/components/flows/earlyReturnCandidates.ts new file mode 100644 index 0000000000..0d838c9d2a --- /dev/null +++ b/frontend/src/lib/components/flows/earlyReturnCandidates.ts @@ -0,0 +1,28 @@ +import type { FlowModule } from '$lib/gen' + +export type EarlyReturnCandidate = { + id: string + // False for a step inside a branch to one: it only runs when its branch is taken. + deterministic: boolean +} + +// A loop's inner steps run once per iteration, so only the loop itself is offered. +export function earlyReturnCandidates(modules: FlowModule[]): EarlyReturnCandidate[] { + return withinBranches(modules, true) +} + +function withinBranches(modules: FlowModule[], deterministic: boolean): EarlyReturnCandidate[] { + return modules.flatMap((module) => { + const value = module.value + const nested = + value.type === 'branchone' + ? withinBranches([value.default, ...value.branches.map((b) => b.modules)].flat(), false) + : value.type === 'branchall' + ? withinBranches( + value.branches.flatMap((b) => b.modules), + deterministic + ) + : [] + return [{ id: module.id, deterministic }, ...nested] + }) +} diff --git a/frontend/src/lib/components/flows/flowInfers.ts b/frontend/src/lib/components/flows/flowInfers.ts index f2beefa0de..38a9a02c2d 100644 --- a/frontend/src/lib/components/flows/flowInfers.ts +++ b/frontend/src/lib/components/flows/flowInfers.ts @@ -3,8 +3,8 @@ import { loadSchemaFlow } from '$lib/scripts' import type { Schema } from '$lib/common' import { emptySchema } from '$lib/utils' import type { FlowModule, InputTransform } from '$lib/gen' -import { AGENT_FLOW_LOCAL_KEYS } from './agentResourceUtils' -import { AGENT_HISTORY_KEYS } from './agentFormFields' +import { AGENT_LINKED_STEP_KEYS } from './agentResourceUtils' +import { AGENT_UNSEEDED_KEYS } from './agentFormFields' /** Display names for the memory field's options, so anything else naming the setting an author * picked cannot drift from the button they see. */ @@ -390,20 +390,17 @@ export async function loadSchemaFromModule( } } else if (mod.type === 'aiagent') { let input_transforms = migrateAiAgentInputTransforms(mod.input_transforms ?? {}) - // A linked step's brain lives in the resource, so only the flow-local inputs get a placeholder - // transform: filling the brain keys back in would re-add the very fields linking strips, and - // they would be persisted on the next save. - const keys = mod.agent - ? (AGENT_FLOW_LOCAL_KEYS as readonly string[]) - : Object.keys(AI_AGENT_SCHEMA.properties ?? {}) + // A linked step's brain lives in the resource, so only the inputs it holds itself are kept: + // filling the brain keys back in would re-add the very fields linking strips, and they would + // be persisted on the next save. + const keys = mod.agent ? AGENT_LINKED_STEP_KEYS : Object.keys(AI_AGENT_SCHEMA.properties ?? {}) return { input_transforms: keys.reduce((accu, key) => { const transform = input_transforms[key] ?? - // A present history input is the step's choice at runtime, so it gets no placeholder. - ((AGENT_HISTORY_KEYS as readonly string[]).includes(key) - ? undefined - : { type: 'static', value: undefined }) + // A present history input or override is the step's choice at runtime, so it gets no + // placeholder. + (AGENT_UNSEEDED_KEYS.includes(key) ? undefined : { type: 'static', value: undefined }) if (transform) accu[key] = transform return accu }, {}), diff --git a/frontend/src/lib/components/flows/linkedAgentDrafts.ts b/frontend/src/lib/components/flows/linkedAgentDrafts.ts index 9367b21b12..1a5b7ac7f1 100644 --- a/frontend/src/lib/components/flows/linkedAgentDrafts.ts +++ b/frontend/src/lib/components/flows/linkedAgentDrafts.ts @@ -3,7 +3,7 @@ import { type FlowModule, type FlowValue, type InputTransform, - type OperatorSettings, + type RoleCapabilities, type Resource } from '$lib/gen' import { UserDraft } from '$lib/userDraft.svelte' @@ -123,15 +123,15 @@ export interface LinkedAgentDraft { extraPerms: Record<string, boolean> } -/** Whether `user` may write this agent's resource, in a workspace with these `operator_settings`. +/** Whether `user`, holding a role with `caps`, may write this agent's resource. * Split from the load so that resolving the drafts of a whole flow costs no `whoami` — only the * deploy dialog needs the answer, and it looks the user up once for every agent it lists. */ export function agentDraftCanWrite( draft: LinkedAgentDraft, user: UserExt | undefined, - operatorSettings: OperatorSettings | undefined + caps: RoleCapabilities ): boolean { - return canEditItem('agent', draft.path, draft.extraPerms, user, operatorSettings) + return canEditItem('agent', draft.path, draft.extraPerms, user, caps) } /** A link that cannot resolve for the user rather than because something went wrong: the agent was diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte index 06c01dd75c..00b9172479 100644 --- a/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte +++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaItem.svelte @@ -35,9 +35,9 @@ import DiffActionBar from './DiffActionBar.svelte' import { getGraphContext } from '$lib/components/graph/graphContext' import MoveHandleButton from '$lib/components/graph/MoveHandleButton.svelte' - import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' + import { useRoleCapabilities } from '$lib/operatorWriteRights' - const operatorBuilderFlows = useOperatorBuilderFlows() + const roleCaps = useRoleCapabilities() interface Props { selected?: boolean @@ -136,7 +136,9 @@ ) // A script step is tested as a script preview, which operators are refused: running the // deployed runnable is left to "Test flow". - let builderCannotTestStep = $derived($operatorBuilderFlows && mod?.value.type === 'script') + let builderCannotTestStep = $derived( + $roleCaps.flows === 'compose' && mod?.value.type === 'script' + ) let moduleTest: ModuleTest | undefined = $state(undefined) let testIsLoading = $state(false) let hover = $state(false) diff --git a/frontend/src/lib/components/flows/map/FlowStickyNode.svelte b/frontend/src/lib/components/flows/map/FlowStickyNode.svelte index 9a33bd2ce5..1d5c1d2fe1 100644 --- a/frontend/src/lib/components/flows/map/FlowStickyNode.svelte +++ b/frontend/src/lib/components/flows/map/FlowStickyNode.svelte @@ -101,7 +101,7 @@ </Popover> {/if} {#if showFlowAiButton} - <OpenInSessionButton source={sessionOpen}> + <OpenInSessionButton source={sessionOpen} deferToHeader> {#snippet fallback()} <AIButton label="Open AI chat" diff --git a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte index 62ec111b6c..2b08653d28 100644 --- a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte +++ b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte @@ -12,14 +12,15 @@ import RefreshButton from '$lib/components/common/button/RefreshButton.svelte' import Button from '$lib/components/common/button/Button.svelte' import { ResourceService } from '$lib/gen' - import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' + import { useRoleCapabilities } from '$lib/operatorWriteRights' import type { FlowEditorContext } from '../types' import { logReusableAgentUsage } from '../agentTelemetry' import { BotIcon, Loader2, Plus } from 'lucide-svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() - const operatorBuilderFlows = useOperatorBuilderFlows() + const roleCaps = useRoleCapabilities() + const composeOnlyFlows = $derived($roleCaps.flows === 'compose') const dispatch = createEventDispatcher() interface Props { @@ -61,7 +62,7 @@ // Builders compose what the workspace already deployed: hub scripts bring in code nobody here // reviewed, and the backend refuses them, so never open on the hub for them. let preFilter: 'all' | 'workspace' | 'hub' = $state( - untrack(() => $operatorBuilderFlows) ? 'workspace' : 'all' + untrack(() => composeOnlyFlows) ? 'workspace' : 'all' ) let loading = $state(false) let small = $derived(smallProp ?? (kind === 'preprocessor' || kind === 'failure')) @@ -84,7 +85,7 @@ let savedAgentsWs: string | undefined = undefined async function loadSavedAgents() { // The backend refuses a linked agent in a builder's flow. - if (!ws || savedAgentsWs === ws || $operatorBuilderFlows) { + if (!ws || savedAgentsWs === ws || composeOnlyFlows) { return } savedAgentsLoading = true @@ -200,13 +201,13 @@ <StepGenQuick bind:this={stepGen} on:escape={() => dispatch('close')} - disableAi={disableAi || $operatorBuilderFlows} + disableAi={disableAi || composeOnlyFlows} on:insert bind:funcDesc {preFilter} {loading} /> - {#if selectedKind != 'preprocessor' && selectedKind != 'flow' && !$operatorBuilderFlows} + {#if selectedKind != 'preprocessor' && selectedKind != 'flow' && !composeOnlyFlows} <ToggleHubWorkspaceQuick bind:selected={preFilter} /> {/if} <RefreshButton @@ -349,7 +350,7 @@ }} /> {/if} - {#if customUi?.aiSandbox != false && !$operatorBuilderFlows} + {#if customUi?.aiSandbox != false && !composeOnlyFlows} <TopLevelNode label="AI Sandbox" selected={selectedKind === 'aisandbox'} @@ -385,7 +386,7 @@ <kbd class="!text-xs">↵</kbd> {/if} </Button> - {#if $operatorBuilderFlows} + {#if composeOnlyFlows} <!-- Linked agents are refused to builders, see `loadSavedAgents`. --> {:else if savedAgentsLoading} <div class="flex items-center gap-2 p-2 text-xs text-tertiary"> diff --git a/frontend/src/lib/components/flows/pickers/PickHubFlow.svelte b/frontend/src/lib/components/flows/pickers/PickHubFlow.svelte index 8c07ad1506..ff926c0c5d 100644 --- a/frontend/src/lib/components/flows/pickers/PickHubFlow.svelte +++ b/frontend/src/lib/components/flows/pickers/PickHubFlow.svelte @@ -60,7 +60,7 @@ {@render children?.()} <TextInput inputProps={{ - placeholder: 'Search Hub Flows' + placeholder: 'Search Hub flows' }} bind:value={filter} {size} diff --git a/frontend/src/lib/components/flows/pickers/PickHubScript.svelte b/frontend/src/lib/components/flows/pickers/PickHubScript.svelte index 5d3de9150b..5311339c3a 100644 --- a/frontend/src/lib/components/flows/pickers/PickHubScript.svelte +++ b/frontend/src/lib/components/flows/pickers/PickHubScript.svelte @@ -174,7 +174,7 @@ <div class="relative w-full"> <TextInput inputProps={{ - placeholder: 'Search Hub Scripts' + placeholder: 'Search Hub scripts' }} bind:value={filter} class="grow !pr-9" diff --git a/frontend/src/lib/components/flows/staleStepLocks.test.ts b/frontend/src/lib/components/flows/staleStepLocks.test.ts new file mode 100644 index 0000000000..ae12860391 --- /dev/null +++ b/frontend/src/lib/components/flows/staleStepLocks.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest' +import { dropStaleStepLocks } from './staleStepLocks' + +const step = (id: string, content: string, lock?: string) => + ({ id, value: { type: 'rawscript', language: 'python3', content, lock } }) as any + +describe('dropStaleStepLocks', () => { + // A rename or duplicate gives the step a new id; its code and lock still match. + it('keeps the lock of a renamed or duplicated step whose code is unchanged', () => { + const base = { modules: [step('a', 'import requests', 'requests==2.0')] } + const value = { + modules: [ + step('a', 'import requests', 'requests==2.0'), + step('a_copy', 'import requests', 'requests==2.0'), + step('a_edited_copy', 'import yaml', 'requests==2.0') + ] + } + dropStaleStepLocks(value as any, base as any) + expect(value.modules.map((m) => m.value.lock)).toEqual([ + 'requests==2.0', + 'requests==2.0', + undefined + ]) + }) +}) diff --git a/frontend/src/lib/components/flows/staleStepLocks.ts b/frontend/src/lib/components/flows/staleStepLocks.ts new file mode 100644 index 0000000000..2b9cacfb72 --- /dev/null +++ b/frontend/src/lib/components/flows/staleStepLocks.ts @@ -0,0 +1,33 @@ +import type { FlowModule, FlowValue } from '$lib/gen' +import { forEachFlowModule } from './dfs' + +/** + * Drops, in place, the lock of every inline step whose code differs from the same step in + * `base`. The worker reuses any non-empty step lock, so a step whose code changed but kept its + * lock runs against the old dependencies. Unchanged steps keep theirs, so a deploy does not + * re-resolve (and possibly upgrade) what nobody touched. A step id `base` lacks (a renamed or + * duplicated step) keeps its lock only when some `base` step has the same code. Without a base + * nothing is dropped: a copied or imported flow carries locks that match its code. + */ +export function dropStaleStepLocks(value: FlowValue, base: FlowValue | undefined): void { + if (!base) return + const allModules = (v: FlowValue) => + [...(v.modules ?? []), v.preprocessor_module, v.failure_module].filter( + (m): m is FlowModule => m != null + ) + const contentOf = (m: FlowModule) => (m.value as { content?: unknown }).content + const baseContentById = new Map<string, unknown>() + const baseContents = new Set<unknown>() + forEachFlowModule(allModules(base), (m) => { + baseContentById.set(m.id, contentOf(m)) + baseContents.add(contentOf(m)) + }) + forEachFlowModule(allModules(value), (m) => { + const step = m.value as { content?: unknown; lock?: unknown } + if (step.lock == undefined) return + const unchanged = baseContentById.has(m.id) + ? baseContentById.get(m.id) === step.content + : baseContents.has(step.content) + if (!unchanged) delete step.lock + }) +} diff --git a/frontend/src/lib/components/flows/utils.svelte.ts b/frontend/src/lib/components/flows/utils.svelte.ts index 3efb4b30f0..3cac53cfe4 100644 --- a/frontend/src/lib/components/flows/utils.svelte.ts +++ b/frontend/src/lib/components/flows/utils.svelte.ts @@ -207,7 +207,8 @@ export async function runFlowPreview( tempScriptRefs?: Record<string, string>, // The session's acting workspace when previewing inside an AI-session flow // editor; falls back to the navigation workspace for full-page previews. - workspace?: string + workspace?: string, + onBehalfOf?: string ) { const ws = workspace ?? get(workspaceStore) ?? '' const value = await withAgentDrafts(flow.value, ws) @@ -219,7 +220,8 @@ export async function runFlowPreview( path: path, tag: flow.tag, restarted_from: restartedFrom, - temp_script_refs: tempScriptRefs + temp_script_refs: tempScriptRefs, + on_behalf_of: onBehalfOf }, memoryId: conversationId }) diff --git a/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte index cf2a7339c6..17a5e635dd 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte @@ -210,10 +210,9 @@ import { deepEqual } from 'fast-equals' import { slide } from 'svelte/transition' import AssetColumnBadges from '$lib/components/assets/AssetColumnBadges.svelte' - import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte' + import { useRoleCapabilities } from '$lib/operatorWriteRights' - const operatingUser = useOperatingUser() - const actingUser = $derived(operatingUser.current) + const roleCaps = useRoleCapabilities() interface Props { data: AssetN['data'] @@ -282,7 +281,7 @@ Could not find resource {/snippet} </Tooltip> - {:else if isSelected && assetCanBeExplored(data.asset, cachedResourceMetadata) && !actingUser?.operator} + {:else if isSelected && assetCanBeExplored(data.asset, cachedResourceMetadata) && $roleCaps.code} <div transition:slide={{ axis: 'x', duration: 100 }}> <ExploreAssetButton btnClasses="rounded-none" diff --git a/frontend/src/lib/components/home/BulkActionsBar.svelte b/frontend/src/lib/components/home/BulkActionsBar.svelte index fffd18a3c0..1874008fc2 100644 --- a/frontend/src/lib/components/home/BulkActionsBar.svelte +++ b/frontend/src/lib/components/home/BulkActionsBar.svelte @@ -12,7 +12,19 @@ import { sendUserToast } from '$lib/toast' import { useReducedMotion } from '$lib/svelte5Utils.svelte' import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' - import { Archive, ArchiveRestore, FolderInput, MoreHorizontal, Trash, X } from 'lucide-svelte' + import { + Archive, + ArchiveRestore, + FolderInput, + MoreHorizontal, + Trash, + WandSparkles, + X + } from 'lucide-svelte' + import { + homeComposerContext, + itemContextElement + } from '$lib/components/sessions/homeComposerContext.svelte' import { fly } from 'svelte/transition' import { blockedReason, @@ -25,6 +37,7 @@ type BulkOutcome } from './bulkActions' import type { BulkItem, HomeSelection } from './homeSelection.svelte' + import { roleCapabilities } from '$lib/stores' interface Props { selection: HomeSelection @@ -38,7 +51,7 @@ let { selection, workspace, isAdmin, moveTargets, onDone }: Props = $props() - let ctx: BulkContext = $derived({ workspace, isAdmin }) + let ctx: BulkContext = $derived({ workspace, isAdmin, caps: $roleCapabilities }) // The bar is still on screen while it flies out, and unticking the last row is // what starts that — by then the live selection is empty. Rendering the last // non-empty one keeps the count and the action states from blinking on the way @@ -49,6 +62,17 @@ }) let items = $derived(selection.items.length > 0 ? selection.items : lastNonEmpty) + // What of the selection the composer can take as context; the rest has no context element. + let askAiElements = $derived( + items.flatMap((i) => itemContextElement({ ...i, summary: i.summary || undefined }) ?? []) + ) + // The selection was the way to gather them; once they are in the composer it is done. + async function askAi() { + const elements = askAiElements + selection.exit() + for (const element of elements) await homeComposerContext.attach(element) + } + let reducedMotion = useReducedMotion() const plural = (n: number) => (n === 1 ? '' : 's') @@ -184,6 +208,20 @@ {items.length} selected </span> <div class="h-4 border-l"></div> + {#if homeComposerContext.available && askAiElements.length > 0} + <Button + variant="subtle" + unifiedSize="sm" + btnClasses="!text-ai" + startIcon={{ icon: WandSparkles }} + title={askAiElements.length < items.length + ? `Add the ${askAiElements.length} scripts, flows and code apps of the selection to the chat above` + : 'Add the selection to the chat above'} + on:click={askAi} + > + Ask AI{askAiElements.length < items.length ? ` (${askAiElements.length})` : ''} + </Button> + {/if} <Button variant="subtle" unifiedSize="sm" diff --git a/frontend/src/lib/components/home/CreateActionsMenu.svelte b/frontend/src/lib/components/home/CreateActionsMenu.svelte index 2e22b0765c..72b75b82e1 100644 --- a/frontend/src/lib/components/home/CreateActionsMenu.svelte +++ b/frontend/src/lib/components/home/CreateActionsMenu.svelte @@ -27,6 +27,7 @@ import { importStore } from '$lib/components/apps/store' import { conditionalMelt, getLocalSetting, storeLocalSetting } from '$lib/utils' import { useEditRights } from '$lib/operatorWriteRights' + import type { EditKind } from '$lib/editRights' import { createDropdownMenu, melt } from '@melt-ui/svelte' import YAML from 'yaml' import type { Snippet } from 'svelte' @@ -313,11 +314,15 @@ } } - // Only the kinds this user's role authors: a builder operator gets the kinds its rights grant. + // The edit kind of an option whose key is not one: a low-code app needs `full` apps, a + // full-code one `compose`. Every other key is itself a kind (`wac` falls to `code`). + const EDIT_KIND: Record<string, EditKind> = { 'app-fullcode': 'raw_app', 'app-lowcode': 'app' } + + // Only the kinds this user's role authors. // Derived, not computed once: switching workspace only sets `workspaceStore`, it does not // remount this component, so a snapshot would keep the previous workspace's kinds. const options: Option[] = $derived( - allOptions.filter((o) => editRights.roleCanAuthor(o.key === 'app-fullcode' ? 'raw_app' : o.key)) + allOptions.filter((o) => editRights.roleCanAuthor(EDIT_KIND[o.key] ?? o.key)) ) // the doc panel only shows while an option is hovered or focused, so the menu opens compact diff --git a/frontend/src/lib/components/home/HomeAIChat.svelte b/frontend/src/lib/components/home/HomeAIChat.svelte index 83e940602d..0aad0adb1a 100644 --- a/frontend/src/lib/components/home/HomeAIChat.svelte +++ b/frontend/src/lib/components/home/HomeAIChat.svelte @@ -1,72 +1,40 @@ <script lang="ts" module> - /** Example prompts: the short `label` is shown as a clickable tag under the chat, - * the `prompt` is what rotates through the placeholder / gets dropped into the input. */ - export const homeAIExamples: { label: string; prompt: string }[] = [ - { - label: 'Sync Salesforce', - prompt: 'Sync new Salesforce leads into a postgres table every hour' - }, - { - 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' - }, - { - label: 'Weekly Slack report', - prompt: 'Generate a weekly sales report from postgres and post it to Slack every Monday' - } - ] + import type HomeSessionStarterComponent from '../sessions/HomeSessionStarter.svelte' - /** An operator's session can run what is deployed, read runs and call MCP servers, but - * cannot author scripts or apps. It composes flows only where the workspace grants - * `operator_settings.builder_flows`, so none of these asks it to build anything. */ - export const homeAIOperatorExamples: { label: string; prompt: string }[] = [ - { - label: 'Run a flow', - prompt: 'Find the flow that onboards a new employee and run it for Jane Doe, starting Monday' - }, - { - label: 'Rerun failed jobs', - prompt: 'List the jobs that failed today, tell me why, and rerun the ones that timed out' - }, - { - label: 'Summarize GitHub issues', - prompt: "Read this week's open GitHub issues and summarize the ones that mention billing" - } - ] + // The starter is a full AIChat, which reaches monaco, and the build refuses monaco in Home's + // static imports. Kept once loaded, so a return to Home renders it in its first frame: the + // AI Sessions → Home view transition snapshots that frame. + let loadedStarter: typeof HomeSessionStarterComponent | undefined + function loadStarter() { + return import('../sessions/HomeSessionStarter.svelte').then((m) => (loadedStarter = m.default)) + } </script> <script lang="ts"> - import TextInput from '$lib/components/text_input/TextInput.svelte' - import { - ArrowUp, - ExternalLink, - Globe2, - KeyRound, - PlugZap, - Settings, - WandSparkles - } from 'lucide-svelte' + import { ExternalLink, Globe2, PinOff, PlugZap } from 'lucide-svelte' import Button from '../common/button/Button.svelte' - import { Badge } from '../common' import CloseButton from '../common/CloseButton.svelte' - import { startSessionWithPrompt } from '../sessions/sessionSwitch.svelte' - import { copilotInfo, copilotWorkspace } from '$lib/aiStore' - import { loadCopilot } from '$lib/components/copilot/loadCopilot' - import { aiUserDisabled, hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores' + import { logFeatureUsage } from '$lib/utils/featureUsage' + import { hubBaseUrlStore, roleCapabilities, workspaceStore } from '$lib/stores' + import { sessionState } from '../sessions/sessionState.svelte' + import { currentWorkspaceRootId } from '../sessions/sessionScope.svelte' import { HOME_SHOW_HUB } from '$lib/consts' import { getLocalSetting, storeLocalSetting } from '$lib/utils' - import { isRuleActive } from '$lib/workspaceProtectionRules.svelte' + import { isRuleActive, protectionRulesState } from '$lib/workspaceProtectionRules.svelte' import { useReducedMotion } from '$lib/svelte5Utils.svelte' import { BROWSER } from 'esm-env' - import AIChatModelSettings from '../copilot/chat/AIChatModelSettings.svelte' import HomeConnectDrawer from './HomeConnectDrawer.svelte' - import { USER_SETTINGS_HASH } from '../sidebar/settings' + import HomeComposerSkeleton, { + composerHeight, + rememberComposerHeight + } from './HomeComposerSkeleton.svelte' import { prefersSessionHandoff } from '../copilot/chat/global/gate' import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' import DropdownV2 from '$lib/components/DropdownV2.svelte' import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' - import { onboardingProfile } from '$lib/onboardingProfile' + import { prefetchItemCount } from '../copilot/chat/starter/starterPrompts.svelte' + import { useWorkspaceAiHidden } from '../copilot/workspaceAiHidden.svelte' + import { homeQuickAccessSettings } from './homeQuickAccessSettings.svelte' const COLLAPSED_SETTING = 'home-ai-composer-collapsed' @@ -75,79 +43,42 @@ * labelled buttons without the trail starting to truncate. Below it they fold back. */ const BAR_FITS_SIDE_TRIPS = 1024 const sideTripsInBar = $derived(pageHeader.barWidth >= BAR_FITS_SIDE_TRIPS) - const hubOffered = $derived(!$userStore?.operator && HOME_SHOW_HUB) + const hubOffered = $derived($roleCapabilities.code && HOME_SHOW_HUB) - let value = $state('') - // The stock examples for the reader's role, unless the invite that brought this person here wrote prompts for - // them — those replace the set outright rather than joining it, since a prompt written - // for someone's own stack next to "Ban Discord users" reads as the generic one. - let invitePrompts: { label: string; prompt: string }[] | undefined = $state(undefined) - void onboardingProfile().then((p) => { - if (!p?.starter_prompts?.length) return - invitePrompts = p.starter_prompts - promptIndex = 0 - }) - let examples = $derived( - invitePrompts ?? ($userStore?.operator ? homeAIOperatorExamples : homeAIExamples) - ) - let placeholderVisible = $state(true) let homeConnectDrawer: HomeConnectDrawer | undefined = $state(undefined) - // How much of the home page this reader wants the composer to take, so it lives per browser - // rather than per workspace or account. - let collapsed = $state(BROWSER && getLocalSetting(COLLAPSED_SETTING) === 'true') - function setCollapsed(next: boolean) { - collapsed = next + // Whether this reader wants the composer on the home page at all, so it lives per browser + // rather than per workspace or account. It has nothing to do with the composer's one-line + // state despite the key's name; renaming the key would reset every reader's choice. + let removed = $state(BROWSER && getLocalSetting(COLLAPSED_SETTING) === 'true') + function setRemoved(next: boolean) { + removed = next storeLocalSetting(COLLAPSED_SETTING, next ? 'true' : undefined) } // Removing puts the composer away rather than throwing it out, and the way back is one item in // the band's menu — so the ellipsis flashes as it appears, in a corner nobody is watching while - // the hero collapses in the middle of the page. + // the composer leaves the page. // - // A keyframe animation, not a transition: this mounts in the same flush that collapses the - // hero, so the frame that would paint it at rest is the frame it is already told to leave, and + // A keyframe animation, not a transition: this mounts in the same flush that removes the + // composer, so the frame that would paint it at rest is the frame it is already told to leave, and // one `requestAnimationFrame` is not reliably past that paint. The flash then goes straight to // invisible without ever being drawn. Mounted only while pulsing, so each removal replays it. const PULSE_MS = 1000 + const reducedMotion = useReducedMotion() let pulsing = $state(false) let pulseEndTimer: ReturnType<typeof setTimeout> | undefined function removeComposer() { - setCollapsed(true) + // The `close` key keeps these events in the series they have always been counted in. + logFeatureUsage('home', 'ai_hero_remove', { key: 'close' }) + setRemoved(true) if (reducedMotion.val) return clearTimeout(pulseEndTimer) pulsing = true pulseEndTimer = setTimeout(() => (pulsing = false), PULSE_MS) } - // In global-AI mode the layout's chat panel is disabled and never loads the copilot - // config, so the home chat loads it for the current workspace itself. - $effect(() => { - if ($workspaceStore) { - loadCopilot($workspaceStore) - } - }) - - // Whether the copilot config has actually loaded for the current workspace. - let configLoaded = $derived($copilotWorkspace === $workspaceStore) - // No usable model (no provider configured, or AI disabled): the input is blurred and an overlay - // explains why and links to the fix. Gate on `configLoaded` so the initial (unloaded) state - // doesn't flash the overlay while a provider is in fact configured. - let disabled = $derived(configLoaded && !$copilotInfo.enabled) - // Submission is stricter than the overlay: block it until the config is loaded AND - // enabled. Submitting during the unknown-config window hands the prompt to a session - // that only sends once `copilotInfo.enabled` flips true — on an unconfigured/disabled - // workspace that never happens and the prompt is silently lost. - let canSend = $derived(configLoaded && $copilotInfo.enabled) - - // The input alone: what the overlay covers and the one part a missing provider makes unusable. - let blurClass = $derived(disabled ? 'blur-sm pointer-events-none select-none' : '') - - // Disabled because the user spent their free Windmill AI grant, not because AI was never - // set up — the two look identical otherwise, and the "configure AI" copy would be a lie. - let freeTierExhausted = $derived($copilotInfo.freeTier?.exhausted === true) - // 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 @@ -155,202 +86,73 @@ // unlocked, so the far commoner unlocked workspace never pops the composer in mid-load. let runOnlyWorkspace = $derived(isRuleActive('DisableDirectDeployment')) - // The composer hands off to /sessions, which refuses operators and users opted out of the - // sessions beta — so hide it from them (the prompt would be silently dropped) while the + // 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) + + // 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 + // AI would otherwise take the whole block, and the picker that could undo it, off Home. + const homeAiHidden = useWorkspaceAiHidden(() => $workspaceStore ?? undefined) + + // The composer hands off to /sessions, which refuses roles that cannot code and users opted out + // 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($userStore?.operator) && - !runOnlyWorkspace && - !$copilotInfo.workspaceDisabled + prefersSessionHandoff(!$roleCapabilities.code) && !runOnlyWorkspace && !homeAiHidden.hidden ) - // The hero's margins and centered column are for the full block. The lone button row left - // by a collapsed, opted-out, run-only or hidden-assistant view is a hint line and should - // cost the page almost nothing: no top margin, and the content column's full width so it - // hugs the right edge instead of floating centered in empty space. - let hero = $derived(showComposer && !collapsed) - let outerSpacing = $derived(hero ? 'mt-20 mb-16' : 'mt-0 mb-1') - - let starting = $state(false) - async function start() { - if (!canSend || starting || !value.trim()) return - starting = true - try { - await startSessionWithPrompt(value, { autoSend: true }) - } finally { - starting = false - } - } - - // Enter starts the session; Shift+Enter keeps inserting a newline. - function onKeydown(e: KeyboardEvent) { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault() - start() - } - } - - let prompts = $derived(examples.map((e) => e.prompt)) - - const CYCLE_MS = 7_000 - // Must match the `duration-*` class on the placeholder overlay: the swap happens once the - // fade-out has finished, and Tailwind only emits classes it finds written out in full. - const FADE_MS = 600 - - // Rotate the example prompt every CYCLE_MS: fade the placeholder out, swap it, fade it back in. - // Only while the composer is shown — otherwise it would loop forever driving an - // unrendered input — and not under reduced motion, where the first prompt simply stays put. - // The index lives outside the effect so re-showing the composer resumes the rotation from the - // prompt currently displayed rather than restarting it. - const reducedMotion = useReducedMotion() - let promptIndex = $state(0) - let placeholder = $derived(prompts[promptIndex % prompts.length]) - $effect(() => { - if (!showComposer || collapsed || reducedMotion.val) return - let timer: ReturnType<typeof setTimeout> - - function next() { - placeholderVisible = false - timer = setTimeout(() => { - promptIndex = (promptIndex + 1) % prompts.length - placeholderVisible = true - timer = setTimeout(next, CYCLE_MS) - }, FADE_MS) - } - - timer = setTimeout(next, CYCLE_MS) - return () => { - clearTimeout(timer) - // Torn down mid-fade, the input would otherwise remount with an invisible placeholder. - placeholderVisible = true - } - }) + let hero = $derived(showComposer && !removed) </script> -<div class="w-full flex justify-center {outerSpacing}"> - <div class="{hero ? 'max-w-[40rem]' : ''} grow relative group"> - {#if showComposer && !collapsed} - {#if !disabled} - <!-- The one dismiss control while the composer is usable; the overlay below carries its - own once it takes over, so the two never show at the same time. --> - <div class="absolute right-0 top-0 z-20"> - <CloseButton small noBg title="Remove session chat" onClick={removeComposer} /> - </div> - {/if} - <div class="flex items-center justify-center gap-2 mb-4"> - <p class="text-center font-regular text-3xl"> - {$userStore?.operator ? 'Run with AI' : 'Build with AI'} - </p> - <Badge color="blue" small>Beta</Badge> +<div class="w-full {hero ? 'mt-2' : 'mb-1'}"> + <div class="relative group/composer"> + {#if hero} + <!-- In the page's side padding, clear of the composer's own controls at its right end, + and centred on the composer's first row: the one-line box's middle, and where that + row stays once the box opens downward. Shown on hover; the container starts at the + composer's edge so the pointer reaches the unpin button without leaving the hovered + zone. Below `sm` there is no side padding to hold it, so unpinning is in the band's + menu instead. --> + <div + class="hidden sm:block absolute top-4 left-full pl-1 z-20 transition-opacity opacity-0 group-hover/composer:opacity-100 focus-within:opacity-100" + > + <CloseButton small noBg Icon={PinOff} title="Unpin from Home" onClick={removeComposer} /> </div> - <!-- Anchors the send button / model settings to the input, not to the whole block — the row - below would otherwise push them down. The inner wrapper stays `relative` in both - states: `blur-sm` is a filter, which makes an element the containing block for its - absolutely positioned children, so those two would shift when the blur turns on. --> - <div class="relative"> - <div class="relative {blurClass}" inert={disabled}> - <TextInput - bind:value - class="resize-none px-4 py-3 pb-9 shadow-sm border-accent" - underlyingInputEl="textarea" - inputProps={{ - rows: 4, - 'aria-label': $userStore?.operator - ? 'Describe what you want to do' - : 'Describe what you want to build', - onkeydown: onKeydown - }} + <!-- 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)} + {#key $currentWorkspaceRootId} + <HomeSessionStarter + onShown={() => (shownForRoot = $currentWorkspaceRootId)} + {placeholder} + onSettled={(height) => rememberComposerHeight($workspaceStore ?? undefined, height)} /> - {#if !value} - <!-- Drawn over the textarea instead of set as its `placeholder`: WebKit and Gecko do - not run transitions on `::placeholder`, so the fade would be a hard cut there. - The 1px margin is the textarea's border, so the text sits where typing starts. --> - <span - aria-hidden="true" - class="pointer-events-none absolute inset-x-4 top-3 m-px text-xs text-hint transition-opacity duration-[600ms] ease-in-out {placeholderVisible - ? 'opacity-100' - : 'opacity-0'}" - > - {placeholder} - </span> - {/if} - <Button - endIcon={starting ? {} : { icon: ArrowUp }} - wrapperClasses="absolute right-2 bottom-3.5" - variant={value.trim() ? 'accent' : 'subtle'} - iconOnly - loading={starting} - disabled={!value.trim() || starting || !canSend} - onclick={start} - ></Button> - <div class="absolute left-3 bottom-4 flex items-center gap-1.5 px-0.5"> - <AIChatModelSettings /> - </div> - </div> - {#if disabled} - <!-- Covers the input alone: the title, the example prompts and the CLI/MCP row are all - still legible and usable without a provider. Static, not hover-gated, so keyboard - and touch users see it too. --> - <div - class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-md bg-surface/70" - > - <p class="text-sm text-secondary"> - {#if $aiUserDisabled} - Windmill AI is disabled in your account settings - {:else if freeTierExhausted} - You have used all of your free Windmill AI tokens - {:else} - No AI provider is configured - {/if} - </p> - <div class="flex items-center gap-2"> - {#if $aiUserDisabled} - <!-- The fix lives in account settings (a hash-opened drawer, not a route), so link - the hash the sidebar's Account menu uses rather than the workspace AI settings. --> - <Button - unifiedSize="sm" - variant="accent" - startIcon={{ icon: Settings }} - href={USER_SETTINGS_HASH} - > - Open account settings - </Button> - {:else} - <Button - unifiedSize="sm" - variant="accent" - startIcon={{ icon: freeTierExhausted ? KeyRound : Settings }} - href="/workspace_settings?tab=ai" - > - {freeTierExhausted ? 'Add your own API key' : 'Configure AI'} - </Button> - {/if} - <Button unifiedSize="sm" variant="default" onClick={removeComposer}>Remove</Button> - </div> - </div> - {/if} - </div> - {/if} - - <div class="flex items-center justify-between gap-2 pt-2"> - {#if showComposer && !collapsed} - <div class="flex flex-row flex-wrap items-center gap-1.5"> - {#each examples as example (example.label)} - <Button - variant="default" - unifiedSize="xs" - btnClasses="!rounded-full !text-2xs !text-hint" - onClick={() => (value = example.prompt)} - > - {example.label} - </Button> - {/each} - </div> + {/key} + {:else} + {@render placeholder()} {/if} - </div> + {/if} </div> </div> +{#snippet placeholder()} + <HomeComposerSkeleton height={composerHeight($workspaceStore ?? undefined)} /> +{/snippet} + <HomeConnectDrawer bind:this={homeConnectDrawer} /> <!-- Everything the hero offered beside the composer lives in the band's menu: the connect helper, @@ -396,9 +198,9 @@ <!-- Nothing left to hold once the side trips stand in the bar and the composer is on the page: an ellipsis that opens an empty menu is a button that does nothing. --> <!-- No menu while the hero is on the page and its side trips are in the bar: the hero's own - cross removes it, so an ellipsis holding a second way to do that is a button whose only + unpin button removes it, so an ellipsis holding a second way to do that is a button whose only item is a duplicate. --> - {#if !sideTripsInBar || (showComposer && collapsed)} + {#if !sideTripsInBar || (showComposer && removed) || homeQuickAccessSettings.hidden} <!-- The flash rides the ellipsis itself, not the row: with the side trips beside it the row is three controls wide, and marking all three names none of them. --> <span class="relative inline-flex"> @@ -411,16 +213,40 @@ class="pointer-events-none absolute -inset-1 rounded-lg bg-blue-500/30 opacity-0 animate-fade-out" ></span> {/if} + <!-- The same flash for hiding the favorites & recent section. It rests at opacity 0, so + it can stay mounted; the key replays it on each hide. --> + {#if homeQuickAccessSettings.hideCount > 0 && !reducedMotion.val} + {#key homeQuickAccessSettings.hideCount} + <span + aria-hidden="true" + class="pointer-events-none absolute -inset-1 rounded-lg bg-blue-500/30 opacity-0 animate-fade-out" + ></span> + {/key} + {/if} <DropdownV2 placement="bottom-end" size="sm" + customWidth={240} items={[ - ...(showComposer && collapsed + // Every phone gets the menu, and there the composer has no room for its own unpin + // button. The menu can also hold the section's pin, so the label names the session. + ...(hero ? [{ displayName: 'Unpin session from Home', action: removeComposer }] : []), + ...(showComposer && removed ? [ { displayName: 'Pin session hero to homepage', - icon: WandSparkles, - action: () => setCollapsed(false) + action: () => { + logFeatureUsage('home', 'ai_hero_pin') + setRemoved(false) + } + } + ] + : []), + ...(homeQuickAccessSettings.hidden + ? [ + { + displayName: 'Pin favorites & recent to homepage', + action: () => homeQuickAccessSettings.setHidden(false) } ] : []), @@ -429,14 +255,12 @@ : [ { displayName: 'CLI / MCP', - icon: PlugZap, action: () => homeConnectDrawer?.openDrawer?.() }, ...(hubOffered ? [ { displayName: 'Hub', - icon: Globe2, href: $hubBaseUrlStore, hrefTarget: '_blank' as const } diff --git a/frontend/src/lib/components/home/HomeComposerSkeleton.svelte b/frontend/src/lib/components/home/HomeComposerSkeleton.svelte new file mode 100644 index 0000000000..cd81077342 --- /dev/null +++ b/frontend/src/lib/components/home/HomeComposerSkeleton.svelte @@ -0,0 +1,108 @@ +<script lang="ts" module> + import { getLocalSetting, storeLocalSetting } from '$lib/utils' + + const HEIGHT_SETTING = 'home-composer-height:' + // The box's 4px top margin and the 8px under the block. + const BLOCK_MARGINS = 12 + // The suggestion chips' row under an open box. A disabled composer has none, and its box, + // the shortest open one, is 78px. + const SUGGESTIONS_ROW = 36 + const OPEN_BOX_MIN = 78 + // The one-line box is 48px in Chromium but taller in Firefox, so the shape is told apart by + // the shortest open block: anything below it is one line, whatever the browser. + const OPEN_MIN_HEIGHT = OPEN_BOX_MIN + BLOCK_MARGINS + + /** The height Home's composer took when it last appeared in this workspace. */ + export function composerHeight(workspace: string | undefined): number | undefined { + const stored = workspace ? Number(getLocalSetting(HEIGHT_SETTING + workspace)) : NaN + return Number.isFinite(stored) && stored > 0 ? stored : undefined + } + + export function rememberComposerHeight(workspace: string | undefined, height: number) { + if (workspace && height > 0) storeLocalSetting(HEIGHT_SETTING + workspace, String(height)) + } +</script> + +<script lang="ts"> + import { ArrowUp, MessagesSquare } from 'lucide-svelte' + import { twMerge } from 'tailwind-merge' + import Button from '../common/button/Button.svelte' + import { + composerBoxClass, + HERO_BOX_CLASS, + STARTER_PLACEHOLDER + } from '../copilot/chat/composerBox' + + // Stands in for the composer while its code and config load, drawn as the composer it + // replaces: the same box, mark and placeholder, in the shape it last took in this workspace. + // Without a remembered height it is the one-line shape, which is what a workspace past a + // handful of items rests in. + let { height }: { height?: number } = $props() + + const open = $derived(height !== undefined && height >= OPEN_MIN_HEIGHT) + const suggestions = $derived(open && height! - BLOCK_MARGINS - SUGGESTIONS_ROW >= OPEN_BOX_MIN) + const boxClass = twMerge(composerBoxClass(), HERO_BOX_CLASS) +</script> + +{#snippet bar(width: string)} + <span class="animate-skeleton dark:bg-frost-900/50 rounded h-3 {width}"></span> +{/snippet} + +{#snippet sendButton()} + <Button variant="subtle" unifiedSize="sm" iconOnly disabled startIcon={{ icon: ArrowUp }} /> +{/snippet} + +<div + class="w-full pb-2 flex flex-col" + style:height={height !== undefined ? `${height}px` : undefined} + aria-busy="true" + aria-label="Loading the AI composer" +> + <!-- The placeholder's `-px` offsets stand for the real field's transparent 1px border. --> + {#if open} + <div class="mt-1 flex-1 min-h-0 flex flex-col {boxClass}"> + <div class="flex items-start"> + <span class="flex items-center h-5 mt-[13px] ml-4 shrink-0"> + <MessagesSquare size={16} class="text-ai" /> + </span> + <span class="mt-px ml-px pl-2 pr-4 pt-3 text-xs leading-5 text-gray-500 truncate"> + {STARTER_PLACEHOLDER} + </span> + </div> + <div class="mt-auto flex flex-row items-center gap-1.5 px-3 pb-1.5"> + <div class="flex-1 min-w-0 flex flex-row items-center gap-3"> + {@render bar('w-24')} + {@render bar('w-20')} + </div> + {@render bar('w-12')} + {@render sendButton()} + </div> + </div> + {#if suggestions} + <div class="pt-2 flex flex-row items-center gap-1.5 shrink-0"> + {#each ['w-20', 'w-24', 'w-24'] as width, i (i)} + <span class="h-7 px-3 flex items-center rounded-full border border-border-light"> + {@render bar(width)} + </span> + {/each} + </div> + {/if} + {:else} + <div + class="mt-1 {height !== undefined + ? 'flex-1 min-h-0' + : 'h-12'} flex flex-row items-center {boxClass}" + > + <span class="flex items-center ml-4 shrink-0"> + <MessagesSquare size={16} class="text-ai" /> + </span> + <span class="flex-1 min-w-0 ml-px pl-2 pr-2 text-xs leading-5 text-gray-500 truncate"> + {STARTER_PLACEHOLDER} + </span> + <div class="flex flex-row items-center gap-1 pr-3 shrink-0"> + {@render bar('w-12')} + {@render sendButton()} + </div> + </div> + {/if} +</div> diff --git a/frontend/src/lib/components/home/HomeConnectDrawer.svelte b/frontend/src/lib/components/home/HomeConnectDrawer.svelte index 2b86025549..ff451b1da4 100644 --- a/frontend/src/lib/components/home/HomeConnectDrawer.svelte +++ b/frontend/src/lib/components/home/HomeConnectDrawer.svelte @@ -5,13 +5,11 @@ import CopyableCodeBlock from '$lib/components/details/CopyableCodeBlock.svelte' import { Bot, ExternalLink, Terminal } from 'lucide-svelte' import { shell } from 'svelte-highlight/languages' - import { mcpTokenUrlDisabled } from '$lib/mcpAuth' import { apiBaseUrl } from '$lib/apiBaseUrl.svelte' type ConnectTab = 'cli' | 'mcp' let drawer: Drawer | undefined = $state() - let tokenUrlDisabled = $state(false) let selectedTab: ConnectTab = $state('cli') let openVersion = $state(0) @@ -27,10 +25,6 @@ wmill sync pull`) export function openDrawer(tab: ConnectTab = 'cli') { selectedTab = tab openVersion += 1 - // Falls back like CreateToken below, which shows the bare URL when the read fails. - void mcpTokenUrlDisabled() - .then((v) => (tokenUrlDisabled = v)) - .catch(() => (tokenUrlDisabled = true)) drawer?.openDrawer() } @@ -105,15 +99,12 @@ wmill sync pull`) <div class="flex flex-col gap-4"> <div class="flex items-start justify-between gap-3 flex-wrap"> <div class="flex flex-col gap-1"> - <h3 class="text-sm font-semibold text-emphasis">MCP URL</h3> + <h3 class="text-sm font-semibold text-emphasis" + >Build and run from your AI client</h3 + > <p class="text-xs text-secondary max-w-xl"> - {#if tokenUrlDisabled} - The MCP server URL for the current workspace. Your client signs in to - Windmill to use it. - {:else} - Generate an MCP server URL for the current workspace and choose which - scripts, flows, and endpoints the client can access. - {/if} + Connect Claude, ChatGPT or Cursor to Windmill: build scripts, flows and + apps, run your automations and check their runs, from your chat. </p> </div> diff --git a/frontend/src/lib/components/home/HomeQuickAccess.svelte b/frontend/src/lib/components/home/HomeQuickAccess.svelte new file mode 100644 index 0000000000..b81f848838 --- /dev/null +++ b/frontend/src/lib/components/home/HomeQuickAccess.svelte @@ -0,0 +1,481 @@ +<script lang="ts"> + import { + AtSign, + Check, + ChevronLeft, + ChevronRight, + Copy, + Eye, + EyeOff, + GitFork, + LayoutGrid, + List, + Pen, + Star as StarIcon, + StarOff, + X + } from 'lucide-svelte' + import { Button } from '$lib/components/common' + import DropdownV2 from '$lib/components/DropdownV2.svelte' + import { useEditRights } from '$lib/operatorWriteRights' + import { roleCapabilities } from '$lib/stores' + import { AppService } from '$lib/gen' + import { copyToClipboard, type Item } from '$lib/utils' + import RowIcon from '$lib/components/common/table/RowIcon.svelte' + import Star from '$lib/components/Star.svelte' + import TimeAgo from '$lib/components/TimeAgo.svelte' + import { + favoriteManager, + getFavoriteHref, + type FavoriteKind + } from '$lib/components/sidebar/FavoriteMenu.svelte' + import { MAX_AGE_DAYS, recentItems, type RecentKind, type RecentVia } from './recentItems.svelte' + import { useReducedMotion } from '$lib/svelte5Utils.svelte' + import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate' + import { + homeComposerContext, + itemContextElement + } from '$lib/components/sessions/homeComposerContext.svelte' + import HomeSectionLabel from './HomeSectionLabel.svelte' + import HomeSectionCount from './HomeSectionCount.svelte' + import { QUICK_ACCESS_ROWS, homeQuickAccessSettings } from './homeQuickAccessSettings.svelte' + + interface Props { + workspace: string + /** Cleared where direct-deploy protection hides the rows' edit actions. */ + showEditButtons?: boolean + /** For the items card's heading, which shows only under this section: alone on the + * page, a heading has nothing to tell apart. */ + itemCount?: number + } + + let { workspace, showEditButtons = true, itemCount }: Props = $props() + + type Entry = { + kind: RecentKind + /** The kind its favorite is stored under: every app, raw ones included, stars as `app` + * (AppRow, and the app listing's starred join), so a raw app's must not be `raw_app`. */ + starKind: FavoriteKind + path: string + title: string + at?: number + via?: RecentVia + } + + function asStarKind(kind: RecentKind): FavoriteKind { + return kind === 'raw_app' ? 'app' : kind + } + + const RECENT_COUNT = 6 + + // The way back from Hide from Home is in the header menu HomeAIChat adds, which Home mounts + // only with the AI sessions beta on. Without it the section can't be hidden, and one hidden + // earlier shows again. + const restoreMenuOnPage = isGlobalAiEnabled() + let hidden = $derived(restoreMenuOnPage && homeQuickAccessSettings.hidden) + + // Turning off the last list shown would leave an empty section; Hide from Home does that. + const sectionMenu = (): Item[] => [ + { + displayName: 'Show favorites', + description: 'Items you starred', + toggle: homeQuickAccessSettings.showFavorites, + disabled: homeQuickAccessSettings.showFavorites && !homeQuickAccessSettings.showRecent, + action: () => homeQuickAccessSettings.setShowFavorites(!homeQuickAccessSettings.showFavorites) + }, + { + displayName: 'Show recent', + description: `Scripts, flows and apps you opened or edited in the last ${MAX_AGE_DAYS} days, in this browser`, + toggle: homeQuickAccessSettings.showRecent, + disabled: homeQuickAccessSettings.showRecent && !homeQuickAccessSettings.showFavorites, + action: () => homeQuickAccessSettings.setShowRecent(!homeQuickAccessSettings.showRecent) + }, + { + displayName: 'Grid size', + separatorTop: true, + icon: LayoutGrid, + submenuItems: QUICK_ACCESS_ROWS.map((rows) => ({ + displayName: rows === 1 ? '1 row' : `${rows} rows`, + icon: homeQuickAccessSettings.rows === rows ? Check : undefined, + action: () => homeQuickAccessSettings.setRows(rows) + })) + }, + { + displayName: 'Hide from Home', + icon: EyeOff, + hide: !restoreMenuOnPage, + action: () => homeQuickAccessSettings.setHidden(true) + } + ] + + // Only the role is checked: the cards don't carry the item's own permissions, and an + // editor opened without write access says so itself. + const editRights = useEditRights() + + // A favorite app arrives as plain `app` (the layout lists apps without their subtype), and + // `/apps/edit` mounts the low-code editor whatever it opens, so the editor routes ask which + // one it is, once, when its menu opens. + async function editorKind(item: Entry): Promise<RecentKind> { + if (item.kind !== 'app') return item.kind + try { + const app: any = await AppService.getAppLiteByPath({ workspace, path: item.path }) + return app?.raw_app ? 'raw_app' : 'app' + } catch { + return 'app' + } + } + + // The row menus' actions that need nothing but the kind and path. Moving, deleting, + // deploying and sharing need the full item and the row's own drawers, so they stay there. + async function menuItems(item: Entry, favorite: boolean): Promise<Item[]> { + const kind = await editorKind(item) + const base = kind === 'raw_app' ? 'apps_raw' : `${kind}s` + const contextElement = itemContextElement({ + kind: kind === 'raw_app' ? 'app' : kind, + rawApp: kind === 'raw_app', + path: item.path, + summary: item.title === item.path ? undefined : item.title + }) + return [ + { + displayName: 'Add to AI session context', + icon: AtSign, + type: 'ai', + action: () => contextElement && homeComposerContext.attach(contextElement), + hide: !contextElement || !homeComposerContext.available + }, + { + displayName: 'Edit', + icon: Pen, + href: `/${base}/edit/${item.path}`, + hide: !showEditButtons || !editRights.roleCanAuthor(kind) + }, + { + displayName: 'Duplicate/Fork', + icon: GitFork, + href: `/${base}/add?template=${item.path}`, + hide: !showEditButtons || !$roleCapabilities.code || kind === 'agent' + }, + { + displayName: 'View runs', + icon: List, + // An agent run is a flow preview, which the runs page hides by default. + href: `/runs/${item.path}${item.kind === 'agent' ? '?job_kinds=previews' : ''}`, + hide: item.kind === 'app' || item.kind === 'raw_app' + }, + { + displayName: 'Audit logs', + icon: Eye, + href: `/audit_logs?resource=${item.path}`, + hide: !$roleCapabilities.code || !$roleCapabilities.visibility.audit_logs + }, + { displayName: 'Copy path', icon: Copy, action: () => copyToClipboard(item.path) }, + { + displayName: favorite ? 'Remove from favorites' : 'Add to favorites', + icon: favorite ? StarOff : StarIcon, + separatorTop: true, + action: () => + favorite + ? favoriteManager.unstar(item.path, item.starKind, workspace) + : favoriteManager.star(item.path, item.starKind, workspace, item.title) + }, + { + displayName: 'Remove from recent', + icon: X, + action: () => recentItems.forget(workspace, item.kind, item.path), + hide: favorite + } + ] + } + + // Assets are favorites too, but they open in the database manager rather than at a page, + // so they stay in the sidebar's menu. + let favorites: Entry[] = $derived( + favoriteManager.current.flatMap((f) => + f.kind === 'asset' ? [] : [{ kind: f.kind, starKind: f.kind, path: f.path, title: f.label }] + ) + ) + // Favorites first, then what was opened lately; a starred item is listed once, as a favorite. + // With the favorites turned off, a starred item opened lately shows among the recent ones. + let recentAll: Entry[] = $derived.by(() => { + const { showFavorites, showRecent } = homeQuickAccessSettings + const starred = new Set( + showFavorites ? favorites.map((f) => `${asStarKind(f.kind)}/${f.path}`) : [] + ) + return (showRecent ? recentItems.list(workspace) : []) + .filter((r) => !starred.has(`${asStarKind(r.kind)}/${r.path}`)) + .map((r) => ({ + kind: r.kind, + starKind: asStarKind(r.kind), + path: r.path, + title: r.summary || r.path, + at: r.at, + via: r.via + })) + }) + // Grows by a page from the "Load next" card; back to one page on the next visit. + let recentLimit = $state(RECENT_COUNT) + let recentMore = $derived(Math.max(0, recentAll.length - recentLimit)) + let entries: { item: Entry; favorite: boolean }[] = $derived([ + ...(homeQuickAccessSettings.showFavorites ? favorites : []).map((item) => ({ + item, + favorite: true + })), + ...recentAll.slice(0, recentLimit).map((item) => ({ item, favorite: false })) + ]) + + // The list turned off has items, the one left on has none. The section stays, or its menu + // (the only way to turn the list back on) would leave with it. + let emptiedBySettings = $derived( + entries.length === 0 && + ((!homeQuickAccessSettings.showFavorites && favorites.length > 0) || + (!homeQuickAccessSettings.showRecent && recentItems.list(workspace).length > 0)) + ) + + // Read when the pointer reaches the time, so the tooltip is current however long the + // page has been open. + let now = $state(Date.now()) + + function recentTooltip(at: number, via: RecentVia | undefined): string { + const mins = Math.max(0, Math.floor((now - at) / 60_000)) + const unit = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'} ago` + const ago = + mins < 1 + ? 'just now' + : mins < 60 + ? unit(mins, 'minute') + : mins < 1440 + ? unit(Math.floor(mins / 60), 'hour') + : unit(Math.floor(mins / 1440), 'day') + return `${via === 'view' ? 'Opened' : 'Edited'} by you ${ago}` + } + + // Everything the lists hold, the recent ones not loaded yet included. + let favoriteCount = $derived(homeQuickAccessSettings.showFavorites ? favorites.length : 0) + let recentCount = $derived(recentAll.length) + + // The cards fill one screen at a time, row by row, and the next screen scrolls in from the + // right. Placed by hand: the grid's own flow fills either whole rows across every screen or + // whole columns. The column counts follow the `auto-cols` breakpoints below. + let innerWidth = $state(1280) + let cols = $derived(innerWidth >= 1024 ? 3 : innerWidth >= 640 ? 2 : 1) + let cellCount = $derived(entries.length + (recentMore > 0 ? 1 : 0)) + // Fewer rows when there aren't enough cards to fill a screen. + let rows = $derived(Math.min(homeQuickAccessSettings.rows, Math.ceil(cellCount / cols))) + + function cellPosition(index: number): { row: number; column: number } { + const perScreen = cols * rows + const screen = Math.floor(index / perScreen) + const within = index % perScreen + return { row: Math.floor(within / cols) + 1, column: screen * cols + (within % cols) + 1 } + } + + let scroller: HTMLDivElement | undefined = $state(undefined) + let canBack = $state(false) + let canForward = $state(false) + + function measure() { + if (!scroller) return + canBack = scroller.scrollLeft > 1 + canForward = scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1 + } + + // No scroll snap: when the favorites load in front of the recent cards, a snapped scroller + // follows the card it had snapped to and opens scrolled to the end. One page (the width + // inside the scroller's 1px padding) plus the grid's 8px gap lands on a column edge by itself. + // Animated by hand: `behavior: 'smooth'` runs at the browser's own pace, which is slow for a + // page this wide. A click during the animation moves on from where the last one was going. + const PAGE_MS = 200 + const reducedMotion = useReducedMotion() + let scrollTarget: number | undefined + let scrollFrame: number | undefined + + function page(direction: 1 | -1) { + const el = scroller + if (!el) return + const max = el.scrollWidth - el.clientWidth + const from = el.scrollLeft + const to = Math.max( + 0, + Math.min(max, (scrollTarget ?? from) + direction * (el.clientWidth - 2 + 8)) + ) + if (scrollFrame != undefined) cancelAnimationFrame(scrollFrame) + if (reducedMotion.val) { + el.scrollLeft = to + return + } + scrollTarget = to + const start = performance.now() + const step = (now: number) => { + const t = Math.min(1, (now - start) / PAGE_MS) + el.scrollLeft = from + (to - from) * (1 - Math.pow(1 - t, 3)) + if (t < 1) { + scrollFrame = requestAnimationFrame(step) + } else { + scrollFrame = undefined + scrollTarget = undefined + } + } + scrollFrame = requestAnimationFrame(step) + } + + // A resize changes what fits, and so does a change of cards or rows, which can leave the + // scroller's own box the same size. + function watchOverflow(node: HTMLElement) { + void cellCount + void rows + measure() + const observer = new ResizeObserver(measure) + observer.observe(node) + return () => observer.disconnect() + } +</script> + +<svelte:window bind:innerWidth /> + +{#snippet card(item: Entry, favorite: boolean)} + <div + class="group flex items-center gap-1 rounded-md border border-light bg-surface-tertiary hover:bg-surface-hover transition-colors min-w-0" + > + <a + href={getFavoriteHref(item.path, item.kind)} + class="flex items-center gap-2 min-w-0 pl-2.5 py-2" + title={item.title === item.path ? item.path : `${item.title}\n${item.path}`} + > + <span class="shrink-0"><RowIcon kind={item.kind} size={14} /></span> + <span class="text-2xs font-normal text-primary truncate">{item.title}</span> + </a> + <!-- The star sits after the title, as on the tree's rows: a set one stays, an unset one + shows on hover or focus. --> + <Star + kind={item.starKind} + path={item.path} + workspaceId={workspace} + summary={item.title} + size={13} + class="inline-flex shrink-0 p-px text-yellow-500 opacity-0 data-[starred]:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100" + /> + <!-- A button can't sit inside the link, so the rest of the card is a second, untabbable + link to the same page. --> + <a + href={getFavoriteHref(item.path, item.kind)} + class="flex-1 self-stretch" + tabindex="-1" + aria-hidden="true" + ></a> + {#if !favorite && item.at} + <span + class="shrink-0 text-2xs text-secondary" + title={recentTooltip(item.at, item.via)} + onmouseenter={() => (now = Date.now())} + role="note" + > + <TimeAgo date={new Date(item.at).toISOString()} compact noSeconds /> + </span> + {/if} + <div class="shrink-0 pr-1 text-secondary"> + <DropdownV2 items={() => menuItems(item, favorite)} size="xs" fixedHeight={false} /> + </div> + </div> +{/snippet} + +{#snippet arrow(direction: 1 | -1)} + <!-- In the page's side padding, beside the cards rather than over them, so the cards keep + the items card's width. A touch screen swipes instead. --> + <div + class="absolute inset-y-0 hidden sm:block [@media(hover:none)]:hidden {direction === -1 + ? 'right-full mr-1' + : 'left-full ml-1'}" + > + <Button + variant="subtle" + unifiedSize="sm" + iconOnly + wrapperClasses="h-full" + btnClasses="!h-full !w-6 !px-0" + startIcon={{ icon: direction === -1 ? ChevronLeft : ChevronRight }} + title={direction === -1 ? 'Scroll left' : 'Scroll right'} + aria-label={direction === -1 ? 'Scroll left' : 'Scroll right'} + onClick={() => page(direction)} + /> + </div> +{/snippet} + +<!-- Held back until the favorites are in: the recent cards are ready first, and the favorites + landing in front of them would push every card over. --> +{#if favoriteManager.loadedFor === workspace && (entries.length > 0 || emptiedBySettings) && !hidden} + <!-- 30px under the cards: the gap from the composer down to this section's heading text. --> + <section class="flex flex-col gap-2 px-4 sm:px-0 pb-[30px]"> + <div class="flex items-center gap-1 -my-1"> + <HomeSectionLabel> + {#if favoriteCount > 0} + Favorites <HomeSectionCount count={favoriteCount} /> + {/if} + {#if favoriteCount > 0 && recentCount > 0} + <span class="text-hint">·</span> + {/if} + {#if recentCount > 0} + Recent <HomeSectionCount count={recentCount} /> + {/if} + </HomeSectionLabel> + <div class="text-secondary"> + <DropdownV2 items={sectionMenu} size="xs" fixedHeight={false} /> + </div> + </div> + {#if entries.length === 0} + <p class="text-2xs text-hint"> + {homeQuickAccessSettings.showFavorites + ? 'No favorites yet. Star a script, flow or app to keep it here.' + : 'Nothing opened lately in this browser.'} + </p> + {/if} + <div class="relative"> + <!-- The scrollbar is hidden, so a trackpad, shift-wheel or a swipe scrolls it and the + arrows stand in for the bar. The 1px padding, taken back by the margin, keeps the + cards' borders off the clip edge: a third of the width is rarely a whole pixel, and + the edge then cuts the outer border. --> + <div + bind:this={scroller} + {@attach watchOverflow} + onscroll={measure} + class="grid gap-2 overflow-x-auto no-scrollbar px-px -mx-px auto-cols-[85%] sm:auto-cols-[calc((100%_-_0.5rem)/2)] lg:auto-cols-[calc((100%_-_1rem)/3)]" + style:grid-template-rows="repeat({rows}, auto)" + > + {#each entries as { item, favorite }, i (item.kind + '/' + item.path)} + {@const at = cellPosition(i)} + <div class="min-w-0" style:grid-row={at.row} style:grid-column={at.column}> + {@render card(item, favorite)} + </div> + {/each} + {#if recentMore > 0} + {@const next = Math.min(recentMore, RECENT_COUNT)} + {@const at = cellPosition(entries.length)} + <div class="min-w-0" style:grid-row={at.row} style:grid-column={at.column}> + <Button + variant="subtle" + unifiedSize="sm" + wrapperClasses="h-full min-w-0" + btnClasses="!h-full rounded-md border border-dashed border-border-light !text-2xs text-secondary font-normal" + onClick={() => (recentLimit += RECENT_COUNT)} + > + Load next {next} + {next === 1 ? 'item' : 'items'} + </Button> + </div> + {/if} + </div> + {#if canBack} + {@render arrow(-1)} + {/if} + {#if canForward} + {@render arrow(1)} + {/if} + </div> + </section> + <HomeSectionLabel class="px-4 sm:px-0 pb-1"> + Workspace items + {#if itemCount != undefined} + <HomeSectionCount count={itemCount} /> + {/if} + </HomeSectionLabel> +{/if} diff --git a/frontend/src/lib/components/home/HomeSectionCount.svelte b/frontend/src/lib/components/home/HomeSectionCount.svelte new file mode 100644 index 0000000000..043be77dae --- /dev/null +++ b/frontend/src/lib/components/home/HomeSectionCount.svelte @@ -0,0 +1,5 @@ +<script lang="ts"> + let { count }: { count: number } = $props() +</script> + +<span class="text-hint tabular-nums">({count})</span> diff --git a/frontend/src/lib/components/home/HomeSectionLabel.svelte b/frontend/src/lib/components/home/HomeSectionLabel.svelte new file mode 100644 index 0000000000..54deb6067a --- /dev/null +++ b/frontend/src/lib/components/home/HomeSectionLabel.svelte @@ -0,0 +1,15 @@ +<script lang="ts"> + import type { Snippet } from 'svelte' + + interface Props { + children: Snippet + class?: string + } + + let { children, class: className = '' }: Props = $props() +</script> + +<!-- Names a section of Home without competing with what it labels. --> +<h2 class="flex items-center gap-1 text-xs font-normal text-secondary {className}"> + {@render children()} +</h2> diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index ee22e1a43a..d5383734ec 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -2,7 +2,6 @@ import CenteredPage from '$lib/components/CenteredPage.svelte' import { PIPELINE_DRAFT_KIND, pipelineFolderFromBundlePath } from '$lib/pipelinePaths' import { Button, Skeleton } from '$lib/components/common' - import Toggle from '$lib/components/Toggle.svelte' import { AssetService, FolderService, @@ -10,7 +9,6 @@ type ListableApp, type Script, ScriptService, - ResourceService, type Flow, type ListableRawApp, type ListableResource, @@ -18,24 +16,26 @@ } from '$lib/gen' import { resource } from 'runed' import { getDraftItems } from '$lib/workspaceDrafts.svelte' - import { disableHubStore, userStore, workspaceStore } from '$lib/stores' + import { disableHubStore, roleCapabilities, userStore, workspaceStore } from '$lib/stores' import { useEditRights } from '$lib/operatorWriteRights' import type uFuzzy from '@leeoniya/ufuzzy' import { - ArrowDownUp, Bot, + ChevronDown, ChevronsDownUp, ChevronsUpDown, Code2, LayoutDashboard, + MoreHorizontal, + Settings, Tag } from 'lucide-svelte' import DropdownV2 from '$lib/components/DropdownV2.svelte' import CreateActionsMenu from './CreateActionsMenu.svelte' import ContentSearchInner from '$lib/components/ContentSearchInner.svelte' - import type { Item as MenuItem } from '$lib/utils' + import type { IconType, Item as MenuItem } from '$lib/utils' - import { HOME_SEARCH_SHOW_FLOW, HOME_SEARCH_PLACEHOLDER } from '$lib/consts' + import { HOME_COLUMN_WIDTH, HOME_SEARCH_SHOW_FLOW, HOME_SEARCH_PLACEHOLDER } from '$lib/consts' import SearchItems from '../SearchItems.svelte' import FilterSearchbar, { @@ -47,22 +47,25 @@ import HubProjectPickerModal from './HubProjectPickerModal.svelte' import type { HubProjectPick } from '$lib/hubProject' import ListFilters from './ListFilters.svelte' - import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte' - import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte' - import FlowIcon from './FlowIcon.svelte' + import LabelBadge from '../common/badge/LabelBadge.svelte' + import { clampRows } from './clampRows' + import { setUnruledList } from '../common/table/unruledList' + import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte' import { getLocalSetting, isOwner, storeLocalSetting } from '$lib/utils' import { sendUserToast } from '$lib/toast' import Drawer from '../common/drawer/Drawer.svelte' import HighlightCode from '../HighlightCode.svelte' import DrawerContent from '../common/drawer/DrawerContent.svelte' - import Item from './Item.svelte' import TreeViewRoot from './TreeViewRoot.svelte' + import Item from './Item.svelte' + import PipelineRow from '../common/table/PipelineRow.svelte' + import { TreeOpenMemory } from './treeViewState.svelte' import { effectivePath, type ItemType } from './treeViewUtils' import { tick, untrack } from 'svelte' import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte' - import PipelineRow from '../common/table/PipelineRow.svelte' import BulkActionsBar from './BulkActionsBar.svelte' import { HomeSelection, setHomeSelection, toBulkItem } from './homeSelection.svelte' + import HomeQuickAccess from './HomeQuickAccess.svelte' const editRights = useEditRights() @@ -73,6 +76,8 @@ let { subtab = $bindable('script'), showEditButtons = true }: Props = $props() + setUnruledList() + // Which user-folder scoping toggle (if any) this role gets. Declared before the // FilterSearchbar schema and the derived filters that both read it. let filterUserFoldersType: 'only f/*' | 'u/username and f/*' | undefined = $derived( @@ -120,7 +125,7 @@ label: 'Include library scripts', // On by default, so selecting it means "turn it off" — keep the picker. default: true, - hidden: !($userStore && !$userStore.operator) + hidden: !($userStore && $roleCapabilities.code) }, only_user_folders: { type: 'boolean' as const, @@ -166,7 +171,17 @@ } // Single URL-synced source of truth for the searchbar-driven filters. - let filterValues = useUrlSyncedFilterInstance(untrack(() => searchFilterSchema)) + // The library and user-folder choices outlive the URL: a link that sets one still wins, + // but a bare visit restores the last one made in this browser. + const HIDE_LIBRARY_SETTING_NAME = 'homeHideLibrary' + const ONLY_USER_FOLDERS_SETTING_NAME = 'homeOnlyUserFolders' + let filterValues = useUrlSyncedFilterInstance( + untrack(() => searchFilterSchema), + { + include_library: getLocalSetting(HIDE_LIBRARY_SETTING_NAME) == 'true' ? false : undefined, + only_user_folders: getLocalSetting(ONLY_USER_FOLDERS_SETTING_NAME) == 'true' || undefined + } + ) // Derived views the rest of the data layer reads. The merged-endpoint reload effect // depends on these (search, kind, archived, library, user-folder scope), so changing a @@ -175,21 +190,27 @@ let itemKind = $derived( (filterValues.val.kind ?? 'all') as 'script' | 'flow' | 'app' | 'agent' | 'all' ) - // Agents are `ai_agent` resources, listed through the resource endpoint: the runnables - // endpoint has no such kind, so it is asked for nothing on the agent view and for every - // kind on the combined one. - let showsRunnables = $derived(itemKind !== 'agent') - let showsAgents = $derived(itemKind === 'all' || itemKind === 'agent') - let runnableKinds = $derived(itemKind === 'all' || itemKind === 'agent' ? undefined : itemKind) + let runnableKinds = $derived(itemKind === 'all' ? undefined : itemKind) let archived = $derived(!!filterValues.val.archived) let includeWithoutMain = $derived((filterValues.val.include_library ?? true) as boolean) - let filterUserFolders = $derived(!!filterValues.val.only_user_folders) + // Gated on the role so a choice remembered from an admin session can't hide other users' + // spaces for a role that is never offered the toggle. + let filterUserFolders = $derived(!!filterValues.val.only_user_folders && !!filterUserFoldersType) + $effect(() => { + storeLocalSetting(HIDE_LIBRARY_SETTING_NAME, includeWithoutMain ? undefined : 'true') + }) + $effect(() => { + storeLocalSetting( + ONLY_USER_FOLDERS_SETTING_NAME, + filterValues.val.only_user_folders ? 'true' : undefined + ) + }) // Content search is a distinct mode: its results come from ContentSearchInner // (which carries only path + content), so the row-list filters can't // apply to it. When it's active we restrict the searchbar to just the content filter, // clear any other filters so they don't linger as ignored chips, and hide the row-list - // controls (kind toggle, tree view) that no longer drive anything. + // controls (kind filter, tree view) that no longer drive anything. let contentActive = $derived(!!filterValues.val.content) let searchbarSchema = $derived( contentActive ? { content: searchFilterSchema.content } : searchFilterSchema @@ -222,7 +243,7 @@ starred?: boolean hash?: string // Fetch ordinal: the position this row arrived in from the server (which already - // applied the chosen order + starred-first). Sorting by it reproduces the server's + // applied its order + starred-first). Sorting by it reproduces the server's // global order EXACTLY — no client re-derivation that could disagree on collation // or sub-millisecond ties and make a later page jump above shown rows. ord?: number @@ -318,11 +339,7 @@ let flows: TableFlow[] | undefined = $state() let apps: TableApp[] | undefined = $state() let raw_apps: TableRawApp[] | undefined = $state() - // Starts empty rather than undefined: the runnables page decides when the list renders, - // and agents join it when their own request lands. - let agents: TableAgent[] = $state([]) - let agentOwnerCounts: Record<string, number> = $state({}) - let agentsGen = 0 + let agents: TableAgent[] | undefined = $state() // Monotonic fetch-order counter stamped onto each row as it arrives (see TableItem.ord). let fetchOrd = 0 @@ -330,6 +347,7 @@ let loading = $state(true) + // Rows the flat list shows of the loaded window. let nbDisplayed = $state(15) // Keyset cursor for the next browse page; null once the stream is exhausted. @@ -338,22 +356,7 @@ // Guards against out-of-order responses: only the latest request applies. let loadGen = 0 - // Sort selector value -> merged-endpoint order params. - function sortToParams(o: SortOrder): { orderBy: 'updated' | 'name'; orderDesc: boolean } { - switch (o) { - case 'updated_asc': - return { orderBy: 'updated', orderDesc: false } - case 'name_asc': - return { orderBy: 'name', orderDesc: false } - case 'name_desc': - return { orderBy: 'name', orderDesc: true } - case 'updated_desc': - default: - return { orderBy: 'updated', orderDesc: true } - } - } - - function mapRunnable(it: RunnableItem): TableScript | TableFlow | TableApp { + function mapRunnable(it: RunnableItem): TableScript | TableFlow | TableApp | TableAgent { const base = { ...it, canWrite: @@ -367,126 +370,36 @@ // combinedItems reads a script's time from `created_at`; the endpoint's // unified `edited_at` holds exactly that for scripts. if (it.type === 'script') return { ...base, created_at: it.edited_at } as unknown as TableScript + // An agent row reads its resource's description, which the endpoint reports as `summary`. + if (it.type === 'agent') return { ...base, description: it.summary } as unknown as TableAgent return base as unknown as TableFlow | TableApp } - /** - * The workspace's saved agents, as the kind the home page shows them as. Read from the - * resource listing, which the runnables' keyset order knows nothing of, so the order is applied - * here and the rows are stamped before every runnable ordinal: the agents lead as one block and - * a page of runnables keeps the server's order. Leading rather than trailing, or the list's - * first window would never reach them past a page of runnables. - */ - async function loadAgents(): Promise<void> { - const ws = $workspaceStore - const gen = ++agentsGen - if (!ws || !$userStore || !showsAgents || archived) { - agents = [] - agentOwnerCounts = {} - return - } - const rows: ListableResource[] = [] - try { - // Paged even though search and scoping run over the whole set here: the listing caps a - // page, and only the first carries the draft-only rows, so a short page ends it. - const perPage = 1000 - for (let page = 1; ; page++) { - const batch = await ResourceService.listResource({ - workspace: ws, - resourceType: 'ai_agent', - includeDraftOnly: true, - page, - perPage - }) - rows.push(...batch) - if (batch.length < perPage || gen !== agentsGen) break - } - } catch (e: any) { - if (gen !== agentsGen) return - sendUserToast(`Failed to load agents: ${e?.body ?? e?.message ?? e}`, true) - agents = [] - agentOwnerCounts = {} - return - } - if (gen !== agentsGen) return - // Counted before the owner scope: the chips and tree nodes list every owner. - const counts: Record<string, number> = {} - for (const r of rows) { - const owner = effectivePath(r).split('/').slice(0, 2).join('/') - counts[owner] = (counts[owner] ?? 0) + 1 - } - agentOwnerCounts = counts - const scoped = ownerFilter - ? rows.filter((r) => effectivePath(r).startsWith(ownerFilter + '/')) - : rows - const byTime = (r: ListableResource) => new Date(r.edited_at ?? 0).getTime() - const sorted = [...scoped].sort((a, b) => { - switch (sortOrder) { - case 'updated_asc': - return byTime(a) - byTime(b) - case 'name_asc': - return cmp(effectivePath(a), effectivePath(b)) - case 'name_desc': - return cmp(effectivePath(b), effectivePath(a)) - default: - return byTime(b) - byTime(a) - } - }) - agents = sorted.map((r, i) => ({ - ...r, - summary: r.description || undefined, - canWrite: editRights.canEditItem('agent', r.path, (r.extra_perms ?? {}) as any), - ord: AGENT_ORD_BASE + i - })) - } - /** Below any ordinal a runnables page stamps (they count up from 0), so agents lead as a block. */ - const AGENT_ORD_BASE = -1_000_000 - // The merged, server-ordered, keyset-paginated source. `reset` reloads from - // the first page (order/filter change or workspace switch); otherwise it - // appends the next page. All three kinds arrive interleaved and are split into + // the first page (filter change or workspace switch); otherwise it + // appends the next page. Every kind arrives interleaved and are split into // the existing per-kind arrays so the downstream pipeline is unchanged. async function loadRunnables(reset: boolean): Promise<void> { const ws = $workspaceStore if (!ws || !$userStore) return - // Only the very first load shows the skeleton; reorder/filter reloads keep - // the toolbar and current items visible (they're replaced on arrival) so the - // sort control itself doesn't flicker out when you use it. + // Only the very first load shows the skeleton; filter reloads keep the toolbar + // and current items visible (they're replaced on arrival) so the filter control + // itself doesn't flicker out when you use it. // An append (load-more) needs a live cursor. If there isn't one — the stream // is exhausted, or a reset just cleared it and its page-1 response hasn't // landed — bail so a load-more can't append a fresh page-1 onto the old // arrays (mixing streams) or clobber the pending reset's generation. if (!reset && serverCursor === undefined) return if (scripts === undefined) loading = true - // One load per reset: agents are fetched whole, so a load-more has nothing to append. - const agentsLoad = reset ? loadAgents() : undefined - if (!showsRunnables) { - // Supersedes a page still in flight, which would otherwise land in the agent view. - const gen = ++loadGen - serverCursor = undefined - hasMoreServer = false - // The rows on screen stay until the agents replace them, as a reload keeps them, or the - // view reads as empty in between. - await agentsLoad - if (gen !== loadGen) return - scripts = [] - flows = [] - apps = [] - raw_apps = [] - pipelineMemberFolders = new Set() - loading = false - return - } if (reset) { serverCursor = undefined hasMoreServer = false - // Note: this resets only the global/flat stream. The lazy tree's own store + // Note: this resets only the global stream. The lazy tree's own store // (treeOwnerItems/ownerLoad/openOwners) is managed separately — a mode change - // clears it (see the treeKey effect), while an in-place sort/filter change + // clears it (see the treeKey effect), while an in-place filter change // replaces each open owner's rows atomically as they re-load (see - // loadOwnerItems), so expanded folders don't blank out mid-reorder. + // loadOwnerItems), so expanded folders don't blank out mid-reload. } - const { orderBy, orderDesc } = sortToParams(sortOrder) const gen = ++loadGen // Snapshot the cursor now: a later request must not consume a cursor minted // by an order/filter that has since changed. @@ -495,8 +408,7 @@ try { res = await ScriptService.listRunnables({ workspace: ws, - orderBy, - orderDesc, + ...sortToParams(sortOrder), showArchived: archived ? true : undefined, includeWithoutMain: includeWithoutMain ? true : undefined, kinds: runnableKinds, @@ -519,18 +431,13 @@ // A newer request superseded this one (e.g. order changed mid-flight); drop // this response so a stale page/cursor can't be mixed with the new order. if (gen !== loadGen) return - // Landed together with the agents fetched beside it, or a workspace holding only agents - // reads as empty until they arrive. - if (agentsLoad) { - await agentsLoad - if (gen !== loadGen) return - } serverCursor = res.next_cursor ?? undefined hasMoreServer = !!res.next_cursor const s: TableScript[] = reset ? [] : [...(scripts ?? [])] const f: TableFlow[] = reset ? [] : [...(flows ?? [])] const a: TableApp[] = reset ? [] : [...(apps ?? [])] + const ag: TableAgent[] = reset ? [] : [...(agents ?? [])] const memberFolders = reset ? new Set<string>() : new Set(pipelineMemberFolders) if (reset) fetchOrd = 0 for (const it of res.items ?? []) { @@ -546,11 +453,14 @@ f.push({ ...mapRunnable(it), ord: fetchOrd++ } as TableFlow) } else if (it.type === 'app') { a.push({ ...mapRunnable(it), ord: fetchOrd++ } as TableApp) + } else if (it.type === 'agent') { + ag.push({ ...mapRunnable(it), ord: fetchOrd++ } as TableAgent) } } scripts = s flows = f apps = a + agents = ag raw_apps = [] pipelineMemberFolders = memberFolders loading = false @@ -564,10 +474,13 @@ // skipping ones already present. Lets a search reach matches outside the loaded // browse window without re-fetching the whole list. function mergeRunnables(newItems: RunnableItem[]) { - const have = new Set([...(scripts ?? []), ...(flows ?? []), ...(apps ?? [])].map(itemKey)) + const have = new Set( + [...(scripts ?? []), ...(flows ?? []), ...(apps ?? []), ...(agents ?? [])].map(itemKey) + ) const s = [...(scripts ?? [])] const f = [...(flows ?? [])] const a = [...(apps ?? [])] + const ag = [...(agents ?? [])] const memberFolders = new Set(pipelineMemberFolders) let changed = false for (const it of newItems) { @@ -578,17 +491,19 @@ } if (have.has(itemKey(it))) continue // Appended after the browse window; a higher ord keeps them after it (search - // itself ranks by relevance, so this ordinal only matters if sort resumes). + // itself ranks by relevance, so this ordinal only matters once it is cleared). const mapped = { ...mapRunnable(it), ord: fetchOrd++ } if (it.type === 'script') s.push(mapped as TableScript) else if (it.type === 'flow') f.push(mapped as TableFlow) else if (it.type === 'app') a.push(mapped as TableApp) + else if (it.type === 'agent') ag.push(mapped as TableAgent) changed = true } if (changed) { scripts = s flows = f apps = a + agents = ag pipelineMemberFolders = memberFolders } } @@ -600,9 +515,9 @@ // // Rows for every prefix live in ONE store (`treeOwnerItems`), never in the global // browse arrays: those advance by a single `serverCursor`, so out-of-window rows there - // would make the flat stream non-contiguous and duplicate once pagination reached them. + // would make the global stream non-contiguous and duplicate once pagination reached them. // - // `treeGen` ties every request to the active scope (order/archived/library/kind/ + // `treeGen` ties every request to the active scope (archived/library/kind/ // workspace); a reset bumps it so an in-flight response from a stale scope is dropped. type OwnerLoadState = { cursor?: string @@ -611,7 +526,7 @@ loaded: boolean gen: number } - // Rows per request when loading a prefix in the tree. Larger than the flat list's + // Rows per request when loading a prefix in the tree. Larger than the global stream's // page because a tree row is a single line and a folder is opened to see what it // holds — most folders come in whole on the first click. const OWNER_PAGE_SIZE = 300 @@ -642,7 +557,7 @@ // `owner` is the full prefix a node covers: `f/<folder>`, `u/<username>`, or any // folder under one. `force` re-fetches its first page even if already loaded (a - // re-sort / re-filter reload uses it to refresh the loaded rows in place); `all` + // re-filter reload uses it to refresh the loaded rows in place); `all` // keeps paging until the prefix's stream is exhausted instead of stopping at a page. async function loadOwnerItems( owner: string, @@ -656,16 +571,9 @@ // Track the prefix as open first — even a no-op call (re-expanding a cached node) // means it's on screen, so later reloads must refresh it. openOwners.add(owner) - // The agent view has no runnables to page in: its rows are the loaded agents, which - // `treeSource` adds itself. Rows an owner held from another kind go with the switch. - if (!showsRunnables) { - treeOwnerItems = treeOwnerItems.filter((x) => !effectivePath(x).startsWith(`${owner}/`)) - ownerLoad[owner] = { hasMore: false, loading: false, loaded: true, gen: treeGen } - return - } const st = ownerLoad[owner] // Only a load for the CURRENT generation blocks a new one. A load left in flight - // by a superseded generation (treeGen bumped on a sort/filter reload) has already + // by a superseded generation (treeGen bumped on a filter reload) has already // been invalidated, so it must not wedge the owner as permanently loading. if (st?.loading && st.gen === treeGen) return if (!force && (more ? !st?.hasMore : st?.loaded)) return @@ -677,9 +585,8 @@ loaded: st?.loaded ?? false, gen } - const { orderBy, orderDesc } = sortToParams(sortOrder) const prefix = `${owner}/` - // Only a forced refresh replaces this prefix's rows, so a re-sort swaps them without + // Only a forced refresh replaces this prefix's rows, so a reload swaps them without // blanking the tree. Everything else merges: a nested prefix inherits rows from an // ancestor's pages, and one page of its own can cover fewer of them than are shown — // replacing would delete rows on the click meant to add them. @@ -721,8 +628,7 @@ try { res = await ScriptService.listRunnables({ workspace: ws, - orderBy, - orderDesc, + ...sortToParams(sortOrder), showArchived: archived ? true : undefined, includeWithoutMain: includeWithoutMain ? true : undefined, kinds: runnableKinds, @@ -749,7 +655,7 @@ sendUserToast(`Failed to load ${owner}: ${e?.body ?? e?.message ?? e}`, true) return } - // The scope moved on while this was in flight (order/archive/library/kind/ + // The scope moved on while this was in flight (archive/library/kind/ // workspace changed and reset the tree); drop the response so stale rows from // another scope can't appear under the current one. if (gen !== treeGen) return @@ -781,13 +687,13 @@ } // Reload the merged list once and re-fetch the owners that are currently expanded so // they don't go blank. Used both for row mutations (create/edit/archive/move/share) - // and for in-place scope changes (sort/archive/library/kind): those keep the tree in + // and for in-place scope changes (archive/library/kind): those keep the tree in // lazy mode (treeKey unchanged, so folders stay open like a file explorer), and the - // re-fetch reloads each open owner's items in the new order/filter. When a mode + // re-fetch reloads each open owner's items under the new filter. When a mode // change (owner/search) has switched the tree out of lazy mode, there's nothing to // re-fetch — the tree remounts and groups the global stream instead. async function reloadItems(): Promise<void> { - // Invalidate any in-flight owner loads from the previous sort/filter so their + // Invalidate any in-flight owner loads from the previous filter so their // late responses can't overwrite the fresh ones. treeGen++ const toReload = treeLazyMode ? [...openOwners] : [] @@ -823,7 +729,7 @@ } // For row mutations (create/delete/move/archive), which also change how many - // runnables an owner holds. A scope change (sort/archive/kind/…) doesn't go + // runnables an owner holds. A scope change (archive/kind/…) doesn't go // through here: the counts resource keys on those itself. async function reloadItemsAndCounts(): Promise<void> { // The answer can change with the rows: archiving the last item leaves the listing empty @@ -872,45 +778,87 @@ const cmp = new Intl.Collator('en').compare - // The selected order maps to the endpoint's order_by/order_desc (see - // sortToParams) so it is applied server-side and stays correct across the - // whole workspace, not just loaded pages. Starred items are pinned on top of - // the first page. + // The order is applied server-side so it holds across the whole workspace, not just the + // loaded pages. Starred items are pinned on top of the first page. type SortOrder = 'updated_desc' | 'updated_asc' | 'name_asc' | 'name_desc' const SORT_SETTING_NAME = 'homeSort' - // `short` labels the trigger button next to the sort icon (the button is icon-only - // only while searching, when sorting is disabled — see below). - const sortOptions: { value: SortOrder; label: string; short: string }[] = [ - { value: 'updated_desc', label: 'Recently updated', short: 'Recent' }, - { value: 'updated_asc', label: 'Oldest updated', short: 'Oldest' }, - { value: 'name_asc', label: 'Name (A-Z)', short: 'A-Z' }, - { value: 'name_desc', label: 'Name (Z-A)', short: 'Z-A' } + const sortOptions: { value: SortOrder; label: string }[] = [ + { value: 'updated_desc', label: 'Recently updated' }, + { value: 'updated_asc', label: 'Oldest updated' }, + { value: 'name_asc', label: 'Name (A-Z)' }, + { value: 'name_desc', label: 'Name (Z-A)' } ] let sortOrder = $state<SortOrder>( sortOptions.find((o) => o.value === getLocalSetting(SORT_SETTING_NAME))?.value ?? 'updated_desc' ) - $effect(() => { - storeLocalSetting(SORT_SETTING_NAME, sortOrder === 'updated_desc' ? undefined : sortOrder) - }) - let sortItems: MenuItem[] = $derived( - sortOptions.map((o) => ({ + function setSortOrder(o: SortOrder) { + sortOrder = o + storeLocalSetting(SORT_SETTING_NAME, o === 'updated_desc' ? undefined : o) + } + function sortToParams(o: SortOrder): { orderBy: 'updated' | 'name'; orderDesc: boolean } { + return { + orderBy: o.startsWith('name') ? 'name' : 'updated', + orderDesc: o.endsWith('desc') + } + } + + // Preserve the endpoint's exact order rather than re-deriving it on the client: each row + // carries its server fetch ordinal (`ord`), which already reflects the chosen order, the + // (path, kind) tiebreaks, full-precision timestamps and the database collation. Sorting by + // it can never disagree with the server, so a later page never jumps above shown rows on + // "load more". + function compareItems(a: { ord?: number }, b: { ord?: number }): number { + return (a.ord ?? 0) - (b.ord ?? 0) + } + + const FLAT_LIST_SETTING_NAME = 'homeFlatList' + let flatList = $state(getLocalSetting(FLAT_LIST_SETTING_NAME) == 'true') + function setFlatList(v: boolean) { + flatList = v + storeLocalSetting(FLAT_LIST_SETTING_NAME, v ? 'true' : undefined) + } + + let listSettingsItems: MenuItem[] = $derived([ + { + displayName: 'Flat list', + description: 'List items without grouping them by folder', + toggle: flatList, + action: () => setFlatList(!flatList) + }, + { + displayName: 'Show library scripts', + description: 'Scripts without a main function', + toggle: includeWithoutMain, + hide: !($userStore && $roleCapabilities.code), + action: () => { + // On by default, so showing them again clears the key rather than leaving a chip. + if (includeWithoutMain) filterValues.val.include_library = false + else delete filterValues.val.include_library + } + }, + { + displayName: + filterUserFoldersType === 'only f/*' ? 'Hide user folders' : "Hide other users' folders", + description: + filterUserFoldersType === 'only f/*' + ? 'Only f/*' + : `Only u/${$userStore?.username} and f/*`, + toggle: filterUserFolders, + hide: !filterUserFoldersType, + action: () => { + if (filterUserFolders) delete filterValues.val.only_user_folders + else filterValues.val.only_user_folders = true + } + }, + ...sortOptions.map((o, i) => ({ displayName: o.label, selected: o.value === sortOrder, - action: () => (sortOrder = o.value) + separatorTop: i === 0, + disabled: filter !== '', + tooltip: filter !== '' ? 'Search results are ranked by relevance' : undefined, + action: () => setSortOrder(o.value) })) - ) - // Preserve the endpoint's exact order rather than re-deriving it on the client: - // each row carries its server fetch ordinal (`ord`), which already reflects the - // chosen order, the (path, kind) tiebreaks, full-precision timestamps, the database - // collation, and starred-first pinning. Sorting by it can never disagree with the - // server, so a later page never jumps above shown rows on "load more". Depends on - // `sortOrder` only so its identity changes when the order does (re-grouping the tree, - // whose leaves also sort by ord); the actual reordering comes from the reload that - // restamps ord. - let compareItems = $derived.by(() => { - sortOrder - return (a: { ord?: number }, b: { ord?: number }): number => (a.ord ?? 0) - (b.ord ?? 0) - }) + ]) const opts: uFuzzy.Options = { sort: (info, haystack, needle) => { @@ -963,8 +911,8 @@ } } - const TREE_VIEW_SETTING_NAME = 'treeView' - let treeView = $state(getLocalSetting(TREE_VIEW_SETTING_NAME) == 'true') + let toolbarHeight = $state(0) + let chipsExpanded = $state(false) // Pipeline entries are rendered independently of the item list, so apply the // same gates the items get — otherwise a pipeline would still show under the @@ -1001,6 +949,12 @@ } let collapseAll = $state(true) + let treeOpenMemory = $derived(new TreeOpenMemory(`homeTreeOpen:${$workspaceStore}`)) + function toggleCollapseAll() { + // Collapsing all is also how to forget the folders reopened on every visit. + if (!collapseAll) treeOpenMemory.clear() + collapseAll = !collapseAll + } // Human-readable list of the filters currently narrowing the list. Empty means no // filter is active, so an empty result then means the workspace itself has no items // (NoItemFound shows the welcome message); otherwise the filters are just too narrow @@ -1085,14 +1039,14 @@ // 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 view 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 view, or a selected owner / active search / label filter, the - // lazy store is unused (the flat list and reloadItems must not touch it), so this is - // false and those views group the global loaded window instead. + // 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( - treeView && !searching && ownerFilter == undefined && labelFilter == undefined + !flatList && !searching && ownerFilter == undefined && labelFilter == undefined ) // 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 @@ -1105,7 +1059,6 @@ [() => $workspaceStore, () => archived, () => itemKind, () => includeWithoutMain], async ([ws, showArchived, kind, withoutMain]) => { if (!ws || showArchived) return undefined - if (kind === 'agent') return {} try { const res = await ScriptService.countRunnablesByOwner({ workspace: ws, @@ -1121,16 +1074,54 @@ } } ) - // The runnables endpoint never counts an agent, so the loaded agents are added per owner. - let ownerCounts = $derived.by(() => { - const runnables = ownerCountsRes.current - if (runnables == undefined) return undefined - const counts = { ...runnables } - for (const [owner, n] of Object.entries(agentOwnerCounts)) { - counts[owner] = (counts[owner] ?? 0) + n + let ownerCounts = $derived(ownerCountsRes.current) + // Every path sits under a folder or a user, so the owners' counts add up to the workspace. + let workspaceItemCount = $derived( + ownerCounts == undefined ? undefined : Object.values(ownerCounts).reduce((sum, n) => sum + n, 0) + ) + + type KindOption = { + value: 'all' | 'script' | 'flow' | 'app' | 'agent' + label: string + icon?: IconType + /** The kind's colour in the rows (`RowIcon`). */ + iconClass?: string + } + const kindOptions: KindOption[] = [ + { value: 'all', label: 'All kinds' }, + { value: 'script', label: 'Scripts', icon: Code2, iconClass: 'text-blue-500' }, + ...(HOME_SEARCH_SHOW_FLOW + ? [ + { + value: 'flow', + label: 'Flows', + icon: BarsStaggered, + iconClass: 'text-teal-500' + } satisfies KindOption + ] + : []), + { value: 'app', label: 'Apps', icon: LayoutDashboard, iconClass: 'text-orange-500' }, + { value: 'agent', label: 'Agents', icon: Bot, iconClass: 'text-violet-500' } + ] + function setItemKind(v: KindOption['value']) { + // itemKind is derived from the shared filter object (which URL-syncs itself); `all` + // clears the kind filter (delete, not null, so it doesn't linger as a `kind: null` chip). + if (v === 'all') { + delete filterValues.val.kind + } else { + filterValues.val.kind = v + subtab = v } - return counts - }) + } + let kindItems = $derived( + kindOptions.map((o) => ({ + displayName: o.label, + icon: o.icon, + iconProps: o.iconClass ? { class: `shrink-0 ${o.iconClass}` } : undefined, + selected: itemKind === o.value, + action: () => setItemKind(o.value) + })) + ) // The counts decide which owners the tree renders, so drawing it before they land // would show every workspace folder and then prune it away. Hold the skeleton // until the first response instead — it is fetched in parallel with the listing, @@ -1140,16 +1131,8 @@ treeLazyMode && ownerCountsRes.current == undefined && ownerCountsRes.loading ) - // An import just landed, so the rows about to replace the empty state are all new: they - // fade in one after another rather than appearing as a finished list. Cleared on a timer - // because nothing else marks the end — the reload resolves before the rows animate. - let justImported = $state(false) - let justImportedTimer: ReturnType<typeof setTimeout> | undefined function onImported() { reloadItemsAndCounts() - justImported = true - clearTimeout(justImportedTimer) - justImportedTimer = setTimeout(() => (justImported = false), 2500) } // The hub import, owned here rather than by either entry point: the empty state's link and @@ -1246,6 +1229,8 @@ * tell leaves it live as the fallback. */ let toolbarInert = $derived(placeholderTakesOver && !archivedUnknown) + // Expand/collapse: nothing to group on an empty workspace. + let listControls = $derived(!loading && !workspaceEmpty) // Owners the counts found the user has something in, split by kind. They cover // what the folder/username lists miss: an item shared individually out of a @@ -1265,7 +1250,7 @@ const s = new Set<string>() // Always inject your own personal space (u/<you>): list_usernames can be empty // (you may not be a listed workspace member) yet u/<you> still holds runnables, - // and it must not vanish under a name sort whose first page is all folders. + // and it must not vanish when the first page holds none of its rows. if ($userStore?.username) s.add($userStore.username) // Other users only when no user-folder restriction is active. if (!filterUserFolders) { @@ -1276,18 +1261,17 @@ }) // The bottom "load more" only pages the *global* stream, which in lazy mode holds // folder rows the tree ignores (folders come from the store and are all injected - // already) — so surfacing it there is confusing. Restrict it to scoped mode, where - // it pages within the selected owner. In lazy mode the footer instead reveals more - // root nodes purely client-side (nbDisplayed) when there are more than are shown, - // and each folder paginates within itself; users past the window are reached via - // their owner chip. - let treeGlobalHasMore = $derived(ownerFilter != undefined && !searching ? hasMoreServer : false) + // already) — so surfacing it there is confusing. Offer it wherever the tree groups that + // stream instead: a selected owner pages within it, and a label filter with no owner + // matches against it, so later pages hold matches the first one lacks. In lazy mode each + // folder paginates within itself; users past the window are reached via their owner chip. + let treeGlobalHasMore = $derived(!treeLazyMode && !searching ? hasMoreServer : false) $effect(() => { if ($userStore && $workspaceStore) { ;[archived, includeWithoutMain, sortOrder, searching, ownerFilter, itemKind] // reloadItems (not a bare loadRunnables) so an in-place change — sort, archive, - // library, kind — reloads the global stream AND re-fetches the currently-open - // owners in the new order/filter, keeping folders expanded (file-explorer style) + // library, kind — reloads the global stream AND re-fetches the currently-open owners + // under the new filter, keeping folders expanded (file-explorer style) // rather than collapsing them. Mode changes (owner/search) flip treeLazyMode, so // reloadItems skips the re-fetch and the {#key} remount handles the reset. untrack(() => { @@ -1314,8 +1298,7 @@ const kind = itemKind // Any term/scope change restarts search paging. searchCursor = undefined - // Agents are all loaded, so the client-side search over them is already complete. - if (term === '' || !ws || !$userStore || kind === 'agent') return + if (term === '' || !ws || !$userStore) return const handle = setTimeout(async () => { let res: { items: RunnableItem[]; next_cursor?: string } try { @@ -1371,7 +1354,7 @@ search: term, showArchived: showArchived ? true : undefined, includeWithoutMain: withoutMain ? true : undefined, - kinds: kind !== 'all' && kind !== 'agent' ? kind : undefined, + kinds: kind !== 'all' ? kind : undefined, pathStart: owner ? owner + '/' : undefined, includeDraftOnly: true, perPage: 1000, @@ -1395,7 +1378,7 @@ return mergeRunnables(res.items ?? []) searchCursor = res.next_cursor - // Reveal the freshly fetched matches instead of leaving them behind the display cap. + // Reveal the freshly fetched matches instead of leaving them behind the flat list's cap. nbDisplayed += 30 } @@ -1423,7 +1406,7 @@ type: 'raw_app' as 'raw_app', time: new Date(x.edited_at).getTime() })), - ...agents.map((x) => ({ + ...(agents ?? []).map((x) => ({ ...x, type: 'agent' as 'agent', time: new Date(x.edited_at ?? 0).getTime() @@ -1487,27 +1470,29 @@ // injected as a node and its rows come from the on-demand `treeOwnerItems` store, // so the tree never depends on which owners happen to be in the loaded window. // Otherwise (scoped/search/label) the tree just groups the global `items`. - // Agents ride along in lazy mode too: the owner store is fed by the runnables endpoint, - // which never returns one, while the resource listing already holds them all. let treeSource = $derived( treeLazyMode - ? [...treeOwnerItems, ...agents.map((x) => ({ ...x, type: 'agent' as 'agent' }))].filter( - (x) => filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType) + ? treeOwnerItems.filter((x) => + filterItemsPathsBaseOnUserFilters(x, filterUserFolders, filterUserFoldersType) ) : items ) // Remount identity: only a *mode* change (workspace, selected owner, entering/leaving // search, label filter) restructures the tree, so only those key the {#key} remount. - // A sort/archive/library/kind change keeps the same lazy tree and is applied in place + // An archive/library/kind change keeps the same lazy tree and is applied in place // by reloadItems re-fetching the open owners — so expanded folders don't collapse when - // you re-sort (searching is a boolean so per-keystroke query changes don't remount). + // you change it (searching is a boolean so per-keystroke query changes don't remount). let treeKey = $derived( - `${treeView}|${$workspaceStore}|${ownerFilter}|${searching}|${labelFilter}` + `${flatList}|${$workspaceStore}|${ownerFilter}|${searching}|${labelFilter}` ) // A mode change restructures the tree (it remounts on treeKey), so the lazy owner // store is stale — clear it here (the global reset no longer does). An in-place - // sort/filter change leaves treeKey unchanged, so the store survives and its owners + // filter change leaves treeKey unchanged, so the store survives and its owners // are refreshed in place instead of blanking. + // A selected owner or label narrows the tree to what matches, so its folders open on + // the way in and close again on the way out; in between, expand/collapse stays the + // user's. Closed again because "expand all" in the lazy tree loads every owner it opens. + let wasScoped = false $effect(() => { treeKey untrack(() => { @@ -1515,6 +1500,10 @@ ownerLoad = {} treeOwnerItems = [] openOwners = new Set() + nbDisplayed = 15 + const scoped = ownerFilter != undefined || labelFilter != undefined + if (scoped !== wasScoped) collapseAll = !scoped + wasScoped = scoped }) }) let displayedItems = $derived((items ?? []).slice(0, nbDisplayed)) @@ -1523,14 +1512,14 @@ }) let selectedIndex: number = $state(-1) - // More to show: either loaded items not yet sliced in, or the server has further - // browse pages (not while searching — the browse cursor is paused then; further + // More to show in the flat list: either loaded items not yet sliced in, or the server has + // further browse pages (not while searching — the browse cursor is paused then; further // search matches load on demand via searchCursor / "Load more results"). let hasMore = $derived( items != undefined && (items.length > nbDisplayed || (hasMoreServer && !searching)) ) let loadMoreIndex = $derived(displayedItems.length) - let loadMoreEl: HTMLButtonElement | undefined = $state() + let loadMoreEl: HTMLElement | undefined = $state() let pendingAutoSelect = $state(true) let firstWorkspaceRun = true $effect(() => { @@ -1543,7 +1532,7 @@ // On workspace switch, melt-ui restores focus to the workspace-picker trigger // button asynchronously after the menu closes. Without overriding it, pressing // an arrow key would re-open / re-highlight the workspace picker instead of - // moving the items-list selection. Run several times to win the focus race. + // moving the flat list's selection. Run several times to win the focus race. const focusSearch = () => { const el = document.getElementById('home-search-input') as HTMLInputElement | null el?.focus() @@ -1602,9 +1591,9 @@ nbDisplayed += 30 } - // Fetch the next server page directly (tree view and the empty-state control - // use this — their "all shown" threshold is on a different scale than the flat - // list's, so they must not route through loadMore's item-count check). + // Fetch the next server page directly (the tree's scoped footer and the empty-state + // control): their "all shown" threshold is on a different scale than the flat list's, + // so they must not route through loadMore's item-count check. async function fetchMoreServer() { if (hasMoreServer && !searching) { await loadRunnables(false) @@ -1623,8 +1612,7 @@ // open (free-text mode passes them through to the list); track that so nav stands down then. let searchbarDropdownOpen = $state(false) - // Caret position inside the searchbar's contenteditable, via the Selection API — the equivalent - // of an <input>'s selectionStart/End the list navigation used before the searchbar swap. + // Caret position inside the searchbar's contenteditable, via the Selection API. function searchCaret(el: HTMLElement): { atStart: boolean; atEnd: boolean; empty: boolean } { const text = el.textContent ?? '' const sel = window.getSelection() @@ -1639,7 +1627,7 @@ } // Elements that own the keyboard themselves (menus, dialogs, comboboxes): the - // list's own shortcuts stand down while one of them has focus. + // flat list's own shortcuts stand down while one of them has focus. const SKIP_SELECTOR = '[role="menu"], [role="menuitem"], [role="dialog"], [role="listbox"], [role="combobox"], [aria-expanded="true"], [data-menu], [data-chat-keyboard-scope]' @@ -1671,7 +1659,7 @@ return } } - if (treeView) return + if (!flatList || contentActive) return // When focus is inside a row's action buttons, handle arrow keys ourselves: // - Left/Right cycle between buttons (Left from the first returns to search). @@ -1751,7 +1739,6 @@ return } - const skipSelector = SKIP_SELECTOR if (target) { const tag = target.tagName const isEditable = @@ -1760,10 +1747,10 @@ if (isEditable && !isOurSearch) return // While the searchbar's suggestion dropdown is open it owns the arrows/Enter itself. if (isOurSearch && searchbarDropdownOpen) return - if (target.closest(skipSelector)) return + if (target.closest(SKIP_SELECTOR)) return } const active = document.activeElement as HTMLElement | null - if (active?.closest(skipSelector)) return + if (active?.closest(SKIP_SELECTOR)) return // ArrowRight from search input / body → focus first action button of selected row. // Guard: if cursor is in the middle of typed search text, let the cursor move. @@ -1780,14 +1767,7 @@ } return } - // ArrowLeft from search input with cursor at start: no-op (let default handle). - if (e.key === 'ArrowLeft') { - if (target?.id === 'home-search-input') { - const c = searchCaret(target) - if (!c.empty && !c.atStart) return - } - return - } + if (e.key === 'ArrowLeft') return if (e.key === 'ArrowDown') { if (displayedItems.length === 0) return @@ -1853,15 +1833,12 @@ } } } - $effect(() => { - storeLocalSetting(TREE_VIEW_SETTING_NAME, treeView ? 'true' : undefined) - }) // Multi-selection + bulk actions. Published through context so the tree's // nested levels don't have to carry it; `Item` is the only reader. const homeSelection = new HomeSelection() setHomeSelection(homeSelection) $effect(() => { - homeSelection.available = showEditButtons && !!$userStore && !$userStore.operator + homeSelection.available = showEditButtons && !!$userStore && $roleCapabilities.code }) // A selected path means nothing in another workspace. Narrowing the view // within one (kind, owner, label, search) keeps the selection instead, so @@ -1911,273 +1888,283 @@ </DrawerContent> </Drawer> -<CenteredPage wrapperClasses="w-full" handleOverflow={false}> - <div - class="flex flex-wrap gap-2 items-center justify-between w-full" - use:triggerableByAI={{ - id: 'home-items-list', - description: 'Lists of scripts, flows, and apps' - }} - > - {#if !contentActive} - <!-- Kept mounted, not hidden, so the toolbar doesn't reflow the moment the first item - lands; `inert` takes it out of the tab order and off the pointer meanwhile. A - workspace with nothing but archived items reaches them from its own placeholder, - so these controls are not the way there — but only where that placeholder - renders, which is what `placeholderTakesOver` tracks. --> - <div class="flex justify-start" class:opacity-40={placeholderTakesOver} inert={toolbarInert}> - <ToggleButtonGroup - selected={itemKind} - onSelected={(v) => { - // itemKind is derived from the shared filter object (which URL-syncs itself); - // `all` clears the kind filter (delete, not null, so it doesn't linger as a - // `kind: null` chip). - if (v === 'all') { - delete filterValues.val.kind - } else { - filterValues.val.kind = v - subtab = v - } - }} +<CenteredPage + wrapperClasses="w-full pb-6" + class="{HOME_COLUMN_WIDTH} sm:px-8" + handleOverflow={false} +> + <!-- The shortcuts are the user's own, not a view of the list, so the list's filters leave + them be. A search is a question about the whole workspace: they step aside for its + answer rather than sit above its results (or above its empty state). --> + {#if !searching && !contentActive && !workspaceEmpty && $workspaceStore} + <HomeQuickAccess workspace={$workspaceStore} {showEditButtons} itemCount={workspaceItemCount} /> + {/if} + <!-- The items section, one card: its controls, its filters and its rows. No `overflow-hidden` + on it: the page scrolls, and that would stop the controls sticking. --> + <div class="flex flex-col bg-surface-tertiary border border-border-light rounded-lg"> + <div + bind:clientHeight={toolbarHeight} + class="sticky top-0 z-10 bg-surface-tertiary rounded-t-lg flex flex-wrap gap-2 items-center justify-between px-5 py-3 shrink-0" + use:triggerableByAI={{ + id: 'home-items-list', + description: 'Lists of scripts, flows, and apps' + }} + > + <!-- The search takes the row; on a phone it takes a row of its own above the kind + filter and New. --> + <div class="flex flex-wrap grow items-center gap-2 min-w-0"> + <div + class="relative text-primary w-full basis-full sm:basis-0 sm:grow sm:min-w-[200px]" + class:opacity-40={placeholderTakesOver} + inert={toolbarInert} > - {#snippet children({ item })} - <ToggleButton value="all" label="All" size="md" {item} /> - <ToggleButton value="script" icon={Code2} label="Scripts" size="md" {item} /> - {#if HOME_SEARCH_SHOW_FLOW} - <ToggleButton - value="flow" - label="Flows" - icon={FlowIcon} - selectedColor="#14b8a6" - size="md" - {item} - /> - {/if} - <ToggleButton - value="app" - label="Apps" - icon={LayoutDashboard} - selectedColor="#fb923c" - size="md" - {item} - /> - <ToggleButton - value="agent" - label="Agents" - icon={Bot} - selectedColor="#8b5cf6" - size="md" - {item} - /> - {/snippet} - </ToggleButtonGroup> - </div> - {/if} - - {#if !loading && !contentActive && !workspaceEmpty} - <!-- List controls, between the kind toggle and the searchbar: tree view, - expand/collapse (tree only), sort. Nothing to group or order on an empty - workspace, so the whole row goes. --> - <div class="flex items-center gap-2"> - <Toggle size="xs" bind:checked={treeView} options={{ right: 'Tree view' }} /> - {#if treeView} - <Button - unifiedSize="sm" - variant="subtle" - on:click={() => (collapseAll = !collapseAll)} - startIcon={{ icon: collapseAll ? ChevronsUpDown : ChevronsDownUp }} - > - {#if collapseAll} - Expand all - {:else} - Collapse all - {/if} - </Button> + <FilterSearchbar + schema={searchbarSchema} + bind:value={filterValues.val} + placeholder={HOME_SEARCH_PLACEHOLDER} + presets={contentActive ? [] : searchPresets} + autofocus + hideDropdownOnFreeText + inputId="home-search-input" + onDropdownVisibleChange={(v) => (searchbarDropdownOpen = v)} + /> + </div> + {#if !contentActive} + <!-- Kept mounted, not hidden, so the toolbar doesn't reflow the moment the first + item lands; `inert` takes it out of the tab order and off the pointer meanwhile. + A workspace with nothing but archived items reaches them from its own + placeholder, so this is not the way there — but only where that placeholder + renders, which is what `placeholderTakesOver` tracks. --> + <div class="shrink-0" class:opacity-40={placeholderTakesOver} inert={toolbarInert}> + <DropdownV2 items={kindItems} placement="bottom-end" fixedHeight={false}> + {#snippet buttonReplacement()} + {@const active = kindOptions.find((o) => o.value === itemKind)} + <Button + nonCaptureEvent + unifiedSize="md" + variant="default" + title="Filter by kind" + startIcon={active?.icon + ? { icon: active.icon, classes: active.iconClass } + : undefined} + endIcon={{ icon: ChevronDown }} + > + {active?.label ?? 'All kinds'} + </Button> + {/snippet} + </DropdownV2> + </div> {/if} - <DropdownV2 - items={sortItems} - disabled={filter !== ''} - placement="bottom-end" - fixedHeight={false} - > - {#snippet buttonReplacement()} - {@const active = sortOptions.find((o) => o.value === sortOrder)} - {@const short = filter !== '' ? '' : (active?.short ?? '')} + {#if !contentActive && listControls} + {#if !flatList} <Button - nonCaptureEvent - disabled={filter !== ''} - iconOnly={short === ''} - unifiedSize="xs" + unifiedSize="md" variant="default" - startIcon={{ icon: ArrowDownUp }} - title={filter !== '' - ? 'Sorting is disabled while searching (results are ranked by relevance)' - : `Sort: ${active?.label ?? ''}`} - > - {#if short !== ''}{short}{/if} - </Button> - {/snippet} - </DropdownV2> - </div> - {/if} - - <div class="flex grow items-center justify-end gap-2 min-w-0"> - <div - class="relative text-primary w-full min-w-[200px] max-w-[26rem]" - class:opacity-40={placeholderTakesOver} - inert={toolbarInert} - > - <FilterSearchbar - schema={searchbarSchema} - bind:value={filterValues.val} - placeholder={HOME_SEARCH_PLACEHOLDER} - presets={contentActive ? [] : searchPresets} - autofocus - hideDropdownOnFreeText - inputId="home-search-input" - onDropdownVisibleChange={(v) => (searchbarDropdownOpen = v)} - /> - </div> - <!-- Same gate the old create actions used: hidden from operators and in workspaces + iconOnly + title={collapseAll ? 'Expand all' : 'Collapse all'} + on:click={toggleCollapseAll} + startIcon={{ icon: collapseAll ? ChevronsUpDown : ChevronsDownUp }} + /> + {/if} + <DropdownV2 items={listSettingsItems} placement="bottom-end" fixedHeight={false}> + {#snippet buttonReplacement()} + <Button + nonCaptureEvent + unifiedSize="md" + variant="default" + iconOnly + title="List settings" + startIcon={{ icon: Settings }} + /> + {/snippet} + </DropdownV2> + {/if} + <!-- Same gate the old create actions used: hidden from operators and in workspaces whose direct-deploy protection cleared showEditButtons (NoDirectDeployAlert), since the menu itself does no permission check. --> - {#if canCreateHere} - <!-- No hub entry where the instance has the hub turned off: the same setting the + {#if canCreateHere} + <!-- No hub entry where the instance has the hub turned off: the same setting the script and flow hub pickers observe. Nor for a builder: a hub project brings scripts and apps along. --> - <CreateActionsMenu - onImportHubProject={$disableHubStore || !editRights.roleCanAuthor('script') - ? undefined - : () => (hubPickerOpen = true)} - /> - {/if} + <CreateActionsMenu + onImportHubProject={$disableHubStore || !editRights.roleCanAuthor('script') + ? undefined + : () => (hubPickerOpen = true)} + /> + {/if} + </div> </div> - </div> - {#if !contentActive && hasChips} - <!-- Owner and label chips on one line. Each function binding routes the chip's + <!-- The chips scroll away with the rows; only the controls above stay. --> + {#if !contentActive && hasChips} + <!-- Owner and label chips on one line. Each function binding routes the chip's selection into the searchbar key of the same name, and `queryName` points ListFilters' own mount-time URL read at the param the filter instance syncs, so the two writers agree. No `syncQuery`: the filter instance owns the URL. --> - <div class="gap-2 w-full flex flex-wrap mt-3"> - <ListFilters - inline - bind:selectedFilter={() => ownerFilter, setOwnerFilter} - filters={owners} - queryName="owner" - maxDisplayed={10} - /> - <ListFilters - inline - bind:selectedFilter={() => labelFilter, setLabelFilter} - filters={allLabels} - queryName="label" - maxDisplayed={10} - color="blue" - icon={Tag} - /> - </div> - {/if} - {#if filteredItems?.length == 0 && !workspaceEmpty} - <div class="mt-10"></div> - {/if} - <div class="mt-3"> - {#if filterValues.val.content} - <!-- Content filter: swap the normal list/tree for the content-match view (the same one + <div + class="gap-x-2 gap-y-0.5 w-full flex flex-wrap px-5 pb-3" + use:clampRows={{ rows: 2, expanded: chipsExpanded }} + > + <ListFilters + inline + bind:selectedFilter={() => ownerFilter, setOwnerFilter} + filters={owners} + color="subtle" + queryName="owner" + /> + <ListFilters + inline + bind:selectedFilter={() => labelFilter, setLabelFilter} + filters={allLabels} + queryName="label" + color="subtle" + icon={Tag} + /> + <div data-clamp-toggle hidden> + <LabelBadge + icon={chipsExpanded ? undefined : MoreHorizontal} + title={chipsExpanded ? 'Show fewer filters' : 'Show all filters'} + onclick={() => (chipsExpanded = !chipsExpanded)} + > + {#if chipsExpanded}Show less{/if} + </LabelBadge> + </div> + </div> + <!-- Its own element, not the chips' border: it stays under the controls once the chips + have scrolled away. --> + <div class="sticky z-10 border-b mx-5" style:top="{toolbarHeight}px"></div> + {/if} + {#if filteredItems?.length == 0 && !workspaceEmpty} + <div class="mt-10"></div> + {/if} + <!-- Inset so a row's hover fill clears the card's border; the controls above take the same + extra 4px, so they line up with the rows' content. --> + <div class="px-1"> + {#if filterValues.val.content} + <!-- Content filter: swap the normal list/tree for the content-match view (the same one used by the Ctrl-K "Content" modal). It loads the workspace's scripts/flows/apps/ resources and matches their contents client-side — usable on any instance. Keyed by workspace so a switch remounts a fresh instance and late in-flight responses from the previous workspace can't land in it. --> - <!-- -mx-2 cancels ContentSearchInner's own px-2 so its rows line up flush with the - runnable list instead of sitting slightly inset. --> - <div class="-mx-2"> - {#key $workspaceStore} - <ContentSearchInner bind:this={contentSearchEl} search={filterValues.val.content} /> - {/key} - </div> - {:else if filteredItems == undefined || treeCountsPending} - <div class="mt-4"></div> - <Skeleton layout={[[2], 1]} /> - {#each new Array(6) as _} - <Skeleton layout={[[4], 0.5]} /> - {/each} - {:else if filteredItems.length === 0 && (filter !== '' || visiblePipelineFolders.size === 0)} - <!-- Pipelines aren't part of the text filter, so only fall through to show + <!-- px-1 plus ContentSearchInner's own px-2 lines its rows up with the controls + above. --> + <div class="px-1 pb-2"> + {#key $workspaceStore} + <ContentSearchInner bind:this={contentSearchEl} search={filterValues.val.content} /> + {/key} + </div> + {:else if filteredItems == undefined || treeCountsPending} + <div class="px-4 pb-3"> + <div class="mt-4"></div> + <Skeleton layout={[[2], 1]} /> + {#each new Array(6) as _} + <Skeleton layout={[[4], 0.5]} /> + {/each} + </div> + {:else if filteredItems.length === 0 && (filter !== '' || visiblePipelineFolders.size === 0)} + <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; a no-match search still reads as empty. --> - {#if workspaceEmpty} - <!-- Held until the archived probe answers rather than drawn and swapped: the two + {#if workspaceEmpty} + <!-- Held until the archived probe answers rather than drawn and swapped: the two placeholders say different things, and showing the wrong one first says the workspace is empty when it is not. --> - {#if emptyStateAnswered} - {#if archivedProbe?.hasArchived || canCreateHere} - <!-- Shown to whoever has something to do here: the archived notice to + {#if emptyStateAnswered} + {#if archivedProbe?.hasArchived || canCreateHere} + <!-- Shown to whoever has something to do here: the archived notice to everyone, since reading archived items is not a write, and the create actions only to a user who may take them. --> - <WorkspaceEmptyState - archivedOnly={archivedProbe?.hasArchived === true} - canCreate={canCreateHere} - onPick={(project) => (hubPick = project)} - onShowArchived={() => (filterValues.val = { ...filterValues.val, archived: true })} - /> + <WorkspaceEmptyState + archivedOnly={archivedProbe?.hasArchived === true} + canCreate={canCreateHere} + onPick={(project) => (hubPick = project)} + onShowArchived={() => + (filterValues.val = { ...filterValues.val, archived: true })} + /> + {:else} + <NoItemFound {activeFilters} /> + {/if} + {/if} {:else} <NoItemFound {activeFilters} /> {/if} - {/if} - {:else} - <NoItemFound {activeFilters} /> - {/if} - {#if hasMoreServer && !searching} - <!-- The active filter matched nothing on the loaded pages, but the server + {#if hasMoreServer && !searching} + <!-- The active filter matched nothing on the loaded pages, but the server has more: keep paging reachable so matches on later pages aren't lost. --> - <div class="text-center text-xs text-secondary mt-2"> - <button class="text-primary hover:text-emphasis underline" onclick={fetchMoreServer} - >Load more to search further</button - > + <div class="text-center text-xs text-secondary mt-2"> + <button class="text-primary hover:text-emphasis underline" onclick={fetchMoreServer} + >Load more to search further</button + > + </div> + {/if} </div> - {/if} - {:else if treeView} - <!-- Remount the tree on a MODE change only (treeKey = view/owner/search/label): + {:else if flatList} + <!-- Every row draws its own border-b; pulling the content 1px down hides the last + one under the clip, as the tree does. --> + <div class="overflow-hidden"> + <div class="-mb-px"> + {#if filter === ''} + {#each [...visiblePipelineFolders].sort() as folder (folder)} + <PipelineRow {folder} onDeleted={reloadItemsAndCounts} /> + {/each} + {/if} + {#each displayedItems as item, i (item.type + '/' + item.path + (item.hash ? '/' + item.hash : ''))} + <Item + {item} + on:scriptChanged={reloadItemsAndCounts} + on:flowChanged={reloadItemsAndCounts} + on:appChanged={reloadItemsAndCounts} + on:rawAppChanged={reloadItemsAndCounts} + on:reload={reloadItemsAndCounts} + {showCode} + showEditButton={showEditButtons} + keyboardSelected={selectedIndex === i} + /> + {/each} + {#if items && hasMore} + <div + bind:this={loadMoreEl} + class="px-4 py-3 flex flex-row items-center justify-between gap-4 {selectedIndex === + loadMoreIndex + ? 'bg-surface-hover' + : 'bg-surface-secondary'}" + > + <span class="text-xs text-secondary"> + Showing {Math.min(nbDisplayed, items.length)}{(hasMoreServer && !searching) || + (searching && searchCursor) + ? '' + : ` of ${items.length}`} items + </span> + <Button unifiedSize="sm" variant="subtle" on:click={loadMore}>Load more</Button> + </div> + {/if} + </div> + </div> + {:else} + <!-- Remount the tree on a MODE change only (treeKey = view/owner/search/label): expanded folders (their `opened` state is local to each TreeView) collapse - and re-load fresh for the new mode. An in-place order/archive/library/kind + and re-load fresh for the new mode. An in-place archive/library/kind change keeps treeKey stable, so folders stay open and refresh via reloadItems instead (see loadOwnerItems). --> - {#key treeKey} - <TreeViewRoot - items={treeSource} - {collapseAll} - sortCompare={compareItems} - groupDesc={sortOrder === 'name_desc'} - hasMoreServer={treeGlobalHasMore} - onLoadMore={fetchMoreServer} - pipelineFolders={visiblePipelineFolders} - allFolders={treeInjectFolders} - allUsers={treeInjectUsers} - ownerCounts={!searching && labelFilter == undefined ? ownerCounts : undefined} - selfUsername={$userStore?.username} - groupOtherUsers={treeLazyMode} - ownerLoad={treeLazyMode ? ownerLoad : undefined} - onExpandOwner={treeLazyMode ? loadOwnerItems : undefined} - onCollapseOwner={treeLazyMode ? collapseOwner : undefined} - isSearching={filter !== ''} - on:scriptChanged={reloadItemsAndCounts} - on:flowChanged={reloadItemsAndCounts} - on:appChanged={reloadItemsAndCounts} - on:rawAppChanged={reloadItemsAndCounts} - on:reload={reloadItemsAndCounts} - {showCode} - showEditButton={showEditButtons} - /> - {/key} - {:else} - <div class="border rounded-md bg-surface-tertiary" class:wm-imported={justImported}> - {#if filter === ''} - {#each [...visiblePipelineFolders].sort() as folder (folder)} - <PipelineRow {folder} onDeleted={reloadItemsAndCounts} /> - {/each} - {/if} - {#each displayedItems as item, i (item.type + '/' + item.path + (item.hash ? '/' + item.hash : ''))} - <Item - {item} + {#key treeKey} + <TreeViewRoot + items={treeSource} + {collapseAll} + sortCompare={compareItems} + groupDesc={sortOrder === 'name_desc'} + openMemory={treeOpenMemory} + hasMoreServer={treeGlobalHasMore} + onLoadMore={fetchMoreServer} + pipelineFolders={visiblePipelineFolders} + allFolders={treeInjectFolders} + allUsers={treeInjectUsers} + ownerCounts={!searching && labelFilter == undefined ? ownerCounts : undefined} + selfUsername={$userStore?.username} + groupOtherUsers={treeLazyMode} + ownerLoad={treeLazyMode ? ownerLoad : undefined} + onExpandOwner={treeLazyMode ? loadOwnerItems : undefined} + onCollapseOwner={treeLazyMode ? collapseOwner : undefined} + isSearching={filter !== ''} on:scriptChanged={reloadItemsAndCounts} on:flowChanged={reloadItemsAndCounts} on:appChanged={reloadItemsAndCounts} @@ -2185,45 +2172,31 @@ on:reload={reloadItemsAndCounts} {showCode} showEditButton={showEditButtons} - keyboardSelected={selectedIndex === i} + framed={false} /> - {/each} - </div> - {#if items && hasMore} - <span class="text-xs font-normal text-secondary" - >{Math.min(nbDisplayed, items.length)} items{hasMoreServer && !searching - ? '' - : ` out of ${items.length}`} - <button - bind:this={loadMoreEl} - class="ml-4 text-xs font-normal text-primary hover:text-emphasis rounded px-1 {selectedIndex === - loadMoreIndex - ? 'bg-gray-200 dark:bg-gray-700 underline' - : ''}" - onclick={loadMore}>load 30 more</button - ></span - > + {/key} {/if} - {/if} - {#if searching && searchCursor} - <!-- The server has further search matches beyond the page already merged in; + {#if searching && searchCursor} + <!-- The server has further search matches beyond the page already merged in; fetch them one page at a time on demand rather than auto-downloading a broad query's entire result set. --> - <div class="text-center text-xs text-secondary mt-2"> - {#if searchLoadingMore} - Loading more results… - {:else} - <button class="text-primary hover:text-emphasis underline" onclick={loadMoreSearchResults} - >Load more results</button - > - {/if} - </div> - {/if} - {#if homeSelection.active} - <!-- The bar floats over the page bottom; without this the last rows sit - under it with no way to scroll them clear. --> - <div class="h-20"></div> - {/if} + <div class="text-center text-xs text-secondary py-2 border-t"> + {#if searchLoadingMore} + Loading more results… + {:else} + <button + class="text-primary hover:text-emphasis underline" + onclick={loadMoreSearchResults}>Load more results</button + > + {/if} + </div> + {/if} + {#if homeSelection.active} + <!-- The bar floats over the list's bottom; without this the last rows sit + under it with no way to scroll them clear. --> + <div class="h-20"></div> + {/if} + </div> </div> </CenteredPage> @@ -2252,59 +2225,3 @@ <ImportProjectModal.default pick={hubPick} onClose={() => (hubPick = undefined)} {onImported} /> {/await} {/if} - -<style> - /* Rows arriving after an import, one after another. The animation is declared on the - container's children rather than on each row: a wrapper element around a row would make - every row `first-of-type` and `last-of-type`, which is how Row draws its corners and - separators. The delay steps for the first rows only — past those the stagger is longer - than anyone waits, so they share the last one. */ - @keyframes wm-row-in { - from { - opacity: 0; - transform: translateY(4px); - } - to { - opacity: 1; - transform: none; - } - } - - /* `backwards`, not `both`: the fill hides rows until their staggered start, but a forwards - fill would keep `transform` animated afterwards and confine the row's remove confirmation - (`position: fixed`) to the row. The `to` keyframe equals the row's resting style. */ - .wm-imported > :global(*) { - animation: wm-row-in 260ms ease-out backwards; - animation-delay: 320ms; - } - .wm-imported > :global(*:nth-child(1)) { - animation-delay: 0ms; - } - .wm-imported > :global(*:nth-child(2)) { - animation-delay: 40ms; - } - .wm-imported > :global(*:nth-child(3)) { - animation-delay: 80ms; - } - .wm-imported > :global(*:nth-child(4)) { - animation-delay: 120ms; - } - .wm-imported > :global(*:nth-child(5)) { - animation-delay: 160ms; - } - .wm-imported > :global(*:nth-child(6)) { - animation-delay: 200ms; - } - .wm-imported > :global(*:nth-child(7)) { - animation-delay: 240ms; - } - .wm-imported > :global(*:nth-child(8)) { - animation-delay: 280ms; - } - - @media (prefers-reduced-motion: reduce) { - .wm-imported > :global(*) { - animation: none; - } - } -</style> diff --git a/frontend/src/lib/components/home/ListFilters.svelte b/frontend/src/lib/components/home/ListFilters.svelte index 5482c59c42..9714b18aa4 100644 --- a/frontend/src/lib/components/home/ListFilters.svelte +++ b/frontend/src/lib/components/home/ListFilters.svelte @@ -1,6 +1,6 @@ <script lang="ts"> import { Folder, User } from 'lucide-svelte' - import { Badge } from '../common' + import LabelBadge from '../common/badge/LabelBadge.svelte' import type { BadgeColor, BadgeIconProps } from '../common/badge/model' import { appIconComponent } from '../icons/appIcon.svelte' import { integrationDisplayName } from '../resourceTypeDisplay' @@ -14,9 +14,6 @@ queryName?: string syncQuery?: boolean bottomMargin?: boolean - // Keep only the first N filters visible, the rest behind a "…" toggle. Unset - // shows every one. - maxDisplayed?: number // Chip look. `icon` is drawn before every chip; unset, the icon follows the value // (user/folder prefix, or the app icon under `resourceType`). color?: BadgeColor @@ -34,7 +31,6 @@ queryName = 'filter', syncQuery = false, bottomMargin = true, - maxDisplayed, color = 'transparent', icon, inline = false @@ -76,71 +72,39 @@ : [selectedFilter, ...filters] : filters ) - - let expanded = $state(false) - let truncated = $derived.by(() => { - if (maxDisplayed == undefined || filtersAndSelected.length <= maxDisplayed) - return filtersAndSelected - const shown = filtersAndSelected.slice(0, maxDisplayed) - // Clicking the selected chip is how the filter is cleared, so it is never truncated - // away, however far down the order it sits. - if (selectedFilter && !shown.includes(selectedFilter)) shown.push(selectedFilter) - return shown - }) - // Derived from what is actually withheld, so the selected chip kept above never counts - // as hidden — otherwise the toggle offers to reveal a chip that is already on screen. - let hiddenCount = $derived(filtersAndSelected.length - truncated.length) - let displayedFilters = $derived(expanded ? filtersAndSelected : truncated) </script> {#if Array.isArray(filtersAndSelected) && filtersAndSelected.length > 0} <div class={inline ? 'contents' : `gap-2 w-full flex flex-wrap ${bottomMargin ? 'my-4' : 'mt-4'}`} > - {#each displayedFilters as filter (filter)} - <div> - <Badge - class="inline-flex items-center gap-1 align-middle" + {#each filtersAndSelected as filter (filter)} + <div data-chip data-selected={filter === selectedFilter ? '' : undefined}> + <LabelBadge + {color} + icon={icon ?? + (resourceType + ? undefined + : filter.startsWith('u/') + ? User + : filter.startsWith('f/') + ? Folder + : undefined)} + selected={filter === selectedFilter} onclick={() => { selectedFilter = selectedFilter == filter ? undefined : filter writeQuery(selectedFilter) }} - {color} - clickable - selected={filter === selectedFilter} > - <span style="height: 12px" class="-mt-0.5"> - {#if icon} - {@const Icon = icon} - <Icon class="mr-0.5" size={12} /> - {:else if resourceType} - {@const SvelteComponent = appIconComponent(filter)} - {#if SvelteComponent} - <SvelteComponent height="14px" width="14px" /> - {/if} - {:else if filter.startsWith('u/')} - <User class="mr-0.5" size={14} /> - {:else if filter.startsWith('f/')} - <Folder class="mr-0.5" size={14} /> + {#if !icon && resourceType} + {@const AppIcon = appIconComponent(filter)} + {#if AppIcon} + <AppIcon height="12px" width="12px" /> {/if} - </span> + {/if} {resourceType ? integrationDisplayName(filter) : filter} - {#if filter === selectedFilter}✗{/if} - </Badge> + </LabelBadge> </div> {/each} - {#if hiddenCount > 0} - <div> - <Badge - class="inline-flex items-center gap-1 align-middle" - {color} - clickable - title={expanded ? 'Show fewer' : `Show ${hiddenCount} more`} - onclick={() => (expanded = !expanded)} - > - {expanded ? 'Show less' : '…'} - </Badge> - </div> - {/if} </div> {/if} diff --git a/frontend/src/lib/components/home/TreeOwnerRow.svelte b/frontend/src/lib/components/home/TreeOwnerRow.svelte new file mode 100644 index 0000000000..6a5f95dee3 --- /dev/null +++ b/frontend/src/lib/components/home/TreeOwnerRow.svelte @@ -0,0 +1,58 @@ +<script lang="ts"> + import { ChevronDown, ChevronUp } from 'lucide-svelte' + import type { Snippet } from 'svelte' + import { inUnruledList, rowRule } from '../common/table/unruledList' + + const unruled = inUnruledList() + + /** A row of the tree that groups others under it (a folder, a user, "Other users"): clicking + * anywhere on it opens or closes what it groups. */ + interface Props { + icon: typeof import('lucide-svelte').Folder + name: string + opened: boolean + onToggle: () => void + /** Extra left padding in px, for a nested folder. */ + indent?: number + /** The toggle's accessible name; the row's name when unset. */ + toggleLabel?: string + /** The line under the name, usually its item count. */ + detail: Snippet + } + + let { icon: Icon, name, opened, onToggle, indent = 0, toggleLabel, detail }: Props = $props() +</script> + +<!-- svelte-ignore a11y_click_events_have_key_events --> +<!-- svelte-ignore a11y_no_static_element_interactions --> +<div + onclick={onToggle} + class="px-4 py-2 {rowRule( + unruled + )} w-full flex flex-row items-center justify-between cursor-pointer hover:bg-surface-hover" +> + <div + class="flex flex-row items-center gap-4" + style={indent > 0 ? `padding-left: ${indent}px;` : ''} + > + <Icon size={16} class="text-secondary shrink-0" /> + <div> + <span class="whitespace-nowrap text-xs text-emphasis font-semibold">{name}</span> + <div class="text-2xs font-normal text-secondary whitespace-nowrap"> + {@render detail()} + </div> + </div> + </div> + <!-- Keyboard access only: its click bubbles to the row, which does the toggling. --> + <button + class="w-full flex flex-row-reverse" + aria-expanded={opened} + aria-label={toggleLabel ?? name} + > + {#if opened} + <ChevronUp size={16} /> + {:else} + <ChevronDown size={16} /> + {/if} + </button> +</div> diff --git a/frontend/src/lib/components/home/TreeView.svelte b/frontend/src/lib/components/home/TreeView.svelte index 806787cb76..7331318727 100644 --- a/frontend/src/lib/components/home/TreeView.svelte +++ b/frontend/src/lib/components/home/TreeView.svelte @@ -3,13 +3,15 @@ import { createEventDispatcher, onDestroy, untrack, type Snippet } from 'svelte' import ResizeTransitionWrapper from '$lib/components/common/ResizeTransitionWrapper.svelte' - import { ChevronDown, ChevronUp, Folder, FolderTree, User } from 'lucide-svelte' + import { Folder, FolderTree, User } from 'lucide-svelte' + import TreeOwnerRow from './TreeOwnerRow.svelte' import PipelineRow from '../common/table/PipelineRow.svelte' import Item from './Item.svelte' import { countLeaves, type FolderItem, type ItemType, type UserItem } from './treeViewUtils' - import { twMerge } from 'tailwind-merge' import { pluralize } from '$lib/utils' import { Button } from '$lib/components/common' + import { inUnruledList, rowRule } from '../common/table/unruledList' + import type { TreeOpenMemory } from './treeViewState.svelte' interface Props { item: ItemType | FolderItem | UserItem @@ -51,6 +53,9 @@ // Renders a leaf in place of the home page's `<Item>`, for lists whose rows are not // scripts/flows/apps. Receives the leaf and its visual depth. leaf?: Snippet<[ItemType, number]> + // 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 } let { @@ -69,9 +74,12 @@ parentPrefix, ancestorHasMore = false, indent = 0, - leaf + leaf, + openMemory }: Props = $props() + const unruled = inUnruledList() + let visualDepth = $derived(depth + indent) const dispatch = createEventDispatcher<{ reload: void }>() @@ -205,7 +213,8 @@ ownerKey != undefined && ownerLoad != undefined && rootIndex >= EXPAND_ALL_LOAD_LIMIT - const shouldOpen = expandAll && !cappedOwner + const remembered = !expandAll && nodePrefix != undefined && !!openMemory?.isOpen(nodePrefix) + const shouldOpen = (expandAll && !cappedOwner) || remembered opened = shouldOpen if (ownerKey == undefined) return if (shouldOpen) { @@ -241,6 +250,7 @@ if (now - lastToggle < 300) return lastToggle = now opened = !opened + if (nodePrefix != undefined) openMemory?.setOpen(nodePrefix, opened) if (opened) { // A top-level owner loads on expand. A subfolder's rows come from an ancestor's // pages, so opening one must not fire a request per subfolder — its own "Load @@ -262,56 +272,30 @@ {#if isFolder(item) || isUser(item)} <div> - <!-- svelte-ignore a11y_click_events_have_key_events --> - <!-- svelte-ignore a11y_no_static_element_interactions --> - <div - onclick={toggleOwner} - class="px-4 py-2 border-b w-full flex flex-row items-center justify-between cursor-pointer" + <TreeOwnerRow + icon={isUser(item) ? User : depth === 0 ? Folder : FolderTree} + name={isUser(item) ? `u/${item.username}` : `${depth === 0 ? 'f/' : ''}${item.folderName}`} + {opened} + onToggle={toggleOwner} + indent={visualDepth * 16} > - <div - class={twMerge('flex flex-row items-center gap-4 text-sm font-semibold')} - style={visualDepth > 0 ? `padding-left: ${visualDepth * 16}px;` : ''} - > - <div class="flex justify-center items-center"> - {#if isUser(item)} - <User size={16} class="text-secondary" /> - {:else if depth === 0} - <Folder size={16} class="text-secondary" /> - {:else} - <FolderTree size={16} class="text-secondary" /> - {/if} - </div> - - <div> - <span class="whitespace-nowrap text-xs text-emphasis font-semibold"> - {#if isUser(item)}u/{item.username}{:else}{#if depth === 0}f/{/if}{item.folderName}{/if} - </span> - <div class="text-2xs font-normal text-secondary whitespace-nowrap"> - {#if ownerCount != undefined} - ({pluralize(ownerCount, ' item')}) - {:else if isLazyOwner && !ownerState?.loaded} - <!-- Lazy owner not expanded yet and no count for it: its true item count - is unknown until loaded, so showing "(0 items)" would be misleading. --> -   - {:else if nodeHasMore} - <!-- Partial: this node still has pages to load (its own once it has been - loaded directly, otherwise its ancestor's), so what is grouped under - it is only what has arrived so far. --> - ({loadedHere}+ items) - {:else} - ({pluralize(loadedHere, ' item')}) - {/if} - </div> - </div> - </div> - <button class="w-full flex flex-row-reverse"> - {#if opened} - <ChevronUp size={16} /> + {#snippet detail()} + {#if ownerCount != undefined} + ({pluralize(ownerCount, ' item')}) + {:else if isLazyOwner && !ownerState?.loaded} + <!-- Lazy owner not expanded yet and no count for it: its true item count + is unknown until loaded, so showing "(0 items)" would be misleading. --> +   + {:else if nodeHasMore} + <!-- Partial: this node still has pages to load (its own once it has been + loaded directly, otherwise its ancestor's), so what is grouped under + it is only what has arrived so far. --> + ({loadedHere}+ items) {:else} - <ChevronDown size={16} /> + ({pluralize(loadedHere, ' item')}) {/if} - </button> - </div> + {/snippet} + </TreeOwnerRow> <!-- ResizeObserver, not a slide: a freshly-opened owner fetches its rows, so its height changes twice (open-empty, then rows land) and a slide would only animate the first. --> <ResizeTransitionWrapper vertical innerClass="w-full"> @@ -336,6 +320,7 @@ {onCollapseOwner} parentPrefix={nodePrefix} ancestorHasMore={nodeHasMore} + {openMemory} on:scriptChanged on:flowChanged on:appChanged @@ -350,7 +335,9 @@ {/each} {#if effectiveMax < item.items.length} <div - class="px-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary" + class="px-4 py-2 {rowRule( + unruled + )} flex flex-row items-center justify-between gap-4 bg-surface-secondary" style="padding-left: {(visualDepth + 1) * 16}px;" > <!-- Rows, not items: this slices the node's own entries, where a subfolder @@ -387,7 +374,9 @@ is the point: without them this reads as an optional extra rather than as rows still missing. --> <div - class="px-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary" + class="px-4 py-2 {rowRule( + unruled + )} flex flex-row items-center justify-between gap-4 bg-surface-secondary" style="padding-left: {(visualDepth + 1) * 16}px;" > <span class="text-xs text-secondary"> diff --git a/frontend/src/lib/components/home/TreeViewRoot.svelte b/frontend/src/lib/components/home/TreeViewRoot.svelte index 753feb6a8b..86c1984254 100644 --- a/frontend/src/lib/components/home/TreeViewRoot.svelte +++ b/frontend/src/lib/components/home/TreeViewRoot.svelte @@ -3,8 +3,11 @@ import TreeView from './TreeView.svelte' import { countLeaves, groupItems, type ItemType, type UserItem } from './treeViewUtils' import { Button } from '$lib/components/common' - import { ChevronDown, ChevronUp, Users } from 'lucide-svelte' + import { Users } from 'lucide-svelte' + import TreeOwnerRow from './TreeOwnerRow.svelte' + import { inUnruledList, rowRule } from '../common/table/unruledList' import { getLocalSetting, pluralize, storeLocalSetting } from '$lib/utils' + import type { TreeOpenMemory } from './treeViewState.svelte' interface Props { collapseAll: boolean @@ -40,12 +43,16 @@ onExpandOwner?: (owner: string, more?: boolean, opts?: { all?: boolean }) => void onCollapseOwner?: (owner: string) => void showEditButton?: boolean + /** Off where the tree is part of its host's list rather than a box of its own (Home). */ + framed?: boolean // Tuck every user space but `selfUsername`'s into one collapsible "Other users" row // under it. Only for browsing: a search or filter must not hide its matches behind // a closed row. groupOtherUsers?: boolean // See TreeView's `leaf`. leaf?: Snippet<[ItemType, number]> + // See TreeView's `openMemory`. + openMemory?: TreeOpenMemory } let { @@ -66,10 +73,14 @@ onExpandOwner, onCollapseOwner, showEditButton = true, + framed = true, groupOtherUsers = false, - leaf + leaf, + openMemory }: Props = $props() + const unruled = inUnruledList() + // How many root nodes render at once. A root node is a collapsed owner row that // fetches nothing until expanded, so a large slice costs a row each and no request // — and an owner sliced off the end is indistinguishable from one that doesn't @@ -264,6 +275,7 @@ {showCode} {showEditButton} {leaf} + {openMemory} /> {/snippet} @@ -280,42 +292,25 @@ <!-- Every row draws its own border-b, so the last one would double the frame's bottom border. Pulling the content 1px down puts that border under the frame, where overflow-hidden clips it, whichever row (collapsed owner, leaf, expanded subtree) ends the list. --> - <div class="border rounded-md bg-surface-tertiary overflow-hidden"> + <div class="{framed ? 'border rounded-md bg-surface-tertiary' : ''} overflow-hidden"> <div class="-mb-px"> {#each rows.slice(0, nbDisplayed) as row (row.kind === 'otherUsers' ? 'other_users' : 'folderName' in row.node ? `f__${row.node.folderName}` : 'username' in row.node ? `u__${row.node.username}` : `i__${row.node.type}__${row.node.path}`)} {#if row.kind === 'otherUsers'} <!-- Same shape as an owner row, so it reads as part of the tree. It only reveals the user rows under it: each still loads its own items when opened. --> - <!-- svelte-ignore a11y_click_events_have_key_events --> - <!-- svelte-ignore a11y_no_static_element_interactions --> - <div - onclick={toggleOtherUsers} - class="px-4 py-2 border-b w-full flex flex-row items-center justify-between cursor-pointer" + <TreeOwnerRow + icon={Users} + name="Other users" + opened={otherUsersOpen} + onToggle={toggleOtherUsers} + toggleLabel={otherUsersOpen ? 'Hide other users' : 'Show other users'} > - <div class="flex flex-row items-center gap-4"> - <Users size={16} class="text-secondary" /> - <div> - <span class="whitespace-nowrap text-xs text-emphasis font-semibold" - >Other users</span - > - <div class="text-2xs font-normal text-secondary whitespace-nowrap"> - ({pluralize(otherUsers.length, 'user')}{otherUsersItemCount != undefined - ? ` · ${pluralize(otherUsersItemCount, 'item')}` - : ''}) - </div> - </div> - </div> - <Button - iconOnly - unifiedSize="xs" - variant="subtle" - startIcon={{ icon: otherUsersOpen ? ChevronUp : ChevronDown }} - title={otherUsersOpen ? 'Hide other users' : 'Show other users'} - aria-label="Other users" - aria-expanded={otherUsersOpen} - onClick={toggleOtherUsers} - /> - </div> + {#snippet detail()} + ({pluralize(otherUsers.length, 'user')}{otherUsersItemCount != undefined + ? ` · ${pluralize(otherUsersItemCount, 'item')}` + : ''}) + {/snippet} + </TreeOwnerRow> {#if otherUsersOpen} <!-- Ranked after every root owner, so opening this row can't push folders out of what "expand all" auto-loads. --> @@ -324,7 +319,9 @@ {/each} {#if nbOtherUsersDisplayed < otherUsers.length} <div - class="pl-8 pr-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary" + class="pl-8 pr-4 py-2 {rowRule( + unruled + )} flex flex-row items-center justify-between gap-4 bg-surface-secondary" > <span class="text-xs text-secondary"> Showing {nbOtherUsersDisplayed} of {otherUsers.length} users diff --git a/frontend/src/lib/components/home/WorkspaceEmptyState.svelte b/frontend/src/lib/components/home/WorkspaceEmptyState.svelte index 6e828eee34..813cb3a265 100644 --- a/frontend/src/lib/components/home/WorkspaceEmptyState.svelte +++ b/frontend/src/lib/components/home/WorkspaceEmptyState.svelte @@ -4,11 +4,11 @@ import Popover from '$lib/components/meltComponents/Popover.svelte' import type { HubProjectPick } from '$lib/hubProject' import { disableHubStore } from '$lib/stores' - import { useOperatorBuilderRights } from '$lib/operatorWriteRights' + import { useRoleCapabilities } from '$lib/operatorWriteRights' import CreateActionsMenu from './CreateActionsMenu.svelte' import HubTemplatePicker from './HubTemplatePicker.svelte' - const operatorBuilderRights = useOperatorBuilderRights() + const roleCaps = useRoleCapabilities() interface Props { /** A project was chosen here. The list owns the import dialog, and opens it on this. */ @@ -38,8 +38,8 @@ // `border-light`; only the container outline steps up, so nothing outweighs its frame. const rowOpacities = [1, 0.7, 0.4] - // A builder gets no hub template: a hub project brings scripts and apps along. - const showHub = $derived(!$disableHubStore && !$operatorBuilderRights) + // A hub project brings scripts and apps along, which only a role with code may deploy. + const showHub = $derived(!$disableHubStore && $roleCaps.code) // The inline "create a new one" link is the anchor for the very same New menu the // toolbar button opens, so the menu pops next to the words that promised it. @@ -57,7 +57,7 @@ </script> <div - class="rounded-md border-[1.5px] border-dashed border-border-normal/60 bg-surface" + class="rounded-md border-[1.5px] border-dashed border-border-normal/60" role="status" aria-label={archivedOnly ? 'Everything here is archived' : 'Your workspace is empty'} > diff --git a/frontend/src/lib/components/home/bulkActions.test.ts b/frontend/src/lib/components/home/bulkActions.test.ts index e00d5f5da5..f0edc5ffad 100644 --- a/frontend/src/lib/components/home/bulkActions.test.ts +++ b/frontend/src/lib/components/home/bulkActions.test.ts @@ -1,3 +1,4 @@ +import { FULL_CAPABILITIES } from '$lib/roleCapabilities' import { describe, it, expect } from 'vitest' import { blockedReason, movedPath, type BulkContext } from './bulkActions' import type { BulkItem } from './homeSelection.svelte' @@ -21,8 +22,8 @@ function item(over: Partial<BulkItem> = {}): BulkItem { } } -const admin: BulkContext = { workspace: 'w', isAdmin: true } -const member: BulkContext = { workspace: 'w', isAdmin: false } +const admin: BulkContext = { workspace: 'w', isAdmin: true, caps: FULL_CAPABILITIES } +const member: BulkContext = { workspace: 'w', isAdmin: false, caps: FULL_CAPABILITIES } // Each action's gate has to keep matching what the equivalent per-row menu entry // allows (common/table/{Script,Flow,App}Row.svelte, MoveDrawer.svelte) — a bulk @@ -45,6 +46,17 @@ describe('blockedReason', () => { expect(blockedReason('delete', item({ kind: 'app', canWrite: false }), member)).toBeDefined() }) + // The selection opens for any role with `code`, which may still lack apps. + it("refuses the kinds the member's role cannot author", () => { + const scripter = { ...member, caps: { ...FULL_CAPABILITIES, apps: 'none' as const } } + expect(blockedReason('delete', item({ kind: 'app' }), scripter)).toBeDefined() + expect(blockedReason('move', item({ kind: 'app', rawApp: true }), scripter)).toBeDefined() + expect(blockedReason('move', item({ kind: 'flow' }), scripter)).toBeUndefined() + const ownDraft = item({ kind: 'app', draftOnly: true }) + expect(blockedReason('discard', ownDraft, scripter)).toBeUndefined() + expect(blockedReason('discard', { ...ownDraft, legacyDraft: true }, scripter)).toBeDefined() + }) + it('requires ownership to move, and refuses archived rows', () => { expect(blockedReason('move', item({ owner: false }), admin)).toBeDefined() expect(blockedReason('move', item({ canWrite: false }), admin)).toBeDefined() diff --git a/frontend/src/lib/components/home/bulkActions.ts b/frontend/src/lib/components/home/bulkActions.ts index 20a65ec596..2cc5cf1f94 100644 --- a/frontend/src/lib/components/home/bulkActions.ts +++ b/frontend/src/lib/components/home/bulkActions.ts @@ -9,7 +9,8 @@ * there is nothing deployed at its path. */ import { AppService, DraftService, FlowService, ResourceService, ScriptService } from '$lib/gen' -import type { UserDraftItemKind } from '$lib/gen' +import type { RoleCapabilities, UserDraftItemKind } from '$lib/gen' +import { roleCanAuthor, roleCanDraft } from '$lib/editRights' import { updateItemPathAndSummary } from '$lib/components/moveRenameManager' import { discardDraft } from '$lib/utils_draft_deploy' import type { BulkItem } from './homeSelection.svelte' @@ -30,6 +31,8 @@ export type BulkContext = { workspace: string /** Deleting a script is admin-only, mirroring the row menu. */ isAdmin: boolean + /** The role's capabilities: a role that codes may still lack the flows or apps it selects. */ + caps: RoleCapabilities } /** Why `action` cannot be applied to `item`; `undefined` means it can. The @@ -41,6 +44,12 @@ export function blockedReason( ctx: BulkContext ): string | undefined { const notOwner = 'you are not an owner of this path' + const kind = draftKind(item) + // Discarding one's own draft needs no authoring right server-side, so a member who lost it + // can still clean up; a legacy (owner-less) draft does. + const roleGated = action !== 'discard' || item.legacyDraft + if (roleGated && !(action === 'discard' ? roleCanDraft : roleCanAuthor)(kind, ctx.caps)) + return 'your role cannot author this kind of item' switch (action) { case 'move': if (item.archived) return 'archived items cannot be moved' diff --git a/frontend/src/lib/components/home/clampRows.ts b/frontend/src/lib/components/home/clampRows.ts new file mode 100644 index 0000000000..b62b45dcf5 --- /dev/null +++ b/frontend/src/lib/components/home/clampRows.ts @@ -0,0 +1,65 @@ +import type { Action } from 'svelte/action' + +export type ClampRowsOptions = { rows: number; expanded: boolean } + +/** + * Keeps a wrapping row of `[data-chip]` items to `rows` lines, measured rather than counted, so + * how many fit follows the width. The `[data-clamp-toggle]` child shows only on overflow, and + * while collapsed it takes the end of the last line: items are hidden from there back until it + * fits. A `[data-selected]` item is never hidden, since clicking it is how its filter clears. + */ +export const clampRows: Action<HTMLElement, ClampRowsOptions> = (node, initial) => { + let options = initial + let frame = 0 + let width = -1 + + function run() { + frame = 0 + const toggle = node.querySelector<HTMLElement>('[data-clamp-toggle]') + const items = Array.from(node.querySelectorAll<HTMLElement>('[data-chip]')) + for (const item of items) item.hidden = false + if (toggle) toggle.hidden = true + + // Every position is read before any item is hidden: reading one after a write forces a + // layout per item, quadratic in the number of chips. + const tops = items.map((item) => item.offsetTop) + const lines = [...new Set(tops)].sort((a, b) => a - b) + if (lines.length <= options.rows || !toggle) return + toggle.hidden = false + if (options.expanded) return + + const lastLine = lines[options.rows - 1] + items.forEach((item, i) => { + if (tops[i] > lastLine && !item.hasAttribute('data-selected')) item.hidden = true + }) + const hideable = items.filter((item) => !item.hidden && !item.hasAttribute('data-selected')) + while (toggle.offsetTop > lastLine && hideable.length) hideable.pop()!.hidden = true + } + + function schedule() { + if (!frame) frame = requestAnimationFrame(run) + } + + // Width only: hiding items changes the height, which must not measure again. + const resize = new ResizeObserver(([entry]) => { + if (entry.contentRect.width === width) return + width = entry.contentRect.width + schedule() + }) + resize.observe(node) + // The items themselves changing; the `hidden` flips above are attributes, not seen here. + const mutations = new MutationObserver(schedule) + mutations.observe(node, { childList: true, subtree: true, characterData: true }) + + return { + update(next) { + options = next + schedule() + }, + destroy() { + cancelAnimationFrame(frame) + resize.disconnect() + mutations.disconnect() + } + } +} diff --git a/frontend/src/lib/components/home/homeQuickAccessSettings.svelte.ts b/frontend/src/lib/components/home/homeQuickAccessSettings.svelte.ts new file mode 100644 index 0000000000..af88da656a --- /dev/null +++ b/frontend/src/lib/components/home/homeQuickAccessSettings.svelte.ts @@ -0,0 +1,56 @@ +import { BROWSER } from 'esm-env' +import { getLocalSetting, storeLocalSetting } from '$lib/utils' + +// Per browser, like the composer's own pin: how a reader likes their Home laid out. +const HIDDEN_SETTING = 'home-quick-access-hidden' +const ROWS_SETTING = 'home-quick-access-rows' +const NO_FAVORITES_SETTING = 'home-quick-access-no-favorites' +const NO_RECENT_SETTING = 'home-quick-access-no-recent' + +export const QUICK_ACCESS_ROWS = [1, 2, 3, 4] as const +export type QuickAccessRows = (typeof QUICK_ACCESS_ROWS)[number] +const DEFAULT_ROWS: QuickAccessRows = 2 + +// Blocked storage (some private windows) throws on write; the setting then lasts the visit. +function save(name: string, value: string | undefined) { + try { + storeLocalSetting(name, value) + } catch {} +} + +function readRows(): QuickAccessRows { + const raw = getLocalSetting(ROWS_SETTING) + return QUICK_ACCESS_ROWS.find((r) => String(r) === raw) ?? DEFAULT_ROWS +} + +class HomeQuickAccessSettings { + hidden = $state(BROWSER && getLocalSetting(HIDDEN_SETTING) === 'true') + rows = $state<QuickAccessRows>(BROWSER ? readRows() : DEFAULT_ROWS) + showFavorites = $state(!(BROWSER && getLocalSetting(NO_FAVORITES_SETTING) === 'true')) + showRecent = $state(!(BROWSER && getLocalSetting(NO_RECENT_SETTING) === 'true')) + /** Bumped on each hide, so the header menu that holds the way back can flash. */ + hideCount = $state(0) + + setHidden(next: boolean) { + this.hidden = next + if (next) this.hideCount++ + save(HIDDEN_SETTING, next ? 'true' : undefined) + } + + setShowFavorites(next: boolean) { + this.showFavorites = next + save(NO_FAVORITES_SETTING, next ? undefined : 'true') + } + + setShowRecent(next: boolean) { + this.showRecent = next + save(NO_RECENT_SETTING, next ? undefined : 'true') + } + + setRows(next: QuickAccessRows) { + this.rows = next + save(ROWS_SETTING, next === DEFAULT_ROWS ? undefined : String(next)) + } +} + +export const homeQuickAccessSettings = new HomeQuickAccessSettings() diff --git a/frontend/src/lib/components/home/recentItems.svelte.ts b/frontend/src/lib/components/home/recentItems.svelte.ts new file mode 100644 index 0000000000..29057df0f3 --- /dev/null +++ b/frontend/src/lib/components/home/recentItems.svelte.ts @@ -0,0 +1,112 @@ +import { untrack } from 'svelte' +import { onUserChange, scopedKey } from '$lib/userScopedStorage' + +export type RecentKind = 'script' | 'flow' | 'app' | 'raw_app' | 'agent' + +export type RecentItem = { + kind: RecentKind + path: string + summary?: string + /** Epoch ms of the last time the item was opened, in its editor or on its own page. */ + at: number + /** How it was last opened: in its editor, or on its page to run or use it. Items stored + * before views were recorded have none, and were all edits. */ + via?: RecentVia +} + +export type RecentVia = 'edit' | 'view' + +type RecordedItem = Omit<RecentItem, 'at' | 'via'> & { via: RecentVia } + +const MAX_ITEMS = 30 +export const MAX_AGE_DAYS = 7 +const MAX_AGE_MS = MAX_AGE_DAYS * 24 * 60 * 60 * 1000 + +function fresh(items: RecentItem[]): RecentItem[] { + const cutoff = Date.now() - MAX_AGE_MS + return items.filter((x) => typeof x.at === 'number' && x.at >= cutoff) +} + +// Per user (scopedKey) and per workspace: what one person opened in one workspace says nothing +// about another, and a shared browser must not show the previous user's items. +function storageKey(workspace: string): string | undefined { + return scopedKey(`homeRecentItems:${workspace}`) +} + +function read(workspace: string): RecentItem[] { + const key = storageKey(workspace) + if (!key) return [] + try { + const parsed = JSON.parse(localStorage.getItem(key) ?? '[]') + return Array.isArray(parsed) + ? parsed.filter((x) => x && typeof x.path === 'string' && typeof x.kind === 'string') + : [] + } catch { + return [] + } +} + +function write(workspace: string, items: RecentItem[]): void { + const key = storageKey(workspace) + if (!key) return + try { + localStorage.setItem(key, JSON.stringify(items)) + } catch {} +} + +class RecentItems { + // Bumped on every write and on the user resolving, so readers re-read storage. + #version = $state(0) + // `++` reads the counter too: bumped from an effect (a page recording its visit), the effect + // would depend on it and re-run on its own write, forever. + #bump() { + untrack(() => this.#version++) + } + // A page opened by a cold load can finish loading before the user's email resolves, and the + // storage key needs it; those visits wait here instead of being dropped. + #pending: { workspace: string; item: RecordedItem }[] = [] + + constructor() { + onUserChange((email) => { + if (email) { + const pending = this.#pending + this.#pending = [] + for (const p of pending) this.record(p.workspace, p.item) + } + this.#bump() + }) + } + + list(workspace: string): RecentItem[] { + this.#version + return fresh(read(workspace)) + } + + /** Records an item as just opened. A missing summary keeps the one already stored. */ + record(workspace: string, item: RecordedItem): void { + if (!workspace || !item.path) return + if (!storageKey(workspace)) { + this.#pending.push({ workspace, item }) + return + } + const previous = fresh(read(workspace)) + const existing = previous.find((x) => x.kind === item.kind && x.path === item.path) + const next: RecentItem = { + ...item, + summary: item.summary || existing?.summary, + at: Date.now() + } + write(workspace, [next, ...previous.filter((x) => x !== existing)].slice(0, MAX_ITEMS)) + this.#bump() + } + + forget(workspace: string, kind: RecentKind, path: string): void { + write( + workspace, + read(workspace).filter((x) => !(x.kind === kind && x.path === path)) + ) + this.#bump() + } +} + +export const recentItems = new RecentItems() diff --git a/frontend/src/lib/components/home/treeViewState.svelte.ts b/frontend/src/lib/components/home/treeViewState.svelte.ts index b12597f51d..b8f23a7bcf 100644 --- a/frontend/src/lib/components/home/treeViewState.svelte.ts +++ b/frontend/src/lib/components/home/treeViewState.svelte.ts @@ -20,3 +20,39 @@ export class TreeViewState { storeLocalSetting(this.#settingName, value ? 'true' : undefined) } } + +/** The tree nodes a user opened by hand, by full path prefix, persisted under `settingName`. */ +export class TreeOpenMemory { + // Bounds what a long-lived browser accumulates; the oldest openings drop first. + static readonly MAX = 200 + #settingName: string + #open: string[] + + constructor(settingName: string) { + this.#settingName = settingName + let stored: unknown + try { + stored = JSON.parse(getLocalSetting(settingName) ?? '[]') + } catch {} + this.#open = Array.isArray(stored) ? stored.filter((k) => typeof k === 'string') : [] + } + + isOpen(prefix: string): boolean { + return this.#open.includes(prefix) + } + + setOpen(prefix: string, open: boolean) { + this.#open = this.#open.filter((k) => k !== prefix) + if (open) this.#open = [...this.#open, prefix].slice(-TreeOpenMemory.MAX) + this.#save() + } + + clear() { + this.#open = [] + this.#save() + } + + #save() { + storeLocalSetting(this.#settingName, this.#open.length ? JSON.stringify(this.#open) : undefined) + } +} diff --git a/frontend/src/lib/components/icons/ExcelIcon.svelte b/frontend/src/lib/components/icons/ExcelIcon.svelte new file mode 100644 index 0000000000..250214c56f --- /dev/null +++ b/frontend/src/lib/components/icons/ExcelIcon.svelte @@ -0,0 +1,76 @@ +<script lang="ts"> + interface Props { + height?: string + width?: string + size?: number + } + + let { height, width, size = 24 }: Props = $props() + let effectiveWidth = $derived(width ?? `${size}px`) + let effectiveHeight = $derived(height ?? `${size}px`) +</script> + +<!-- Microsoft's Excel-Icon-FY26 asset (cdn-dynmedia-1.microsoft.com), paths and gradient stops verbatim. + Microsoft's trademark guidelines forbid altering their brand assets, so the full-colour mark ships unchanged in both themes. --> +<svg + width={effectiveWidth} + height={effectiveHeight} + viewBox="0 0 48 48" + xmlns="http://www.w3.org/2000/svg" + fill="none" +> +<path d="M9 17.5C9 15.0147 11.0147 13 13.5 13H39V39C39 40.6569 37.6569 42 36 42H15C11.6863 42 9 39.3137 9 36V17.5Z" fill="url(#ms-excel-0)"/> +<path d="M9 17.5C9 15.0147 11.0147 13 13.5 13H39V39C39 40.6569 37.6569 42 36 42H15C11.6863 42 9 39.3137 9 36V17.5Z" fill="url(#ms-excel-1)" fill-opacity="0.7"/> +<path d="M9 22.4999C9 20.0147 11.0147 17.9999 13.5 17.9999H27C25.3431 17.9999 24 19.3431 24 20.9999V26.9999C24 28.6568 22.6569 29.9999 21 29.9999H15C11.6863 29.9999 9 32.6862 9 35.9999V22.4999Z" fill="url(#ms-excel-2)"/> +<path d="M9 22.4999C9 20.0147 11.0147 17.9999 13.5 17.9999H27C25.3431 17.9999 24 19.3431 24 20.9999V26.9999C24 28.6568 22.6569 29.9999 21 29.9999H15C11.6863 29.9999 9 32.6862 9 35.9999V22.4999Z" fill="url(#ms-excel-3)" fill-opacity="0.3"/> +<path d="M9 12C9 8.68629 11.6863 6 15 6H27V18H15C11.6863 18 9 20.6863 9 24V12Z" fill="url(#ms-excel-4)"/> +<path d="M9 12C9 8.68629 11.6863 6 15 6H27V18H15C11.6863 18 9 20.6863 9 24V12Z" fill="url(#ms-excel-5)"/> +<path d="M9 12C9 8.68629 11.6863 6 15 6H27V18H15C11.6863 18 9 20.6863 9 24V12Z" fill="url(#ms-excel-6)"/> +<rect width="15" height="12" rx="3" transform="matrix(-1 0 0 1 39 6)" fill="url(#ms-excel-7)"/> +<rect x="4" y="23" width="16" height="16" rx="3.25" fill="url(#ms-excel-8)"/> +<rect x="4" y="23" width="16" height="16" rx="3.25" fill="url(#ms-excel-9)" fill-opacity="0.3"/> +<path d="M16.2056 35.5714H13.7393L12.1907 32.6577C12.1355 32.5557 12.093 32.4749 12.0633 32.4154C12.0378 32.3517 12.0102 32.2794 11.9804 32.1987H11.9549C11.9167 32.3007 11.8806 32.3836 11.8466 32.4473C11.8126 32.5111 11.7722 32.5897 11.7255 32.6832L10.1196 35.5714H7.79352L10.5848 30.9936L7.9847 26.4286H10.4191L11.7956 29.0299C11.8508 29.1362 11.8976 29.2297 11.9358 29.3104C11.9783 29.3869 12.0208 29.4783 12.0633 29.5846H12.0888C12.1482 29.4613 12.195 29.3636 12.229 29.2913C12.2672 29.2191 12.3182 29.1234 12.3819 29.0044L13.8094 26.4286H16.1291L13.4908 30.9235L16.2056 35.5714Z" fill="white"/> +<defs> +<radialGradient id="ms-excel-0" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(39 44.4167) rotate(-133.549) scale(41.1236 31.9082)"> +<stop offset="0.0647233" stop-color="#379539"/> +<stop offset="0.422149" stop-color="#297C2D"/> +<stop offset="0.703448" stop-color="#15561C"/> +</radialGradient> +<radialGradient id="ms-excel-1" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(18.1667 23.4722) rotate(-135.971) scale(16.6611 12.8906)"> +<stop stop-color="#073B10"/> +<stop offset="0.992234" stop-color="#084A13" stop-opacity="0"/> +</radialGradient> +<linearGradient id="ms-excel-2" x1="9" y1="26.9999" x2="22.7848" y2="26.9999" gradientUnits="userSpaceOnUse"> +<stop stop-color="#52D17C"/> +<stop offset="0.328512" stop-color="#4AA647"/> +</linearGradient> +<linearGradient id="ms-excel-3" x1="22.5" y1="17.9999" x2="22.5" y2="30.5027" gradientUnits="userSpaceOnUse"> +<stop stop-color="#29852F"/> +<stop offset="0.499968" stop-color="#4AA647" stop-opacity="0"/> +</linearGradient> +<linearGradient id="ms-excel-4" x1="9.66301" y1="20.5895" x2="26.2944" y2="6.16483" gradientUnits="userSpaceOnUse"> +<stop stop-color="#66D052"/> +<stop offset="1" stop-color="#85E972"/> +</linearGradient> +<radialGradient id="ms-excel-5" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(26.352 13.6634) rotate(-180) scale(9.01883 19.094)"> +<stop offset="0.292495" stop-color="#4EB43B"/> +<stop offset="1" stop-color="#72CC61" stop-opacity="0"/> +</radialGradient> +<linearGradient id="ms-excel-6" x1="17.1147" y1="14.3923" x2="9" y2="14.3923" gradientUnits="userSpaceOnUse"> +<stop offset="0.183694" stop-color="#C0E075" stop-opacity="0"/> +<stop offset="1" stop-color="#D1EB95"/> +</linearGradient> +<radialGradient id="ms-excel-7" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(-1.91094 13.6794) rotate(-38.9697) scale(21.751 21.6904)"> +<stop offset="0.439983" stop-color="#79E96D"/> +<stop offset="1" stop-color="#D0EB76"/> +</radialGradient> +<radialGradient id="ms-excel-8" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(4 23) rotate(45) scale(22.6274 64.4141)"> +<stop stop-color="#20A85E"/> +<stop offset="0.94375" stop-color="#09442A"/> +</radialGradient> +<radialGradient id="ms-excel-9" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(12 32.6) rotate(90) scale(11.2 12.9)"> +<stop offset="0.580357" stop-color="#33A662" stop-opacity="0"/> +<stop offset="0.973806" stop-color="#98F0B0"/> +</radialGradient> +</defs> +</svg> diff --git a/frontend/src/lib/components/icons/OnedriveIcon.svelte b/frontend/src/lib/components/icons/OnedriveIcon.svelte new file mode 100644 index 0000000000..71b5f60cf4 --- /dev/null +++ b/frontend/src/lib/components/icons/OnedriveIcon.svelte @@ -0,0 +1,69 @@ +<script lang="ts"> + interface Props { + height?: string + width?: string + size?: number + } + + let { height, width, size = 24 }: Props = $props() + let effectiveWidth = $derived(width ?? `${size}px`) + let effectiveHeight = $derived(height ?? `${size}px`) +</script> + +<!-- Microsoft's OneDrive-Icon-FY26 asset (cdn-dynmedia-1.microsoft.com), paths and gradient stops verbatim. + Microsoft's trademark guidelines forbid altering their brand assets, so the full-colour mark ships unchanged in both themes. --> +<svg + width={effectiveWidth} + height={effectiveHeight} + viewBox="0 0 48 48" + xmlns="http://www.w3.org/2000/svg" + fill="none" +> +<path d="M14.3386 13.6726C7.73405 13.6728 2.7972 19.0791 2.42554 25.1018C2.65561 26.3995 3.41182 28.9621 4.59605 28.8305C6.07633 28.666 9.80446 28.8305 12.9843 23.0738C15.3072 18.8685 20.0856 13.6725 14.3386 13.6726Z" fill="url(#ms-onedrive-0)"/> +<path d="M12.8114 15.9208C10.5913 19.4358 7.60292 24.4724 6.59433 26.0573C5.39543 27.9413 2.22036 27.1409 2.48339 24.4405C2.45746 24.6597 2.43754 24.8807 2.42381 25.1032C1.98946 32.1289 7.55988 37.9635 14.4916 37.9635C22.1313 37.9635 40.3512 28.4448 38.5073 18.9074C36.5639 13.3012 31.1015 9.28009 24.911 9.28009C18.7205 9.28009 14.753 12.8466 12.8114 15.9208Z" fill="url(#ms-onedrive-1)"/> +<path d="M12.8114 15.9208C10.5913 19.4358 7.60292 24.4724 6.59433 26.0573C5.39543 27.9413 2.22036 27.1409 2.48339 24.4405C2.45746 24.6597 2.43754 24.8807 2.42381 25.1032C1.98946 32.1289 7.55988 37.9635 14.4916 37.9635C22.1313 37.9635 40.3512 28.4448 38.5073 18.9074C36.5639 13.3012 31.1015 9.28009 24.911 9.28009C18.7205 9.28009 14.753 12.8466 12.8114 15.9208Z" fill="url(#ms-onedrive-2)" fill-opacity="0.4"/> +<path d="M12.8114 15.9208C10.5913 19.4358 7.60292 24.4724 6.59433 26.0573C5.39543 27.9413 2.22036 27.1409 2.48339 24.4405C2.45746 24.6597 2.43754 24.8807 2.42381 25.1032C1.98946 32.1289 7.55988 37.9635 14.4916 37.9635C22.1313 37.9635 40.3512 28.4448 38.5073 18.9074C36.5639 13.3012 31.1015 9.28009 24.911 9.28009C18.7205 9.28009 14.753 12.8466 12.8114 15.9208Z" fill="url(#ms-onedrive-3)"/> +<path d="M12.8114 15.9208C10.5913 19.4358 7.60292 24.4724 6.59433 26.0573C5.39543 27.9413 2.22036 27.1409 2.48339 24.4405C2.45746 24.6597 2.43754 24.8807 2.42381 25.1032C1.98946 32.1289 7.55988 37.9635 14.4916 37.9635C22.1313 37.9635 40.3512 28.4448 38.5073 18.9074C36.5639 13.3012 31.1015 9.28009 24.911 9.28009C18.7205 9.28009 14.753 12.8466 12.8114 15.9208Z" fill="url(#ms-onedrive-4)" fill-opacity="0.6"/> +<path d="M12.8114 15.9208C10.5913 19.4358 7.60292 24.4724 6.59433 26.0573C5.39543 27.9413 2.22036 27.1409 2.48339 24.4405C2.45746 24.6597 2.43754 24.8807 2.42381 25.1032C1.98946 32.1289 7.55988 37.9635 14.4916 37.9635C22.1313 37.9635 40.3512 28.4448 38.5073 18.9074C36.5639 13.3012 31.1015 9.28009 24.911 9.28009C18.7205 9.28009 14.753 12.8466 12.8114 15.9208Z" fill="url(#ms-onedrive-5)" fill-opacity="0.9"/> +<path d="M14.38 37.9665C14.38 37.9665 32.6214 38.0024 35.7155 38.0024C41.3306 38.0024 45.5999 33.4181 45.6 28.0688C45.6 22.7194 41.2449 18.1629 35.7155 18.1629C30.1861 18.1629 27.0019 22.2996 24.6102 26.815C21.8077 32.1062 18.2326 37.903 14.38 37.9665Z" fill="url(#ms-onedrive-6)"/> +<path d="M14.38 37.9665C14.38 37.9665 32.6214 38.0024 35.7155 38.0024C41.3306 38.0024 45.5999 33.4181 45.6 28.0688C45.6 22.7194 41.2449 18.1629 35.7155 18.1629C30.1861 18.1629 27.0019 22.2996 24.6102 26.815C21.8077 32.1062 18.2326 37.903 14.38 37.9665Z" fill="url(#ms-onedrive-7)" fill-opacity="0.4"/> +<path d="M14.38 37.9665C14.38 37.9665 32.6214 38.0024 35.7155 38.0024C41.3306 38.0024 45.5999 33.4181 45.6 28.0688C45.6 22.7194 41.2449 18.1629 35.7155 18.1629C30.1861 18.1629 27.0019 22.2996 24.6102 26.815C21.8077 32.1062 18.2326 37.903 14.38 37.9665Z" fill="url(#ms-onedrive-8)" fill-opacity="0.9"/> +<defs> +<radialGradient id="ms-onedrive-0" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(3.24464 15.2511) rotate(50.1526) scale(13.6159 22.5845)"> +<stop stop-color="#4894FE"/> +<stop offset="0.695072" stop-color="#0934B3"/> +</radialGradient> +<radialGradient id="ms-onedrive-1" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(39.7971 -0.425349) rotate(130.923) scale(58.5497 43.3858)"> +<stop offset="0.165327" stop-color="#23C0FE"/> +<stop offset="0.534" stop-color="#1C91FF"/> +</radialGradient> +<radialGradient id="ms-onedrive-2" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(12.0798 16.0264) rotate(-139.986) scale(11.9037 27.2501)"> +<stop stop-color="white"/> +<stop offset="0.660528" stop-color="#ADC0FF" stop-opacity="0"/> +</radialGradient> +<radialGradient id="ms-onedrive-3" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(25.0236 30.0957) rotate(-139.764) scale(13.4172 20.3769)"> +<stop stop-color="#033ACC"/> +<stop offset="1" stop-color="#368EFF" stop-opacity="0"/> +</radialGradient> +<radialGradient id="ms-onedrive-4" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(11.2919 8.37262) rotate(66.5713) scale(29.4404 31.7826)"> +<stop offset="0.592618" stop-color="#3464E3" stop-opacity="0"/> +<stop offset="1" stop-color="#033ACC"/> +</radialGradient> +<radialGradient id="ms-onedrive-5" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(39.3251 2.63231) rotate(135) scale(43.306 67.8459)"> +<stop stop-color="#4BFDE8"/> +<stop offset="0.543937" stop-color="#4BFDE8" stop-opacity="0"/> +</radialGradient> +<linearGradient id="ms-onedrive-6" x1="29.9997" y1="37.9823" x2="29.9997" y2="18.3982" gradientUnits="userSpaceOnUse"> +<stop stop-color="#0086FF"/> +<stop offset="0.49" stop-color="#00BBFF"/> +</linearGradient> +<radialGradient id="ms-onedrive-7" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(19.7659 20.4961) rotate(21.6324) scale(19.6236 37.1605)"> +<stop stop-color="white"/> +<stop offset="0.785262" stop-color="white" stop-opacity="0"/> +</radialGradient> +<radialGradient id="ms-onedrive-8" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(44.9897 16.6252) rotate(139.243) scale(26.9034 26.9946)"> +<stop stop-color="#4BFDE8"/> +<stop offset="0.584724" stop-color="#4BFDE8" stop-opacity="0"/> +</radialGradient> +</defs> +</svg> diff --git a/frontend/src/lib/components/icons/SharepointIcon.svelte b/frontend/src/lib/components/icons/SharepointIcon.svelte new file mode 100644 index 0000000000..26e1a30e72 --- /dev/null +++ b/frontend/src/lib/components/icons/SharepointIcon.svelte @@ -0,0 +1,88 @@ +<script lang="ts"> + interface Props { + height?: string + width?: string + size?: number + } + + let { height, width, size = 24 }: Props = $props() + let effectiveWidth = $derived(width ?? `${size}px`) + let effectiveHeight = $derived(height ?? `${size}px`) +</script> + +<!-- Microsoft's SharePoint-Icon-FY26 asset (cdn-dynmedia-1.microsoft.com), paths and gradient stops verbatim. + Microsoft's trademark guidelines forbid altering their brand assets, so the full-colour mark ships unchanged in both themes. --> +<svg + width={effectiveWidth} + height={effectiveHeight} + viewBox="0 0 48 48" + xmlns="http://www.w3.org/2000/svg" + fill="none" +> +<path d="M19 28C25.6274 28 31 22.6274 31 16C31 9.37258 25.6274 4 19 4C12.3726 4 7 9.37258 7 16C7 22.6274 12.3726 28 19 28Z" fill="url(#ms-sharepoint-0)"/> +<path d="M19 28C25.6274 28 31 22.6274 31 16C31 9.37258 25.6274 4 19 4C12.3726 4 7 9.37258 7 16C7 22.6274 12.3726 28 19 28Z" fill="url(#ms-sharepoint-1)" fill-opacity="0.2"/> +<path d="M19 28C25.6274 28 31 22.6274 31 16C31 9.37258 25.6274 4 19 4C12.3726 4 7 9.37258 7 16C7 22.6274 12.3726 28 19 28Z" fill="url(#ms-sharepoint-2)" fill-opacity="0.31"/> +<path d="M19 28C25.6274 28 31 22.6274 31 16C31 9.37258 25.6274 4 19 4C12.3726 4 7 9.37258 7 16C7 22.6274 12.3726 28 19 28Z" fill="url(#ms-sharepoint-3)" fill-opacity="0.7"/> +<path d="M29.9453 36C35.4682 36 39.9453 31.5228 39.9453 26C39.9453 20.4772 35.4682 16 29.9453 16C24.4225 16 19.9453 20.4772 19.9453 26C19.9453 31.5228 24.4225 36 29.9453 36Z" fill="url(#ms-sharepoint-4)"/> +<path d="M29.9453 36C35.4682 36 39.9453 31.5228 39.9453 26C39.9453 20.4772 35.4682 16 29.9453 16C24.4225 16 19.9453 20.4772 19.9453 26C19.9453 31.5228 24.4225 36 29.9453 36Z" fill="url(#ms-sharepoint-5)" fill-opacity="0.5"/> +<path d="M29.9453 36C35.4682 36 39.9453 31.5228 39.9453 26C39.9453 20.4772 35.4682 16 29.9453 16C24.4225 16 19.9453 20.4772 19.9453 26C19.9453 31.5228 24.4225 36 29.9453 36Z" fill="url(#ms-sharepoint-6)" fill-opacity="0.7"/> +<path d="M20.5 44C25.1944 44 29 40.1944 29 35.5C29 30.8056 25.1944 27 20.5 27C15.8056 27 12 30.8056 12 35.5C12 40.1944 15.8056 44 20.5 44Z" fill="url(#ms-sharepoint-7)"/> +<path d="M20.5 44C25.1944 44 29 40.1944 29 35.5C29 30.8056 25.1944 27 20.5 27C15.8056 27 12 30.8056 12 35.5C12 40.1944 15.8056 44 20.5 44Z" fill="url(#ms-sharepoint-8)" fill-opacity="0.32"/> +<rect x="4" y="23" width="16" height="16" rx="3.25" fill="url(#ms-sharepoint-9)"/> +<rect x="4" y="23" width="16" height="16" rx="3.25" fill="url(#ms-sharepoint-10)" fill-opacity="0.6"/> +<path d="M8.55579 33.6836L10.2892 32.8076C10.4846 33.1901 10.7395 33.4718 11.0539 33.6528C11.3725 33.8338 11.7209 33.9243 12.099 33.9243C12.5196 33.9243 12.8404 33.842 13.0613 33.6775C13.2823 33.5089 13.3927 33.2559 13.3927 32.9187C13.3927 32.6554 13.2865 32.4333 13.0741 32.2524C12.8617 32.0673 12.4857 31.9275 11.9461 31.8329C10.918 31.6519 10.1702 31.3352 9.70289 30.8828C9.2398 30.4304 9.00825 29.8669 9.00825 29.1924C9.00825 28.3534 9.31415 27.683 9.92593 27.1812C10.5377 26.6795 11.3449 26.4286 12.3476 26.4286C13.0231 26.4286 13.6179 26.5623 14.132 26.8296C14.646 27.0969 15.0539 27.4794 15.3555 27.9771L13.6604 28.8223C13.4734 28.5426 13.2716 28.3411 13.055 28.2177C12.8383 28.0902 12.5664 28.0264 12.2392 28.0264C11.8484 28.0264 11.551 28.1087 11.3471 28.2732C11.1474 28.4377 11.0475 28.6516 11.0475 28.9148C11.0475 29.141 11.1431 29.3405 11.3343 29.5132C11.5297 29.6818 11.9206 29.8196 12.5069 29.9266C13.4926 30.1075 14.2275 30.4366 14.7119 30.9136C15.2005 31.3866 15.4447 31.985 15.4447 32.7089C15.4447 33.5891 15.1537 34.2862 14.5717 34.8003C13.9896 35.3144 13.1591 35.5714 12.0799 35.5714C11.2982 35.5714 10.5929 35.4069 9.96417 35.0779C9.33964 34.7448 8.87018 34.28 8.55579 33.6836Z" fill="white"/> +<path d="M8.55322 33.772L10.2878 32.8665C10.4833 33.2618 10.7384 33.5531 11.053 33.7401C11.3718 33.9272 11.7204 34.0207 12.0988 34.0207C12.5197 34.0207 12.8407 33.9357 13.0617 33.7656C13.2828 33.5913 13.3933 33.3299 13.3933 32.9813C13.3933 32.7092 13.287 32.4796 13.0745 32.2925C12.8619 32.1012 12.4857 31.9567 11.9458 31.8589C10.9169 31.6719 10.1687 31.3445 9.70107 30.8769C9.23768 30.4092 9.00599 29.8268 9.00599 29.1296C9.00599 28.2623 9.31208 27.5694 9.92427 27.0507C10.5365 26.532 11.3442 26.2727 12.3475 26.2727C13.0235 26.2727 13.6186 26.4109 14.1331 26.6872C14.6475 26.9635 15.0556 27.3589 15.3574 27.8733L13.6612 28.747C13.4741 28.4579 13.2722 28.2496 13.0554 28.122C12.8385 27.9902 12.5665 27.9243 12.2391 27.9243C11.848 27.9243 11.5504 28.0094 11.3463 28.1794C11.1465 28.3495 11.0466 28.5705 11.0466 28.8426C11.0466 29.0764 11.1423 29.2826 11.3336 29.4612C11.5291 29.6355 11.9203 29.7779 12.5069 29.8884C13.4932 30.0755 14.2287 30.4156 14.7134 30.9088C15.2023 31.3976 15.4467 32.0162 15.4467 32.7644C15.4467 33.6742 15.1555 34.3948 14.5731 34.9262C13.9906 35.4576 13.1595 35.7233 12.0797 35.7233C11.2974 35.7233 10.5917 35.5533 9.96253 35.2132C9.33759 34.8688 8.86782 34.3884 8.55322 33.772Z" fill="white"/> +<defs> +<linearGradient id="ms-sharepoint-0" x1="10" y1="7" x2="26" y2="28" gradientUnits="userSpaceOnUse"> +<stop stop-color="#00E3DF"/> +<stop offset="0.410156" stop-color="#0097A8"/> +<stop offset="1" stop-color="#007791"/> +</linearGradient> +<radialGradient id="ms-sharepoint-1" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(22.6718 31.3198) rotate(-112.448) scale(18.4261 33.1291)"> +<stop offset="0.28573" stop-color="#003B5D"/> +<stop offset="0.612265" stop-color="#004A6C" stop-opacity="0.688298"/> +<stop offset="0.968041" stop-color="#006F94" stop-opacity="0"/> +</radialGradient> +<radialGradient id="ms-sharepoint-2" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(23.0954 26.0253) rotate(-112.063) scale(15.5519 27.9272)"> +<stop offset="0.259744" stop-color="#002A42"/> +<stop offset="0.612265" stop-color="#004261" stop-opacity="0.688298"/> +<stop offset="0.968041" stop-color="#006F94" stop-opacity="0"/> +</radialGradient> +<radialGradient id="ms-sharepoint-3" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(25.8486 5.27069) rotate(124.447) scale(13.0107)"> +<stop stop-color="#78EDFF"/> +<stop offset="1" stop-color="#2CCFCA" stop-opacity="0"/> +</radialGradient> +<linearGradient id="ms-sharepoint-4" x1="22.4453" y1="18.5" x2="35.7786" y2="36" gradientUnits="userSpaceOnUse"> +<stop stop-color="#00E3DF"/> +<stop offset="0.476427" stop-color="#00A2B8"/> +<stop offset="0.945063" stop-color="#00637C"/> +</linearGradient> +<radialGradient id="ms-sharepoint-5" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(24.2161 34.3333) rotate(-70.8012) scale(12.3537 22.2587)"> +<stop stop-color="#003B5D"/> +<stop offset="0.492035" stop-color="#004C6C" stop-opacity="0.72"/> +<stop offset="0.968041" stop-color="#007A86" stop-opacity="0"/> +</radialGradient> +<radialGradient id="ms-sharepoint-6" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(35.6525 17.0589) rotate(124.447) scale(10.8423)"> +<stop stop-color="#78EDFF"/> +<stop offset="1" stop-color="#2CCFCA" stop-opacity="0"/> +</radialGradient> +<linearGradient id="ms-sharepoint-7" x1="14.55" y1="27.85" x2="23.05" y2="44.85" gradientUnits="userSpaceOnUse"> +<stop offset="0.0534989" stop-color="#75FFF6"/> +<stop offset="0.51144" stop-color="#00C7D1"/> +<stop offset="0.96002" stop-color="#0096AD"/> +</linearGradient> +<linearGradient id="ms-sharepoint-8" x1="28.961" y1="44.0008" x2="24.4188" y2="37.6258" gradientUnits="userSpaceOnUse"> +<stop offset="0.259744" stop-color="#0E5A5D"/> +<stop offset="0.535716" stop-color="#126C6B" stop-opacity="0.688298"/> +<stop offset="0.968041" stop-color="#1C948A" stop-opacity="0"/> +</linearGradient> +<radialGradient id="ms-sharepoint-9" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(4 23) rotate(45) scale(22.6274)"> +<stop offset="0.0625" stop-color="#00B6BD"/> +<stop offset="0.890131" stop-color="#00495C"/> +</radialGradient> +<radialGradient id="ms-sharepoint-10" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(12 32.6) rotate(90) scale(11.2 12.75)"> +<stop offset="0.566964" stop-color="#1E8581" stop-opacity="0"/> +<stop offset="0.973806" stop-color="#1ECBE6"/> +</radialGradient> +</defs> +</svg> diff --git a/frontend/src/lib/components/icons/WordIcon.svelte b/frontend/src/lib/components/icons/WordIcon.svelte new file mode 100644 index 0000000000..e72371b1e6 --- /dev/null +++ b/frontend/src/lib/components/icons/WordIcon.svelte @@ -0,0 +1,65 @@ +<script lang="ts"> + interface Props { + height?: string + width?: string + size?: number + } + + let { height, width, size = 24 }: Props = $props() + let effectiveWidth = $derived(width ?? `${size}px`) + let effectiveHeight = $derived(height ?? `${size}px`) +</script> + +<!-- Microsoft's Word-Icon-FY26 asset (cdn-dynmedia-1.microsoft.com), paths and gradient stops verbatim. + Microsoft's trademark guidelines forbid altering their brand assets, so the full-colour mark ships unchanged in both themes. --> +<svg + width={effectiveWidth} + height={effectiveHeight} + viewBox="0 0 48 48" + xmlns="http://www.w3.org/2000/svg" + fill="none" +> +<path d="M9 33.0901L23 16L39 27.1052V38.5C39 40.433 37.433 42 35.5 42H15C11.6863 42 9 39.3137 9 36V33.0901Z" fill="url(#ms-word-0)"/> +<path d="M9 21.0385C9 18.5532 11.0147 16.5385 13.5 16.5385H33.8889L39 14V26.5C39 28.433 37.433 30 35.5 30H15C11.6863 30 9 32.6863 9 36V21.0385Z" fill="url(#ms-word-1)"/> +<path d="M9 21.0385C9 18.5532 11.0147 16.5385 13.5 16.5385H33.8889L39 14V26.5C39 28.433 37.433 30 35.5 30H15C11.6863 30 9 32.6863 9 36V21.0385Z" fill="url(#ms-word-2)" fill-opacity="0.6"/> +<path d="M9 21.0385C9 18.5532 11.0147 16.5385 13.5 16.5385H33.8889L39 14V26.5C39 28.433 37.433 30 35.5 30H15C11.6863 30 9 32.6863 9 36V21.0385Z" fill="url(#ms-word-3)" fill-opacity="0.1"/> +<path d="M9 12C9 8.68629 11.6863 6 15 6H35.5C37.433 6 39 7.567 39 9.5V14.5C39 16.433 37.433 18 35.5 18H15C11.6863 18 9 20.6863 9 24V12Z" fill="url(#ms-word-4)"/> +<path d="M9 12C9 8.68629 11.6863 6 15 6H35.5C37.433 6 39 7.567 39 9.5V14.5C39 16.433 37.433 18 35.5 18H15C11.6863 18 9 20.6863 9 24V12Z" fill="url(#ms-word-5)" fill-opacity="0.8"/> +<rect x="4" y="23" width="16" height="16" rx="3.25" fill="url(#ms-word-6)"/> +<rect x="4" y="23" width="16" height="16" rx="3.25" fill="url(#ms-word-7)" fill-opacity="0.65"/> +<path d="M17.4859 26.4286L15.5167 35.5703L13.1628 35.5714L12.0002 30.0857L10.7832 35.5714H8.40686L6.51451 26.4297H8.45546L9.62308 32.4629L10.7832 26.4297H13.1628L14.3774 32.4629L15.5167 26.4297L17.4859 26.4286Z" fill="white"/> +<defs> +<radialGradient id="ms-word-0" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(38.1254 41.9993) scale(47.5733 20.1494)"> +<stop offset="0.180414" stop-color="#1657F4"/> +<stop offset="0.574542" stop-color="#0036C4"/> +</radialGradient> +<linearGradient id="ms-word-1" x1="9" y1="25" x2="31.9747" y2="25" gradientUnits="userSpaceOnUse"> +<stop stop-color="#66C0FF"/> +<stop offset="0.25606" stop-color="#0094F0"/> +</linearGradient> +<radialGradient id="ms-word-2" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(39 14.7158) rotate(133.546) scale(29.3648 72.3194)"> +<stop offset="0.140259" stop-color="#D471FF"/> +<stop offset="0.830508" stop-color="#509DF5" stop-opacity="0"/> +</radialGradient> +<radialGradient id="ms-word-3" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(34.6111 29.2308) rotate(90) scale(18.6154 101.647)"> +<stop offset="0.283333" stop-color="#4F006F" stop-opacity="0"/> +<stop offset="1" stop-color="#4F006F"/> +</radialGradient> +<linearGradient id="ms-word-4" x1="9" y1="15" x2="38.9918" y2="15.4954" gradientUnits="userSpaceOnUse"> +<stop stop-color="#9DEAFF"/> +<stop offset="0.201276" stop-color="#3BD5FF"/> +</linearGradient> +<radialGradient id="ms-word-5" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(39 6.58563) rotate(166.855) scale(29.4949 70.6435)"> +<stop offset="0.0606583" stop-color="#E4A7FE"/> +<stop offset="0.538869" stop-color="#E4A7FE" stop-opacity="0"/> +</radialGradient> +<radialGradient id="ms-word-6" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(4 23) rotate(45) scale(22.6274)"> +<stop offset="0.0811439" stop-color="#367AF2"/> +<stop offset="0.871875" stop-color="#001A8F"/> +</radialGradient> +<radialGradient id="ms-word-7" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(12 32.6) rotate(90) scale(11.2 12.765)"> +<stop offset="0.586954" stop-color="#2763E5" stop-opacity="0"/> +<stop offset="0.973806" stop-color="#58AAFE"/> +</radialGradient> +</defs> +</svg> diff --git a/frontend/src/lib/components/icons/index.ts b/frontend/src/lib/components/icons/index.ts index 70d2450f29..c8f4cd8f38 100644 --- a/frontend/src/lib/components/icons/index.ts +++ b/frontend/src/lib/components/icons/index.ts @@ -3,6 +3,10 @@ import DbIcon from './DbIcon.svelte' import DiscordIcon from './DiscordIcon.svelte' import GcalIcon from './GcalIcon.svelte' import GdriveIcon from './GdriveIcon.svelte' +import OnedriveIcon from './OnedriveIcon.svelte' +import SharepointIcon from './SharepointIcon.svelte' +import ExcelIcon from './ExcelIcon.svelte' +import WordIcon from './WordIcon.svelte' import GithubIcon from './GithubIcon.svelte' import GitlabIcon from './GitlabIcon.svelte' import GmailIcon from './GmailIcon.svelte' @@ -361,6 +365,10 @@ export const APP_TO_ICON_COMPONENT = { gcloud: GoogleCloudIcon, gcal: GcalIcon, gdrive: GdriveIcon, + ms_onedrive: OnedriveIcon, + ms_sharepoint: SharepointIcon, + ms_excel: ExcelIcon, + ms_word: WordIcon, airtable: AirtableIcon, toggl: TogglIcon, s3: S3Icon, @@ -444,6 +452,7 @@ export const APP_TO_ICON_COMPONENT = { resend: ResendIcon, pushover: PushoverIcon, quickbooks: QuickbooksIcon, + ms_teams: MsTeamsIcon, ms_teams_webhook: MsTeamsIcon, teams: MsTeamsIcon, ms_outlook: OutlookIcon, @@ -694,6 +703,10 @@ export { DiscordIcon, GcalIcon, GdriveIcon, + OnedriveIcon, + SharepointIcon, + ExcelIcon, + WordIcon, GithubIcon, GitlabIcon, GmailIcon, diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index 9681e821e6..54c59f3362 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -6,6 +6,7 @@ import { ACCENT_COLOR_SETTING, parseAccentColor } from '$lib/accentColor' import { parseMaxTokenExpirationDays } from '$lib/tokenExpiration' import { writable } from 'svelte/store' import { OidcService } from '$lib/gen' +import { workspaceRolesErrors } from '$lib/workspaceRoles' /** * Bumped after instance settings are successfully saved. Settings whose display @@ -77,6 +78,8 @@ export interface Setting { | 'retention_overrides' | 'instance_banner' | 'accent_color' + | 'agent_workload_identity_issuers' + | 'workspace_roles' storage: SettingStorage advancedToggle?: { label: string @@ -634,6 +637,16 @@ export const settings: Record<string, Setting[]> = { (Number.isInteger(Number(v)) && Number(v) >= 0 && Number(v) <= 3650) } ], + 'Workspace roles': [ + { + label: 'Workspace roles', + key: 'workspace_roles', + fieldType: 'workspace_roles', + storage: 'setting', + ee_only: 'Custom workspace roles are only available in the EE version', + validate: workspaceRolesErrors + } + ], 'Managed Postgres': [ { label: 'External instance', @@ -830,6 +843,17 @@ export const settings: Record<string, Setting[]> = { ee_only: '' } ], + 'Agent workers': [ + { + label: 'Workload identity issuers', + key: 'agent_workload_identity_issuers', + description: + 'An issuer lets an agent worker authenticate with a short-lived token its platform issues it (a Kubernetes projected service account token, a SPIFFE JWT-SVID, ...) in place of an agent token: point AGENT_IDENTITY_TOKEN_FILE at it. The token must be signed by a trusted issuer, carry its audience, and match a rule, which decides the worker group and tags the agent may serve. <a href="https://www.windmill.dev/docs/core_concepts/agent_workers">Learn more</a>', + fieldType: 'agent_workload_identity_issuers', + storage: 'setting', + ee_only: '' + } + ], 'DB Health': [], 'OIDC signing': [], Registries: [ @@ -1313,6 +1337,13 @@ export const instanceSettingsNavigationGroups = [ label: 'Jobs', aiId: 'instance-settings-jobs', aiDescription: 'Instance jobs settings' + }, + { + id: 'workspace_roles', + label: 'Workspace roles', + aiId: 'instance-settings-workspace-roles', + aiDescription: 'Roles workspace members can hold and their capabilities', + isEE: true } ] }, @@ -1338,6 +1369,13 @@ export const instanceSettingsNavigationGroups = [ aiDescription: 'Instance SCIM/SAML settings', isEE: true }, + { + id: 'agent_workers', + label: 'Agent workers', + aiId: 'instance-settings-agent-workers', + aiDescription: 'Workload identity issuers agent workers can authenticate with', + isEE: true + }, { id: 'oidc_signing', label: 'OIDC signing', @@ -1497,11 +1535,13 @@ export const tabToCategoryMap: Record<string, string> = { object_storage: 'Object Storage', managed_postgres: 'Managed Postgres', jobs: 'Jobs', + workspace_roles: 'Workspace roles', private_hub: 'Private Hub', github_enterprise_app: 'GitHub App', websocket: 'WebSocket', db_health: 'DB Health', oidc_signing: 'OIDC signing', + agent_workers: 'Agent workers', lsp: 'LSP' } @@ -1559,11 +1599,13 @@ export const categoryToTabMap: Record<string, string> = { 'Object Storage': 'object_storage', 'Managed Postgres': 'managed_postgres', Jobs: 'jobs', + 'Workspace roles': 'workspace_roles', 'Private Hub': 'private_hub', 'GitHub App': 'github_enterprise_app', WebSocket: 'websocket', 'DB Health': 'db_health', 'OIDC signing': 'oidc_signing', + 'Agent workers': 'agent_workers', LSP: 'lsp' } diff --git a/frontend/src/lib/components/instanceSettings/AgentWorkloadIdentityIssuers.svelte b/frontend/src/lib/components/instanceSettings/AgentWorkloadIdentityIssuers.svelte new file mode 100644 index 0000000000..f7eb91a8b7 --- /dev/null +++ b/frontend/src/lib/components/instanceSettings/AgentWorkloadIdentityIssuers.svelte @@ -0,0 +1,276 @@ +<script lang="ts"> + import type { Writable } from 'svelte/store' + import { Button } from '../common' + import TextInput from '../text_input/TextInput.svelte' + import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte' + import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte' + import { Plus, Trash2, X } from 'lucide-svelte' + + interface Props { + values: Writable<Record<string, any>> + disabled?: boolean + } + + let { values, disabled = false }: Props = $props() + + const KEY = 'agent_workload_identity_issuers' + + type KeySource = 'discovery' | 'jwks_uri' | 'inline' + type ClaimRow = { id: number; path: string; value: string } + type RuleRow = { id: number; claims: ClaimRow[]; worker_group: string; tags: string } + type IssuerRow = { + id: number + issuer: string + audience: string + keySource: KeySource + jwks_uri: string + jwks: string + rules: RuleRow[] + } + + let nextId = 0 + // Rows keep a stable identity while editing; `lastSynced` tells our own writes to + // `$values[KEY]` apart from external ones (load, save, discard), which re-hydrate. + let issuers = $state<IssuerRow[]>([]) + let lastSynced: string | undefined = undefined + + function newRule(): RuleRow { + return { + id: nextId++, + claims: [{ id: nextId++, path: 'sub', value: '' }], + worker_group: '', + tags: '' + } + } + + function toRows(v: unknown): IssuerRow[] { + if (!Array.isArray(v)) return [] + return v.map((i: any) => ({ + id: nextId++, + issuer: i.issuer ?? '', + audience: i.audience ?? '', + keySource: i.jwks ? 'inline' : i.jwks_uri ? 'jwks_uri' : 'discovery', + jwks_uri: i.jwks_uri ?? '', + jwks: i.jwks ?? '', + rules: (i.rules ?? []).map((r: any) => ({ + id: nextId++, + claims: Object.entries(r.claims ?? {}).map(([path, value]) => ({ + id: nextId++, + path, + value: String(value) + })), + worker_group: r.worker_group ?? '', + tags: (r.tags ?? []).join(', ') + })) + })) + } + + function serialize(rows: IssuerRow[]): any[] | undefined { + if (rows.length === 0) return undefined + return rows.map((i) => ({ + issuer: i.issuer.trim(), + ...(i.keySource === 'jwks_uri' && i.jwks_uri.trim() ? { jwks_uri: i.jwks_uri.trim() } : {}), + ...(i.keySource === 'inline' && i.jwks.trim() ? { jwks: i.jwks.trim() } : {}), + audience: i.audience.trim(), + rules: i.rules.map((r) => ({ + claims: Object.fromEntries( + r.claims.filter((c) => c.path.trim()).map((c) => [c.path.trim(), c.value]) + ), + worker_group: r.worker_group.trim(), + tags: r.tags + .split(',') + .map((t) => t.trim()) + .filter((t) => t) + })) + })) + } + + $effect(() => { + const cur = JSON.stringify($values[KEY] ?? null) + if (cur === lastSynced) return + lastSynced = cur + issuers = toRows($values[KEY]) + }) + + $effect(() => { + const next = serialize(issuers) + if (lastSynced === undefined) return + const nextStr = JSON.stringify(next ?? null) + // Compare against the stored value normalized the same way, so loading a value whose + // keys are ordered differently does not mark the form dirty. + if (nextStr === JSON.stringify(serialize(toRows($values[KEY])) ?? null)) return + lastSynced = nextStr + $values[KEY] = next + }) + + function addIssuer() { + issuers.push({ + id: nextId++, + issuer: '', + audience: 'windmill', + keySource: 'discovery', + jwks_uri: '', + jwks: '', + rules: [newRule()] + }) + } +</script> + +<div class="flex flex-col gap-4"> + {#each issuers as issuer, i (issuer.id)} + <div class="flex flex-col gap-3 p-4 rounded-md border"> + <div class="flex items-center justify-between"> + <span class="text-xs font-semibold text-emphasis">Issuer {i + 1}</span> + <Button + variant="subtle" + unifiedSize="xs" + startIcon={{ icon: Trash2 }} + iconOnly + {disabled} + aria-label="Remove issuer" + onclick={() => issuers.splice(i, 1)} + /> + </div> + <label class="flex flex-col gap-1"> + <span class="text-xs text-secondary">Issuer (must equal the token's iss)</span> + <TextInput + bind:value={issuer.issuer} + inputProps={{ placeholder: 'https://kubernetes.default.svc.cluster.local', disabled }} + /> + </label> + <label class="flex flex-col gap-1"> + <span class="text-xs text-secondary">Audience (must be in the token's aud)</span> + <TextInput + bind:value={issuer.audience} + inputProps={{ placeholder: 'windmill', disabled }} + /> + </label> + <div class="flex flex-col gap-1"> + <span class="text-xs text-secondary">Signing keys</span> + <ToggleButtonGroup bind:selected={issuer.keySource} {disabled}> + {#snippet children({ item })} + <ToggleButton + value="discovery" + label="OIDC discovery" + tooltip="Read jwks_uri from <issuer>/.well-known/openid-configuration" + {item} + /> + <ToggleButton value="jwks_uri" label="JWKS URL" {item} /> + <ToggleButton + value="inline" + label="Inline JWKS" + tooltip="For an issuer this instance cannot reach, e.g. the output of kubectl get --raw /openid/v1/jwks" + {item} + /> + {/snippet} + </ToggleButtonGroup> + {#if issuer.keySource === 'jwks_uri'} + <TextInput + bind:value={issuer.jwks_uri} + inputProps={{ placeholder: 'https://issuer.example.com/keys', disabled }} + /> + {:else if issuer.keySource === 'inline'} + <TextInput + underlyingInputEl="textarea" + bind:value={issuer.jwks} + inputProps={{ placeholder: '{"keys": [...]}', rows: 4, disabled }} + /> + {/if} + </div> + <div class="flex flex-col gap-2"> + <span class="text-xs text-secondary"> + Rules, tried in order: the first whose claims all match gives the agent its worker group + and tags. A token no rule matches is refused. + </span> + {#each issuer.rules as rule, j (rule.id)} + <div class="flex flex-col gap-2 border-l-2 border-surface-selected pl-3"> + <div class="flex items-center justify-between"> + <span class="text-2xs font-semibold text-secondary">Rule {j + 1}</span> + <Button + variant="subtle" + unifiedSize="xs" + startIcon={{ icon: Trash2 }} + iconOnly + {disabled} + aria-label="Remove rule" + onclick={() => issuer.rules.splice(j, 1)} + /> + </div> + {#each rule.claims as claim, k (claim.id)} + <div class="flex items-center gap-2"> + <TextInput + bind:value={claim.path} + class="w-64" + inputProps={{ placeholder: 'kubernetes.io.namespace', disabled }} + /> + <span class="text-xs text-secondary">=</span> + <TextInput + bind:value={claim.value} + inputProps={{ placeholder: 'system:serviceaccount:<ns>:<sa>', disabled }} + /> + <Button + variant="subtle" + unifiedSize="xs" + startIcon={{ icon: X }} + iconOnly + disabled={disabled || rule.claims.length <= 1} + aria-label="Remove claim" + onclick={() => rule.claims.splice(k, 1)} + /> + </div> + {/each} + <div class="flex"> + <Button + variant="subtle" + unifiedSize="xs" + startIcon={{ icon: Plus }} + {disabled} + onclick={() => rule.claims.push({ id: nextId++, path: '', value: '' })} + > + Add claim + </Button> + </div> + <div class="flex gap-2"> + <label class="flex flex-col gap-1 w-48"> + <span class="text-2xs text-secondary">Worker group</span> + <TextInput + bind:value={rule.worker_group} + inputProps={{ placeholder: 'k8s', disabled }} + /> + </label> + <label class="flex flex-col gap-1 grow"> + <span class="text-2xs text-secondary">Tags (comma separated)</span> + <TextInput + bind:value={rule.tags} + inputProps={{ placeholder: 'dbt, etl', disabled }} + /> + </label> + </div> + </div> + {/each} + <div class="flex"> + <Button + variant="default" + unifiedSize="sm" + startIcon={{ icon: Plus }} + {disabled} + onclick={() => issuer.rules.push(newRule())} + > + Add rule + </Button> + </div> + </div> + </div> + {/each} + <div class="flex"> + <Button + variant="default" + unifiedSize="sm" + startIcon={{ icon: Plus }} + {disabled} + onclick={addIssuer} + > + Add issuer + </Button> + </div> +</div> diff --git a/frontend/src/lib/components/instanceSettings/CriticalAlertChannels.svelte b/frontend/src/lib/components/instanceSettings/CriticalAlertChannels.svelte index 5b1289eefc..6df7dd51d6 100644 --- a/frontend/src/lib/components/instanceSettings/CriticalAlertChannels.svelte +++ b/frontend/src/lib/components/instanceSettings/CriticalAlertChannels.svelte @@ -42,7 +42,8 @@ ) } - const isTeamsConnected = $derived(isTeamsOAuthConfigured(oauths?.teams)) + // TODO: drop the pre-rename `teams` fallback with the backend one. + const isTeamsConnected = $derived(isTeamsOAuthConfigured(oauths?.ms_teams ?? oauths?.teams)) // Compute dynamic order based on channel presence const slackOrder = $derived(slackChannels.length > 0 ? 1 : 4) diff --git a/frontend/src/lib/components/instanceSettings/WorkspaceRolesSetting.svelte b/frontend/src/lib/components/instanceSettings/WorkspaceRolesSetting.svelte new file mode 100644 index 0000000000..404f27e638 --- /dev/null +++ b/frontend/src/lib/components/instanceSettings/WorkspaceRolesSetting.svelte @@ -0,0 +1,253 @@ +<script lang="ts"> + import type { Writable } from 'svelte/store' + import { untrack } from 'svelte' + import { Alert, Badge, Button } from '../common' + import TextInput from '../text_input/TextInput.svelte' + import RoleCapabilitiesEditor from '../settings/RoleCapabilitiesEditor.svelte' + import type { RoleCapabilities, RoleCapabilitiesPatch, RoleDefinition } from '$lib/gen' + import { + applyCapabilitiesPatch, + authorLevelLabel, + baseCapabilitiesFor, + DEFAULT_INSTANCE_ROLES, + DEVELOPER_ROLE, + isDefaultInstanceRoles, + isEmptyPatch, + workspaceRolesErrors + } from '$lib/workspaceRoles' + import { dragHandle, dragHandleZone } from '@windmill-labs/svelte-dnd-action' + import { ChevronDown, ChevronRight, GripVertical, Plus, RotateCcw, Trash2 } from 'lucide-svelte' + + interface Props { + values: Writable<Record<string, any>> + loading?: boolean + /** Without a valid license the stored list keeps applying but can only be reset. */ + unlicensed?: boolean + } + + let { values, loading = false, unlicensed = false }: Props = $props() + + const disabled = $derived(loading || unlicensed) + + const KEY = 'workspace_roles' + + type Row = { + id: number + name: string + label: string + capabilities: RoleCapabilitiesPatch + open: boolean + } + + let nextId = 0 + let rows = $state<Row[]>([]) + // The JSON last read from or written to `$values[KEY]`, to tell our own writes from external + // ones (the async load, Save, Discard) and re-hydrate only on the latter. + let lastSynced: string | undefined = undefined + + function toRows(v: unknown): Row[] { + const list = Array.isArray(v) && v.length > 0 ? (v as RoleDefinition[]) : DEFAULT_INSTANCE_ROLES + return list.map((r) => ({ + id: nextId++, + name: r.name ?? '', + label: r.label ?? '', + capabilities: structuredClone(r.capabilities ?? {}), + open: false + })) + } + + function serialize(rs: Row[]): RoleDefinition[] | undefined { + const list: RoleDefinition[] = rs.map((r) => ({ + name: r.name.trim(), + ...(r.label.trim() ? { label: r.label.trim() } : {}), + ...(isEmptyPatch(r.capabilities) ? {} : { capabilities: r.capabilities }) + })) + return isDefaultInstanceRoles(list) ? undefined : list + } + + $effect(() => { + const cur = JSON.stringify($values[KEY] ?? null) + if (cur === lastSynced) return + lastSynced = cur + const open = new Set(rows.filter((r) => r.open).map((r) => r.name)) + rows = toRows($values[KEY]).map((r) => ({ + ...r, + open: open.has(r.name) + })) + }) + + $effect(() => { + const next = serialize($state.snapshot(rows) as Row[]) + if (lastSynced === undefined || disabled) return + const nextStr = JSON.stringify(next ?? null) + // Compared with the stored value in serialized form, so one spelled differently (the + // defaults written out, an empty `capabilities`, another key order) is not rewritten + // and the category not marked unsaved before anything is edited. + const stored = untrack(() => $values[KEY]) + if (nextStr === JSON.stringify(serialize(toRows(stored)) ?? null)) return + lastSynced = nextStr + $values[KEY] = next + }) + + const errors = $derived(workspaceRolesErrors(serialize(rows) ?? DEFAULT_INSTANCE_ROLES)) + + const flipDurationMs = 200 + + function addRole() { + rows = [...rows, { id: nextId++, name: '', label: '', capabilities: {}, open: true }] + } + + function summary(caps: RoleCapabilities): string { + return [ + caps.code ? 'Code' : 'No code', + `Flows: ${authorLevelLabel(caps.flows).toLowerCase()}`, + `Apps: ${authorLevelLabel(caps.apps).toLowerCase()}` + ].join(' · ') + } +</script> + +<div class="flex flex-col gap-3"> + {#if unlicensed} + <Alert + type="info" + size="xs" + title="Changing workspace roles requires a valid Windmill Enterprise Edition license" + > + The roles below keep applying as they are. A workspace admin can still override what they + allow in that workspace's settings. + {#if serialize(rows) !== undefined} + <div class="mt-2"> + <Button + variant="default" + unifiedSize="sm" + startIcon={{ icon: RotateCcw }} + disabled={loading} + onclick={() => ($values[KEY] = undefined)} + > + Reset to the default roles + </Button> + </div> + {/if} + </Alert> + {/if} + <span class="text-xs text-secondary"> + Roles members can hold in every workspace, in order: when several instance groups map a member + to different roles, the role listed first wins. A capability left unset uses the default: + everything for a role named <code>developer</code>, the operator rights for any other name. + Workspace admins can override a role in their workspace. A role still held by a member, invite, + auto-invite or instance group mapping cannot be removed. + </span> + + <section + class="flex flex-col gap-3" + use:dragHandleZone={{ + items: rows, + flipDurationMs, + dragDisabled: disabled, + // The handle zone is drag-disabled between drags, which would otherwise mark every + // input in it aria-disabled. + autoAriaDisabled: true, + dropTargetStyle: {} + }} + onconsider={(e) => (rows = e.detail.items)} + onfinalize={(e) => (rows = e.detail.items)} + > + {#each rows as row, i (row.id)} + {@const base = baseCapabilitiesFor(row.name)} + {@const caps = applyCapabilitiesPatch(base, row.capabilities)} + <div class="flex flex-col rounded-md border bg-surface-tertiary"> + <div class="flex items-center gap-2 p-2"> + <div + class="shrink-0 flex items-center {disabled + ? 'text-disabled cursor-not-allowed' + : 'text-secondary cursor-move'}" + use:dragHandle + aria-label="Reorder role {row.name || i + 1}" + > + <GripVertical size={16} /> + </div> + <div class="flex flex-col gap-0.5 w-40 shrink-0"> + <TextInput + size="sm" + bind:value={row.name} + error={errors[`${i}.name`]} + inputProps={{ placeholder: 'name', disabled, 'aria-label': 'Role name' }} + /> + </div> + <span class="text-2xs text-secondary truncate grow"> + {#if row.label.trim()} + <span class="text-primary">{row.label.trim()}</span> · + {/if} + {summary(caps)} + </span> + {#if i === 0} + <Badge color="gray" verySmall>Highest</Badge> + {/if} + <Button + variant="subtle" + unifiedSize="sm" + startIcon={{ icon: row.open ? ChevronDown : ChevronRight }} + onclick={() => (row.open = !row.open)} + > + Capabilities + </Button> + <Button + variant="subtle" + unifiedSize="sm" + destructive + iconOnly + startIcon={{ icon: Trash2 }} + aria-label="Remove role" + title={rows.length === 1 ? 'Keep at least one role' : 'Remove role'} + disabled={disabled || rows.length === 1} + onclick={() => (rows = rows.filter((r) => r.id !== row.id))} + /> + </div> + {#if errors[`${i}.name`]} + <span class="text-2xs text-red-600 dark:text-red-400 px-2 pb-2 -mt-1"> + {errors[`${i}.name`]} + </span> + {/if} + {#if row.open} + <div class="border-t p-3 flex flex-col gap-3"> + <div class="flex items-center gap-2"> + <span class="text-xs font-semibold text-emphasis w-24">Label</span> + <div class="w-60"> + <TextInput + size="sm" + bind:value={row.label} + inputProps={{ + placeholder: row.name || 'label', + disabled, + 'aria-label': 'Role label' + }} + /> + </div> + </div> + <RoleCapabilitiesEditor + {base} + patch={row.capabilities} + baseName={row.name === DEVELOPER_ROLE ? 'developer default' : 'operator default'} + setText="Set" + inheritedText="Default" + {disabled} + onchange={(p) => (row.capabilities = p)} + /> + </div> + {:else if errors[`${i}.capabilities`]} + <span class="text-2xs text-red-600 dark:text-red-400 px-2 pb-2"> + {errors[`${i}.capabilities`]} + </span> + {/if} + </div> + {/each} + </section> + + {#if !disabled} + <div> + <Button variant="default" unifiedSize="sm" startIcon={{ icon: Plus }} onclick={addRole}> + Add role + </Button> + </div> + {/if} +</div> diff --git a/frontend/src/lib/components/jobs/MissingWorkerTagAlert.svelte b/frontend/src/lib/components/jobs/MissingWorkerTagAlert.svelte index 950700b28f..1dbeb984bd 100644 --- a/frontend/src/lib/components/jobs/MissingWorkerTagAlert.svelte +++ b/frontend/src/lib/components/jobs/MissingWorkerTagAlert.svelte @@ -4,6 +4,14 @@ import { resource } from 'runed' import { hasWorkerForTag } from './missingWorker' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { userWorkspaces } from '$lib/stores' + import { + devWorkspaceTagSuffix, + devWorkspaceSkipNonDevForks, + isIsolatedWorkspaceId, + loadDevWorkspaceTagSuffix, + withDevSuffix + } from '$lib/devWorkspaceTags' const operatingWorkspace = useOperatingWorkspace() @@ -24,16 +32,34 @@ // `tag`, so probing the bare tag would warn about a tag nothing uses. The // in-flight check in `pollJobResult` reads the job's real tag and still covers // those instances. + // A dev workspace's jobs run on the suffixed tag, which only groups serving dev workspaces pull. + let devSuffix = $derived( + $devWorkspaceTagSuffix && + isIsolatedWorkspaceId(ws, $userWorkspaces, $devWorkspaceSkipNonDevForks) + ? $devWorkspaceTagSuffix + : undefined + ) + let runTag = $derived(devSuffix ? withDevSuffix(tag, devSuffix) : tag) const served = resource( - () => [ws, tag] as const, - async ([ws, tag]) => { + () => [ws, runTag] as const, + async ([ws, runTag]) => { + await loadDevWorkspaceTagSuffix() if (!ws || (await WorkerService.isDefaultTagsPerWorkspace())) return true - return await hasWorkerForTag(ws, tag) + return await hasWorkerForTag(ws, runTag) } ) </script> -{#if served.current === false} +{#if served.current === false && devSuffix} + <div class={className}> + <Alert type="warning" title="No worker is running for the "{runTag}" tag" size="xs"> + {subject} run as Windmill jobs tagged <b>{tag}</b>, which this workspace, a dev workspace or a + fork, runs as <b>{runTag}</b>. No worker is currently listening to it, so they stay queued + until one is. An admin can serve it by flagging a worker group that lists <b>{tag}</b> as + serving dev workspaces on the <a href="/workers">workers page</a>. + </Alert> + </div> +{:else if served.current === false} <div class={className}> <!-- Deliberately not phrased as "this tag is not configured": a correctly tagged group sitting at zero replicas is indistinguishable from one in diff --git a/frontend/src/lib/components/jobs/missingWorker.ts b/frontend/src/lib/components/jobs/missingWorker.ts index e392e06d3e..699f0ab9c1 100644 --- a/frontend/src/lib/components/jobs/missingWorker.ts +++ b/frontend/src/lib/components/jobs/missingWorker.ts @@ -1,4 +1,5 @@ import { JobService, WorkerService } from '$lib/gen' +import { devTagNoWorkerHint, loadDevWorkspaceTagSuffix } from '$lib/devWorkspaceTags' /** * A queued job whose tag no running worker serves is never picked up: without @@ -15,9 +16,10 @@ export class NoWorkerForTagError extends Error { constructor(tag: string) { super( `No worker has been listening to the tag "${tag}" while this job waited, so it was never ` + - `picked up. It stays queued and will run once a worker with that tag comes online. ` + - `Add "${tag}" to the worker tags of one of your worker groups (Workers page), or run a ` + - `worker that serves it.` + `picked up. It stays queued and will run once a worker with that tag comes online.` + + (devTagNoWorkerHint(tag) || + ` Add "${tag}" to the worker tags of one of your worker groups (Workers page), or run ` + + `a worker that serves it.`) ) this.name = 'NoWorkerForTagError' this.tag = tag @@ -28,8 +30,10 @@ export class NoWorkerForTagError extends Error { export function queuedWithoutWorkerMessage(tag: string): string { return ( `No worker is listening to the tag "${tag}", so this operation is queued and will only run ` + - `once one is. Add "${tag}" to the worker tags of one of your worker groups (Workers page), ` + - `or run a worker that serves it.` + `once one is.` + + (devTagNoWorkerHint(tag) || + ` Add "${tag}" to the worker tags of one of your worker groups (Workers page), or run a ` + + `worker that serves it.`) ) } @@ -68,7 +72,9 @@ export async function missingWorkerTagOfQueuedJob( try { const job = await JobService.getJob({ workspace, id: jobId, noCode: true, noLogs: true }) if (job.type !== 'QueuedJob' || job.running || !job.tag) return undefined - return (await hasWorkerForTag(workspace, job.tag)) ? undefined : job.tag + if (await hasWorkerForTag(workspace, job.tag)) return undefined + await loadDevWorkspaceTagSuffix() + return job.tag } catch (err) { console.warn('Could not determine whether a worker serves the job tag', err) return undefined diff --git a/frontend/src/lib/components/mcp/McpAccessPicker.svelte b/frontend/src/lib/components/mcp/McpAccessPicker.svelte new file mode 100644 index 0000000000..c80ab00ed2 --- /dev/null +++ b/frontend/src/lib/components/mcp/McpAccessPicker.svelte @@ -0,0 +1,146 @@ +<script lang="ts" module> + export type McpAccess = 'read' | 'read_run' | 'full' | 'selected' + + /** Marks a selection, so the per-workspace URLs add no API endpoint tools to it. */ + export const RUNNABLES_AS_TOOLS = 'mcp:runnables_as_tools' + + const ACCESS_SCOPES: Record<Exclude<McpAccess, 'selected'>, string[]> = { + read: ['mcp:all', 'mcp:read_only'], + read_run: ['mcp:all', 'mcp:read_run'], + full: ['mcp:all'] + } + + /** The access a token's MCP scopes give: `mcp:all` and how it is narrowed, or else a + * selection of scripts, flows and endpoints. */ + export function mcpAccessOf(scopes: string[]): McpAccess { + scopes = scopes.filter((s) => s !== RUNNABLES_AS_TOOLS) + if (!scopes.includes('mcp:all')) return scopes.length > 0 ? 'selected' : 'full' + if (scopes.includes('mcp:read_only')) return 'read' + if (scopes.includes('mcp:read_run')) return 'read_run' + return 'full' + } +</script> + +<script lang="ts"> + import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' + import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' + import McpScopeSelector from './McpScopeSelector.svelte' + import Popover from '$lib/components/Popover.svelte' + import { Badge } from '$lib/components/common' + import Section from '$lib/components/Section.svelte' + import { SvelteMap } from 'svelte/reactivity' + + interface Props { + /** The workspace the selection picker browses. */ + workspaceId: string + /** The token's MCP scopes, the workspace list aside. */ + scopes?: string[] + access?: McpAccess + initialScopes?: string[] + } + + let { + workspaceId, + scopes = $bindable(), + access = $bindable(), + initialScopes = [] + }: Props = $props() + + const initialAccess = mcpAccessOf(initialScopes) + access = initialAccess + const initialSelection = + initialAccess === 'selected' + ? initialScopes.filter((s) => s !== RUNNABLES_AS_TOOLS).join(' ') + : undefined + let selection = $state(initialSelection ?? 'mcp:favorites') + + $effect(() => { + scopes = + access === 'selected' || access === undefined + ? [ + ...selection.split(' ').filter((s) => s.length > 0 && s !== RUNNABLES_AS_TOOLS), + RUNNABLES_AS_TOOLS + ] + : ACCESS_SCOPES[access] + }) + + // The tools each access level gives, as the server lists them. + type ToolsetEntry = { name: string; description: string } + let toolsCollapsed = $state(true) + const toolsets = new SvelteMap<string, ToolsetEntry[]>() + const toolsetAccess = $derived(access === 'selected' ? undefined : access) + $effect(() => { + const level = toolsetAccess + if (toolsCollapsed || !level || toolsets.has(level)) return + fetch(`/api/mcp/toolset?access=${level}`) + .then((r) => (r.ok ? r.json() : [])) + .then((tools: ToolsetEntry[]) => toolsets.set(level, tools)) + .catch(() => toolsets.set(level, [])) + }) + const tools = $derived(toolsetAccess ? toolsets.get(toolsetAccess) : undefined) +</script> + +<div class="flex flex-col gap-4"> + <div> + <span class="block mb-1 text-emphasis text-xs font-semibold">Access</span> + <ToggleButtonGroup bind:selected={access} allowEmpty={false}> + {#snippet children({ item })} + <ToggleButton + {item} + value="full" + label="Build and run" + tooltip="Everything you can do yourself: explore, write, test and deploy drafts, and run." + /> + <ToggleButton + {item} + value="read_run" + label="Read and run" + tooltip="Explore, and run deployed scripts and flows. The client changes nothing itself; the scripts and flows it runs can." + /> + <ToggleButton + {item} + value="read" + label="Read only" + tooltip="Explore and read. Nothing is run or changed." + /> + <ToggleButton + {item} + value="selected" + label="Scripts and flows as tools" + tooltip="The scripts and flows you pick, each as its own tool, and nothing else. To share a few automations." + /> + {/snippet} + </ToggleButtonGroup> + </div> + {#if toolsetAccess} + <Section label="Tools the client gets" small collapsable bind:collapsed={toolsCollapsed}> + <div class="flex flex-col gap-1 -mt-2"> + <span class="text-2xs text-secondary"> + {tools ? `${tools.length} tools. Hover one for what it does.` : 'Loading tools…'} + </span> + <div class="flex flex-wrap gap-1"> + {#each tools ?? [] as tool (tool.name)} + <Popover notClickable> + {#snippet text()} + <div class="text-xs max-w-md">{tool.description}</div> + {/snippet} + <Badge rounded small color="gray">{tool.name}</Badge> + </Popover> + {/each} + </div> + </div> + </Section> + {/if} + {#if access === 'selected'} + <p class="text-2xs text-secondary -mt-2"> + The client can run these scripts and flows and nothing else: it cannot explore the workspace, + read runs, build, or run anything outside the selection. + </p> + <McpScopeSelector + {workspaceId} + bind:scope={selection} + initialScope={initialSelection} + runnablesOnly + /> + {/if} +</div> diff --git a/frontend/src/lib/components/mcp/McpConnect.svelte b/frontend/src/lib/components/mcp/McpConnect.svelte index a8808aa052..5cf286be80 100644 --- a/frontend/src/lib/components/mcp/McpConnect.svelte +++ b/frontend/src/lib/components/mcp/McpConnect.svelte @@ -278,27 +278,21 @@ if (!t) return const { workspace, path } = t try { - let account: number | undefined = undefined - if (res?.expires_in != undefined) { - account = Number( - await OauthService.createAccount({ - workspace, - requestBody: { - refresh_token: res.refresh_token ?? '', - expires_in: res.expires_in, - client: t.client, - scopes: t.scopes - } - }) - ) - } await upsertSecretVariable({ workspace, path, value: res.access_token, resourcePath: path, isOauth: true, - account + account: + res?.expires_in != undefined + ? { + refresh_token: res.refresh_token ?? '', + expires_in: res.expires_in, + client: t.client, + scopes: t.scopes + } + : undefined }) await createMcpResource(t, `$var:${path}`) sendUserToast(`Connected ${t.label}`) diff --git a/frontend/src/lib/components/mcp/McpScopeSelector.svelte b/frontend/src/lib/components/mcp/McpScopeSelector.svelte index 46274d10d0..35b815fcff 100644 --- a/frontend/src/lib/components/mcp/McpScopeSelector.svelte +++ b/frontend/src/lib/components/mcp/McpScopeSelector.svelte @@ -25,9 +25,19 @@ scope: string initialScope?: string readOnly?: boolean + /** Scripts and flows alone, for `/api/mcp`: a selection gets no endpoint tools there, so + * endpoints are left out, and since `mcp:all` is the full toolset, "All scripts/flows" + * is written as `mcp:scripts:* mcp:flows:*`. */ + runnablesOnly?: boolean } - let { workspaceId, scope = $bindable(), initialScope, readOnly = false }: Props = $props() + let { + workspaceId, + scope = $bindable(), + initialScope, + readOnly = false, + runnablesOnly = false + }: Props = $props() // Mirrors ITEMS_FETCH_MAX_LIMIT in backend/windmill-api/src/mcp/utils.rs: the // server exposes at most this many scripts and this many flows (per type) as @@ -147,8 +157,15 @@ scriptFolders.length === flowFolders.length && scriptFolders.every((f, i) => f === flowFolders[i]) const endpointsIsAll = byKind.endpoints?.length === 1 && byKind.endpoints[0] === '*' + // Without endpoints, scopes say "folder" or "all" with the scripts and flows alone. + const endpointsAsExpected = runnablesOnly ? !byKind.endpoints : endpointsIsAll + const isWildcard = (patterns?: string[]) => patterns?.length === 1 && patterns[0] === '*' - if (mode !== 'favorites' && mode !== 'all' && sameFolders && endpointsIsAll) { + if (runnablesOnly && isWildcard(byKind.scripts) && isWildcard(byKind.flows)) { + return { ...empty, mode: 'all', hubApps } + } + + if (mode !== 'favorites' && mode !== 'all' && sameFolders && endpointsAsExpected) { return { mode: 'folder', folders: scriptFolders.filter((f): f is string => !!f), @@ -205,14 +222,21 @@ scopeParts.push(`mcp:flows:${flowPaths.join(',')}`) } - if (selectedEndpoints.length > 0) { + if (!runnablesOnly && selectedEndpoints.length > 0) { scopeParts.push(`mcp:endpoints:${selectedEndpoints.join(',')}`) } } else if (selectedMode === 'folder') { const folderPaths = selectedFolders.map((f) => `f/${f}/*`).join(',') if (selectedFolders.length > 0) { - scopeParts = [`mcp:scripts:${folderPaths}`, `mcp:flows:${folderPaths}`, `mcp:endpoints:*`] + scopeParts = [`mcp:scripts:${folderPaths}`, `mcp:flows:${folderPaths}`] + if (!runnablesOnly) { + scopeParts.push(`mcp:endpoints:*`) + } } + } else if (selectedMode === 'all' && runnablesOnly) { + // `mcp:all` is the full toolset on `/api/mcp`: every script and flow as its own + // tool is said with wildcards instead. + scopeParts = ['mcp:scripts:*', 'mcp:flows:*'] } else { scopeParts = [`mcp:${selectedMode}`] } @@ -656,16 +680,18 @@ {/if} </div> - <div class="flex flex-col gap-2 mt-2"> - {@render sectionHeader('API Endpoints', selectAllEndpoints, clearAllEndpoints)} - <MultiSelect - items={safeSelectItems(selectableEndpointTools.map((e) => e.name))} - placeholder="Select endpoints" - bind:value={selectedEndpoints} - /> - </div> + {#if !runnablesOnly} + <div class="flex flex-col gap-2 mt-2"> + {@render sectionHeader('API Endpoints', selectAllEndpoints, clearAllEndpoints)} + <MultiSelect + items={safeSelectItems(selectableEndpointTools.map((e) => e.name))} + placeholder="Select endpoints" + bind:value={selectedEndpoints} + /> + </div> + {/if} - {#if withheldEndpoints.length > 0} + {#if !runnablesOnly && withheldEndpoints.length > 0} <Alert type="warning" size="xs" title="Some selected endpoints will not be exposed"> <span class="text-xs"> Endpoints that cannot be limited to specific script paths are withheld from a token @@ -675,7 +701,7 @@ </Alert> {/if} - {#if runByPathTools.length > 0} + {#if !runnablesOnly && runByPathTools.length > 0} <div class="flex flex-col gap-2 mt-2"> <span class="block text-xs font-semibold">Run tools</span> <p class="text-xs text-tertiary"> @@ -701,8 +727,9 @@ {/if} <div class="text-xs text-primary mt-2"> - Selected: {selectedScripts.length} scripts, {selectedFlows.length} flows, {selectedEndpoints.length} - endpoints + Selected: {selectedScripts.length} scripts, {selectedFlows.length} flows{#if !runnablesOnly}, + {selectedEndpoints.length} + endpoints{/if} </div> <!-- Wildcard Patterns Section --> @@ -785,23 +812,25 @@ </p> {/if} - <span class="block text-xs mt-2">API endpoint tools that will be available via MCP</span> - <div class="flex flex-wrap gap-1"> - {#each exposedEndpointTools as endpoint (endpoint.name)} - <Popover notClickable> - {#snippet text()} - <div class="flex flex-col gap-1"> - <div class="text-xs">{endpoint.description}</div> - <div class="text-xs"> - {endpoint.method} - {endpoint.path} + {#if !runnablesOnly} + <span class="block text-xs mt-2">API endpoint tools that will be available via MCP</span> + <div class="flex flex-wrap gap-1"> + {#each exposedEndpointTools as endpoint (endpoint.name)} + <Popover notClickable> + {#snippet text()} + <div class="flex flex-col gap-1"> + <div class="text-xs">{endpoint.description}</div> + <div class="text-xs"> + {endpoint.method} + {endpoint.path} + </div> </div> - </div> - {/snippet} - <Badge rounded small color="green">{endpoint.name}</Badge> - </Popover> - {/each} - </div> + {/snippet} + <Badge rounded small color="green">{endpoint.name}</Badge> + </Popover> + {/each} + </div> + {/if} </div> {/if} {/if} diff --git a/frontend/src/lib/components/mcp/McpServerOAuthConnect.svelte b/frontend/src/lib/components/mcp/McpServerOAuthConnect.svelte index bafbf4b7b4..3d721d6882 100644 --- a/frontend/src/lib/components/mcp/McpServerOAuthConnect.svelte +++ b/frontend/src/lib/components/mcp/McpServerOAuthConnect.svelte @@ -1,10 +1,5 @@ <script lang="ts"> - import { - McpOauthService, - OauthService, - ResourceService, - type DiscoverMcpOauthResponse - } from '$lib/gen' + import { McpOauthService, ResourceService, type DiscoverMcpOauthResponse } from '$lib/gen' import { Button } from '$lib/components/common' import Label from '$lib/components/Label.svelte' import { sendUserToast } from '$lib/toast' @@ -136,27 +131,21 @@ if (!target) return const { workspace, path } = target try { - let accountId: number | undefined - if (data.expires_in && data.refresh_token) { - const accountIdStr = await OauthService.createAccount({ - workspace, - requestBody: { - refresh_token: data.refresh_token, - expires_in: data.expires_in, - client: 'mcp', - mcp_server_url: data.mcp_server_url - } - }) - accountId = Number(accountIdStr) - } - await upsertSecretVariable({ workspace, path, value: data.access_token, resourcePath: path, isOauth: true, - account: accountId + account: + data.expires_in && data.refresh_token + ? { + refresh_token: data.refresh_token, + expires_in: data.expires_in, + client: 'mcp', + mcp_server_url: data.mcp_server_url + } + : undefined }) await ResourceService.createResource({ diff --git a/frontend/src/lib/components/mcp/secretVariable.test.ts b/frontend/src/lib/components/mcp/secretVariable.test.ts index ced0f8e611..60e01ba7b8 100644 --- a/frontend/src/lib/components/mcp/secretVariable.test.ts +++ b/frontend/src/lib/components/mcp/secretVariable.test.ts @@ -71,7 +71,7 @@ describe('upsertSecretVariable', () => { existsVariable.mockResolvedValue(true) getVariable.mockResolvedValue({ description: OURS }) - await upsertSecretVariable({ ...ARGS, isOauth: true, account: 7 }) + await upsertSecretVariable({ ...ARGS, isOauth: true }) expect(deleteVariable).toHaveBeenCalled() expect(createVariable).toHaveBeenCalledWith({ @@ -81,7 +81,6 @@ describe('upsertSecretVariable', () => { value: 'token', is_secret: true, is_oauth: true, - account: 7, description: OURS } }) diff --git a/frontend/src/lib/components/mcp/secretVariable.ts b/frontend/src/lib/components/mcp/secretVariable.ts index e84a5ac641..4240983afc 100644 --- a/frontend/src/lib/components/mcp/secretVariable.ts +++ b/frontend/src/lib/components/mcp/secretVariable.ts @@ -1,4 +1,4 @@ -import { ResourceService, VariableService } from '$lib/gen' +import { OauthService, ResourceService, VariableService } from '$lib/gen' /** * What this connection writes in its token variable's description, and the only @@ -33,7 +33,14 @@ export async function upsertSecretVariable(args: { /** The MCP resource this token belongs to; stamped into the description. */ resourcePath: string isOauth?: boolean - account?: number + /** The account an expiring token refreshes through, created with the variable and linked to it. */ + account?: { + refresh_token: string + expires_in: number + client: string + scopes?: string[] + mcp_server_url?: string + } }): Promise<void> { const { workspace, path, value, resourcePath, isOauth, account } = args const description = mcpTokenDescription(resourcePath) @@ -64,8 +71,10 @@ export async function upsertSecretVariable(args: { await VariableService.deleteVariable({ workspace, path }) } - await VariableService.createVariable({ - workspace, - requestBody: { path, value, is_secret: true, is_oauth: isOauth, account, description } - }) + const variable = { path, value, is_secret: true, is_oauth: isOauth, description } + if (account) { + await OauthService.createAccount({ workspace, requestBody: { ...account, variable } }) + } else { + await VariableService.createVariable({ workspace, requestBody: variable }) + } } diff --git a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts index 6098095a30..4fc47ef101 100644 --- a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts +++ b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts @@ -1,5 +1,6 @@ import type { Snippet } from 'svelte' import type { WorkspaceItemKind } from './workspacePicker' +import type { OpenInSessionSource } from './sessions/OpenInSessionButton.svelte' /** An item the page is working on: the breadcrumb walks its path and ends with its summary. */ export type PageHeaderItem = { @@ -31,6 +32,9 @@ export type PageHeaderSection = { content?: Snippet /** Scopes the segment's picker, when the section maps to a kind of item. */ kind?: WorkspaceItemKind + /** No chevron after the name: the page's siblings are not the workspace's pages and items, + * which is all that picker offers. A session is one. */ + noNavPicker?: boolean } export type PageHeaderContent = { @@ -38,6 +42,10 @@ export type PageHeaderContent = { section?: PageHeaderSection /** Rendered at the right end of the bar — the page's own buttons. */ actions?: Snippet + /** What the "Open in AI session" button in `actions` takes this page to. The page draws that + * button itself, right before its main action (Deploy, Edit), since only the page knows which + * that is; naming it here lets in-page buttons to the same destination stand down. */ + sessionSource?: OpenInSessionSource /** Orders these buttons among the other registrations', ascending; registration order breaks a * tie. For a menu that must stay at the far end however many pages register before it. */ actionsOrder?: number @@ -196,6 +204,14 @@ export const pageHeader = { }) .sort((a, b) => a.order - b.order || a.i - b.i) }, + /** The innermost registration's session source: the hand-off the header offers. */ + get sessionSource(): OpenInSessionSource | undefined { + for (let i = entries.length - 1; i >= 0; i--) { + const c = read(entries[i]) + if (c?.sessionSource) return c.sessionSource + } + return undefined + }, /** Width of the bar in px, or 0 before it has been measured. */ get barWidth(): number { return barWidth diff --git a/frontend/src/lib/components/pageHeaderRegistry.test.ts b/frontend/src/lib/components/pageHeaderRegistry.test.ts index 035eabb60e..74cbb27d5d 100644 --- a/frontend/src/lib/components/pageHeaderRegistry.test.ts +++ b/frontend/src/lib/components/pageHeaderRegistry.test.ts @@ -52,4 +52,20 @@ describe('pageHeader', () => { register({ actions: snippet('nested') }) expect(pageHeader.actions.map((a) => a.contexts)).toEqual([pageContexts, undefined]) }) + + // In-page buttons stand down when the header offers the same hand-off, so which registration + // is read decides which of them disappear; a nested registration that names no source must not + // hide the page's. + it('takes the session source of the innermost registration that has one', () => { + const route = { page: () => '/runs' } + const editor = { target: { kind: 'script' as const, path: 'u/admin/s' } } + register({ sessionSource: route }) + register({ sessionSource: editor }) + register({ section: { label: 'Nested' } }) + expect(pageHeader.sessionSource).toBe(editor) + pageHeader.release(ids.splice(1, 1)[0]) + expect(pageHeader.sessionSource).toBe(route) + pageHeader.release(ids.shift()!) + expect(pageHeader.sessionSource).toBeUndefined() + }) }) diff --git a/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte b/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte index 9f675664b1..19281fc9e8 100644 --- a/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte @@ -1,11 +1,12 @@ <script lang="ts"> + import { onDestroy } from 'svelte' import { executeRunnable } from '../apps/components/helpers/executeRunnable' import { userStore } from '$lib/stores' import { waitJob } from '../waitJob' import type { JobById } from '../apps/types' import { JobService } from '$lib/gen' import type { Runnable } from './rawAppPolicy' - import { RAW_APP_PREVIEW_RELAY } from './utils' + import { RAW_APP_PREVIEW_RELAY, rawAppPreviewModules } from './utils' import { undefinedIfEmpty } from '$lib/utils' interface Props { @@ -16,6 +17,8 @@ jobsById?: Record<string, JobById> editor: boolean workspace: string + /** Editor only: identity the previewed runnables run as (see `appPreviewOnBehalfOf`). */ + previewOnBehalfOf?: string /** * Restrict waitJob/getJob/streamJob to job ids launched by this app * instance (WIN-2006): a SANDBOXED bundle must not read arbitrary @@ -45,12 +48,16 @@ jobsById = $bindable({}), editor, workspace, + previewOnBehalfOf, gateJobIds = true, extraSourceWindow }: Props = $props() // Job ids launched by this app instance — see `gateJobIds`. const launchedJobs = new Set<string>() + // A stream resumed on every server timeout would otherwise outlive the app it answers. + let destroyed = false + onDestroy(() => (destroyed = true)) /** For a host that loads this component after the bundle may already have sent requests. */ export function handleMessage(event: MessageEvent) { @@ -121,44 +128,64 @@ args[key] = `$ctx:${field.ctx}` } } - const uuid = await executeRunnable( - runnable, - workspace, - undefined, - $userStore?.username, - path, - runnable_id, - { - component: runnable_id, - args, - force_viewer_allow_user_resources: editor - ? undefinedIfEmpty( - Object.keys(runnable?.fields ?? {}).filter( - (k) => - runnable?.fields?.[k]?.type == 'user' && - runnable?.fields?.[k]?.allowUserResources + let uuid: string + try { + uuid = await executeRunnable( + runnable, + workspace, + undefined, + $userStore?.username, + path, + runnable_id, + { + component: runnable_id, + args, + force_viewer_allow_user_resources: editor + ? undefinedIfEmpty( + Object.keys(runnable?.fields ?? {}).filter( + (k) => + runnable?.fields?.[k]?.type == 'user' && + runnable?.fields?.[k]?.allowUserResources + ) ) - ) - : undefined, - force_viewer_one_of_fields: undefined, - force_viewer_static_fields: editor - ? Object.fromEntries( - Object.entries(runnable?.fields ?? {}) - .filter(([k, v]) => v.type == 'static') - .map(([k, v]) => [k, v?.['value']]) - ) - : undefined, - force_viewer_sensitive_inputs: editor - ? undefinedIfEmpty( - Object.keys(runnable?.fields ?? {}).filter( - (k) => runnable?.fields?.[k]?.type == 'user' && runnable?.fields?.[k]?.sensitive + : undefined, + force_viewer_one_of_fields: undefined, + force_viewer_static_fields: editor + ? Object.fromEntries( + Object.entries(runnable?.fields ?? {}) + .filter(([k, v]) => v.type == 'static') + .map(([k, v]) => [k, v?.['value']]) ) - ) - : undefined, - force_viewer_delete_after_secs: editor ? runnable?.delete_after_secs : undefined - }, - undefined - ) + : undefined, + force_viewer_sensitive_inputs: editor + ? undefinedIfEmpty( + Object.keys(runnable?.fields ?? {}).filter( + (k) => + runnable?.fields?.[k]?.type == 'user' && runnable?.fields?.[k]?.sensitive + ) + ) + : undefined, + force_viewer_delete_after_secs: editor ? runnable?.delete_after_secs : undefined, + on_behalf_of: editor ? previewOnBehalfOf : undefined, + viewer_data_access: editor ? runnable?.viewer_data_access || undefined : undefined + }, + undefined, + undefined, + editor ? rawAppPreviewModules(runnables, runnable_id) : undefined + ) + } catch (e) { + // A run refused before it started still answers the app, which would otherwise + // wait for ever; the refusal's body says why (a 409 names the turn still running). + const body = (e as { body?: unknown })?.body + const message = + typeof body === 'string' + ? body + : body + ? JSON.stringify(body) + : String((e as Error)?.message ?? e) + respond({ result: { message }, error: true }) + return + } launchedJobs.add(uuid) let job: JobById = { component: runnable_id, created_at: Date.now(), job: uuid } if (data.type == 'backendAsync') { @@ -202,97 +229,105 @@ ) return } - const params = new URLSearchParams() - params.set('fast', 'true') - params.set('only_result', 'true') + let lastOffset: number | undefined + const connect = (streamOffset?: number) => { + const params = new URLSearchParams() + params.set('fast', 'true') + params.set('only_result', 'true') + if (streamOffset !== undefined) params.set('stream_offset', String(streamOffset)) - const sseUrl = `/api/w/${workspace}/jobs_u/getupdate_sse/${jobId}?${params.toString()}` - const eventSource = new EventSource(sseUrl) + const sseUrl = `/api/w/${workspace}/jobs_u/getupdate_sse/${jobId}?${params.toString()}` + const eventSource = new EventSource(sseUrl) - eventSource.onmessage = (sseEvent) => { - try { - const update = JSON.parse(sseEvent.data) - const type = update.type + eventSource.onmessage = (sseEvent) => { + try { + const update = JSON.parse(sseEvent.data) + const type = update.type - if (type === 'ping' || type === 'timeout') { - if (type === 'timeout') { - eventSource.close() + if (type === 'ping' || type === 'timeout') { + if (type === 'timeout') { + // The server ends every stream after a while; the job goes on, so it is resumed. + eventSource.close() + if (!destroyed) connect(lastOffset) + } + return } - return - } - if (type === 'error') { - eventSource.close() - sourceWindow?.postMessage( - { - type: 'streamJobRes', - reqId, - error: true, - result: { message: update.error || 'SSE error' } - }, - '*' - ) - return - } + if (type === 'error') { + eventSource.close() + sourceWindow?.postMessage( + { + type: 'streamJobRes', + reqId, + error: true, + result: { message: update.error || 'SSE error' } + }, + '*' + ) + return + } - if (type === 'not_found') { - eventSource.close() - sourceWindow?.postMessage( - { - type: 'streamJobRes', - reqId, - error: true, - result: { message: 'Job not found' } - }, - '*' - ) - return - } + if (type === 'not_found') { + eventSource.close() + sourceWindow?.postMessage( + { + type: 'streamJobRes', + reqId, + error: true, + result: { message: 'Job not found' } + }, + '*' + ) + return + } - // Send stream update if there's new stream data - if (update.new_result_stream !== undefined) { - sourceWindow?.postMessage( - { - type: 'streamJobUpdate', - reqId, - new_result_stream: update.new_result_stream, - stream_offset: update.stream_offset - }, - '*' - ) - } + // Send stream update if there's new stream data + if (update.stream_offset !== undefined) lastOffset = update.stream_offset + if (update.new_result_stream !== undefined) { + sourceWindow?.postMessage( + { + type: 'streamJobUpdate', + reqId, + new_result_stream: update.new_result_stream, + stream_offset: update.stream_offset + }, + '*' + ) + } - // Check if job is completed - if (update.completed) { - eventSource.close() - sourceWindow?.postMessage( - { - type: 'streamJobRes', - reqId, - error: false, - result: update.only_result - }, - '*' - ) + // Check if job is completed + if (update.completed) { + eventSource.close() + sourceWindow?.postMessage( + { + type: 'streamJobRes', + reqId, + error: false, + result: update.only_result + }, + '*' + ) + } + } catch (parseErr) { + console.warn('Failed to parse SSE data:', parseErr) } - } catch (parseErr) { - console.warn('Failed to parse SSE data:', parseErr) + } + + eventSource.onerror = (error) => { + console.warn('SSE stream error:', error) + eventSource.close() + sourceWindow?.postMessage( + { + type: 'streamJobRes', + reqId, + error: true, + result: { message: 'SSE connection error' } + }, + '*' + ) } } - - eventSource.onerror = (error) => { - console.warn('SSE stream error:', error) - eventSource.close() - sourceWindow?.postMessage( - { - type: 'streamJobRes', - reqId, - error: true, - result: { message: 'SSE connection error' } - }, - '*' - ) - } + connect() } } </script> diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 454030009b..3d22087317 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -40,7 +40,7 @@ import { getAppliedDarkModeVariant, type DarkModeVariant } from '$lib/darkModeVariant' import RawAppSidebar from './RawAppSidebar.svelte' import type { Modules } from './RawAppModules.svelte' - import { isRunnableByName, isRunnableByPath } from '../apps/inputType' + import { isRunnableByName, isRunnableByPath, type PathRunType } from '../apps/inputType' import { aiChatManager, AIMode } from '../copilot/chat/AIChatManager.svelte' import { onMount, onDestroy, untrack } from 'svelte' import type { @@ -52,6 +52,8 @@ import { captureScale, MAX_IMAGE_EDGE } from '../copilot/chat/imageUtils' import { rawAppLintStore } from './lintStore' import { dbSchemas, userStore } from '$lib/stores' + import { useActingUser } from '$lib/actingUser.svelte' + import { appPreviewOnBehalfOf } from '$lib/utils/previewOnBehalfOf' import RawAppSdkConsent from './RawAppSdkConsent.svelte' import { hasStoredSdkConsent, @@ -88,11 +90,11 @@ import { randomSecret, randomUUID } from '$lib/utils/uuid' import { editorFontSize } from '$lib/editorFontSize.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' - import { useOperatorBuilderApps } from '$lib/operatorWriteRights' + import { useRoleCapabilities } from '$lib/operatorWriteRights' import { conformBuilderAppPolicy } from './builderAppPolicy' const operatingWorkspace = useOperatingWorkspace() - const operatorBuilderApps = useOperatorBuilderApps() + const roleCaps = useRoleCapabilities() interface Props { files?: Record<string, string> @@ -154,6 +156,10 @@ * still toggle the mode after mount; this prop only seeds the * initial state. */ defaultSplitWithPreview?: boolean + /** Open on the preview alone, with the file sidebar and the build logs retracted, + * whatever the width, as a narrow container does. The saved sidebar preference is + * left as it is. */ + compact?: boolean /** User-typed path when it differs from `savedApp.path`. The route injects * it as `draft_path` so the home row shows the friendly name, not `draft_{uuid}`. */ pendingDraftPath?: string | undefined @@ -228,6 +234,7 @@ autosaveWorkspace = undefined, autosavePath = undefined, defaultSplitWithPreview = true, + compact = false, pendingDraftPath = $bindable(undefined), onDetails, onResetToDeployed, @@ -258,6 +265,8 @@ const opWorkspace = $derived(autosaveWorkspace ?? $operatingWorkspace) // Everything under the editor acts on it too (see operatingWorkspace.svelte.ts). setOperatingWorkspace(() => opWorkspace) + const operatingUser = useActingUser(() => opWorkspace) + let previewOnBehalfOf = $derived(appPreviewOnBehalfOf(operatingUser.current, policy)) // The path autosaves land on, which is what the session preview loads the app by. const draftStoragePath = $derived(autosavePath ?? liveEditorDraftStoragePath) @@ -419,7 +428,7 @@ // The deploy panel cannot switch Viewer mode off, nor drop the identity an app another user // deployed carries, so the editor conforms the policy as soon as it opens. $effect(() => { - if ($operatorBuilderApps && policy) conformBuilderAppPolicy(policy, $userStore) + if ($roleCaps.apps === 'compose' && policy) conformBuilderAppPolicy(policy, $userStore) }) // An app with sandbox isolation previews the way it runs once deployed: in the // served, opaque-origin shell, framed with the deployed wrapper's flags. The nonce @@ -460,7 +469,7 @@ addEventListener('message', function (e) { n.hidden = !e.data.text } else if (e.source === opener) frame.postMessage(e.data, '*') }) -<\/script></head><body><div id="n" hidden></div><iframe src="${window.location.origin}${sandboxedShellUrl}" sandbox="${RAW_APP_SANDBOX_FLAGS}" referrerpolicy="no-referrer"></iframe></body></html>` +<\/script></head><body><div id="n" hidden></div><iframe src="${window.location.origin}${sandboxedShellUrl}" sandbox="${RAW_APP_SANDBOX_FLAGS}" allow="clipboard-read; clipboard-write; fullscreen" referrerpolicy="no-referrer"></iframe></body></html>` ], { type: 'text/html' } ) @@ -508,6 +517,8 @@ addEventListener('message', function (e) { 'The preview is waiting for the user to approve the permissions the app declares in policy.frontend_sdk_scopes, so the app code is not running and the page is empty. This is not a bug in the app: do not change the code. Ask the user to approve the permissions in the preview, then retry.' const SDK_STARTING_TEXT = 'The preview is still loading the permissions the app declares in policy.frontend_sdk_scopes, so the app code has not started yet. Retry in a moment.' + const VIEWER_PENDING_TEXT = + 'The preview is still looking up the viewer the app receives in window.ctx, so the app code has not started yet. This is not a bug in the app: do not change the code. Retry in a moment.' const PREVIEW_SETTLE_MS = 1000 let editorDestroyed = false // Latest uncaught runtime error thrown by the rendered app; cleared on next build. @@ -532,7 +543,9 @@ addEventListener('message', function (e) { call: "createRoot(document.getElementById('root')!).render(<App />)" } }) - let logsCollapsed = $state(false) + // Folded when the editor opens on the preview alone, where the log matters only on a + // failed build (the Preview tab turns red then); shown beside the code in split view. + let logsCollapsed = $state(untrack(() => !defaultSplitWithPreview || compact)) let logsDiv: HTMLDivElement | undefined = $state(undefined) $effect(() => { if (logsDiv && logs && !logsCollapsed) { @@ -560,7 +573,7 @@ addEventListener('message', function (e) { let activeTabId: string = $state(PREVIEW_TAB_ID) // Seed from the prop, then own the state locally so the user's toggle // after mount sticks even if the prop reference changes. - let splitWithPreview: boolean = $state(untrack(() => defaultSplitWithPreview)) + let splitWithPreview: boolean = $state(untrack(() => defaultSplitWithPreview && !compact)) const activeTabKind = $derived<'file' | 'runnable' | 'preview'>( activeTabId === PREVIEW_TAB_ID ? 'preview' @@ -834,7 +847,7 @@ addEventListener('message', function (e) { const w = rootWidth if (appliedNarrowDefault || w <= 0) return appliedNarrowDefault = true - if (w < NARROW_PX) { + if (compact || w < NARROW_PX) { splitWithPreview = false sidebarCollapsed.setWithoutPersist(true) } @@ -904,6 +917,8 @@ addEventListener('message', function (e) { backendRunnable.type = 'flow' } else if (runnable.runType === 'hubscript') { backendRunnable.type = 'hubscript' + } else if (runnable.runType === 'agent') { + backendRunnable.type = 'agent' } else { backendRunnable.type = 'script' } @@ -1217,7 +1232,7 @@ addEventListener('message', function (e) { runnables[key] = { name: runnable.name, type: 'path', - runType: runnable.type as 'script' | 'flow' | 'hubscript', + runType: runnable.type as PathRunType, path: runnable.path, fields: runnable.staticInputs ? Object.fromEntries( @@ -1813,12 +1828,13 @@ addEventListener('message', function (e) { } function syncExternalPreview() { - if (previewSdkPending || !externalPreviewReady) return + if (previewSdkPending || previewUserPending || !externalPreviewReady) return if (lastBuild) { postToExternalPreview({ type: 'preview', css: lastBuild.css, js: lastBuild.js, + ctx: previewCtx(), sdk: previewSdk, storage: previewStorage() }) @@ -1832,18 +1848,41 @@ addEventListener('message', function (e) { // Between dropping a credential and settling its replacement the shell stays // blank; whichever settles last — the mint or the shell's own `load` — starts // the app. Same for a shell still reloading: it replays once ready. - if (previewSdkPending || !previewIframeLoaded) return + if (previewSdkPending || previewUserPending || !previewIframeLoaded) return runtimeError = undefined emptyRender = false postToPreview({ type: 'preview', css: build.css, js: build.js, + ctx: previewCtx(), sdk: previewSdk, storage: previewStorage() }) } + // The deployed app's `window.ctx`: the viewer's membership in the app's workspace. + function previewCtx() { + return $state.snapshot({ ctx: operatingUser.current, workspace: opWorkspace, path }) + } + + // A build fed before the viewer is known keeps that viewer until the next one. Outside + // the navigation workspace the viewer is looked up; in it, `$userStore` still holds the + // previous workspace's membership right after a switch. A failed lookup also settles + // this: the app then runs with no viewer, as we don't know who it is. + const previewUserPending = $derived( + !!opWorkspace && + (!operatingUser.resolved(opWorkspace) || + (!!operatingUser.current && operatingUser.current.workspace_id !== opWorkspace)) + ) + $effect(() => { + if (previewUserPending) return + untrack(() => { + if (lastBuild) feedPreviewIframe(lastBuild) + syncExternalPreview() + }) + }) + // The shell seeds a fresh document's storage shim from this; unsandboxed // previews use real storage and get none. function previewStorage() { @@ -2005,6 +2044,7 @@ addEventListener('message', function (e) { if (buildTracker.pending) return { text: BUILD_PENDING_TEXT } if (sdkPrompt) return { text: SDK_CONSENT_PENDING_TEXT } if (previewSdkPending) return { text: SDK_STARTING_TEXT } + if (previewUserPending) return { text: VIEWER_PENDING_TEXT } const res = await readPreviewDom(query) return res && buildError !== undefined ? { text: `${buildFailedText(buildError)}\n\n${res.text}` } @@ -2183,6 +2223,7 @@ addEventListener('message', function (e) { if (buildTracker.pending) throw new Error(BUILD_PENDING_TEXT) if (sdkPrompt) throw new Error(SDK_CONSENT_PENDING_TEXT) if (previewSdkPending) throw new Error(SDK_STARTING_TEXT) + if (previewUserPending) throw new Error(VIEWER_PENDING_TEXT) if (buildError !== undefined) throw new Error(buildFailedText(buildError)) const body = await requestFromPreview('elementRect', { selector: 'body' }) if (!body?.rect) throw new Error('App preview is not ready') @@ -2569,6 +2610,7 @@ addEventListener('message', function (e) { bind:jobsById {runnables} {path} + {previewOnBehalfOf} gateJobIds={sandboxed} extraSourceWindow={() => externalPreviewWindow} /> @@ -2776,6 +2818,7 @@ addEventListener('message', function (e) { {#if selectedRunnable !== undefined} <RawAppInlineScriptsPanel appPath={path} + {previewOnBehalfOf} {selectedRunnable} bind:runnables onSelectionChange={(selection) => { @@ -2860,6 +2903,8 @@ addEventListener('message', function (e) { title="Replay the last build into the preview" aria-label="Rebuild" onClick={() => { + // Retries a viewer lookup that failed; the hold replays the build once it answers. + operatingUser.forgetFailures() if (lastBuild) { feedPreviewIframe(lastBuild) } @@ -2901,6 +2946,7 @@ addEventListener('message', function (e) { title="App preview" src={previewShellUrl} sandbox={sandboxed ? RAW_APP_SANDBOX_FLAGS : undefined} + allow="clipboard-read; clipboard-write; fullscreen" referrerpolicy={sandboxed ? 'no-referrer' : undefined} class="w-full flex-1 block" ></iframe> diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index b7ce8d7684..bc14c298e9 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -323,10 +323,19 @@ const compactTopbar = $derived( ownsPageHeader ? pageHeader.actionFold >= 1 : topbarWidth > 0 && topbarWidth < 720 ) - // The second fold keeps the trail, the file sidebar's toggle, Diff and Deploy. AI stands down — - // the sidebar's own Ask AI is the way in at that width — while Jobs and the export are already - // in the menu, Jobs from `compactTopbar` (true at the second fold too) and the export always. + // In the page header no fold drops a button's label: what does not fit goes into the menu. + // The second fold keeps the trail, the file sidebar's toggle and Deploy; Diff joins the menu. + // The docked-chat AI toggle stands down — the sidebar's own Ask AI is the way in at that + // width — while Jobs and the + // export are already in the menu, Jobs from `compactTopbar` (true at the second fold too) and + // the export always. const phoneTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 2) + // The third fold leaves the autosave indicator as its icon, so the AI session button still fits + // right before Deploy on a phone. + const narrowTopbar = $derived(ownsPageHeader && pageHeader.actionFold >= 3) + // The last fold, for the narrowest phones: the file sidebar's toggle joins the menu, which is + // then the way back to a collapsed sidebar. + const sidebarToggleInMenu = $derived(ownsPageHeader && pageHeader.actionFold >= 4) // Top-bar button size + bar height. Condensed (session preview) uses the // smallest well-supported unified size (`sm`) so the bar is thinner. @@ -704,6 +713,26 @@ const mod = isMac() ? '⌘' : 'Ctrl+' let moreItems = $derived([ + ...(sidebarToggleInMenu && onToggleSidebar + ? [ + { + displayName: `${sidebarCollapsed ? 'Expand' : 'Collapse'} file sidebar`, + icon: sidebarCollapsed ? PanelLeft : PanelLeftClose, + action: () => onToggleSidebar?.(), + shortcut: `${mod}B` + } + ] + : []), + ...(phoneTopbar + ? [ + { + displayName: 'Diff', + icon: DiffIcon, + action: () => openDiffDrawer(), + disabled: !savedApp || newApp || savedApp?.no_deployed === true + } + ] + : []), ...(compactTopbar ? [ { @@ -956,7 +985,8 @@ pathTrigger: rawAppPathTrigger }} actions={rawAppHeaderActions} - actionFolds={2} + sessionSource={sessionOpen} + actionFolds={4} contexts={headerContexts} separator="none" /> @@ -996,7 +1026,7 @@ {/if} {#snippet sidebarToggle()} - {#if onToggleSidebar} + {#if onToggleSidebar && !sidebarToggleInMenu} <Button unifiedSize="sm" variant="subtle" @@ -1019,6 +1049,7 @@ {loadedFromDraft} {othersDraftsCount} {onOpenOthersDrafts} + compact={narrowTopbar} /> {/if} {/snippet} @@ -1075,7 +1106,8 @@ {#snippet rawAppHeaderActions()} <!-- The bar's own order kept: the file sidebar, how the draft is doing, who else is here, then what to do with the app. The sidebar's toggle is the only way back once it is folded — the - collapsed sidebar renders nothing of its own — so it stays on the bar at every width. --> + collapsed sidebar renders nothing of its own — so it stays on the bar until the last fold, + which moves it into the ⋮ menu rather than dropping it. --> {@render sidebarToggle()} {@render autosaveIndicator()} {@render awarenessMark()} @@ -1102,9 +1134,11 @@ title={newApp || savedApp?.no_deployed === true ? 'Deploy this app once to compare against the deployed version' : 'Diff'} - class={!savedApp || newApp || savedApp?.no_deployed === true - ? 'flex cursor-not-allowed' - : 'flex'} + class={phoneTopbar + ? 'hidden' + : !savedApp || newApp || savedApp?.no_deployed === true + ? 'flex cursor-not-allowed' + : 'flex'} > <Button variant="default" @@ -1114,7 +1148,7 @@ btnClasses={!savedApp || newApp || savedApp?.no_deployed === true ? 'pointer-events-none' : undefined} - iconOnly={compactTopbar} + iconOnly={compactTopbar && !ownsPageHeader} title={newApp || savedApp?.no_deployed === true ? 'Deploy this app once to compare against the deployed version' : 'Diff'} @@ -1145,9 +1179,11 @@ <!-- Draws nothing in the bar: it is the export drawer, opened from the menu's Export entry through this instance. --> <AppExportButton bind:this={appExport} /> - {#if !phoneTopbar} - <OpenInSessionButton source={sessionOpen}> - {#snippet fallback()} + <!-- Right before Deploy at every fold; only the opted-out docked-chat toggle gives way on a + phone. --> + <OpenInSessionButton source={sessionOpen}> + {#snippet fallback()} + {#if !phoneTopbar} <Button unifiedSize={headerBtnSize} variant="default" @@ -1158,9 +1194,9 @@ > AI </Button> - {/snippet} - </OpenInSessionButton> - {/if} + {/if} + {/snippet} + </OpenInSessionButton> <Button loading={loading.save} startIcon={{ icon: Save }} diff --git a/frontend/src/lib/components/raw_apps/RawAppInlineScriptRunnable.svelte b/frontend/src/lib/components/raw_apps/RawAppInlineScriptRunnable.svelte index 73fe0852f4..71a3dae951 100644 --- a/frontend/src/lib/components/raw_apps/RawAppInlineScriptRunnable.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppInlineScriptRunnable.svelte @@ -21,15 +21,16 @@ import SchemaForm from '../SchemaForm.svelte' import RunnableJobPanelInner from '../apps/editor/RunnableJobPanelInner.svelte' import JobLoader from '../JobLoader.svelte' - import type { Job, OpenFlow, ScriptLang } from '$lib/gen' + import { FlowService, JobService, type Job, type OpenFlow, type ScriptLang } from '$lib/gen' + import { getScriptByPath } from '$lib/scripts' import { slide } from 'svelte/transition' import { DebugToolbar, DebugPanel, debugState } from '$lib/components/debug' import LogViewer from '$lib/components/LogViewer.svelte' import DisplayResult from '$lib/components/DisplayResult.svelte' import RunButton from '$lib/components/RunButton.svelte' - import { userStore } from '$lib/stores' import { isHubFlowPath } from '$lib/utils' import { sendUserToast } from '$lib/toast' + import { rawAppPreviewModules } from './utils' import { useOperatingUser, useOperatingWorkspace @@ -50,6 +51,10 @@ runnable: Runnable id: string appPath: string + /** All of the app's runnables, which a test run ships as modules for `./<id>` imports */ + runnables?: Record<string, Runnable> + /** Identity the test runs as instead of the user (see `appPreviewOnBehalfOf`). */ + previewOnBehalfOf?: string lastDeployedCode?: string | undefined /** Called when code is selected in the editor */ onSelectionChange?: ( @@ -63,7 +68,14 @@ ) => void } - let { runnable = $bindable(), id, appPath, onSelectionChange }: Props = $props() + let { + runnable = $bindable(), + id, + appPath, + runnables, + previewOnBehalfOf, + onSelectionChange + }: Props = $props() const dispatch = createEventDispatcher() @@ -143,15 +155,15 @@ function getCtxValue(expr: string): any { switch (expr) { case 'username': - return $userStore?.username ?? '' + return actingUser?.username ?? '' case 'email': - return $userStore?.email ?? '' + return actingUser?.email ?? '' case 'groups': return actingUser?.groups ?? [] case 'workspace': return opWs ?? '' case 'author': - return $userStore?.email ?? '' // In editor, author is the current user + return actingUser?.email ?? '' // In editor, author is the current user default: return '' } @@ -179,7 +191,15 @@ runnable.inlineScript?.content ?? '', runnable.inlineScript?.language, args, - undefined + undefined, + undefined, + undefined, + undefined, + undefined, + runnables ? rawAppPreviewModules(runnables, id) : undefined, + undefined, + undefined, + { onBehalfOf: previewOnBehalfOf, viewerDataAccess: runnable.viewer_data_access } ) } else if (isRunnableByPath(runnable)) { if (jobLoader && isRunnableByPath(runnable)) { @@ -189,12 +209,58 @@ sendUserToast('Hub flow preview is still loading', true) return } - await jobLoader.runFlowPreview(args, hubFlowPreview, undefined, runnable.path) + await jobLoader.runFlowPreview( + args, + hubFlowPreview, + undefined, + runnable.path, + previewOnBehalfOf + ) + } else if (previewOnBehalfOf) { + // The deployed app runs a path runnable as the app's identity, which a run + // by path cannot ask for: preview the deployed definition instead. + const flow = await FlowService.getFlowByPath({ workspace: opWs!, path: runnable.path }) + await jobLoader.runFlowPreview(args, flow, undefined, runnable.path, previewOnBehalfOf) } else { await jobLoader.runFlowByPath(runnable.path, args) } + } else if (runnable.runType == 'agent') { + // A turn of the agent as it is deployed, as the editor: the direct run has no way + // to ask for the app's identity. + const path = runnable.path + const { user_message, memory_id, user_attachments, previous_messages } = args as Record< + string, + any + > + await jobLoader.abstractRun(() => + JobService.runAgent({ + workspace: opWs!, + path, + memoryId: memory_id || undefined, + requestBody: { user_message, user_attachments, previous_messages } + }) + ) } else if (runnable.runType == 'script' || runnable.runType == 'hubscript') { - await jobLoader.runScriptByPath(runnable.path, args) + if (previewOnBehalfOf) { + const script = await getScriptByPath(runnable.path, opWs) + await jobLoader.runPreview( + runnable.path, + script.content, + script.language, + args, + script.tag, + script.lock, + script.hash, + undefined, + undefined, + undefined, + undefined, + undefined, + { onBehalfOf: previewOnBehalfOf } + ) + } else { + await jobLoader.runScriptByPath(runnable.path, args) + } } } } diff --git a/frontend/src/lib/components/raw_apps/RawAppInlineScriptsPanel.svelte b/frontend/src/lib/components/raw_apps/RawAppInlineScriptsPanel.svelte index 891fcfa7c8..5a2399457f 100644 --- a/frontend/src/lib/components/raw_apps/RawAppInlineScriptsPanel.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppInlineScriptsPanel.svelte @@ -10,6 +10,7 @@ runnables: Record<string, Runnable> selectedRunnable: string | undefined appPath: string + previewOnBehalfOf?: string /** Called when code is selected in the editor */ onSelectionChange?: ( selection: { @@ -26,6 +27,7 @@ runnables = $bindable(), selectedRunnable = $bindable(), appPath, + previewOnBehalfOf, onSelectionChange }: Props = $props() </script> @@ -38,6 +40,8 @@ {#key selectedRunnable} <RawAppInlineScripRunnable {appPath} + {runnables} + {previewOnBehalfOf} on:createScriptFromInlineScript={(e) => { createScriptFromInlineScript(selectedRunnable ?? '', e.detail, opWs ?? '', appPath) }} diff --git a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte index fa3e7e6211..1c82cdcd33 100644 --- a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte @@ -134,7 +134,7 @@ const html = unsandboxedRawAppHtml( workspace, secret, - { ctx: u, workspace }, + { ctx: u, workspace, path }, window.location.origin, pageHash() ) @@ -207,7 +207,7 @@ // Same shape as the unsandboxed wrapper: always the object, so // `window.ctx.workspace` works for anonymous viewers too. Snapshotted: a // caller's `$state` user is a proxy, which postMessage cannot clone. - ctx: { ctx: $state.snapshot(user), workspace }, + ctx: { ctx: $state.snapshot(user), workspace, path }, initialHash, storage: { local: bundleStorage ?? {}, session: {} }, // The wrapper turns this into `window.process.env` before it injects the @@ -351,6 +351,7 @@ title="raw-app" src={iframeSrc} sandbox={sandboxAttr} + allow="clipboard-read; clipboard-write; fullscreen" referrerpolicy={unsandboxed ? undefined : 'no-referrer'} onload={() => (loadedSrc = iframeSrc)} style="display: block; width: 100%; height: 100%; border: none" diff --git a/frontend/src/lib/components/raw_apps/RawAppSdkConsent.svelte b/frontend/src/lib/components/raw_apps/RawAppSdkConsent.svelte index 28940eb02a..babe0b8c85 100644 --- a/frontend/src/lib/components/raw_apps/RawAppSdkConsent.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppSdkConsent.svelte @@ -2,7 +2,7 @@ import { Button } from '$lib/components/common' import Toggle from '$lib/components/Toggle.svelte' import { ShieldCheck } from 'lucide-svelte' - import { sdkScopeDescription, sdkScopeLabel } from './sdkScopes' + import { sdkScopeDescription, sdkScopeLabel, sdkScopesToConfirm } from './sdkScopes' let { scopes, @@ -35,7 +35,7 @@ The app's code will be able to call the Windmill API on your behalf, restricted to: </p> <ul class="flex flex-col gap-2"> - {#each scopes as scope (scope)} + {#each sdkScopesToConfirm(scopes) as scope (scope)} <li class="text-sm"> <span class="font-medium">{sdkScopeLabel(scope)}</span >{#if sdkScopeDescription(scope)}<span class="text-tertiary" diff --git a/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte b/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte index a052e54f97..e5e25a5ff1 100644 --- a/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte @@ -46,7 +46,7 @@ import RawAppDataTableDrawer from './RawAppDataTableDrawer.svelte' import FileEditorIcon from './FileEditorIcon.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' - import { useOperatorBuilderApps } from '$lib/operatorWriteRights' + import { useRoleCapabilities } from '$lib/operatorWriteRights' import { prefersSessionHandoff } from '$lib/components/copilot/chat/global/gate' export type RawAppTemplatePickerResult = { @@ -68,7 +68,7 @@ } = $props() const templates = $derived.by(() => { - const files = $operatorBuilderApps ? BUILDER_FRAMEWORK_TEMPLATES : FRAMEWORK_TEMPLATES + const files = composeOnlyApps ? BUILDER_FRAMEWORK_TEMPLATES : FRAMEWORK_TEMPLATES return [ { name: 'React 19', icon: 'tsx', files: files.react19, recommended: true }, { name: 'React 18', icon: 'tsx', files: files.react18 }, @@ -87,7 +87,8 @@ let dataTableDrawer: RawAppDataTableDrawer | undefined = $state() const operatingWorkspace = useOperatingWorkspace() - const operatorBuilderApps = useOperatorBuilderApps() + const roleCaps = useRoleCapabilities() + const composeOnlyApps = $derived($roleCaps.apps === 'compose') let opWs = $derived($operatingWorkspace) const datatables = createDatatablesResource(() => opWs) @@ -284,8 +285,7 @@ // With AI turned off for the workspace there is nothing to choose between. A builder is // offered AI only through a session: the docked app mode writes inline code they cannot save. const aiOffered = $derived( - !$copilotInfo.workspaceDisabled && - (!$operatorBuilderApps || prefersSessionHandoff($userStore?.operator)) + !$copilotInfo.workspaceDisabled && (!composeOnlyApps || prefersSessionHandoff(!$roleCaps.code)) ) const canStart = $derived( @@ -298,9 +298,10 @@ ? $userStore?.username : `u/${$userStore?.username}`, on_behalf_of_email: $userStore?.email, - execution_mode: 'publisher' + execution_mode: 'publisher', + sandbox: true } - if ($operatorBuilderApps) conformBuilderAppPolicy(policy, $userStore) + if (composeOnlyApps) conformBuilderAppPolicy(policy, $userStore) return policy } diff --git a/frontend/src/lib/components/raw_apps/agentRunnable.ts b/frontend/src/lib/components/raw_apps/agentRunnable.ts new file mode 100644 index 0000000000..cca1964197 --- /dev/null +++ b/frontend/src/lib/components/raw_apps/agentRunnable.ts @@ -0,0 +1,43 @@ +import type { Schema, SchemaProperty } from '$lib/common' +import { agentMemoryMode, keepsManagedMemory } from '$lib/components/flows/agentFormFields' + +/** + * The inputs of a raw app's agent runnable, after the agent's memory as its own page reads it: a + * chat (`memory_id`, the conversation and memory key) when it keeps managed memory, single runs + * answered from the history the app passes (`previous_messages`) when it is off, and neither for an + * older `manual` setting (`memory_id` is the run's, which an older `auto` one keeps memory under). + * The server accepts both either way (see `execute_component`), so a schema gone stale only + * mistypes the inputs. + */ +export function agentRunnableSchema(memory: unknown): Schema { + const history: Record<string, SchemaProperty> = {} + if (keepsManagedMemory(memory)) { + history.memory_id = { + type: 'string', + description: + 'The conversation the message belongs to: reuse it to continue one, or omit it for a single run' + } + } + if (agentMemoryMode(memory) === 'off') { + history.previous_messages = { + type: 'array', + description: 'The history to answer from, as chat messages: this agent keeps no memory', + items: { type: 'object' } + } + } + return { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + user_message: { type: 'string', description: 'The message to answer' }, + ...history, + user_attachments: { + type: 'array', + description: 'Files the message carries, as object storage references', + items: { type: 'object', resourceType: 's3object' } + } + }, + required: ['user_message'], + order: ['user_message', ...Object.keys(history), 'user_attachments'] + } +} diff --git a/frontend/src/lib/components/raw_apps/sdkScopes.test.ts b/frontend/src/lib/components/raw_apps/sdkScopes.test.ts index f72918e311..c1db89272f 100644 --- a/frontend/src/lib/components/raw_apps/sdkScopes.test.ts +++ b/frontend/src/lib/components/raw_apps/sdkScopes.test.ts @@ -6,33 +6,41 @@ describe('stored frontend SDK consent', () => { beforeEach(() => localStorage.clear()) it('only covers scopes the viewer actually approved', () => { - storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read']) - expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'])).toBe(true) + storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read']) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read'])).toBe(true) // The app added a scope after the viewer consented: it must ask again // rather than silently minting a broader token. - expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read', 'jobs:run'])).toBe(false) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read', 'jobs:run'])).toBe(false) }) it('does not leak one viewer or app to another', () => { - storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read']) - expect(hasStoredSdkConsent('b@w.dev', 'ws', 'u/a/app', ['users:read'])).toBe(false) - expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/other', ['users:read'])).toBe(false) - expect(hasStoredSdkConsent('a@w.dev', 'other', 'u/a/app', ['users:read'])).toBe(false) + storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read']) + expect(hasStoredSdkConsent('b@w.dev', 'ws', 'u/a/app', ['jobs:read'])).toBe(false) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/other', ['jobs:read'])).toBe(false) + expect(hasStoredSdkConsent('a@w.dev', 'other', 'u/a/app', ['jobs:read'])).toBe(false) }) it('keeps the editor preview and the deployed app apart, and moves preview consent', () => { - storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read']) - expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'], true)).toBe(false) - storeSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', ['users:read'], true) - expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', ['users:read'])).toBe(false) + storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read']) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read'], true)).toBe(false) + storeSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', ['jobs:read'], true) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', ['jobs:read'])).toBe(false) movePreviewSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', 'u/a/real') - expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/real', ['users:read'], true)).toBe(true) - expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', ['users:read'], true)).toBe(false) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/real', ['jobs:read'], true)).toBe(true) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/draft_1', ['jobs:read'], true)).toBe(false) + }) + + it('never asks for the identity scope every token carries', () => { + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'])).toBe(true) + storeSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read']) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read', 'users:read'])).toBe(true) }) it('treats unreadable storage as no consent', () => { localStorage.setItem('wm_sdk_consent:a@w.dev:ws:u/a/app', 'not json') - expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'])).toBe(false) + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['jobs:read'])).toBe(false) + // Nothing to ask for: storage must not turn that into an empty prompt. + expect(hasStoredSdkConsent('a@w.dev', 'ws', 'u/a/app', ['users:read'])).toBe(true) }) }) diff --git a/frontend/src/lib/components/raw_apps/sdkScopes.ts b/frontend/src/lib/components/raw_apps/sdkScopes.ts index 238f1b7278..a4600979ba 100644 --- a/frontend/src/lib/components/raw_apps/sdkScopes.ts +++ b/frontend/src/lib/components/raw_apps/sdkScopes.ts @@ -1,7 +1,12 @@ // Frontend-SDK permissions for raw apps: the curated scopes an app author may // declare in `policy.frontend_sdk_scopes` (mirrors FRONTEND_SDK_ALLOWED_SCOPES -// in the backend `apps.rs` — both lists must stay in sync), plus the viewer-side -// consent persistence for the permission banner. +// in the backend `apps.rs` — both lists must stay in sync, except for the +// implicit scope below), plus the viewer-side consent persistence for the +// permission banner. + +/** Never toggled nor asked for: every SDK token reaches whoami without it, and whoami + * returns what the bundle already gets in `window.ctx`. */ +export const IMPLICIT_SDK_SCOPE = 'users:read' export const FRONTEND_SDK_SCOPES: { value: string; label: string; description: string }[] = [ { @@ -14,11 +19,6 @@ export const FRONTEND_SDK_SCOPES: { value: string; label: string; description: s label: 'Read jobs and results', description: 'Read jobs and their results, and list the runs the viewer can see' }, - { - value: 'users:read', - label: 'Read your identity', - description: 'Call whoami (Read the viewer username, email, groups, is_super_admin...)' - }, { value: 'resources:read', label: 'Read resources', @@ -59,10 +59,18 @@ function sdkConsentKey(viewer: string, workspace: string, path: string, preview: return `wm_sdk_consent:${preview ? 'p:' : ''}${viewer}:${workspace}:${path}` } +/** The declared scopes the viewer is asked to approve. */ +export function sdkScopesToConfirm(scopes: string[]): string[] { + return scopes.filter((s) => s !== IMPLICIT_SDK_SCOPE) +} + /** True when `approved` (a consent the viewer gave) covers every scope the app now * declares. Fewer scopes need no new consent; one more does. */ export function sdkConsentCovers(approved: unknown, scopes: string[]): boolean { - return Array.isArray(approved) && scopes.every((s) => approved.includes(s)) + const needed = sdkScopesToConfirm(scopes) + return ( + needed.length === 0 || (Array.isArray(approved) && needed.every((s) => approved.includes(s))) + ) } /** True when a previously stored "do not ask again" consent covers every @@ -80,7 +88,7 @@ export function hasStoredSdkConsent( ) return sdkConsentCovers(stored, scopes) } catch (_) { - return false + return sdkConsentCovers(null, scopes) } } diff --git a/frontend/src/lib/components/raw_apps/templates.ts b/frontend/src/lib/components/raw_apps/templates.ts index 00d7594386..e54f89fdfa 100644 --- a/frontend/src/lib/components/raw_apps/templates.ts +++ b/frontend/src/lib/components/raw_apps/templates.ts @@ -1074,20 +1074,14 @@ const App = () => { description="Windmill passes the workspace and the viewer to the app." code={${JSON.stringify(BUILDER_CONTEXT_CODE)}} > - {ctx.workspace ? ( - <> - <div> - <span className="muted">Workspace </span> - <strong>{ctx.workspace}</strong> - </div> - <div> - <span className="muted">Viewer </span> - <strong>{viewer ? viewer.name || viewer.username : 'Anonymous visitor'}</strong> - </div> - </> - ) : ( - <span className="muted">The editor preview has no viewer. Deploy the app to see yours here.</span> - )} + <div> + <span className="muted">Workspace </span> + <strong>{ctx.workspace}</strong> + </div> + <div> + <span className="muted">Viewer </span> + <strong>{viewer ? viewer.name || viewer.username : 'Anonymous visitor'}</strong> + </div> </Card> </div> </main> @@ -1166,15 +1160,11 @@ const builderAppSvelte = `<script lang="ts"> <section class="card"> {@render header(steps.length + 1, 'Know who is viewing', 'Windmill passes the workspace and the viewer to the app.')} <div class="card-body"> - {#if ctx.workspace} - <div><span class="muted">Workspace </span><strong>{ctx.workspace}</strong></div> - <div> - <span class="muted">Viewer </span> - <strong>{viewer ? viewer.name || viewer.username : 'Anonymous visitor'}</strong> - </div> - {:else} - <span class="muted">The editor preview has no viewer. Deploy the app to see yours here.</span> - {/if} + <div><span class="muted">Workspace </span><strong>{ctx.workspace}</strong></div> + <div> + <span class="muted">Viewer </span> + <strong>{viewer ? viewer.name || viewer.username : 'Anonymous visitor'}</strong> + </div> </div> <pre class="snippet"><code>{contextCode}</code></pre> </section> diff --git a/frontend/src/lib/components/raw_apps/utils.test.ts b/frontend/src/lib/components/raw_apps/utils.test.ts index 74a5c4c097..36b187b6a9 100644 --- a/frontend/src/lib/components/raw_apps/utils.test.ts +++ b/frontend/src/lib/components/raw_apps/utils.test.ts @@ -7,10 +7,37 @@ import { formatRuntimeLogsForChat, genWmillTs, normalizeRawAppRuntimeLogs, + rawAppPreviewModules, stripRawAppDiffNoise, type Runnable } from './utils' +describe('rawAppPreviewModules', () => { + const inline = (language: 'bun' | 'python3', content: string) => + ({ type: 'inline', inlineScript: { language, content } }) as unknown as Runnable + + it('ships only the same-language runnables reached through imports', () => { + const runnables = { + get: inline('bun', "import { a } from './a'\nimport { p } from './py'"), + a: inline('bun', 'import { b } from "./b.ts"'), + b: inline('bun', "import { g } from './get'\nexport const b = 1"), + unused: inline('bun', 'export const u = 1'), + main: inline('bun', 'export const m = 1'), + py: inline('python3', 'from .helpers import x') + } + // `get` itself ships too once the cycle through `b` reaches it, as deploy does. + expect(Object.keys(rawAppPreviewModules(runnables, 'get') ?? {}).sort()).toEqual([ + 'a.ts', + 'b.ts', + 'get.ts' + ]) + expect(rawAppPreviewModules(runnables, 'unused')).toBeUndefined() + expect( + rawAppPreviewModules({ ...runnables, helpers: inline('python3', 'x = 1') }, 'py') + ).toEqual({ 'helpers.py': { language: 'python3', content: 'x = 1' } }) + }) +}) + const flowSchema = { $schema: 'https://json-schema.org/draft/2020-12/schema', type: 'object', diff --git a/frontend/src/lib/components/raw_apps/utils.ts b/frontend/src/lib/components/raw_apps/utils.ts index 12be87dfd9..f87f014d8e 100644 --- a/frontend/src/lib/components/raw_apps/utils.ts +++ b/frontend/src/lib/components/raw_apps/utils.ts @@ -1,4 +1,4 @@ -import type { ScriptLang } from '../../gen/types.gen' +import type { ScriptLang, ScriptModule } from '../../gen/types.gen' import type { Schema } from '../../common' import { schemaToTsType } from '../../schema' import { isRunnableByName, isRunnableByPath, type RunnableWithFields } from '../apps/inputType' @@ -18,6 +18,49 @@ export type RawApp = { files: string[] } +function moduleExtension(language: ScriptLang | undefined): string | undefined { + if (language === 'bun' || language === 'bunnative') return 'ts' + if (language === 'python3') return 'py' + return undefined +} + +/** Ids of the sibling runnables `content` imports as `./<id>` (TS) or `.<id>` (Python). */ +function siblingImports(content: string, ext: string): string[] { + const pattern = + ext === 'ts' + ? /(?:from|import)\s*['"]\.\/([^'"/]+?)(?:\.ts)?['"]/g + : /^\s*from\s+\.([A-Za-z_]\w*)\s+import\b/gm + return Array.from(content.matchAll(pattern), (m) => m[1]) +} + +/** + * The app's other inline runnables that runnable `id` imports, directly or through one + * another, as the modules its preview ships with so those imports resolve to the + * editor's code. Deploy stores the same set on the runnable. + */ +export function rawAppPreviewModules( + runnables: Record<string, Runnable>, + id: string +): Record<string, ScriptModule> | undefined { + const own = runnables[id]?.inlineScript + const ext = moduleExtension(own?.language) + if (!own || !ext) return undefined + const modules: Record<string, ScriptModule> = {} + const queue = siblingImports(own.content, ext) + while (queue.length > 0) { + const siblingId = queue.pop()! + const key = `${siblingId}.${ext}` + const inlineScript = runnables[siblingId]?.inlineScript + // `main.ts` is the Bun entrypoint's own file, so no module may take that name. The + // runnable itself is kept when a cycle reaches it: the entrypoint is not under its id. + if (key in modules || key === 'main.ts') continue + if (!inlineScript || moduleExtension(inlineScript.language) !== ext) continue + modules[key] = { content: inlineScript.content, language: inlineScript.language } + queue.push(...siblingImports(inlineScript.content, ext)) + } + return Object.keys(modules).length > 0 ? modules : undefined +} + // Server-managed columns the deployed app row (getAppByPath) carries but the // editor's current value never does — leaving them in renders as spurious diff. const RAW_APP_DEPLOYED_METADATA_KEYS = [ diff --git a/frontend/src/lib/components/resources/ResourcesList.svelte b/frontend/src/lib/components/resources/ResourcesList.svelte index 2b0cc5eb1e..b592d0b86c 100644 --- a/frontend/src/lib/components/resources/ResourcesList.svelte +++ b/frontend/src/lib/components/resources/ResourcesList.svelte @@ -42,7 +42,7 @@ import Tooltip from '$lib/components/Tooltip.svelte' import type { ResourceType, WorkspaceDeployUISettings } from '$lib/gen' import { OauthService, ResourceService, WorkspaceService, type ListableResource } from '$lib/gen' - import { enterpriseLicense, userWorkspaces } from '$lib/stores' + import { enterpriseLicense } from '$lib/stores' import { sendUserToast } from '$lib/toast' import { useEditRights } from '$lib/operatorWriteRights' import { @@ -1027,10 +1027,10 @@ </DrawerContent> </Drawer> -{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.resources} +{#if $operatingWorkspace && !editRights.capabilities.visibility.resources} <div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert"> <p class="font-bold">Unauthorized</p> - <p>Page not available for operators</p> + <p>Page not available for your role</p> </div> {:else} <CenteredPage> @@ -1358,7 +1358,7 @@ </Cell> <Cell last actions> <div class="flex justify-end items-center gap-2"> - {#if path && assetCanBeExplored({ kind: 'resource', path }, { resource_type }) && !operatingUser.current?.operator} + {#if path && assetCanBeExplored({ kind: 'resource', path }, { resource_type }) && editRights.capabilities.code} <ExploreAssetButton asset={{ kind: 'resource', path }} _resourceMetadata={{ resource_type }} diff --git a/frontend/src/lib/components/runs/JobDetailHeader.svelte b/frontend/src/lib/components/runs/JobDetailHeader.svelte index 9a1aed6145..1caf43fbfb 100644 --- a/frontend/src/lib/components/runs/JobDetailHeader.svelte +++ b/frontend/src/lib/components/runs/JobDetailHeader.svelte @@ -30,14 +30,11 @@ import { flowPathToHref } from '$lib/scripts' import { slide } from 'svelte/transition' import { twMerge } from 'tailwind-merge' - import { - useOperatingUser, - useOperatingWorkspace - } from '$lib/components/operatingWorkspace.svelte' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { useRoleCapabilities } from '$lib/operatorWriteRights' const operatingWorkspace = useOperatingWorkspace() - const operatingUser = useOperatingUser() - const actingUser = $derived(operatingUser.current) + const roleCaps = useRoleCapabilities() interface Props { job: Job @@ -503,7 +500,7 @@ <div class="flex items-baseline flex-wrap gap-x-2 gap-y-1"> <JobStatus {job} /> - {#if isJobResolvable(job) && !actingUser?.operator} + {#if isJobResolvable(job) && $roleCaps.code} <!-- No startIcon on these: the row is baseline-aligned, and a Button is itself a flex container whose baseline comes from its icon rather than its label, which sits the text ~3px above the badges. Text @@ -514,7 +511,7 @@ variant="subtle" disabled={togglingResolution} onClick={() => setResolution(false)} - title="Show this run as a failure again" + title="Show this run as unhandled again" > Unresolve </Button> @@ -525,7 +522,7 @@ size="xs2" variant="subtle" nonCaptureEvent - title="Mark this failure as handled" + title="Mark this run as handled" > Mark resolved </Button> @@ -533,8 +530,7 @@ {#snippet content({ close })} <div class="p-3 flex flex-col gap-2 w-80"> <p class="text-xs text-secondary"> - Resolving keeps the run a failure but stops it showing as one in the runs - list. + Resolving keeps the run's status but marks it as handled in the runs list. </p> <TextInput bind:value={resolutionNote} diff --git a/frontend/src/lib/components/runs/JobStatusIcon.svelte b/frontend/src/lib/components/runs/JobStatusIcon.svelte index 5f93f8b6dd..358ab1edeb 100644 --- a/frontend/src/lib/components/runs/JobStatusIcon.svelte +++ b/frontend/src/lib/components/runs/JobStatusIcon.svelte @@ -29,7 +29,12 @@ <ShieldQuestion size={14} /> </Badge> {:else if job.canceled && 'success' in job} - <Badge color="gray" {roundedFull} baseClass={roundedFull ? '' : '!px-1.5'} title="Canceled"> + <Badge + color="gray" + {roundedFull} + baseClass={roundedFull ? '' : '!px-1.5'} + title={job.resolved ? 'Canceled, marked as resolved' : 'Canceled'} + > <Ban size={14} /> </Badge> {:else if 'success' in job && job.success} diff --git a/frontend/src/lib/components/runs/NoWorkerWithTagWarning.svelte b/frontend/src/lib/components/runs/NoWorkerWithTagWarning.svelte index 5ff51c9c04..2a4d0a8de6 100644 --- a/frontend/src/lib/components/runs/NoWorkerWithTagWarning.svelte +++ b/frontend/src/lib/components/runs/NoWorkerWithTagWarning.svelte @@ -4,6 +4,7 @@ import Popover from '../Popover.svelte' import { onDestroy, untrack } from 'svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { devWorkspaceTagSuffix, loadDevWorkspaceTagSuffix } from '$lib/devWorkspaceTags' const operatingWorkspace = useOperatingWorkspace() interface Props { @@ -28,6 +29,13 @@ return tag }) + loadDevWorkspaceTagSuffix() + let devSuffix = $derived( + $devWorkspaceTagSuffix && customTag?.endsWith($devWorkspaceTagSuffix) + ? $devWorkspaceTagSuffix + : undefined + ) + async function lookForTag(): Promise<void> { try { if (!customTag) return @@ -50,7 +58,10 @@ $effect(() => { customTag - untrack(() => timeout && setTimeout(() => lookForTag(), 2500)) + untrack(() => { + clearTimeout(timeout) + timeout = setTimeout(() => lookForTag(), 2500) + }) }) onDestroy(() => { @@ -66,6 +77,10 @@ <AlertTriangle size={16} class="text-yellow-500" /> {#snippet text()} No worker with {tagLabel ?? 'tag'} <b>{customTag}</b> is currently running. + {#if devSuffix} + <br />It carries the <code>{devSuffix}</code> suffix of dev workspace and fork jobs: only worker + groups flagged as serving dev workspaces listen to it. An admin can flag one on the Workers page. + {/if} {/snippet} </Popover> {/if} diff --git a/frontend/src/lib/components/runs/RunBadges.svelte b/frontend/src/lib/components/runs/RunBadges.svelte index 3568eb5c14..a04b6e646a 100644 --- a/frontend/src/lib/components/runs/RunBadges.svelte +++ b/frontend/src/lib/components/runs/RunBadges.svelte @@ -8,8 +8,16 @@ import { ListFilter, Tag } from 'lucide-svelte' import type { Job } from '$lib/gen' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { userWorkspaces } from '$lib/stores' + import { + devWorkspaceTagSuffix, + devWorkspaceSkipNonDevForks, + isIsolatedWorkspaceId, + loadDevWorkspaceTagSuffix + } from '$lib/devWorkspaceTags' const operatingWorkspace = useOperatingWorkspace() + loadDevWorkspaceTagSuffix() interface Props { job: Job @@ -67,7 +75,17 @@ {#if job.tag != undefined} <!-- for related places search: ADD_NEW_LANG --> <div> - <Badge color="indigo" {large}>Tag: {job.tag}</Badge> + <Badge color="indigo" {large}> + Tag: {job.tag} + {#if $devWorkspaceTagSuffix && job.tag.endsWith($devWorkspaceTagSuffix) && isIsolatedWorkspaceId(job.workspace_id, $userWorkspaces, $devWorkspaceSkipNonDevForks)} + <Tooltip> + {#snippet text()} + Suffixed with <code>{$devWorkspaceTagSuffix}</code> because this workspace is a dev workspace + or a fork: its jobs run only on worker groups serving dev workspaces. + {/snippet} + </Tooltip> + {/if} + </Badge> </div> {/if} {#if !job.visible_to_owner} @@ -107,9 +125,7 @@ <ListFilter class="inline-block" size={10} /> </Button> {:else} - <a - href={`/runs/?job_kinds=all&graph=ConcurrencyChart&concurrency_key=${concurrencyKey}`} - > + <a href={`/runs/?job_kinds=all&graph=ConcurrencyChart&concurrency_key=${concurrencyKey}`}> {concurrencyKey} </a> {/if} @@ -119,9 +135,7 @@ <Badge {large}>Concurrency: {truncateRev(concurrencyKey, 20)}</Badge> </button> {:else} - <a - href={`/runs/?job_kinds=all&graph=ConcurrencyChart&concurrency_key=${concurrencyKey}`} - > + <a href={`/runs/?job_kinds=all&graph=ConcurrencyChart&concurrency_key=${concurrencyKey}`}> <Badge {large}>Concurrency: {truncateRev(concurrencyKey, 20)}</Badge> </a> {/if} diff --git a/frontend/src/lib/components/runs/RunDetail.svelte b/frontend/src/lib/components/runs/RunDetail.svelte index d372d5d540..7ab0c13d06 100644 --- a/frontend/src/lib/components/runs/RunDetail.svelte +++ b/frontend/src/lib/components/runs/RunDetail.svelte @@ -90,7 +90,9 @@ import ExecutionDuration from '$lib/components/ExecutionDuration.svelte' import { isWindmillTooBigObject } from '$lib/components/job_args' import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte' - import OpenInSessionButton from '$lib/components/sessions/OpenInSessionButton.svelte' + import OpenInSessionButton, { + type OpenInSessionSource + } from '$lib/components/sessions/OpenInSessionButton.svelte' import { onDestroy, setContext, untrack } from 'svelte' import { getJobStatusKind, resetFavicon, setStatusFavicon } from '$lib/favicon' @@ -609,7 +611,7 @@ // Offer to resolve this failure once the re-run succeeds. Captured here because the // new job carries no back-pointer to the run it supersedes. An already-resolved // failure needs no offer, and its note must not be restated as a supersession. - if (job && isJobResolvable(job) && !job.resolved && !actingUser?.operator) { + if (job && isJobResolvable(job) && !job.resolved && editRights.capabilities.code) { rememberRerunOrigin({ originalId: job.id, rerunId: id, workspace: workspaceId! }) } await onNavigate('/run/' + id + '?workspace=' + workspaceId) @@ -715,11 +717,10 @@ onDestroy(resetFavicon) } - // The bar folds these (`actionFolds` below) once its breadcrumb has given all it can: first the - // buttons drop their labels, then the plain actions go into one menu. Embedded, the buttons - // have a row of their own that wraps. - const compactBar = $derived(!embedded && pageHeader.actionFold >= 1) - const actionsInMenu = $derived(!embedded && pageHeader.actionFold >= 2) + // The bar folds these (`actionFolds` below) once its breadcrumb has given all it can: the plain + // actions go into one menu rather than losing their labels. Embedded, the buttons have a row + // of their own that wraps. + const actionsInMenu = $derived(!embedded && pageHeader.actionFold >= 1) const isScriptJob = $derived(job?.job_kind === 'script') const isRunnableJob = $derived( @@ -755,12 +756,22 @@ ) const canEdit = $derived( (job?.job_kind === 'script' || job?.job_kind === 'flow') && - !actingUser?.operator && editRights.canEditItem(isScriptJob ? 'script' : 'flow', job?.script_path ?? '', {}) ) + // Opens the deployed runnable at this job's path, like Edit — unlike "View script", which pins + // the hash this run executed. Not gated on edit rights: a session also reads and explains, and + // the server still refuses a deploy the user has no right to. + const sessionOpen: OpenInSessionSource | undefined = $derived( + (job?.job_kind === 'script' || job?.job_kind === 'flow') && job?.script_path + ? { + target: { kind: isScriptJob ? 'script' : 'flow', path: job?.script_path ?? '' }, + workspaceId: workspaceId ?? undefined + } + : undefined + ) const canEditInFork = $derived( (job?.job_kind === 'script' || job?.job_kind === 'flow') && - !actingUser?.operator && + editRights.capabilities.code && !showEditButton && !isCloudHosted() && editInForkAllowed(workspaceId, $userWorkspaces) @@ -1081,7 +1092,8 @@ <PageHeaderContent section={{ label: `run/${runId}` }} actions={runActions} - actionFolds={2} + sessionSource={sessionOpen} + actionFolds={1} {actionsVersion} /> {/if} @@ -1091,15 +1103,6 @@ {#if restartStep !== undefined && canRestart(restart)} {@render flowRestartButton(restart, restartStep, 'button')} {/if} - {#if canEdit && showEditButton} - <OpenInSessionButton - source={{ - target: { kind: isScriptJob ? 'script' : 'flow', path: job?.script_path ?? '' }, - workspaceId: workspaceId ?? undefined - }} - btnProps={{ unifiedSize: 'sm' }} - /> - {/if} <Dropdown items={menuItems} customWidth={260}> {#snippet buttonReplacement()} <Button @@ -1112,6 +1115,7 @@ /> {/snippet} </Dropdown> + <OpenInSessionButton source={sessionOpen} /> {:else} {@render runActionsRow()} {/if} @@ -1139,7 +1143,6 @@ href={runsHref} variant="default" unifiedSize="sm" - iconOnly={compactBar} title="View runs" startIcon={{ icon: List }} > @@ -1174,7 +1177,6 @@ nonCaptureEvent variant="default" unifiedSize="sm" - iconOnly={compactBar} title="Share" startIcon={{ icon: Share2 }} > @@ -1210,7 +1212,6 @@ <Button unifiedSize="sm" variant="default" - iconOnly={compactBar} title={isHubFlowPreview ? 'Fork flow into workspace' : `Fork ${isFlowPreview(job?.job_kind) ? 'flow' : 'code'} preview`} @@ -1226,7 +1227,6 @@ <Button unifiedSize="sm" variant="default" - iconOnly={compactBar} title="Current runs" startIcon={{ icon: Activity }} on:click={() => { @@ -1303,7 +1303,6 @@ on:click={runAgain} unifiedSize="sm" variant="default" - iconOnly={compactBar} title="Run again" startIcon={{ icon: RefreshCw }} loading={runImmediatelyLoading} @@ -1323,7 +1322,7 @@ </Button> {/if} {#if job?.job_kind === 'script' || job?.job_kind === 'flow'} - {#if !actingUser?.operator} + {#if canEdit || canEditInFork} {#if editRights.canEditItem(isScriptJob ? 'script' : 'flow', job?.script_path ?? '', {})} <Button href={`${stem}/edit/${job?.script_path}?workspace=${workspaceId}`} @@ -1333,24 +1332,11 @@ unifiedSize="sm" variant="default" disabled={!showEditButton} - iconOnly={compactBar} title="Edit" startIcon={{ icon: Pen }}>Edit</Button > - {#if showEditButton} - <!-- Opens the deployed runnable at this job's path, like Edit — unlike - "View script", which pins the hash this run executed. Same gate as - Edit: where direct deployment is off, the way in is "Edit in fork". --> - <OpenInSessionButton - source={{ - target: { kind: isScriptJob ? 'script' : 'flow', path: job?.script_path ?? '' }, - workspaceId: workspaceId ?? undefined - }} - btnProps={{ unifiedSize: 'sm' }} - /> - {/if} {/if} - {#if !showEditButton && !isCloudHosted() && editInForkAllowed(workspaceId, $userWorkspaces)} + {#if canEditInFork} <Button href={buildForkEditUrl( isScriptJob ? 'script' : 'flow', @@ -1360,19 +1346,21 @@ onClick={(e) => editInFork(e, isScriptJob ? 'script' : 'flow', job?.script_path ?? '')} unifiedSize="sm" variant="default" - iconOnly={compactBar} title={editInForkLabel(workspaceId, $userWorkspaces)} startIcon={{ icon: Pen }}>{editInForkLabel(workspaceId, $userWorkspaces)}</Button > {/if} {/if} {/if} + <OpenInSessionButton + source={sessionOpen} + btnProps={embedded ? { unifiedSize: 'sm' } : undefined} + /> {#if job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'} <Button href={viewHref} unifiedSize="sm" variant="accent" - iconOnly={compactBar} title="View {job?.job_kind === 'script_hub' ? 'script' : job?.job_kind}" startIcon={{ icon: diff --git a/frontend/src/lib/components/runs/RunsTable.svelte b/frontend/src/lib/components/runs/RunsTable.svelte index be997f1096..2bf333b1c7 100644 --- a/frontend/src/lib/components/runs/RunsTable.svelte +++ b/frontend/src/lib/components/runs/RunsTable.svelte @@ -21,7 +21,8 @@ import { clickOutside, isJobCancelable, isJobReRunnable, isJobResolvable } from '$lib/utils' import { goto } from '$lib/navigation' import { useHostedPage } from '../hostedPage' - import { useOperatingUser, useOperatingWorkspace } from '../operatingWorkspace.svelte' + import { useOperatingWorkspace } from '../operatingWorkspace.svelte' + import { useRoleCapabilities } from '$lib/operatorWriteRights' import { withWorkspaceParam } from '../sessions/sessionMode.svelte' import BarsStaggered from '../icons/BarsStaggered.svelte' @@ -205,7 +206,7 @@ const dispatch = createEventDispatcher() const hosted = useHostedPage() const operatingWorkspace = useOperatingWorkspace() - const operatingUser = useOperatingUser() + const roleCaps = useRoleCapabilities() // Under a host the link opens where it decides, in the workspace the run belongs to: the // document's own router would leave the host for the navigation workspace. @@ -331,8 +332,8 @@ onClick: () => onCancelJobs?.(selectedIdsPossibleActions.cancellableJobIds), onHover: (hover) => (hoveredDropdownAction = hover ? 'cancel' : null) }) - // Operators are rejected by the endpoint, so offering the action would only 403. - const canResolve = !operatingUser.current?.operator + // A role without `code` is rejected by the endpoint, so offering the action would only 403. + const canResolve = $roleCaps.code const resolvable = canResolve ? selectedIdsPossibleActions.resolvableJobIds.length : 0 const unresolvable = canResolve ? selectedIdsPossibleActions.unresolvableJobIds.length : 0 if (resolvable) diff --git a/frontend/src/lib/components/runs/rerunResolution.svelte.ts b/frontend/src/lib/components/runs/rerunResolution.svelte.ts index 87911d6c8d..f57d7400c8 100644 --- a/frontend/src/lib/components/runs/rerunResolution.svelte.ts +++ b/frontend/src/lib/components/runs/rerunResolution.svelte.ts @@ -37,7 +37,7 @@ export function offerToResolveOriginal(origin: PendingRerun, onResolved?: () => 'success', [ { - label: 'Resolve the original failure', + label: 'Resolve the original run', callback: async () => { const affected = await JobService.resolveCompletedJobs({ workspace: origin.workspace, @@ -50,10 +50,10 @@ export function offerToResolveOriginal(origin: PendingRerun, onResolved?: () => } }) if (affected.length === 0) { - sendUserToast('Could not resolve the original failure', true) + sendUserToast('Could not resolve the original run', true) return } - sendUserToast('Original failure resolved') + sendUserToast('Original run resolved') onResolved?.() } } diff --git a/frontend/src/lib/components/runs/runsFilter.ts b/frontend/src/lib/components/runs/runsFilter.ts index 3ff8a5e54b..cef8cca6e9 100644 --- a/frontend/src/lib/components/runs/runsFilter.ts +++ b/frontend/src/lib/components/runs/runsFilter.ts @@ -186,7 +186,7 @@ export function buildRunsFilterSearchbarSchema({ ], label: 'Resolution', icon: Wrench, - description: 'Filter by whether a failure has been marked as handled' + description: 'Filter by whether a failed or canceled run has been marked as handled' }, job_trigger_kind: { type: 'oneof' as const, @@ -254,7 +254,7 @@ export const buildRunsFilterPresets = ({ { name: 'Hide schedules', value: 'job_trigger_kind:\\ !schedule' }, { name: 'Hide future jobs', value: 'show_future_jobs:\\ false' }, { name: 'Show skipped', value: 'show_skipped:\\ true' }, - { name: 'Hide resolved failures', value: 'resolved:\\ unresolved' }, + { name: 'Hide resolved runs', value: 'resolved:\\ unresolved' }, ...(isSuperAdminOrDevops && isAdminsWorkspace ? [{ name: 'All workspaces', value: 'all_workspaces:\\ true' }] : []) diff --git a/frontend/src/lib/components/schedules/SchedulesList.svelte b/frontend/src/lib/components/schedules/SchedulesList.svelte index 3d8732e088..53a4ebf256 100644 --- a/frontend/src/lib/components/schedules/SchedulesList.svelte +++ b/frontend/src/lib/components/schedules/SchedulesList.svelte @@ -21,7 +21,7 @@ import InheritedLabels from '$lib/components/InheritedLabels.svelte' import ShareModal from '$lib/components/ShareModal.svelte' import Toggle from '$lib/components/Toggle.svelte' - import { userWorkspaces, enterpriseLicense } from '$lib/stores' + import { enterpriseLicense } from '$lib/stores' import { Calendar, Circle, @@ -714,10 +714,10 @@ <DeployWorkspaceDrawer bind:this={deploymentDrawer} /> <ScheduleEditor onUpdate={loadSchedules} bind:this={scheduleEditor} /> -{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.schedules} +{#if $operatingWorkspace && !editRights.capabilities.visibility.schedules} <div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert"> <p class="font-bold">Unauthorized</p> - <p>Page not available for operators</p> + <p>Page not available for your role</p> </div> {:else} <CenteredPage> diff --git a/frontend/src/lib/components/schema/InputSelectedBadge.svelte b/frontend/src/lib/components/schema/InputSelectedBadge.svelte index 7abadf14fd..bf4d3e6211 100644 --- a/frontend/src/lib/components/schema/InputSelectedBadge.svelte +++ b/frontend/src/lib/components/schema/InputSelectedBadge.svelte @@ -6,7 +6,10 @@ import type { Snippet } from 'svelte' interface Props { - inputSelected: 'history' | 'captures' | 'saved' | 'ai' | undefined + /** `pending_run` is the odd one: the others report where the arguments in the form came + * from and undo that substitution on reject, while this one reports a chat tool call + * waiting on the form and declines it. */ + inputSelected: 'history' | 'captures' | 'saved' | 'ai' | 'pending_run' | undefined labelColor?: string className?: string acceptButton?: Snippet @@ -26,23 +29,28 @@ )} > <p class={twMerge(classes['info'].descriptionClass, 'text-xs px-2', labelColor)}> - Using {inputSelected === 'history' - ? 'historic' - : inputSelected === 'captures' - ? 'captures' - : inputSelected === 'ai' - ? 'AI generated' - : 'saved'} input arguments + {#if inputSelected === 'pending_run'} + The agent wants to run this + {:else} + Using {inputSelected === 'history' + ? 'historic' + : inputSelected === 'captures' + ? 'captures' + : inputSelected === 'ai' + ? 'AI generated' + : 'saved'} input arguments + {/if} </p> {#if acceptButton} {@render acceptButton()} {/if} <Button - color="light" - size="xs2" + unifiedSize="2xs" + variant="subtle" + tone="info" startIcon={{ icon: X }} shortCut={{ key: 'esc', withoutModifier: true }} - on:click={onReject} + onClick={onReject} /> </div> </div> diff --git a/frontend/src/lib/components/search/GlobalSearchModal.svelte b/frontend/src/lib/components/search/GlobalSearchModal.svelte index b94e0faefa..0feb82db1d 100644 --- a/frontend/src/lib/components/search/GlobalSearchModal.svelte +++ b/frontend/src/lib/components/search/GlobalSearchModal.svelte @@ -3,16 +3,19 @@ import { AppService, FlowService, + ResourceService, ScriptService, type Flow, type ListableApp, type ListableRawApp, + type ListableResource, type Script, type SearchJobsIndexResponse } from '$lib/gen' import { clickOutside, isMac, scroll_into_view_if_needed_polyfill } from '$lib/utils' import { AlertTriangle, + Bot, BoxesIcon, CalendarIcon, Code2Icon, @@ -36,7 +39,7 @@ import ContentSearchInner from '../ContentSearchInner.svelte' import { goto } from '$app/navigation' import QuickMenuItem from '../search/QuickMenuItem.svelte' - import { devopsRole, enterpriseLicense, userStore, workspaceStore } from '$lib/stores' + import { devopsRole, enterpriseLicense, roleCapabilities, workspaceStore } from '$lib/stores' import uFuzzy from '@leeoniya/ufuzzy' import BarsStaggered from '../icons/BarsStaggered.svelte' import { Alert } from '../common' @@ -103,77 +106,77 @@ label: 'Go to HTTP routes', action: (newtab: boolean = false) => gotoPage('/routes', newtab), icon: Route, - disabled: $userStore?.operator + disabled: !$roleCapabilities.visibility.triggers }, { search_id: 'nav:web_sockets', label: 'Go to WebSockets', action: (newtab: boolean = false) => gotoPage('/websocket_triggers', newtab), icon: Unplug, - disabled: $userStore?.operator + disabled: !$roleCapabilities.visibility.triggers }, { search_id: 'nav:postgres_triggers', label: 'Go to Postgres triggers', action: (newtab: boolean = false) => gotoPage('/postgres_triggers', newtab), icon: Database, - disabled: $userStore?.operator + disabled: !$roleCapabilities.visibility.triggers }, { search_id: 'nav:kafka_triggers', label: 'Go to Kafka triggers' + (!$enterpriseLicense ? '' : ' (EE)'), action: (newtab: boolean = false) => gotoPage('/kafka_triggers', newtab), icon: KafkaIcon, - disabled: $userStore?.operator + disabled: !$roleCapabilities.visibility.triggers }, { search_id: 'nav:nats_triggers', label: 'Go to NATS triggers' + (!$enterpriseLicense ? '' : ' (EE)'), action: (newtab: boolean = false) => gotoPage('/nats_triggers', newtab), icon: NatsIcon, - disabled: $userStore?.operator + disabled: !$roleCapabilities.visibility.triggers }, { search_id: 'nav:sqs_triggers', label: 'Go to SQS triggers' + (!$enterpriseLicense ? '' : ' (EE)'), action: (newtab: boolean = false) => gotoPage('/sqs_triggers', newtab), icon: AwsIcon, - disabled: $userStore?.operator + disabled: !$roleCapabilities.visibility.triggers }, { search_id: 'nav:gcp_pub_sub', label: 'Go to GCP Pub/Sub' + (!$enterpriseLicense ? '' : ' (EE)'), action: (newtab: boolean = false) => gotoPage('/gcp_triggers', newtab), icon: GoogleCloudIcon, - disabled: $userStore?.operator + disabled: !$roleCapabilities.visibility.triggers }, { search_id: 'nav:azure_event_grid', label: 'Go to Azure Event Grid' + (!$enterpriseLicense ? '' : ' (EE)'), action: (newtab: boolean = false) => gotoPage('/azure_triggers', newtab), icon: AzureIcon, - disabled: $userStore?.operator + disabled: !$roleCapabilities.visibility.triggers }, { search_id: 'nav:mqtt_triggers', label: 'Go to MQTT triggers', action: (newtab: boolean = false) => gotoPage('/mqtt_triggers', newtab), icon: MqttIcon, - disabled: $userStore?.operator + disabled: !$roleCapabilities.visibility.triggers }, { search_id: 'nav:amqp_triggers', label: 'Go to AMQP triggers', action: (newtab: boolean = false) => gotoPage('/amqp_triggers', newtab), icon: AmqpIcon, - disabled: $userStore?.operator + disabled: !$roleCapabilities.visibility.triggers }, { search_id: 'nav:email_triggers', label: 'Go to Email triggers', action: (newtab: boolean = false) => gotoPage('/email_triggers', newtab), icon: MailIcon, - disabled: $userStore?.operator + disabled: !$roleCapabilities.visibility.triggers } ] @@ -432,6 +435,9 @@ case 'raw_app': path = `/apps_raw/get/${e.path}` break + case 'agent': + path = `/agents/get/${e.path}` + break default: path = '/' } @@ -501,7 +507,7 @@ } } - type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app'> = T & { + type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app' | 'agent'> = T & { search_id: string marked?: string type?: U @@ -518,7 +524,9 @@ type TableApp = TableItem<ListableApp, 'app'> type TableRawApp = TableItem<ListableRawApp, 'raw_app'> - type TableAny = TableScript | TableFlow | TableApp | TableRawApp + type TableAgent = TableItem<ListableResource & { summary?: string }, 'agent'> + + type TableAny = TableScript | TableFlow | TableApp | TableRawApp | TableAgent let combinedItems: TableAny[] | undefined = $state(undefined) @@ -532,8 +540,18 @@ withoutDescription: true }) const apps = await AppService.listApps({ workspace: $workspaceStore! }) + const [agents, starredAgents] = await Promise.all( + [false, true].map((starredOnly) => + ResourceService.listResource({ + workspace: $workspaceStore!, + resourceType: 'ai_agent', + starredOnly + }) + ) + ) + const starredAgentPaths = new Set(starredAgents.map((x) => x.path)) - let combinedItems: (TableScript | TableFlow | TableApp | TableRawApp)[] | undefined = [ + let combinedItems: TableAny[] | undefined = [ ...flows.map((x) => ({ ...x, type: 'flow' as 'flow', @@ -555,7 +573,17 @@ type: 'app' as 'app', time: new Date(x.edited_at).getTime(), search_id: x.path - })) + })), + ...agents + .filter((x) => !x.draft_only) + .map((x) => ({ + ...x, + summary: x.description, + type: 'agent' as 'agent', + time: x.edited_at ? new Date(x.edited_at).getTime() : 0, + starred: starredAgentPaths.has(x.path), + search_id: x.path + })) ].sort((a, b) => (a.starred != b.starred ? (a.starred ? -1 : 1) : a.time - b.time > 0 ? -1 : 1)) return combinedItems @@ -571,6 +599,8 @@ return LayoutDashboardIcon case 'raw_app': return LayoutDashboardIcon + case 'agent': + return Bot } } @@ -729,7 +759,9 @@ {#if tab === 'default'} {#if (itemMap[tab] ?? []).filter((e) => (combinedItems ?? []).includes(e)).length > 0} <div class="p-2"> - <div class="py-2 px-1 text-xs font-semibold text-primary"> Flows/Scripts/Apps </div> + <div class="py-2 px-1 text-xs font-semibold text-primary"> + Flows/Scripts/Apps/Agents</div + > {#each (itemMap[tab] ?? []).filter((e) => (combinedItems ?? []).includes(e)) as el} <QuickMenuItem onselect={(shift) => { diff --git a/frontend/src/lib/components/sessions/HomeSessionStarter.svelte b/frontend/src/lib/components/sessions/HomeSessionStarter.svelte new file mode 100644 index 0000000000..9ab68c3742 --- /dev/null +++ b/frontend/src/lib/components/sessions/HomeSessionStarter.svelte @@ -0,0 +1,137 @@ +<script lang="ts"> + import { onMount, setContext, type Snippet } from 'svelte' + import AIChat from '$lib/components/copilot/chat/AIChat.svelte' + import type { DraftAttachments } from '$lib/components/copilot/chat/messageDraft.svelte' + import type { AIChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte' + import { StarterPrompts } from '$lib/components/copilot/chat/starter/starterPrompts.svelte' + import { goto } from '$lib/navigation' + import { getOrCreateRuntime } from './sessionRuntime.svelte' + import { workspaceStore } from '$lib/stores' + import { copilotWorkspaceRequested, loadCopilot } from '$lib/components/copilot/loadCopilot' + import { copilotWorkspace } from '$lib/aiStore' + import { + getEffectiveWorkspaceId, + getSessionDraftAttachments, + getSessionDraftPrompt, + homeStarterSession, + setSessionDraftAttachments, + selectSession, + sessionPageHref, + sessionState, + setSessionDraftPrompt + } from './sessionState.svelte' + import SessionWorkspaceBar from './SessionWorkspaceBar.svelte' + import { composerSentFromHome, takeComposerFocus } from './homeSessionsTransition.svelte' + import { homeComposerContext } from './homeComposerContext.svelte' + + // Home's block is the empty session's own chat, run by that session's manager: the + // composer, its controls and what a send does are the session's, not a copy of them. + // Picked once; the parent remounts this on a workspace switch, which changes the family. + // `placeholder` stands in while the chat waits on its config; `onSettled` reports the height + // the chat first took, for the next placeholder to take. + let { + onShown, + placeholder, + onSettled + }: { onShown?: () => void; placeholder?: Snippet; onSettled?: (height: number) => void } = + $props() + + const initialSession = homeStarterSession() + const sessionId = initialSession.id + const runtime = getOrCreateRuntime(initialSession) + setContext<AIChatManager>('aiChatManager', runtime.manager) + + const session = $derived(sessionState.sessions.find((s) => s.id === sessionId)) + const actingWorkspaceId = $derived( + (session ? getEffectiveWorkspaceId(session) : undefined) ?? $workspaceStore ?? undefined + ) + const starterPrompts = new StarterPrompts(() => actingWorkspaceId) + + // 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 + // can judge it at once on arrival instead of covering it while it asks. The layout also + // loads Home's own workspace into the same shared config, and the last request wins, so + // this asks again whenever a later request named another workspace. + $effect(() => { + if (actingWorkspaceId && $copilotWorkspaceRequested !== actingWorkspaceId) + loadCopilot(actingWorkspaceId) + }) + + // The turn is already running on the session's manager; the page it belongs to takes over. + function onSent() { + composerSentFromHome() + selectSession(sessionId) + void goto(sessionPageHref(sessionId)) + } + + function onDraftChange(text: string) { + setSessionDraftPrompt(sessionId, text) + // A prompt typed here is what the AI Sessions switch should open; an empty box leaves + // the switch resuming whatever session the user was last in. + if (text.trim()) selectSession(sessionId) + } + + function onAttachmentsChange(attachments: DraftAttachments) { + setSessionDraftAttachments(sessionId, attachments) + if (Object.values(attachments).some((lane) => lane.length > 0)) selectSession(sessionId) + } + + let aiChat: AIChat | undefined = $state(undefined) + let chatWrapper: HTMLDivElement | undefined = $state(undefined) + // A seeded draft holds the composer open, which is not the shape it rests in once the + // draft is sent, so only a mount with an empty draft is measured. + const initialAttachments = getSessionDraftAttachments(sessionId) + let settled = + !!getSessionDraftPrompt(sessionId)?.trim() || + (!!initialAttachments && Object.values(initialAttachments).some((lane) => lane.length > 0)) + $effect(() => { + if (!aiChat || !chatWrapper || settled) return + settled = true + const wrapper = chatWrapper + requestAnimationFrame(() => onSettled?.(wrapper.offsetHeight)) + }) + onMount(() => { + onShown?.() + if (takeComposerFocus()) setTimeout(() => aiChat?.focusInput(), 0) + // The rows' "Ask AI" lands here. Before the chat has mounted (its config still + // loading) there is nowhere to put the item, and the attach does nothing. + return homeComposerContext.register(async (element) => { + await aiChat?.attachContext(element) + }) + }) +</script> + +{#snippet actingOn()} + {#if session} + <SessionWorkspaceBar {session} switchesWorkspace /> + {/if} +{/snippet} + +<!-- Until a config has loaded the chat reads "AI not configured" and would flash its disabled + state. Any loaded config will do, so a fork switch does not hide it while the fork's loads. + The item count decides whether the composer rests collapsed, so it waits for that too + rather than open and fold up a moment later. --> +{#if session && $copilotWorkspace !== undefined && starterPrompts.fewItems !== undefined} + <div bind:this={chatWrapper}> + <AIChat + bind:this={aiChat} + hideHeader + hideModeSelector + wideLayout + initialInstructions={getSessionDraftPrompt(sessionId)} + {onDraftChange} + initialAttachments={getSessionDraftAttachments(sessionId)} + {onAttachmentsChange} + starter={{ + where: 'home', + suggestions: starterPrompts.suggestions, + fewItems: starterPrompts.fewItems + }} + viewTransitionNames + {onSent} + {actingOn} + /> + </div> +{:else} + {@render placeholder?.()} +{/if} diff --git a/frontend/src/lib/components/sessions/ItemViewerView.svelte b/frontend/src/lib/components/sessions/ItemViewerView.svelte index 5c8299de15..e9fa9b7768 100644 --- a/frontend/src/lib/components/sessions/ItemViewerView.svelte +++ b/frontend/src/lib/components/sessions/ItemViewerView.svelte @@ -28,6 +28,7 @@ kind, path, version, + pendingRunId, locationHash = '', dbtRetryFrom, workspaceId, @@ -44,6 +45,8 @@ path: string /** Deployed version this tab is pinned to, from its URL's `?version=`. */ version?: string + /** The chat tool call this tab was opened to answer, from its URL's `?pending_run=`. */ + pendingRunId?: string /** `#`-fragment of the tab's URL: the args a run's `Run again` prefills the form with. */ locationHash?: string /** The failed dbt run that `Run again` offers to resume, from `?dbt_retry_from=`. */ @@ -82,6 +85,16 @@ reloadKey++ }) + // The call waiting on this item's run form, when the reader chose to confirm it here + // rather than on the card. Read live from the runtime, so it disappears the moment the + // call settles — by Run, by Cancel, by a stopped turn or by this tab closing. + // + // Named by this tab's own url rather than looked up by item, so only the tab opened for + // the call carries it. A tab re-pointed anywhere — another version of this item included + // — loses the name and hands the call back to the card, which is where it can still be + // confirmed as proposed. + const pendingRun = $derived(pendingRunId ? runtime.pendingRunFor(pendingRunId) : undefined) + /** Refetch the deployed item. The host calls this for the reload signals that reach a * tab from outside it; nothing about becoming visible triggers it, so a form the reader * filled in survives switching tabs away and back. */ @@ -98,10 +111,12 @@ : undefined ) - // Remounts the detail page below, which loads on mount. - // The fragment is part of it: the detail pages seed their form from it once, on mount. + // Remounts the detail page below, which loads on mount. The fragment and the call are + // part of it because the detail pages read both once, as they build their form: from the + // fragment for a `Run again`, from the call for what the chat proposed. So a tab already + // on this item, re-pointed to either, has to build that form again. const loadKey = $derived( - `${workspaceId}/${kind}/${path}/${version ?? ''}/${locationHash}/${reloadKey}` + `${workspaceId}/${kind}/${path}/${version ?? ''}/${locationHash}/${pendingRunId ?? ''}/${reloadKey}` ) // Nothing deployed at this path. Stamped with the load it answers, so a refetch or a // re-pointed tab is never answered from an earlier load's verdict. @@ -234,6 +249,7 @@ {onNavigate} {active} embedded + {pendingRun} onLoadState={setLoadState} /> {:else if kind === 'flow'} @@ -245,6 +261,7 @@ {onNavigate} {active} embedded + {pendingRun} onLoadState={setLoadState} /> {:else} diff --git a/frontend/src/lib/components/sessions/OpenInSessionButton.svelte b/frontend/src/lib/components/sessions/OpenInSessionButton.svelte index 00b784138f..2ea4bdbf61 100644 --- a/frontend/src/lib/components/sessions/OpenInSessionButton.svelte +++ b/frontend/src/lib/components/sessions/OpenInSessionButton.svelte @@ -32,6 +32,13 @@ * only `window.location` read at that moment matches what the user sees. */ | { page: () => string | undefined; target?: never } ) + + function sameDestination(a: OpenInSessionSource | undefined, b: OpenInSessionSource | undefined) { + if (!a || !b) return false + if (a.target && b.target) + return a.target.kind === b.target.kind && a.target.path === b.target.path + return !!a.page && !!b.page && a.page() === b.page() + } </script> <script lang="ts"> @@ -43,10 +50,10 @@ import { copilotInfo } from '$lib/aiStore' import { sendUserToast } from '$lib/toast' import { openSourceInSession } from './sessionSwitch.svelte' - import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte' + import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' + import { useRoleCapabilities } from '$lib/operatorWriteRights' - const operatingUser = useOperatingUser() - const actingUser = $derived(operatingUser.current) + const roleCaps = useRoleCapabilities() let { source, @@ -54,7 +61,8 @@ btnProps, label, tooltip, - fallback + fallback, + deferToHeader = false }: { /** Undefined (e.g. an item without a path yet) renders the fallback. */ source?: OpenInSessionSource @@ -72,6 +80,11 @@ * opted-out user (typically the editor's inline-chat * toggle). Never rendered inside the session panel. */ fallback?: Snippet + /** For a button inside a page whose header can carry the same hand-off: while the header + * does, this renders nothing, so the hand-off has one place on screen. The fallback still + * renders for a user who opted out of sessions, since the header then offers nothing. An + * editor in a drawer opened on another item keeps its own button. */ + deferToHeader?: boolean } = $props() // Inside the session panel the chat is already on screen — a second entry @@ -80,13 +93,16 @@ // SessionEditorTarget / the session wrapper); iframe preview tabs are not // the top window. const inSessionPanel = !!getContext('aiChatManager') || (BROWSER && window.self !== window.top) - // prefersSessionHandoff carries the operator clause: the sessions page refuses - // them, so an entry point on a page they can reach (Runs, the trigger lists) + // prefersSessionHandoff carries the role clause: the sessions page refuses a role + // that cannot code, so an entry point on a page they can reach (Runs, the trigger lists) // would only route them into that refusal. - const show = $derived( + const handoff = $derived( !inSessionPanel && !!(source?.target || source?.page) && - prefersSessionHandoff(actingUser?.operator) + prefersSessionHandoff(!$roleCaps.code) + ) + const show = $derived( + handoff && !(deferToHeader && sameDestination(pageHeader.sessionSource, source)) ) // Not $state: only read inside open() as a re-entrancy latch, never rendered. @@ -119,6 +135,6 @@ {label} {tooltip} /> -{:else if !inSessionPanel} +{:else if !inSessionPanel && !handoff} {@render fallback?.()} {/if} diff --git a/frontend/src/lib/components/sessions/PreviewRouterPicker.svelte b/frontend/src/lib/components/sessions/PreviewRouterPicker.svelte index d2d6e51efb..bc03272712 100644 --- a/frontend/src/lib/components/sessions/PreviewRouterPicker.svelte +++ b/frontend/src/lib/components/sessions/PreviewRouterPicker.svelte @@ -44,6 +44,7 @@ section or the flat layout. PREVIEW_PAGES, artifactKey, isArtifactKey, + isPageKey, pageKey, previewModeFor, type PreviewTarget @@ -286,16 +287,18 @@ section or the flat layout. ) ]) - // An artifact highlight lives under the 'artifacts' branch, which the legacy - // {kind, dir} scope can't express — open the picker inside that branch so the - // active artifact is actually visible and highlighted (not the first root row). - // Keyed on the highlight's shape alone: the branch itself may not have - // materialized yet (artifacts hydrate from IndexedDB after tabs restore), and - // the drill entries fill in reactively once it does. + // A page or artifact highlight lives under its own branch, which the legacy + // {kind, dir} scope can't express — open the picker inside that branch, so the + // reader standing on one sees its siblings with it highlighted. Keyed on the + // highlight's shape alone: the artifacts branch may not have materialized yet + // (artifacts hydrate from IndexedDB after tabs restore), and the drill entries + // fill in reactively once it does. const computedInitialScope = untrack(() => initialHighlight && isArtifactKey(initialHighlight) ? ['artifacts'] - : legacyScopeToPath(initialScope, kinds, 'flat') + : initialHighlight && isPageKey(initialHighlight) + ? ['pages'] + : legacyScopeToPath(initialScope, kinds, 'flat') ) // The flat root has no branch to carry a per-kind loading flag — surface diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index 1ecb921c98..c2adaf7688 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -100,6 +100,11 @@ // What a run's `Run again` hands the deployed page it opens: the run's args in the // fragment, and for a dbt run the failure it could resume instead. const viewerUrl = $derived(slot.kind === 'viewer' ? new URL(tab.url, 'http://x') : undefined) + // The chat tool call this tab was opened to answer rides in the same query. Re-pointing + // the tab rebuilds its url without it, so a tab sent anywhere else — another item, + // another version — stops carrying the call rather than answering it from a page it was + // never confirmed against. + const pendingRunId = $derived(viewerUrl?.searchParams.get('pending_run') ?? undefined) // The path an editor reports is its deployed one, which a staged rename can make // differ from this tab's — so the view opens on what actually exists. @@ -401,6 +406,7 @@ kind={itemKind} path={itemPath} version={pinnedVersion} + {pendingRunId} locationHash={viewerUrl?.hash} dbtRetryFrom={viewerUrl?.searchParams.get('dbt_retry_from') ?? undefined} {workspaceId} diff --git a/frontend/src/lib/components/sessions/RawAppEditorView.svelte b/frontend/src/lib/components/sessions/RawAppEditorView.svelte index b944612f91..8bff98802c 100644 --- a/frontend/src/lib/components/sessions/RawAppEditorView.svelte +++ b/frontend/src/lib/components/sessions/RawAppEditorView.svelte @@ -23,7 +23,7 @@ import SessionEditorTarget from './SessionEditorTarget.svelte' import { runResetToDeployed } from '$lib/userDraftToast' import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' - import { operatorBuilderAppsIn } from '$lib/operatorWriteRights' + import { capabilitiesInWorkspace } from '$lib/operatorWriteRights' import type { RawAppRuntimeLogRequester, RawAppRunsProvider, @@ -100,7 +100,7 @@ files: { ...result.files }, runnables: { ...result.runnables, - ...(operatorBuilderAppsIn(workspaceId) ? {} : structuredClone(STARTER_RUNNABLES)) + ...(capabilitiesInWorkspace(workspaceId).apps === 'compose' ? {} : structuredClone(STARTER_RUNNABLES)) }, data: result.data, policy: result.policy diff --git a/frontend/src/lib/components/sessions/SessionChangesBar.svelte b/frontend/src/lib/components/sessions/SessionChangesBar.svelte index 3929b78ca9..c6272b8fc7 100644 --- a/frontend/src/lib/components/sessions/SessionChangesBar.svelte +++ b/frontend/src/lib/components/sessions/SessionChangesBar.svelte @@ -11,7 +11,13 @@ import Badge from '$lib/components/common/badge/Badge.svelte' import SessionStatusPopover from './SessionStatusPopover.svelte' import WorkspaceFamilyPicker from './WorkspaceFamilyPicker.svelte' - import { maybePremium, userStore, userWorkspaces, workspaceStore } from '$lib/stores' + import { + maybePremium, + roleCapabilities, + userStore, + userWorkspaces, + workspaceStore + } from '$lib/stores' import { canCreateFork } from '$lib/utils/editInFork' import { isCloudHosted } from '$lib/cloud' import { sessionState, type Session } from './sessionState.svelte' @@ -63,7 +69,7 @@ // 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) && $workspaceStore !== 'admins' + (!isCloudHosted() || $maybePremium) && canCreateFork($userStore, $roleCapabilities) && $workspaceStore !== 'admins' ) const runtime = $derived(getRuntime(session.id)) diff --git a/frontend/src/lib/components/sessions/SessionPicker.svelte b/frontend/src/lib/components/sessions/SessionPicker.svelte index 7e8283e406..898a97739b 100644 --- a/frontend/src/lib/components/sessions/SessionPicker.svelte +++ b/frontend/src/lib/components/sessions/SessionPicker.svelte @@ -60,7 +60,7 @@ import MenuButton, { sidebarClasses } from '$lib/components/sidebar/MenuButton.svelte' import DropdownV2 from '$lib/components/DropdownV2.svelte' import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate' - import { userWorkspaces, workspaceStore } from '$lib/stores' + import { userStore, userWorkspaces, workspaceStore } from '$lib/stores' import { workspaceIsFork } from '$lib/utils/workspaceHierarchy' import { WorkspaceService } from '$lib/gen' import { sendUserToast } from '$lib/toast' @@ -220,7 +220,7 @@ // Workspace tree scoped to the current family (root + its forks), so the rail // only shows the workspaces in the family we're in — not every workspace. const workspaceTreeItems = $derived.by(() => { - const all = buildWorkspaceHierarchy($userWorkspaces) + const all = buildWorkspaceHierarchy($userWorkspaces, $userStore?.email) const currentRoot = $currentWorkspaceRootId if (!currentRoot) return all return all.filter((i) => workspaceRootId(i.workspace.id, $userWorkspaces) === currentRoot) diff --git a/frontend/src/lib/components/sessions/SessionShareButton.svelte b/frontend/src/lib/components/sessions/SessionShareButton.svelte new file mode 100644 index 0000000000..59f2fd19cb --- /dev/null +++ b/frontend/src/lib/components/sessions/SessionShareButton.svelte @@ -0,0 +1,231 @@ +<script lang="ts"> + import { Globe, Link, Share2 } from 'lucide-svelte' + import { resource } from 'runed' + import { Button } from '$lib/components/common' + import Popover from '$lib/components/meltComponents/Popover.svelte' + import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte' + import Toggle from '$lib/components/Toggle.svelte' + import { AiService, type SharedAiSessionInfo } from '$lib/gen' + import { userWorkspaces } from '$lib/stores' + import { sendUserToast } from '$lib/toast' + import { copyToClipboard, displayDate } from '$lib/utils' + import { shareWorkspaceId } from '$lib/components/copilot/chat/artifacts/artifactSharing' + import type { AIChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte' + import { buildSessionSnapshot, sharedSessionUrl } from './sessionSharing' + + interface Props { + sessionId: string + name: string + /** The workspace the session acts on; undefined until its first message is sent. */ + workspaceId: string | undefined + manager: AIChatManager + } + + let { sessionId, name, workspaceId, manager }: Props = $props() + + const workspace = $derived( + workspaceId ? shareWorkspaceId(workspaceId, $userWorkspaces) : undefined + ) + const messageCount = $derived(manager.displayMessages.length) + const shareName = $derived(name.trim() || 'Untitled session') + + const status = resource( + () => ({ workspace, sessionId }), + async ({ workspace, sessionId }) => { + if (!workspace) return undefined + try { + return await AiService.getAiSessionShareStatus({ workspace, sessionId }) + } catch (err) { + // The button still works without it: sharing reports its own failure. + console.error('Could not read the session share status', err) + return undefined + } + } + ) + + const share = $derived(status.current?.share) + const url = $derived(share && workspace ? sharedSessionUrl(workspace, share.id) : undefined) + // A hint only: an edited and regenerated turn keeps both, so updating stays offered either way. + const outdated = $derived( + share !== undefined && (share.message_count !== messageCount || share.name !== shareName) + ) + + let saving = $state(false) + + function errorMessage(err: unknown): string { + const body = (err as { body?: unknown })?.body + return typeof body === 'string' && body ? body : String((err as Error)?.message ?? err) + } + + function reflect(next: SharedAiSessionInfo | undefined) { + if (status.current) status.mutate({ ...status.current, share: next }) + } + + async function shareSnapshot() { + if (!workspace) return + const updating = share !== undefined + saving = true + try { + const snapshot = buildSessionSnapshot( + $state.snapshot(manager.displayMessages), + $state.snapshot(manager.artifacts.artifacts) + ) + const shared = await AiService.shareAiSession({ + workspace, + requestBody: { + session_id: sessionId, + name: shareName, + message_count: messageCount, + snapshot + } + }) + if (status.current) reflect(shared) + else await status.refetch() + if (updating) { + sendUserToast('The link now shows the latest messages') + } else if (await copyToClipboard(sharedSessionUrl(workspace, shared.id), false)) { + sendUserToast('Link copied to clipboard') + } + } catch (err) { + sendUserToast(`Could not share the session: ${errorMessage(err)}`, true) + } finally { + saving = false + } + } + + async function setPublic(isPublic: boolean) { + if (!workspace || !share) return + saving = true + try { + reflect( + await AiService.setAiSessionSharePublic({ + workspace, + id: share.id, + requestBody: { is_public: isPublic } + }) + ) + } catch (err) { + sendUserToast(`Could not change who can open the link: ${errorMessage(err)}`, true) + } finally { + saving = false + } + } + + async function stopSharing() { + if (!workspace || !share) return + saving = true + try { + await AiService.unshareAiSession({ workspace, id: share.id }) + reflect(undefined) + sendUserToast('Stopped sharing: the link no longer opens') + } catch (err) { + sendUserToast(`Could not stop sharing: ${errorMessage(err)}`, true) + } finally { + saving = false + } + } +</script> + +<Popover + placement="bottom-end" + contentClasses="!bg-surface" + disabled={!workspace || messageCount === 0} + triggerAttrs={{ 'aria-label': 'Share session', 'aria-haspopup': 'dialog' }} +> + {#snippet trigger()} + <!-- Icon only, like the session menu beside it: tinted once a link exists. --> + <Button + unifiedSize="xs" + variant="subtle" + iconOnly + nonCaptureEvent + disabled={!workspace || messageCount === 0} + btnClasses={share ? '!text-accent' : 'text-tertiary'} + startIcon={{ icon: share ? (share.is_public ? Globe : Link) : Share2 }} + title={!workspace || messageCount === 0 + ? 'Send a first message to share this session' + : share + ? share.is_public + ? 'Shared publicly' + : 'Shared with the workspace' + : 'Share session'} + /> + {/snippet} + {#snippet content()} + <div class="flex w-80 flex-col gap-3 p-3 text-xs"> + <div class="flex flex-col gap-1"> + <span class="font-semibold text-emphasis">Share session</span> + <span class="font-normal text-secondary"> + {#if share?.is_public} + Anyone with the link can read a copy of this conversation. Nobody can continue it. + {:else} + Members of {workspace} can read a copy of this conversation with the link. Nobody can continue + it. + {/if} + </span> + </div> + + {#if share && url} + <Toggle + size="xs" + checked={share.is_public} + disabled={saving} + options={{ + right: 'Public', + rightTooltip: 'Anyone with the link can open it, without signing in' + }} + on:change={(e) => setPublic(e.detail)} + /> + <div class="flex flex-col gap-1"> + <ClipboardPanel content={url} size="sm" /> + <span class="text-2xs font-normal text-hint"> + Shows {share.message_count} message{share.message_count === 1 ? '' : 's'} as of {displayDate( + share.shared_at + )} + </span> + </div> + <div class="flex items-center justify-between gap-2"> + <span class="font-normal text-secondary"> + {outdated + ? 'The session changed since it was shared.' + : 'The link shows the session as it was when shared.'} + </span> + <Button + unifiedSize="sm" + variant={outdated ? 'accent' : 'default'} + loading={saving} + disabled={manager.loading} + onClick={shareSnapshot} + > + Update link + </Button> + </div> + <div class="flex justify-end"> + <Button + unifiedSize="sm" + variant="default" + destructive + disabled={saving} + onClick={stopSharing} + > + Stop sharing + </Button> + </div> + {:else} + <div class="flex justify-end"> + <Button + unifiedSize="sm" + variant="accent" + startIcon={{ icon: Link }} + loading={saving} + disabled={status.loading || manager.loading} + title={manager.loading ? 'Wait for the current turn to finish' : undefined} + onClick={shareSnapshot} + > + Create link + </Button> + </div> + {/if} + </div> + {/snippet} +</Popover> diff --git a/frontend/src/lib/components/sessions/SessionWorkspaceBar.svelte b/frontend/src/lib/components/sessions/SessionWorkspaceBar.svelte index 1a992b818b..41cbb73a81 100644 --- a/frontend/src/lib/components/sessions/SessionWorkspaceBar.svelte +++ b/frontend/src/lib/components/sessions/SessionWorkspaceBar.svelte @@ -8,8 +8,22 @@ } 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' - let { session }: { session: Session } = $props() + // `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 + // behind would read as ignored; the session page follows the session's own workspace instead. + 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) @@ -17,19 +31,21 @@ function pick(id: string) { // Pre-send only: writes the pending pick. workspace_id stays undefined until - // the user sends their first message. The global workspaceStore is left - // untouched — the chat targets this pending workspace via the manager's - // workspace resolver, so picking here must not switch the active workspace. + // 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) } function stageFork(req: { parent_workspace_id: string; id: string; name: string }) { setSessionPendingFork(session.id, req) + followPick(req.parent_workspace_id) } </script> -<div class="flex flex-row items-center gap-1 py-0.5 px-1 text-2xs text-secondary"> - <span class="shrink-0">Acting on</span> +<!-- A control of the composer's toolbar; on a narrow window it gives up its label first. --> +<div class="flex flex-row items-center gap-1 min-w-0 text-2xs text-secondary"> + <span class="shrink-0 hidden lg:inline">Acting on</span> <WorkspaceFamilyPicker selectedId={effectiveId} {pendingFork} diff --git a/frontend/src/lib/components/sessions/SessionWrapper.svelte b/frontend/src/lib/components/sessions/SessionWrapper.svelte index 5b54ff8932..68afde0c5f 100644 --- a/frontend/src/lib/components/sessions/SessionWrapper.svelte +++ b/frontend/src/lib/components/sessions/SessionWrapper.svelte @@ -1,7 +1,26 @@ +<script module lang="ts"> + import { preloadComponent } from '$lib/utils/preloadComponent.svelte' + + // AIChat's code is most of this page's: loaded beside it rather than as part of it, so the + // page and its ComposerPlaceholder show without waiting on it. + const aiChatComponent = preloadComponent( + () => import('$lib/components/copilot/chat/AIChat.svelte') + ) +</script> + <script lang="ts"> import { onDestroy, setContext, untrack } from 'svelte' import { Pane, Splitpanes } from 'svelte-splitpanes' - import AIChat from '$lib/components/copilot/chat/AIChat.svelte' + import type AIChat from '$lib/components/copilot/chat/AIChat.svelte' + import ComposerPlaceholder from '$lib/components/copilot/chat/ComposerPlaceholder.svelte' + import SessionGreeting from '$lib/components/copilot/chat/starter/SessionGreeting.svelte' + import ComposerSuggestions from '$lib/components/copilot/chat/starter/ComposerSuggestions.svelte' + import { + globalComposerSuggestion, + STARTER_PLACEHOLDER, + type ComposerSelection + } from '$lib/components/copilot/chat/composerBox' + import { useSessionPermissions } from './sessionPermissions.svelte' import EditableInput from '$lib/components/common/EditableInput.svelte' import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' import { Button } from '$lib/components/common' @@ -17,7 +36,7 @@ import { maskHasDraftRow } from './modifiedItemsMask' import { sendUserToast } from '$lib/toast' import Toggle from '$lib/components/Toggle.svelte' - import { copilotInfo } from '$lib/aiStore' + import { copilotInfo, copilotWorkspace } from '$lib/aiStore' import { loadCopilot } from '$lib/components/copilot/loadCopilot' import { Archive, @@ -26,11 +45,14 @@ GitFork, MessageSquareDashed, Pencil, + Plus, Trash2, X } from 'lucide-svelte' import SessionWorkspaceBar from './SessionWorkspaceBar.svelte' + import { arrivingFromHomeSend } from './homeSessionsTransition.svelte' import SessionChangesBar from './SessionChangesBar.svelte' + import SessionShareButton from './SessionShareButton.svelte' import { clearSessionRecovered, isSessionRecovered } from './sessionRecoveryNotice.svelte' import { composerFocusRequest, @@ -41,6 +63,8 @@ moveSessionToWorkspace, getSessionDraftPrompt, setSessionDraftPrompt, + getSessionDraftAttachments, + setSessionDraftAttachments, peekSessionAutoSend, takeSessionAutoSend, reconcileAfterWorkspaceChange, @@ -55,14 +79,18 @@ import { getOrCreateRuntime, removeSession } from './sessionRuntime.svelte' import { goto } from '$lib/navigation' import { splitterPointerCapture } from '$lib/utils/splitterPointerCapture' + import { StarterPrompts } from '$lib/components/copilot/chat/starter/starterPrompts.svelte' let { sessionId, - headerInPage = false + headerInPage = false, + covered = false }: { 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 } = $props() // Parent keys by sessionId; this wrapper only mounts when the session exists. @@ -77,6 +105,15 @@ // Reactive session reference (mutations to summary/target propagate via the $state proxy) const session = $derived(sessionState.sessions.find((s) => s.id === sessionId)) + // Only a session that has never sent opens as the starter block. Read off the record, not + // the transcript: a cold session's history is still loading from storage when it mounts, + // 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 + ) + // Seed the composer with the unsent prompt a reload preserved on the session // record (script-init: AIChatInput reads it once at mount). const restoredDraftPrompt = getSessionDraftPrompt(sessionId) @@ -89,6 +126,31 @@ // mount-time empty draft would otherwise erase the prompt from the record. const armedAtInit = sessionState.currentSessionId === sessionId && peekSessionAutoSend(sessionId) + // The composer's text before AIChat mounts, typed into the placeholder, and then the real + // composer's initial draft. + let draftText = $state(armedAtInit ? '' : (restoredDraftPrompt ?? '')) + // What the first focus after the swap does: put the caret where the placeholder had it, or, + // when null, leave focus where the user moved it while the chat loaded. Undefined when the + // placeholder never had focus. + let handoff: ComposerSelection | null | undefined = undefined + // The swap waits for an IME composition to end: the new field would not carry it over. + let placeholderComposing = $state(false) + // The placeholder's text counts as unsent input, like the real composer's draft, so the page + // does not evict this session's chat (and the text with it) while it waits for the chat code. + const PLACEHOLDER_DRAFT_KEY = 'composer-placeholder' + function setPlaceholderText(text: string) { + draftText = text + setSessionDraftPrompt(sessionId, text) + runtime?.manager.setComposerHasDraft(PLACEHOLDER_DRAFT_KEY, text.trim() !== '') + } + + // 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) + ) + // A hand-off whose click already stated the intent asks for its prompt to be // sent, not parked. Reactive rather than mount-time: `createSession` reuses an // untouched blank session and the sessions page keys wrappers by id, so a @@ -262,17 +324,43 @@ // warm-session switch via the picker, and the moment copilot finishes // loading. let aiChat: AIChat | undefined = $state(undefined) + let seenFocusNonce = untrack(() => composerFocusRequest.nonce) $effect(() => { // Focus the composer when this session becomes active, or on an explicit // focus request — the latter covers `+` reusing the untouched draft you're // already viewing, where currentSessionId doesn't change so activation alone // wouldn't re-run this. - void composerFocusRequest.nonce - if (sessionState.currentSessionId !== sessionId) return + const focusNonce = composerFocusRequest.nonce + if (focusNonce !== seenFocusNonce) { + // An explicit request overrides the user having left the placeholder before it. + seenFocusNonce = focusNonce + handoff = undefined + } + if (sessionState.currentSessionId !== sessionId) { + // The user left for another session: coming back is an activation, which focuses. + handoff = undefined + return + } if (!aiChat) return if (!$copilotInfo.enabled) return const chat = aiChat - setTimeout(() => chat.focusInput(), 0) + const selection = handoff + handoff = undefined + if (selection === null) return + setTimeout(() => chat.focusInput(selection), 0) + }) + + // The real composer is disabled until the copilot config of the workspace it acts on is in, + // so the active session keeps the placeholder, which takes typing, until then. Latched: a + // later config reload must not swap a mounted chat, and its transcript, back out. + let chatShown = false + const showChat = $derived.by(() => { + chatShown ||= + !!aiChatComponent.current && + !placeholderComposing && + (sessionState.currentSessionId !== sessionId || + $copilotWorkspace === (actingWorkspaceId ?? $workspaceStore)) + return chatShown }) // True when the session committed to a workspace that's no longer in @@ -437,6 +525,10 @@ {#if !session || !runtime} <div class="p-8 text-secondary text-sm">Session not found</div> {: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 inputPreface()} {#if showRecoveryNotice} <!-- mb-6, not the banner column's mb-1: at that gap it reads as one more @@ -464,9 +556,6 @@ /> </div> {/if} - {#if !hasFirstUserMessage} - <SessionWorkspaceBar {session} /> - {/if} <!-- gap-1 (4px) spaces the archived banner and the changes bar when both are visible. Each renders a single in-flow root (or nothing); the diff drawer is position:fixed, so it doesn't count as a flex item — no stray @@ -503,9 +592,17 @@ </div> {/snippet} - <!-- Override the chat's default keyboard-shortcut hint with nothing — - sessions have their own empty-state affordances above. --> - {#snippet sessionEmptyHint()}{/snippet} + {#snippet actingOn()} + <SessionWorkspaceBar {session} /> + {/snippet} + + <!-- The left of AIChatDisplay's controls row: the rest arrives with the chat. --> + {#snippet placeholderToolbar()} + <div class="flex flex-row items-center gap-x-1.5 min-w-0 flex-wrap"> + <Button unifiedSize="2xs" variant="subtle" iconOnly startIcon={{ icon: Plus }} disabled /> + {@render actingOn()} + </div> + {/snippet} {#snippet sessionHeader()} <EditableInput @@ -588,6 +685,13 @@ {/snippet} </WorkspaceFamilyPicker> {/if} + + <SessionShareButton + sessionId={session.id} + name={session.summary ?? ''} + workspaceId={session.workspace_id} + manager={runtime.manager} + /> {/snippet} {#if headerInPage} @@ -596,7 +700,11 @@ breadcrumb's own workspace part, the way every other route names it — a session in a fork reads like anything else in that fork. --> <PageHeaderContent - section={{ label: session.summary ?? 'Untitled session', content: sessionHeader }} + section={{ + label: session.summary ?? 'Untitled session', + content: sessionHeader, + noNavPicker: true + }} {actingWorkspaceId} /> {/if} @@ -607,23 +715,82 @@ <div class="flex-1 min-h-0 flex flex-col" use:splitterPointerCapture> <Splitpanes horizontal={false} class="flex-1 min-h-0 splitter-hidden"> <Pane minSize={25} class="flex flex-col min-h-0 pb-2"> - <div class="flex-1 min-h-0 w-full flex flex-col {hasFirstUserMessage ? '' : 'pt-8'}"> - <AIChat - bind:this={aiChat} - hideHeader - hideModeSelector - wideLayout - initialInstructions={armedAtInit ? undefined : restoredDraftPrompt} - onDraftChange={(text) => setSessionDraftPrompt(sessionId, text)} - forceDisabled={isUnavailable || !!session.archived} - 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.' - : session.archived - ? 'This session is archived. Unarchive it from the banner above to keep working.' - : ''} - emptyHint={sessionEmptyHint} - {inputPreface} - /> + <div class="flex-1 min-h-0 w-full flex flex-col"> + {#if showChat && aiChatComponent.current} + <aiChatComponent.current + bind:this={aiChat} + hideHeader + hideModeSelector + wideLayout + initialInstructions={draftText} + onDraftChange={(text) => setSessionDraftPrompt(sessionId, text)} + initialAttachments={armedAtInit ? undefined : getSessionDraftAttachments(sessionId)} + onAttachmentsChange={(a) => setSessionDraftAttachments(sessionId, a)} + placeholder={composerPlaceholder} + forceDisabled={isUnavailable || !!session.archived} + 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.' + : session.archived + ? 'This session is archived. Unarchive it from the banner above to keep working.' + : ''} + starter={opensSession + ? { where: 'session', suggestions: starterPrompts.suggestions } + : undefined} + viewTransitionNames={headerInPage && (opensSession || arrivingFromHomeSend())} + emptyHint={sessionEmptyHint} + {inputPreface} + actingOn={hasFirstUserMessage ? undefined : actingOn} + /> + {: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' : ''}" + {@attach () => () => + runtime.manager.setComposerHasDraft(PLACEHOLDER_DRAFT_KEY, false)} + > + {#if opensSession} + <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} + <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 + : runtime.manager.displayMessages.length > 0 + ? 'Ask followup' + : composerPlaceholder} + disabled={isUnavailable || !!session.archived} + autofocus={sessionState.currentSessionId === sessionId && !covered} + onSelectionChange={(selection) => (handoff = selection ?? null)} + onCompositionChange={(composing) => (placeholderComposing = composing)} + /> + {#if !opensSession} + <div class="mt-1 h-5"></div> + {:else if !isUnavailable && !session.archived && starterPrompts.suggestions.length > 0} + <div class="pt-2"> + <ComposerSuggestions + suggestions={starterPrompts.suggestions} + wrap + onPick={setPlaceholderText} + /> + </div> + {/if} + </div> + {#if opensSession} + <div class="flex-1 min-h-0"></div> + {/if} + </div> + {/if} </div> </Pane> </Splitpanes> diff --git a/frontend/src/lib/components/sessions/SharedSessionTranscript.svelte b/frontend/src/lib/components/sessions/SharedSessionTranscript.svelte new file mode 100644 index 0000000000..19239fe48c --- /dev/null +++ b/frontend/src/lib/components/sessions/SharedSessionTranscript.svelte @@ -0,0 +1,121 @@ +<script lang="ts"> + import { Button, Drawer, DrawerContent } from '$lib/components/common' + import { FileText } from 'lucide-svelte' + import AIChatMessage from '$lib/components/copilot/chat/AIChatMessage.svelte' + import ToolGroupDisplay from '$lib/components/copilot/chat/ToolGroupDisplay.svelte' + import ArtifactBody from '$lib/components/copilot/chat/artifacts/ArtifactBody.svelte' + import { setChatViewHost } from '$lib/components/copilot/chat/chatViewHost' + import { readOnlyChatViewHost } from '$lib/components/copilot/chat/readOnlyChatViewHost.svelte' + import { chatItemKey, groupToolRuns } from '$lib/components/copilot/chat/toolGroups' + import type { DisplayMessage } from '$lib/components/copilot/chat/shared' + import type { SessionSnapshot } from './sessionSharing' + import { untrack } from 'svelte' + + interface Props { + snapshot: SessionSnapshot + workspace: string + } + + let { snapshot, workspace }: Props = $props() + + let openArtifactId: string | undefined = $state(undefined) + let drawer: Drawer | undefined = $state(undefined) + + // Built once: the page remounts this component for another share. + const initial = untrack(() => ({ snapshot, workspace })) + setChatViewHost( + readOnlyChatViewHost({ + messages: initial.snapshot.messages, + workspace: initial.workspace, + artifacts: initial.snapshot.artifacts.map((a) => ({ + ...a, + sessionId: '', + createdAt: 0, + updatedAt: 0 + })), + openArtifact: (id) => showArtifact(id) + }) + ) + + function showArtifact(id: string) { + openArtifactId = id + drawer?.openDrawer() + } + + const messages = $derived(snapshot.messages) + const chatItems = $derived(groupToolRuns(messages)) + const openArtifact = $derived(snapshot.artifacts.find((a) => a.id === openArtifactId)) + + // An answer's copy row shows once per turn, on its last answer, as in the live chat. + const showsAnswerActions = $derived.by(() => { + const shows: boolean[] = new Array(messages.length).fill(false) + const answeredLater = new Set<string | undefined>() + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + if (message.role === 'user' || message.role === 'summary') { + answeredLater.clear() + } else if (message.role === 'assistant' && message.content) { + const run = message.jobId ?? message.stepName + shows[i] = !answeredLater.has(run) + answeredLater.add(run) + } + } + return shows + }) +</script> + +{#snippet messageRow(message: DisplayMessage, messageIndex: number, inGroup: boolean)} + <AIChatMessage + {message} + {messageIndex} + availableContext={[]} + editingMessageIndex={null} + isLast={messageIndex === messages.length - 1} + {inGroup} + showAnswerActions={showsAnswerActions[messageIndex]} + /> +{/snippet} +{#snippet groupRow(message: DisplayMessage, messageIndex: number)} + {@render messageRow(message, messageIndex, true)} +{/snippet} + +{#if snapshot.artifacts.length > 0} + <!-- The transcript only links plan documents; every other artifact is reached from here. --> + <div class="mb-4 flex flex-wrap items-center gap-2 px-2"> + <span class="text-xs font-semibold text-emphasis">Artifacts</span> + {#each snapshot.artifacts as artifact (artifact.id)} + <Button + unifiedSize="sm" + variant="default" + startIcon={{ icon: FileText }} + onClick={() => showArtifact(artifact.id)} + > + {artifact.name} + </Button> + {/each} + </div> +{/if} + +<div class="flex flex-col pb-2"> + {#each chatItems as item (chatItemKey(item))} + {#if item.kind === 'group'} + <div class="mb-1 min-w-0 text-sm px-2 text-primary"> + <div class="px-[1px]"> + <ToolGroupDisplay group={item} entry={groupRow} /> + </div> + </div> + {:else} + {@render messageRow(item.message, item.index, false)} + {/if} + {/each} +</div> + +<Drawer bind:this={drawer} size="900px"> + <DrawerContent title={openArtifact?.name ?? 'Artifact'} on:close={() => drawer?.closeDrawer()}> + {#if openArtifact} + <ArtifactBody content={openArtifact.content} source={openArtifact.kind !== 'md'} /> + {:else} + <span class="text-xs text-secondary">This artifact was not part of the share.</span> + {/if} + </DrawerContent> +</Drawer> diff --git a/frontend/src/lib/components/sessions/WorkspaceFamilyPicker.svelte b/frontend/src/lib/components/sessions/WorkspaceFamilyPicker.svelte index ce481cbcb3..53f930fa15 100644 --- a/frontend/src/lib/components/sessions/WorkspaceFamilyPicker.svelte +++ b/frontend/src/lib/components/sessions/WorkspaceFamilyPicker.svelte @@ -3,6 +3,7 @@ import { enterpriseLicense, maybePremium, + roleCapabilities, superadmin, userStore, userWorkspaces, @@ -10,11 +11,11 @@ type UserWorkspace } from '$lib/stores' import { - findWorkspaceDescendants, findCanonicalDevWorkspace, findDefaultForkBase, findWorkspaceRoot, - buildWorkspaceHierarchy + buildWorkspaceHierarchy, + workspaceSubtreeRows } from '$lib/utils/workspaceHierarchy' import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte' import { canCreateFork } from '$lib/utils/editInFork' @@ -114,13 +115,17 @@ ? forkableWorkspaces.find((w) => w.id === forksOf) : findWorkspaceRoot(effectiveId, forkableWorkspaces) ) - const forks = $derived( - forksOf - ? forkableWorkspaces.filter((w) => w.parent_workspace_id === forksOf) - : root - ? findWorkspaceDescendants(root.id, forkableWorkspaces) - : [] + // The same hierarchy the sidebar workspace menu builds: forks of forks follow their parent, and the + // user's own forks lead each group of siblings. + const hierarchy = $derived(buildWorkspaceHierarchy(forkableWorkspaces, $userStore?.email)) + const forkRows = $derived( + root + ? workspaceSubtreeRows(root.id, hierarchy).filter( + (h) => !forksOf || h.workspace.parent_workspace_id === forksOf + ) + : [] ) + const forks = $derived(forkRows.map((h) => h.workspace)) // The family's canonical dev workspace, if any — still used for gating (a forking-locked root can be // forked via its dev) and as a selectable base with a "dev" badge. @@ -146,13 +151,10 @@ })) ) - // Depth of each workspace within its family (root = 0, its forks = 1, forks of forks = 2, …), from - // the shared hierarchy builder — the same one the sidebar workspace menu uses — so the list indents - // forks of forks under their parent the same way. `forks` is a DFS of descendants (parent before - // child), so indenting each row by its depth nests it under its parent. - const familyDepths = $derived( - new Map(buildWorkspaceHierarchy(forkableWorkspaces).map((h) => [h.workspace.id, h.depth])) - ) + // Depth of each workspace within its family (root = 0, its forks = 1, forks of forks = 2, …). + // `forks` is a DFS (parent before child), so indenting each row by its depth nests it under its + // parent, the same way the sidebar menu does. + const familyDepths = $derived(new Map(hierarchy.map((h) => [h.workspace.id, h.depth]))) // Extra left padding (on top of the row's base px-3) to nest a workspace one step per depth level, // matching the sidebar menu's `depth * 16px`. function indentStyle(id: string): string | undefined { @@ -204,7 +206,8 @@ !rootUserInfoResource.loading && (!isRuleActiveInRulesets(rootRulesets, 'DisableWorkspaceForking') || canUserBypassRuleKindInRulesets(rootRulesets, 'DisableWorkspaceForking', rootUserInfo)) - : $workspaceStore !== 'admins' && (canCreateFork($userStore) || !!devOfRoot)) + : $workspaceStore !== 'admins' && + (canCreateFork($userStore, $roleCapabilities) || !!devOfRoot)) ) // A fork is a new workspace, so it's subject to the community-edition cap on // the number of non-'admins' workspaces (backend _check_nb_of_workspaces, @@ -455,9 +458,16 @@ /> </div> {/if} - {#each forks as f, fi (f.id)} + {#each forkRows as { workspace: f, separatorBefore }, fi (f.id)} {@const forkIdx = (root ? 1 : 0) + fi} {@const accentStyle = forkAccentStyle(f.color)} + {#if separatorBefore} + <div + class="my-1 border-t border-border-light shrink-0" + style:margin-left={`${forksOf ? 0 : ((familyDepths.get(f.id) ?? 1) - 1) * 16}px`} + role="separator" + ></div> + {/if} <div class="relative group shrink-0"> <button type="button" diff --git a/frontend/src/lib/components/sessions/homeComposerContext.svelte.ts b/frontend/src/lib/components/sessions/homeComposerContext.svelte.ts new file mode 100644 index 0000000000..3a923c0cbc --- /dev/null +++ b/frontend/src/lib/components/sessions/homeComposerContext.svelte.ts @@ -0,0 +1,50 @@ +import type { ContextElement } from '$lib/components/copilot/chat/context' +import type { BulkKind } from '$lib/components/home/homeSelection.svelte' + +/** Resolves once the element is in the draft, so several attach in the order given. */ +type Attach = (element: ContextElement) => Promise<void> + +/** What the composer can take as context from a workspace item: a script, a flow or a code + * app, as "+" → Mention file builds it. A visual-builder app or an agent has no context + * element, so it gets none. */ +export function itemContextElement(item: { + kind: BulkKind + path: string + summary?: string + rawApp?: boolean +}): ContextElement | undefined { + const type = + item.kind === 'script' + ? 'workspace_script' + : item.kind === 'flow' + ? 'workspace_flow' + : item.kind === 'app' && item.rawApp + ? 'workspace_app' + : undefined + if (!type) return undefined + return { type, path: item.path, title: item.path, summary: item.summary, deletable: true } +} + +let attach = $state<Attach | undefined>(undefined) + +/** + * The way from anywhere on Home into Home's composer: a row's "Ask AI" hands its item over + * here, and the composer takes it as context. Only a mounted composer registers, so a page + * whose composer is removed, hidden or not Home offers no button that would go nowhere. + */ +export const homeComposerContext = { + get available(): boolean { + return attach !== undefined + }, + async attach(element: ContextElement): Promise<void> { + await attach?.(element) + }, + /** Returns the unregister, which only clears its own registration: a remount registers + * the new composer before the old one is destroyed. */ + register(fn: Attach): () => void { + attach = fn + return () => { + if (attach === fn) attach = undefined + } + } +} diff --git a/frontend/src/lib/components/sessions/homeSessionsTransition.svelte.ts b/frontend/src/lib/components/sessions/homeSessionsTransition.svelte.ts new file mode 100644 index 0000000000..74a72ec0ce --- /dev/null +++ b/frontend/src/lib/components/sessions/homeSessionsTransition.svelte.ts @@ -0,0 +1,96 @@ +import type { OnNavigate } from '@sveltejs/kit' +import { sessionState } from './sessionState.svelte' + +// Moving between Home and AI Sessions keeps the composer in sight: the browser +// snapshots the page before and after the navigation and glides the element that +// carries the `composer` view-transition-name on both sides (AIChatDisplay names it); +// the rest of the page cross-fades. The timing lives in app.css, keyed on the +// `data-home-sessions-transition` attribute set here for the length of the move. + +const HOME = '/' +const SESSIONS = '/sessions' + +/** Whether the composer had focus when the user left AI Sessions for Home. */ +let composerFocused = false + +/** The focus flag to keep after a navigation from `from` to `to`. Only the return to Home + * hands focus on — the sessions page focuses its composer on its own — and every other + * navigation clears the flag, so a later arrival on Home never acts on a stale one. */ +export function composerFocusAfter( + from: string | undefined, + to: string | undefined, + composerHadFocus: boolean +): boolean { + return from === SESSIONS && to === HOME && composerHadFocus +} + +/** Whether Home should give its composer the focus the sessions page's had. Read once: + * a later visit starts unfocused. */ +export function takeComposerFocus(): boolean { + const focused = composerFocused + composerFocused = false + return focused +} + +// A session that has sent its first message has a composer of its own, not Home's draft, so +// a move between it and Home is not one composer moving: its page names no composer. The one +// exception is the send from Home itself, which lands on that session already under way. +let sentFromHome = $state(false) + +/** Called by Home's composer as its send hands over to the session page. */ +export function composerSentFromHome(): void { + sentFromHome = true +} + +/** Whether the session page arriving now came from a send on Home, so its composer is the + * one Home just sent from. */ +export function arrivingFromHomeSend(): boolean { + return sentFromHome +} + +/** For SvelteKit's `onNavigate`: runs a move between Home and AI Sessions as a view + * transition. Any other navigation, or a browser without the API, is left alone. */ +export function homeSessionsTransition(navigation: OnNavigate): Promise<void> | undefined { + const from = navigation.from?.url.pathname + const to = navigation.to?.url.pathname + composerFocused = composerFocusAfter( + from, + to, + document.activeElement?.closest('[data-session-composer]') != null + ) + const direction = + from === HOME && to === SESSIONS + ? 'enter' + : from === SESSIONS && to === HOME + ? 'leave' + : undefined + // Good for the navigation the send started, and only that one. + if (direction !== 'enter') sentFromHome = false + if (!direction || navigation.willUnload || !document.startViewTransition) return + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return + const root = document.documentElement + // To or from a session under way there is no composer to carry over: Home's would only + // fade out or in on its own, so the move drops every name and the page cross-fades as + // one. Leaving, the page says so; arriving, the session's record does (it has sent once + // it has a workspace), short of the send from Home that just started it. + const composerOnScreen = Array.from(document.querySelectorAll('[data-session-composer]')).some( + (el) => getComputedStyle(el).viewTransitionName === 'composer' + ) + const arrivingId = navigation.to?.url.searchParams.get('session') ?? sessionState.currentSessionId + const arrivingUnderWay = + !sentFromHome && !!sessionState.sessions.find((s) => s.id === arrivingId)?.workspace_id + const plain = direction === 'leave' ? !composerOnScreen : arrivingUnderWay + root.dataset.homeSessionsTransition = plain ? 'fade' : direction + return new Promise((resolve) => { + const transition = document.startViewTransition(async () => { + resolve() + await navigation.complete + }) + // Rejects when the navigation is cancelled or fails; the attribute goes either way. + const done = () => { + delete root.dataset.homeSessionsTransition + sentFromHome = false + } + void transition.finished.then(done, done) + }) +} diff --git a/frontend/src/lib/components/sessions/homeSessionsTransition.test.ts b/frontend/src/lib/components/sessions/homeSessionsTransition.test.ts new file mode 100644 index 0000000000..4be3f18fce --- /dev/null +++ b/frontend/src/lib/components/sessions/homeSessionsTransition.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { composerFocusAfter } from './homeSessionsTransition.svelte' + +describe('composerFocusAfter', () => { + it('hands focus on only for the return from AI Sessions to Home', () => { + expect(composerFocusAfter('/sessions', '/', true)).toBe(true) + expect(composerFocusAfter('/sessions', '/', false)).toBe(false) + // Going to AI Sessions: that page focuses its own composer, so nothing is kept for Home. + expect(composerFocusAfter('/', '/sessions', true)).toBe(false) + // Any other navigation clears it, so a later arrival on Home never reads a stale flag. + expect(composerFocusAfter('/sessions', '/runs', true)).toBe(false) + expect(composerFocusAfter('/runs', '/', true)).toBe(false) + }) +}) diff --git a/frontend/src/lib/components/sessions/previewPaths.ts b/frontend/src/lib/components/sessions/previewPaths.ts index 6c462ff322..31c610889d 100644 --- a/frontend/src/lib/components/sessions/previewPaths.ts +++ b/frontend/src/lib/components/sessions/previewPaths.ts @@ -152,6 +152,7 @@ export function triggerLabelForPath(path: string): string | undefined { } export const pageKey = (path: string) => `page:${path}` +export const isPageKey = (key: string) => key.startsWith('page:') /** Strip the query and hash from a preview path so it can be matched against * `PREVIEW_PAGES` / parsed as an item route. */ diff --git a/frontend/src/lib/components/sessions/previewReload.test.ts b/frontend/src/lib/components/sessions/previewReload.test.ts index e7924a1813..2cc40cd3cc 100644 --- a/frontend/src/lib/components/sessions/previewReload.test.ts +++ b/frontend/src/lib/components/sessions/previewReload.test.ts @@ -9,6 +9,7 @@ describe('toolReloadEffect', () => { expect(toolReloadEffect('write_resource', {}).pages).toEqual(['/resources']) expect(toolReloadEffect('write_variable', {}).pages).toEqual(['/variables']) expect(toolReloadEffect('create_folder', { name: 'f' }).pages).toEqual(['/folders']) + expect(toolReloadEffect('create_resource_type', { name: 'c_x' }).pages).toEqual(['/resources']) }) it('maps a trigger write to its kind-specific page', () => { diff --git a/frontend/src/lib/components/sessions/previewReload.ts b/frontend/src/lib/components/sessions/previewReload.ts index 6d22309ee1..1de6ad6951 100644 --- a/frontend/src/lib/components/sessions/previewReload.ts +++ b/frontend/src/lib/components/sessions/previewReload.ts @@ -49,6 +49,8 @@ export function toolReloadEffect(name: string, args: any): ToolReloadEffect { return withItem(['/variables'], itemRef('variable', args)) case 'create_folder': return { pages: ['/folders'], items: [], deployed: [] } + case 'create_resource_type': + return { pages: ['/resources'], items: [], deployed: [] } // These two change what is deployed at a path, so a tab on that item's View side // is showing a version that no longer exists. case 'delete_workspace_item': diff --git a/frontend/src/lib/components/sessions/previewRouter.ts b/frontend/src/lib/components/sessions/previewRouter.ts index 4d9a678859..9f50621de0 100644 --- a/frontend/src/lib/components/sessions/previewRouter.ts +++ b/frontend/src/lib/components/sessions/previewRouter.ts @@ -1,6 +1,7 @@ import { ASSETS_PATH, AUDIT_LOGS_PATH, + isPageKey, FOLDERS_PATH, GROUPS_PATH, isPreviewListPath, @@ -27,11 +28,12 @@ import { type PreviewItemRoute, type TriggerKind } from './previewPaths' -import type { UserExt, UserWorkspace } from '$lib/stores' -import { sidebarPageAllowed, type OperatorPageKey } from '../sidebar/operatorRoutes' +import type { UserWorkspace } from '$lib/stores' +import { capabilitiesIn, type CapabilitiesUser, type VisibilityKey } from '$lib/roleCapabilities' // Re-exported so the preview code that already reads locations through this module keeps // one import, while a caller needing only a path can reach for the leaf instead. export { + isPageKey, pageItemListPath, pageItemUrl, pageKey, @@ -86,7 +88,19 @@ export type PreviewTarget = // caller that predates the View side keeps opening the editor. `version` pins the // View side to one deployed version, in the query so the tab's identity stays the // item's path rather than becoming the version's own hash. - | { type: 'item'; item: WorkspaceItem; mode?: PreviewItemMode; version?: string } + // `pendingRunId` hands this tab a chat tool call parked on a run form: the deployed page + // shows that form, and Run there answers the call instead of starting a job of its own. + // In the query for the same reason as `version` — the tab stays the item's, and the call + // is a property of this tab rather than of the item. The id alone: what the call proposed + // is read off the chat, since a tab's url is persisted with the session and those + // arguments can hold file bytes and secrets typed into the form. + | { + type: 'item' + item: WorkspaceItem + mode?: PreviewItemMode + version?: string + pendingRunId?: string + } | { type: 'artifact'; id: string; name: string; version?: ArtifactVersionTarget } | { type: 'runform'; toolCallId: string; label: string } | { type: 'pageitem'; ref: PageItemRef } @@ -108,20 +122,20 @@ export type PreviewPage = { label: string path: string icon: DrillIcon - /** The `operator_settings` key that admits an operator, as the sidebar reads it. */ - operatorKey?: OperatorPageKey + /** The role visibility flag that shows the page, as the sidebar reads it. */ + visibilityKey?: VisibilityKey adminOnly?: boolean } /** Whether `page` loads for `user` rather than showing its "not available" state. An unknown - * user is let through to every page but the admin-only ones. */ + * user is held to the workspace entry's role, and kept off the admin-only pages. */ export function previewPageAllowed( page: PreviewPage, - user: Pick<UserExt, 'operator' | 'is_admin' | 'is_super_admin'> | undefined, + user: CapabilitiesUser | undefined, workspace: UserWorkspace | undefined ): boolean { - if (page.adminOnly) return !!user && (user.is_admin || user.is_super_admin) - return !page.operatorKey || sidebarPageAllowed(user?.operator, workspace, page.operatorKey) + if (page.adminOnly) return !!user && (!!user.is_admin || !!user.is_super_admin) + return !page.visibilityKey || capabilitiesIn(user, workspace).visibility[page.visibilityKey] } // Core workspace-level destinations the preview can route to. Intentionally @@ -129,15 +143,15 @@ export function previewPageAllowed( // EE/feature gating in SidebarContent and aren't worth duplicating here). export const PREVIEW_PAGES: PreviewPage[] = [ { label: 'Home', path: '/', icon: Home }, - { label: 'Runs', path: RUNS_PATH, icon: Play, operatorKey: 'runs' }, - { label: 'Variables', path: VARIABLES_PATH, icon: DollarSign, operatorKey: 'variables' }, - { label: 'Resources', path: RESOURCES_PATH, icon: Boxes, operatorKey: 'resources' }, - { label: 'Schedules', path: SCHEDULES_PATH, icon: Calendar, operatorKey: 'schedules' }, - { label: 'Assets', path: ASSETS_PATH, icon: Database, operatorKey: 'assets' }, - { label: 'Folders', path: FOLDERS_PATH, icon: FolderOpen, operatorKey: 'folders' }, - { label: 'Groups', path: GROUPS_PATH, icon: Users, operatorKey: 'groups' }, + { label: 'Runs', path: RUNS_PATH, icon: Play, visibilityKey: 'runs' }, + { label: 'Variables', path: VARIABLES_PATH, icon: DollarSign, visibilityKey: 'variables' }, + { label: 'Resources', path: RESOURCES_PATH, icon: Boxes, visibilityKey: 'resources' }, + { label: 'Schedules', path: SCHEDULES_PATH, icon: Calendar, visibilityKey: 'schedules' }, + { label: 'Assets', path: ASSETS_PATH, icon: Database, visibilityKey: 'assets' }, + { label: 'Folders', path: FOLDERS_PATH, icon: FolderOpen, visibilityKey: 'folders' }, + { label: 'Groups', path: GROUPS_PATH, icon: Users, visibilityKey: 'groups' }, { label: 'Workspace settings', path: WORKSPACE_SETTINGS_PATH, icon: Settings, adminOnly: true }, - { label: 'Audit logs', path: AUDIT_LOGS_PATH, icon: ScrollText, operatorKey: 'audit_logs' } + { label: 'Audit logs', path: AUDIT_LOGS_PATH, icon: ScrollText, visibilityKey: 'audit_logs' } ] // The Compare & Deploy review page. Kept out of PREVIEW_PAGES (it's not a picker @@ -556,6 +570,15 @@ export function parseRunFormRoute(url: string): { toolCallId: string; label: str } } +/** The chat tool call a viewer tab was opened to answer, read off the tab's url. A deployed + * page tab is still the item's tab — only the call rides in its query — so this is how a tab + * says which call it is carrying, and re-pointing it anywhere drops the answer. */ +export function pendingRunIdOf(url: string): string | undefined { + const slot = resolvePreviewTab(url) + if (slot.kind !== 'viewer') return undefined + return new URL(url, 'http://x').searchParams.get('pending_run') ?? undefined +} + /** The run a `/run/<id>` location shows, in the workspace its query names (the opener's, * when it names none). */ export function parseRunRoute( diff --git a/frontend/src/lib/components/sessions/sessionDeployModel.svelte.ts b/frontend/src/lib/components/sessions/sessionDeployModel.svelte.ts index 4ade5552bf..50447d3e06 100644 --- a/frontend/src/lib/components/sessions/sessionDeployModel.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionDeployModel.svelte.ts @@ -1,7 +1,6 @@ import { getDraftItems, type DraftItem } from '$lib/workspaceDrafts.svelte' -import { get } from 'svelte/store' -import { userWorkspaces } from '$lib/stores' import { roleCanAuthor } from '$lib/editRights' +import { capabilitiesInWorkspace } from '$lib/operatorWriteRights' import { checkDeployPermission, checkDeployRules, @@ -232,8 +231,8 @@ export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) { // button disables with a reason instead of failing on click. `ok` defaults // true while resolving (fail-open). let deployPerm = $state<DeployPermission>({ ok: true }) - // `checkDeployPermission` refuses every operator, but flows.rs and apps.rs let one with the - // workspace's matching builder right deploy that kind, under the rules alone. + // `checkDeployPermission` refuses every role that cannot code, but flows.rs and apps.rs let one + // that composes that kind deploy it, under the rules alone. let builder = $state<{ perm: DeployPermission; kinds: Kind[] } | undefined>(undefined) let deployPermFetchedFor = '' $effect(() => { @@ -248,10 +247,8 @@ export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) { void checkDeployPermission(ws).then(async (perm) => { if (deployPermFetchedFor !== ws) return deployPerm = perm - const settings = get(userWorkspaces).find((w) => w.id === ws)?.operator_settings - const kinds = (['flow', 'raw_app'] as const).filter((k) => - roleCanAuthor(k, { operator: true }, settings) - ) + const caps = capabilitiesInWorkspace(ws) + const kinds = (['flow', 'raw_app'] as const).filter((k) => roleCanAuthor(k, caps)) if (perm.refusedBy === 'operator' && kinds.length) { const rules = await checkDeployRules(ws) if (deployPermFetchedFor === ws) builder = { perm: rules, kinds } @@ -261,8 +258,9 @@ export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) { }) function permissionFor(kind: Kind): DeployPermission { - if (builder?.kinds.includes(kind)) return deployPermissionForKind(builder.perm, kind) - return deployPermissionForKind(deployPerm, kind) + const caps = capabilitiesInWorkspace(getArgs().workspaceId) + if (builder?.kinds.includes(kind)) return deployPermissionForKind(builder.perm, kind, caps) + return deployPermissionForKind(deployPerm, kind, caps) } // ── Deploy execution ───────────────────────────────────────────────────── diff --git a/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts b/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts index b1f14fadbe..ee13729d4d 100644 --- a/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts @@ -113,10 +113,16 @@ function targetUrl(target: PreviewTarget, onto?: SessionPreviewTab): string { // an item the user left on View does not yank them back to the editor. const mode = target.mode ?? (onto ? (parsePreviewItemRoute(onto.url)?.mode ?? 'edit') : 'edit') if (mode !== 'view') return editPathFor(target.item) - // Not carried over from `onto`: a version pin is something the reader asked for - // once, so every other re-point of this tab lands on the current deployed version. - const pin = target.version ? `?version=${encodeURIComponent(target.version)}` : '' - return `${viewPathFor(target.item)}${pin}` + // Neither is carried over from `onto`. A version pin is something the reader asked for + // once, so every other re-point of this tab lands on the current deployed version — and a + // chat tool call is carried by the tab that was opened for it, so re-pointing that tab + // anywhere else, a version included, hands the call back rather than running it against + // something it was not confirmed for. + const params = new URLSearchParams() + if (target.version) params.set('version', target.version) + if (target.pendingRunId) params.set('pending_run', target.pendingRunId) + const query = params.size > 0 ? `?${params}` : '' + return `${viewPathFor(target.item)}${query}` } // Point a tab at a new destination. Clears `friendlyLabel`/`friendlyPath` @@ -686,6 +692,15 @@ export class SessionPreviewTabs { this.#flush() } + /** Point one known tab at `url`. For a caller that found the tab itself — the deployed + * page that confirmed a run becomes that run, and only the caller knows which page. */ + retargetById(id: string, url: string): void { + const tab = this.#tabs.find((t) => t.id === id) + if (!tab) return + retargetTab(tab, url) + this.#flush() + } + setCollapsed(collapsed: boolean): void { if (this.#collapsed === collapsed) return this.#collapsed = collapsed diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index b84bd74cbe..a79d98d713 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -1,4 +1,5 @@ import { SvelteMap } from 'svelte/reactivity' +import type { PendingRun } from '$lib/components/details/pendingRun' import { get } from 'svelte/store' import { invalidate as invalidateWorkspaceItems } from '$lib/components/workspacePicker' import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' @@ -35,7 +36,7 @@ import { react19Template, STARTER_RUNNABLES } from '$lib/components/raw_apps/templates' -import { operatorBuilderAppsIn, workspaceListLoaded } from '$lib/operatorWriteRights' +import { capabilitiesInWorkspace, workspaceListLoaded } from '$lib/operatorWriteRights' import { random_adj } from '$lib/components/random_positive_adjetive' import { copilotWorkspace } from '$lib/aiStore' import { loadCopilot } from '$lib/components/copilot/loadCopilot' @@ -57,6 +58,7 @@ import { setSessionPreviewCollapsed, setSessionPreviewSize, setSessionTabs, + setDraftsDiscardedHandler, type Session } from './sessionState.svelte' import { @@ -77,6 +79,7 @@ import { promptSafe, parseRunFormRoute, resolvePreviewTab, + pendingRunIdOf, slotShowsPage, type PreviewSlot } from './previewRouter' @@ -248,6 +251,8 @@ export interface SessionRuntime { * viewer refetches when this changes — it reads the deployed version from the * API, so nothing else tells it the editor beside it just published. */ deployedRevision(kind: SessionTargetKind, path: string): number + /** The chat tool call a viewer tab was opened to answer, named by that tab's own url. */ + pendingRunFor(toolCallId: string): PendingRun | undefined /** Record that a preview tab landed on `url`. Fires for a newly opened tab and * for an existing one switched to the other side of its item. */ logTabUsage(url: string): void @@ -302,6 +307,7 @@ export interface SessionRuntime { } const runtimes = new SvelteMap<string, SessionRuntime>() +const runtimeRoots = new Map<string, () => void>() function emptyFlow(): Flow { return { @@ -588,10 +594,30 @@ function createRuntime(session: Session): SessionRuntime { // Only persist a real width; undefined means "never resized" (defaults to 50). if (snap.previewSize != null) setSessionPreviewSize(session.id, snap.previewSize) }, + // Closing a tab that carried a call is not a refusal: the card still has the form. onTabsChanged: pruneEditorCells, onTabOpened: logTabUsage }) + /** The call a viewer tab was opened to answer, for the page that renders it. Built from + * the id the tab carries rather than from state of our own, so there is nothing to keep in + * step: the tab names the call, and the manager says whether it is still waiting. */ + function pendingRunFor(toolCallId: string): PendingRun | undefined { + if (!manager.isRunFormPending(toolCallId)) return undefined + return { + toolCallId, + // Read here, so the page's `$derived` rebuilds when the posture changes under it. + planModeActive: manager.planModeActive, + // A getter, not a value: the page reads it once, when it builds the form, and this + // object is rebuilt on every keystroke of the card's draft otherwise. + get proposedArgs() { + return manager.pendingRunFormArgs(toolCallId) ?? {} + }, + adopt: (jobId, args, schema) => manager.handleRunFormJob(toolCallId, jobId, args, schema), + decline: () => manager.handleRunFormCancel(toolCallId) + } + } + // Let the jobs tray open a run in this session's preview panel (as a tab on // the run's page). The global side-panel chat leaves this unset and // falls back to a new browser tab. @@ -609,15 +635,89 @@ function createRuntime(session: Session): SessionRuntime { manager.openRunForm = ({ toolCallId, label }) => { previewTabs.open({ type: 'runform', toolCallId, label }) } - manager.closeRunForm = (toolCallId) => previewTabs.closeRunForm(toolCallId) - manager.showRunInPlaceOfForm = ({ toolCallId, jobId, workspace }) => { - previewTabs.retargetRunForm(toolCallId, `/run/${jobId}?workspace=${workspace}`) + // Show a deployed run on the item's own page. With `carries`, that page answers the call: + // the job its Run starts is the one the tool follows. + manager.openRunOnDeployedPage = ({ + toolCallId, + kind, + path, + summary, + carries, + onlyIfAlreadyOpen + }) => { + // Through the shared adapter: a session target spells a code-based app `raw_app` and a + // workspace item spells it `app` with a flag, and one place should know that. + const target = previewTargetForSessionTarget(kind, path) + if (target?.type !== 'item') return false + const viewTarget = { + ...target, + item: { ...target.item, summary }, + mode: 'view' as const, + pendingRunId: carries ? toolCallId : undefined + } + // Re-pointed rather than focused: the url is what hands the tab the call, and + // rebuilding it also drops whatever version the reader had pinned. + const open = previewTabs.tabs.find((t) => { + const slot = resolvePreviewTab(t.url) + return slot.kind === 'viewer' && slot.viewerKind === kind && slot.path === path + }) + if (open) { + // An unasked-for hand-off goes only to the page the reader already has in front of + // them. `navigate` below re-points the tab, which reloads it and rebuilds its url: + // on a tab they cannot see that silently drops whatever they had typed into its + // form and leaves the call on an off-screen page, and on a pinned one it moves them + // off the version they chose. Declining leaves the call on the card, which can run + // it. A reader who presses Preview is asking for all of that. + if ( + onlyIfAlreadyOpen && + (previewTabs.activeId !== open.id || + previewTabs.collapsed || + new URL(open.url, 'http://x').searchParams.get('version')) + ) { + return false + } + previewTabs.navigate(viewTarget, open.id) + // `navigate` re-points a tab in place and nothing more, while the call is about to + // tell the reader the page is open. `open` would have brought it forward and shown + // the panel; this path has to do both itself. + previewTabs.select(open.id) + previewTabs.setCollapsed(false) + } else { + // Only the item's own page carries a form for this call, so a caller routing a call + // that was not asked to open anything gets a no rather than a new tab. + if (onlyIfAlreadyOpen) return false + previewTabs.open(viewTarget) + } + return true } - // Read off the tab list rather than the slot's lifecycle: a tab the user has switched - // away from is unmounted but still open, and the card must keep its form hidden until it - // is closed. A resolver, like activePreviewResolver: the reader's own $derived subscribes - // to `tabs` through it, and the runtime is not inside an effect root to push from. + manager.closeRunForm = (toolCallId) => { + // A page carrying the call outlives it, and stops carrying it because the call stops + // being pending. Only the chat's own form tab, which is the call, goes with it. + previewTabs.closeRunForm(toolCallId) + } + manager.showRunInPlaceOfForm = ({ toolCallId, jobId, workspace }) => { + const runHref = `/run/${jobId}?workspace=${workspace}` + // Either surface the call was waiting on becomes the run it started, in place. + const carrying = previewTabs.tabs.find((t) => pendingRunIdOf(t.url) === toolCallId) + if (carrying) previewTabs.retargetById(carrying.id, runHref) + previewTabs.retargetRunForm(toolCallId, runHref) + } + // Both read off the tab list rather than the slot's lifecycle: a tab the user has switched + // away from is unmounted but still open. Resolvers, like activePreviewResolver: the + // reader's own $derived subscribes to `tabs` through them, and the runtime is not inside + // an effect root to push from. So a tab closed or re-pointed hands the card back whatever + // it had given up. + // Either surface — the chat's own form in a tab, or the item's page carrying the call. manager.isRunFormInPreview = (toolCallId) => + previewTabs.tabs.some( + (t) => + parseRunFormRoute(t.url)?.toolCallId === toolCallId || pendingRunIdOf(t.url) === toolCallId + ) + // The chat's own form alone. The card's form is exclusive with this one and no other: + // both bind the one draft, and SchemaForm reorders in place the schema it is handed, so a + // second mount reorders it again. A page builds its own form from a copy of the proposal + // and shares nothing, so the card keeps its form while a page carries the call. + manager.isRunFormTabOpen = (toolCallId) => previewTabs.tabs.some((t) => parseRunFormRoute(t.url)?.toolCallId === toolCallId) manager.openArtifact = (id, name, version) => { @@ -725,6 +825,7 @@ function createRuntime(session: Session): SessionRuntime { flowCell, loadedEditorPath, deployedRevision, + pendingRunFor, logTabUsage, async loadFlow(workspace: string, path: string, force = false) { @@ -1053,7 +1154,7 @@ function createRuntime(session: Session): SessionRuntime { const user = get(userStore) // A builder can save no inline runnable, so the regular starter could never be saved. await workspaceListLoaded() - const builder = operatorBuilderAppsIn(workspace) + const builder = capabilitiesInWorkspace(workspace).apps === 'compose' saved.val = undefined store.val = { files: { ...(builder ? BUILDER_FRAMEWORK_TEMPLATES.react19 : react19Template) }, @@ -1062,7 +1163,8 @@ function createRuntime(session: Session): SessionRuntime { policy: { on_behalf_of: user?.username.includes('@') ? user.username : `u/${user?.username}`, on_behalf_of_email: user?.email, - execution_mode: 'publisher' + execution_mode: 'publisher', + sandbox: true }, summary: '', path, @@ -1230,7 +1332,18 @@ async function initRuntime(runtime: SessionRuntime, session: Session) { export function getOrCreateRuntime(session: Session): SessionRuntime { let runtime = runtimes.get(session.id) if (!runtime) { - runtime = createRuntime(session) + // The manager's `$derived` fields belong to whatever effect is running when they are + // built. A runtime outlives the component that first asked for it (Home hands its + // session to /sessions and unmounts), so give it a root of its own; built inside the + // component, its deriveds turn inert once that component is gone. + let created: SessionRuntime | undefined + runtimeRoots.set( + session.id, + $effect.root(() => { + created = createRuntime(session) + }) + ) + runtime = created! runtimes.set(session.id, runtime) // The gate opens even when the restore fails: a session that cannot read its // history is still usable, and a send that waits forever is not. @@ -1247,6 +1360,8 @@ export function disposeRuntime(sessionId: string) { runtime.manager.cancel('runtime disposed') runtime.manager.historyManager.close() runtimes.delete(sessionId) + runtimeRoots.get(sessionId)?.() + runtimeRoots.delete(sessionId) // The sidebar reads the stored chat again rather than trust a read that // predates everything this runtime saw. peeks.delete(sessionId) @@ -1375,6 +1490,8 @@ export function removeSession(sessionId: string): void { deleteSessionState(sessionId) } +setDraftsDiscardedHandler((ids) => ids.forEach(disposeRuntime)) + // The chat calls a new item by its chosen name while its draft is stored at // `draft_<uuid>`; resolve the name the way the chat's write tools do. function draftStoragePath( diff --git a/frontend/src/lib/components/sessions/sessionSharing.test.ts b/frontend/src/lib/components/sessions/sessionSharing.test.ts new file mode 100644 index 0000000000..a7720666fa --- /dev/null +++ b/frontend/src/lib/components/sessions/sessionSharing.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' + +import { readSessionSnapshot } from './sessionSharing' + +describe('readSessionSnapshot', () => { + it('keeps only inline images and the rows and artifacts the viewer can render', () => { + const snapshot = readSessionSnapshot({ + messages: [ + { + role: 'user', + content: 'hi', + images: [ + { dataUrl: 'https://tracker.example/u.png', mediaType: 'image/png' }, + { dataUrl: 'data:image/png;base64,AAAA', mediaType: 'image/png' } + ] + }, + { role: 'tool', content: 'shot', imageUrl: 'https://tracker.example/t.png' }, + { role: 'tool', content: 'shot', imageUrl: 'data:image/png;base64,BBBB' }, + { + role: 'tool', + content: 'Run', + isLoading: true, + needsConfirmation: true, + runForm: { kind: 'test', code: 'export async function main() {}' }, + userQuestion: { question: 'ok?' }, + actions: [{ label: 'Open' }], + inspectedRun: { jobId: 'j', workspace: 'w', runId: 'j' } + }, + { role: 'system', content: 'not a chat row' }, + 'not an object' + ], + artifacts: [ + { id: 'a', name: 'Plan', kind: 'md', content: '# Plan' }, + { id: 'b', name: 'Bad', kind: 'svg', content: '<svg/>' } + ] + }) + + expect(snapshot.messages.map((m) => m.role)).toEqual(['user', 'tool', 'tool', 'tool']) + expect(snapshot.messages[0]).toMatchObject({ + images: [{ dataUrl: 'data:image/png;base64,AAAA' }] + }) + expect((snapshot.messages[1] as { imageUrl?: string }).imageUrl).toBeUndefined() + expect(snapshot.messages[2]).toMatchObject({ imageUrl: 'data:image/png;base64,BBBB' }) + // Every affordance that would act as the reader is gone, pending state included. + expect(snapshot.messages[3]).toEqual({ + role: 'tool', + tool_call_id: 'shared-3', + content: 'Run', + showDetails: false, + blockedByPlanMode: false, + declinedByUser: false + }) + expect(snapshot.artifacts.map((a) => a.id)).toEqual(['a']) + }) + + it('reads a malformed snapshot as an empty one', () => { + expect(readSessionSnapshot('nope')).toEqual({ messages: [], artifacts: [] }) + }) +}) diff --git a/frontend/src/lib/components/sessions/sessionSharing.ts b/frontend/src/lib/components/sessions/sessionSharing.ts new file mode 100644 index 0000000000..3ecbe214e9 --- /dev/null +++ b/frontend/src/lib/components/sessions/sessionSharing.ts @@ -0,0 +1,188 @@ +import type { DisplayMessage } from '$lib/components/copilot/chat/shared' +import type { ImageMediaType } from '$lib/components/copilot/chat/imageUtils' +import type { + ArtifactKind, + PersistedArtifact +} from '$lib/components/copilot/chat/artifacts/artifactsDB' + +/** The server's `MAX_SNAPSHOT_BYTES`. */ +export const MAX_SNAPSHOT_BYTES = 10 * 1024 * 1024 + +export type SharedSessionArtifact = { + id: string + name: string + kind: ArtifactKind + content: string + role?: 'plan' +} + +export type SessionSnapshot = { + messages: DisplayMessage[] + artifacts: SharedSessionArtifact[] +} + +export function sharedSessionUrl(workspaceId: string, shareId: string): string { + return `${window.location.origin}/shared_sessions/${encodeURIComponent( + workspaceId + )}/${encodeURIComponent(shareId)}` +} + +/** A row as it reads once the turn is over: the viewer can neither wait on nor answer it. */ +function settled(message: DisplayMessage, keepImages: boolean): DisplayMessage { + switch (message.role) { + case 'tool': + return { + ...message, + isLoading: false, + isQueued: false, + isStreamingArguments: false, + needsConfirmation: false, + resultStream: undefined + } + case 'assistant': + return { ...message, streaming: false } + case 'user': + return keepImages ? message : { ...message, images: undefined } + default: + return message + } +} + +/** + * The snapshot a share carries. Images go first when the whole would not fit: the + * transcript is what the link is for. + */ +export function buildSessionSnapshot( + messages: DisplayMessage[], + artifacts: PersistedArtifact[] +): SessionSnapshot { + const sharedArtifacts: SharedSessionArtifact[] = artifacts.map((a) => ({ + id: a.id, + name: a.name, + kind: a.kind, + content: a.content, + role: a.role + })) + for (const keepImages of [true, false]) { + const snapshot: SessionSnapshot = { + messages: messages.map((m) => settled(m, keepImages)), + artifacts: sharedArtifacts + } + if (new TextEncoder().encode(JSON.stringify(snapshot)).length <= MAX_SNAPSHOT_BYTES) { + return snapshot + } + } + throw new Error( + `the session is larger than the ${MAX_SNAPSHOT_BYTES / 1024 / 1024} MB a share can hold` + ) +} + +function isInlineImage(url: unknown): url is string { + return typeof url === 'string' && url.startsWith('data:image/') +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function str(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined +} + +function records(value: unknown): Record<string, unknown>[] { + return Array.isArray(value) ? value.filter(isRecord) : [] +} + +/** + * A row rebuilt from the fields that only display. Everything else the live chat carries on a + * row is an affordance acting as whoever has the page open (a run form, a question to answer, + * a run to inspect, an item to open) or a pending state that mounts one, so it never survives: + * the author controls every byte of the snapshot. + */ +function readMessage(m: Record<string, unknown>, index: number): DisplayMessage | undefined { + const content = str(m.content) ?? '' + switch (m.role) { + case 'user': + return { + role: 'user', + content, + index, + images: records(m.images) + .filter((i) => isInlineImage(i.dataUrl)) + .map((i) => ({ + dataUrl: i.dataUrl as string, + mediaType: i.mediaType as ImageMediaType, + name: str(i.name) + })), + pastes: records(m.pastes) + .filter((p) => typeof p.id === 'number' && typeof p.content === 'string') + .map((p) => ({ + id: p.id as number, + lines: typeof p.lines === 'number' ? p.lines : 0, + content: p.content as string + })), + files: records(m.files) + .filter((f) => typeof f.name === 'string' && typeof f.content === 'string') + .map((f) => ({ name: f.name as string, content: f.content as string })) + } + case 'assistant': + return { + role: 'assistant', + content, + reasoning: str(m.reasoning), + reasoningDurationMs: + typeof m.reasoningDurationMs === 'number' ? m.reasoningDurationMs : undefined, + createdAt: str(m.createdAt) + } + case 'tool': + return { + role: 'tool', + tool_call_id: str(m.tool_call_id) ?? `shared-${index}`, + content, + toolName: str(m.toolName), + parameters: m.parameters, + result: m.result, + logs: str(m.logs), + error: str(m.error), + showDetails: m.showDetails === true, + imageUrl: isInlineImage(m.imageUrl) ? m.imageUrl : undefined, + planArtifactId: str(m.planArtifactId), + planVersion: typeof m.planVersion === 'number' ? m.planVersion : undefined, + blockedByPlanMode: m.blockedByPlanMode === true, + declinedByUser: m.declinedByUser === true + } + case 'summary': + return { role: 'summary', content } + default: + return undefined + } +} + +/** + * A snapshot as the viewer may render it: rows rebuilt by {@link readMessage}, and an attached + * image only from an inline data URL, since any other source would load a third-party resource + * on behalf of whoever opens the link. Images inside rendered text are left to the viewer + * page's `img-src` policy. + */ +export function readSessionSnapshot(raw: unknown): SessionSnapshot { + const snapshot = isRecord(raw) ? raw : {} + const messages = records(snapshot.messages) + .map(readMessage) + .filter((m): m is DisplayMessage => m !== undefined) + const artifacts = records(snapshot.artifacts) + .filter( + (a) => + typeof a.id === 'string' && + typeof a.name === 'string' && + typeof a.content === 'string' && + (a.kind === 'md' || a.kind === 'html') + ) + .map((a) => ({ + id: a.id as string, + name: a.name as string, + kind: a.kind as ArtifactKind, + content: a.content as string, + role: a.role === 'plan' ? ('plan' as const) : undefined + })) + return { messages, artifacts } +} diff --git a/frontend/src/lib/components/sessions/sessionState.svelte.ts b/frontend/src/lib/components/sessions/sessionState.svelte.ts index f032385d31..4a3abebb4f 100644 --- a/frontend/src/lib/components/sessions/sessionState.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionState.svelte.ts @@ -28,6 +28,7 @@ import { emailOfScopedKey, scopedKeyFor } from '$lib/userScopedStorage' 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 { markSessionDirty, markSessionRemoved, @@ -387,6 +388,32 @@ export function getSessionDraftPrompt(sessionId: string): string | undefined { return s.draftPrompt } +// The rest of an unsent draft (collapsed pastes, images, files), so a composer mounted again +// for the same session, on Home or the session page, gets the draft whole: a collapsed paste +// is a token in `draftPrompt` whose content lives here. In memory only, like the attachments +// themselves before a send, so a reload drops them as it always has. +const draftAttachments = new Map<string, DraftAttachments>() + +export function setSessionDraftAttachments(sessionId: string, attachments: DraftAttachments): void { + const empty = Object.values(attachments).every((lane) => lane.length === 0) + if (empty) { + draftAttachments.delete(sessionId) + return + } + const s = sessionState.sessions.find((x) => x.id === sessionId) + if (!s || s.workspace_id) return + draftAttachments.set(sessionId, attachments) + // Attaching is a touch, as typing is: a draft holding only an image would otherwise still + // read as a blank, be reused by the next hand-off with the image in it, or be dropped. + if (s.transient) persistTouched(s) +} + +export function getSessionDraftAttachments(sessionId: string): DraftAttachments | undefined { + const s = sessionState.sessions.find((x) => x.id === sessionId) + if (!s || s.workspace_id) return undefined + return draftAttachments.get(sessionId) +} + // A hand-off arrives within a navigation; anything older than this is the debris // of one that never landed, and must not fire at whatever the user does next. const AUTO_SEND_TTL_MS = 5 * 60_000 @@ -1070,7 +1097,24 @@ export function createSession(): Session { requestComposerFocus() return reusable } + const session = newTransientSession() + sessionState.currentSessionId = session.id + return session +} + +// Told which never-touched drafts were dropped, so their runtimes go with them. Set by +// sessionRuntime, which imports this module and so cannot be imported back. Home builds a +// runtime for its draft on every mount, so a dropped draft would otherwise leave its +// manager and its history database handle open. +let onDraftsDiscarded: ((ids: string[]) => void) | undefined +export function setDraftsDiscardedHandler(handler: (ids: string[]) => void): void { + onDraftsDiscarded = handler +} + +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. @@ -1103,12 +1147,32 @@ export function createSession(): Session { transient: true } sessionState.sessions = [session, ...sessionState.sessions] - sessionState.currentSessionId = session.id // Transient until first touch: no DB record yet. Persisting is deferred to the // first user edit (the mutation helpers below route through persistTouched). return 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. */ +export function homeStarterSession(): Session { + const unsent = (s: Session) => + !s.archived && !s.workspace_id && !s.previewTabs?.length && sessionInCurrentFamily(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 ( + touched ?? + sessionState.sessions.find((s) => isDiscardableDraft(s) && sessionInCurrentFamily(s)) ?? + newTransientSession() + ) +} + // Promote an in-memory transient session to a persisted IndexedDB record. // No-op when the session isn't transient (already persisted by a prior touch). // Called on the first genuine user touch (via persistTouched) and, idempotently, @@ -1457,6 +1521,7 @@ export function deleteSession(id: string) { // a draft deleted inside the debounce window. clearTimeout(draftPromptFlushHandles.get(id)) draftPromptFlushHandles.delete(id) + draftAttachments.delete(id) sessionState.sessions = sessionState.sessions.filter((x) => x.id !== id) if (sessionState.currentSessionId === id) { sessionState.currentSessionId = sessionState.sessions[0]?.id diff --git a/frontend/src/lib/components/sessions/sessionState.test.ts b/frontend/src/lib/components/sessions/sessionState.test.ts index 710028413e..4c6666a349 100644 --- a/frontend/src/lib/components/sessions/sessionState.test.ts +++ b/frontend/src/lib/components/sessions/sessionState.test.ts @@ -5,11 +5,15 @@ import { createSession, decideSessionLifecycle, findEmptyLandingSession, + getSessionDraftAttachments, + homeStarterSession, isForkSession, isTearingDownOpenSession, renameSession, + setDraftsDiscardedHandler, sessionInCurrentFamily, setGeneratedSessionSummary, + setSessionDraftAttachments, setSessionDraftPrompt, sessionState, withOpenSessionTeardown, @@ -27,6 +31,10 @@ import { type UserWorkspace } from '$lib/stores' import { WorkspaceService } from '$lib/gen' +import type { DraftAttachments } from '$lib/components/copilot/chat/messageDraft.svelte' +import type { PasteAttachment } from '$lib/components/copilot/chat/pasteTokens' + +const NO_ATTACHMENTS: DraftAttachments = { pastes: [], images: [], files: [], blobs: [] } // Force createWorkspaceFork to fail so we can pin commitSessionWorkspace's // failure contract (the invariant the beforeSend abort fix relies on). @@ -451,6 +459,35 @@ describe('createSession — reuses an untouched draft, family-scoped', () => { } }) + it('does not reuse a draft that holds only an attachment', () => { + const restore = withTwoFamilies('rootA') + const prevCurrent = sessionState.currentSessionId + const attached = session({ + id: 'attachment-only', + name: 'session-904', + pending_workspace_id: 'forkA', + transient: true + }) + sessionState.sessions.push(attached) + let createdId: string | undefined + try { + setSessionDraftAttachments('attachment-only', { + ...NO_ATTACHMENTS, + pastes: [{} as PasteAttachment] + }) + createdId = createSession().id + expect(createdId).not.toBe('attachment-only') + expect(getSessionDraftAttachments('attachment-only')).toBeDefined() + } finally { + setSessionDraftAttachments('attachment-only', NO_ATTACHMENTS) + sessionState.sessions = sessionState.sessions.filter( + (s) => s.id !== 'attachment-only' && s.id !== createdId + ) + sessionState.currentSessionId = prevCurrent + restore() + } + }) + it('clears the recovery notice off the draft it reuses, so `+` is not answered with "not found"', () => { const restore = withTwoFamilies('rootA') const prevCurrent = sessionState.currentSessionId @@ -674,3 +711,64 @@ describe('withOpenSessionTeardown — the gate that holds recovery off during a expect(isTearingDownOpenSession()).toBe(false) }) }) + +describe('homeStarterSession', () => { + it("shows the family's stored draft, not the conversation in view, and selects nothing", () => { + const restore = withTwoFamilies('rootA') + 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. + const picked = session({ id: 'picked-draft', pending_workspace_id: 'forkA', lastActivityAt: 2 }) + const otherFamily = session({ + id: 'other-family-draft', + pending_workspace_id: 'rootB', + draftPrompt: 'elsewhere', + lastActivityAt: 3 + }) + sessionState.sessions.push(conversation, picked, otherFamily) + sessionState.currentSessionId = 'conversation' + try { + expect(homeStarterSession().id).toBe('picked-draft') + // Opening Home must leave the AI Sessions switch resuming the conversation. + expect(sessionState.currentSessionId).toBe('conversation') + } finally { + sessionState.sessions = sessionState.sessions.filter( + (s) => !['conversation', 'picked-draft', 'other-family-draft'].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 + const blank = session({ + id: 'blank-other-family', + pending_workspace_id: 'forkA', + transient: true + }) + const typed = session({ + id: 'typed-other-family', + pending_workspace_id: 'rootA', + draftPrompt: 'kept' + }) + sessionState.sessions.push(blank, typed) + const discarded: string[][] = [] + setDraftsDiscardedHandler((ids) => discarded.push(ids)) + let createdId: string | undefined + try { + // No draft of family B exists, so Home starts one, dropping family A's blank draft. + createdId = homeStarterSession().id + expect(discarded).toEqual([['blank-other-family']]) + expect(sessionState.sessions.some((s) => s.id === 'typed-other-family')).toBe(true) + } finally { + setDraftsDiscardedHandler(() => {}) + sessionState.sessions = sessionState.sessions.filter( + (s) => !['blank-other-family', 'typed-other-family', createdId].includes(s.id) + ) + sessionState.currentSessionId = prevCurrent + restore() + } + }) +}) diff --git a/frontend/src/lib/components/settings/CreateToken.svelte b/frontend/src/lib/components/settings/CreateToken.svelte index 56952eb584..b8d512b799 100644 --- a/frontend/src/lib/components/settings/CreateToken.svelte +++ b/frontend/src/lib/components/settings/CreateToken.svelte @@ -1,19 +1,21 @@ <script lang="ts"> import { onMount, untrack } from 'svelte' import { userWorkspaces, usersWorkspaceStore, type UserWorkspace } from '$lib/stores' - import { Alert, Button, Skeleton } from '../common' + import { Button, Skeleton } from '../common' import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte' import Toggle from '../Toggle.svelte' import { SettingService, UserService, type NewToken } from '$lib/gen' import { mcpTokenUrlDisabled } from '$lib/mcpAuth' import TokenDisplay from './TokenDisplay.svelte' import ScopesPicker from './ScopesPicker.svelte' - import CopyableCodeBlock from '../details/CopyableCodeBlock.svelte' - import { shell } from 'svelte-highlight/languages' import { parseMaxTokenExpirationDays } from '$lib/tokenExpiration' import TextInput from '../text_input/TextInput.svelte' import Select from '../select/Select.svelte' + import Section from '../Section.svelte' + import ClipboardPanel from '../details/ClipboardPanel.svelte' + import McpAccessPicker, { type McpAccess } from '../mcp/McpAccessPicker.svelte' + import MultiSelect from '../select/MultiSelect.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() @@ -44,16 +46,20 @@ displayCreateToken = true }: Props = $props() - // Sentinel workspace value meaning "all workspaces the user can access". - // Produces a workspace-less MCP token served through the /api/mcp/gateway - // endpoint, where tools take an explicit workspace_id argument. - const ALL_WORKSPACES = '*' - let newToken = $state<string | undefined>(undefined) let newMcpToken = $state<string | undefined>(undefined) + let newMcpTokenUrl = $state('') + // What an MCP token may do, as the access picker sets it. + let mcpAccess = $state<McpAccess>('full') + let mcpScopes = $state<string[]>(['mcp:all']) // What the user picked; `newTokenExpiration` is that pick held within the ceiling. let pickedExpiration = $state<number | undefined>(undefined) let newTokenWorkspace = $state<string | undefined>(untrack(() => defaultNewTokenWorkspace)) + // The workspaces an MCP token reaches. One binds the token to it; several leave it unbound + // and are listed in its scopes; all of them leaves it unbound with no list, so it also + // reaches the workspaces its owner joins later. + let mcpWorkspaces = $state<string[]>([]) + let mcpAllWorkspaces = $state(false) let mcpCreationMode = $state(false) let lastRequestedMcpMode = $state<boolean | undefined>(undefined) let mcpLabelAutofilled = $state(false) @@ -136,10 +142,11 @@ mcpCreationMode = true void loadMcpUrlPolicy() resetExpirationOnModeChange() - newTokenWorkspace = defaultNewTokenWorkspace ?? $operatingWorkspace + const current = defaultNewTokenWorkspace ?? $operatingWorkspace + mcpWorkspaces = current ? [current] : [] + mcpAllWorkspaces = false newToken = undefined newMcpToken = undefined - readOnly = false if (!newTokenLabel) { newTokenLabel = 'MCP token' mcpLabelAutofilled = true @@ -167,7 +174,9 @@ date = new Date(new Date().getTime() + newTokenExpiration * 1000) } - const tokenScopes = scopes ?? pickedScopes ?? undefined + const pickedOrFixed = scopes ?? (mcpMode ? mcpScopes : pickedScopes) ?? undefined + const tokenScopes = + pickedOrFixed && workspacesScope ? [...pickedOrFixed, workspacesScope] : pickedOrFixed const createdToken = await UserService.createToken({ requestBody: { @@ -175,13 +184,14 @@ expiration: date?.toISOString(), scopes: tokenScopes, workspace_id: tokenWorkspaceId, - read_only: readOnly + read_only: mcpMode ? false : readOnly } as NewToken }) if (mcpMode) { newToken = undefined newMcpToken = `${createdToken}` + newMcpTokenUrl = `${mcpUrl}?token=${createdToken}` } else { newMcpToken = undefined newToken = `${createdToken}` @@ -197,13 +207,15 @@ } const workspaces = $derived(ensureCurrentWorkspaceIncluded($userWorkspaces, $operatingWorkspace)) - const isAllWorkspaces = $derived(newTokenWorkspace === ALL_WORKSPACES) - const tokenWorkspaceId = $derived( - isAllWorkspaces - ? undefined - : mcpCreationMode - ? newTokenWorkspace || $operatingWorkspace - : newTokenWorkspace + const mcpBoundWorkspace = $derived( + !mcpAllWorkspaces && mcpWorkspaces.length === 1 ? mcpWorkspaces[0] : undefined + ) + const mcpReachesWorkspaces = $derived(mcpAllWorkspaces || mcpWorkspaces.length > 0) + const tokenWorkspaceId = $derived(mcpCreationMode ? mcpBoundWorkspace : newTokenWorkspace) + const workspacesScope = $derived( + mcpCreationMode && !mcpAllWorkspaces && mcpWorkspaces.length > 1 + ? `mcp:workspaces:${mcpWorkspaces.join(',')}` + : undefined ) // Mirrors the server's exemption for tokens owned by a service account: one in the workspace @@ -236,25 +248,21 @@ ? maxExpirationSecs : pickedExpiration ) - // The workspace used to browse scripts/flows/endpoints in the scope picker. - // For an all-workspaces token there is no single workspace, so fall back to - // the current one just for populating the endpoint list. + // The workspace used to browse scripts/flows/endpoints in the scope picker: the first + // picked one, or the current one for a token reaching all of them. const scopeWorkspaceId = $derived( - isAllWorkspaces ? $operatingWorkspace || '' : newTokenWorkspace || $operatingWorkspace || '' + (mcpCreationMode && !mcpAllWorkspaces ? mcpWorkspaces[0] : newTokenWorkspace) || + $operatingWorkspace || + '' ) - // Undefined wherever the workspace is: `/api/mcp/w/undefined/mcp` reads like a real URL - // and is copyable, so the OAuth panel withholds it rather than showing a broken one. The - // token branch guards the same case by disabling its generate button. // On the browsing origin, not the API base url: the OAuth metadata the server // publishes for this URL is built from `base_url`, and a client rejects a mismatch. - const mcpUrl = $derived( - isAllWorkspaces - ? `${window.location.origin}/api/mcp/gateway` - : newTokenWorkspace - ? `${window.location.origin}/api/mcp/w/${newTokenWorkspace}/mcp` - : undefined + // One URL whatever the access: the token's scopes decide which tools it gets. + const mcpUrl = `${window.location.origin}/api/mcp` + // Selected scripts and flows are listed per workspace: across several, a path could be two. + const selectionNeedsOneWorkspace = $derived( + mcpAccess === 'selected' && (mcpAllWorkspaces || mcpWorkspaces.length !== 1) ) - const mcpBaseUrl = $derived(`${mcpUrl ?? ''}?token=`) $effect(() => { const requestedMcpMode = mcpOnly || openWithMcpMode @@ -278,13 +286,87 @@ }) </script> +{#snippet mcpTokenForm()} + <div class="flex flex-col gap-3"> + <McpAccessPicker + workspaceId={scopeWorkspaceId} + bind:scopes={mcpScopes} + bind:access={mcpAccess} + /> + {#if !lockWorkspace} + {@render mcpWorkspacePicker()} + {#if selectionNeedsOneWorkspace} + <p class="text-2xs text-secondary"> + Selected scripts and flows come from one workspace: pick one. + </p> + {/if} + {/if} + {#if !mcpOnly} + <div> + <span class="block mb-1 text-emphasis text-xs font-semibold" + >Label <span class="text-xs text-primary">(optional)</span></span + > + <TextInput inputProps={{ type: 'text' }} bind:value={newTokenLabel} class="w-full" /> + </div> + {/if} + {#if maxExpirationSecs != undefined} + <div> + <span class="block mb-1 text-xs text-emphasis font-semibold">Expires In</span> + <Select + bind:value={() => newTokenExpiration, (v) => (pickedExpiration = v)} + placeholder="Pick an expiration" + inputClass="w-full" + items={expirationItems} + /> + <p class="mt-1 text-xs text-tertiary" + >This instance limits tokens to {maxExpirationLabel}.</p + > + </div> + {/if} + <div class="flex"> + <Button + onclick={() => createToken(true)} + disabled={!mcpReachesWorkspaces || selectionNeedsOneWorkspace || mcpScopes.length === 0} + variant="default" + unifiedSize="sm" + > + Generate URL with token + </Button> + </div> + </div> +{/snippet} + +{#snippet mcpWorkspacePicker()} + <div class="flex flex-col gap-1"> + <span class="block text-emphasis text-xs font-semibold">Workspaces</span> + <Toggle + bind:checked={mcpAllWorkspaces} + options={{ right: 'All my workspaces, including ones I join later' }} + size="xs" + /> + {#if !mcpAllWorkspaces} + <MultiSelect + bind:value={mcpWorkspaces} + items={workspaces.map((w) => ({ label: w.name, value: w.id }))} + placeholder="Pick workspaces" + reorderable={false} + /> + {/if} + {#if mcpAllWorkspaces || mcpWorkspaces.length > 1} + <p class="text-xs text-tertiary"> + Tools take a <code>workspace_id</code> argument naming the workspace they act in. + </p> + {/if} + </div> +{/snippet} + <div> <!-- Stays bounded by the panel width: a content-driven width (min-w-min) would let a long scope chip stretch this card and push the rest of the form out of view. --> - <div class="p-4 rounded-md mb-6 bg-surface-tertiary"> - <h3 class="pb-2 font-semibold text-emphasis text-sm"> - {mcpCreationMode && mcpUrlPolicy !== 'token' ? 'MCP URL' : title} - </h3> + <div class="p-4 rounded-md bg-surface-tertiary {!mcpOnly || newMcpToken ? 'mb-6' : ''}"> + {#if !mcpOnly} + <h3 class="pb-2 font-semibold text-emphasis text-sm">{title}</h3> + {/if} {#if showMcpMode && !mcpOnly} <div @@ -316,37 +398,30 @@ {#if mcpCreationMode && mcpUrlPolicy === 'loading'} <Skeleton layout={[[2], 0.5, [1]]} /> - {:else if mcpCreationMode && mcpUrlPolicy === 'oauth'} - {#if !lockWorkspace} - <div class="mb-4 max-w-md"> - <span class="block mb-1 text-emphasis text-xs font-semibold">Workspace</span> - <!-- No all-workspaces entry: the gateway's consent screen binds the token it issues - to the one workspace picked there, so OAuth has no multi-workspace grant to offer. --> - <Select - bind:value={newTokenWorkspace} - items={workspaces.map((w) => ({ label: w.name, value: w.id, subtitle: w.id }))} - /> + {:else if mcpCreationMode} + <div class="flex flex-col gap-6"> + <div class="flex flex-col gap-1"> + <span class="text-xs font-semibold text-emphasis">MCP URL</span> + <ClipboardPanel content={mcpUrl} size="sm" /> + <p class="text-2xs text-secondary"> + Add it to your client as a remote MCP server. It opens a Windmill page where you sign in + and choose the workspaces and what the client may do: build and run, read and run, read + only, or only the scripts and flows you pick. + </p> </div> - {/if} - {#if mcpUrl} - <CopyableCodeBlock code={mcpUrl} language={shell} wrap /> + {#if mcpUrlPolicy === 'token'} + <Section label="Use a token instead of signing in" small collapsable collapsed> + {@render mcpTokenForm()} + </Section> + {/if} - <div class="mt-2"> - <Alert type="info" title="This instance requires MCP clients to sign in" size="xs"> - Paste this URL into your client. It opens a Windmill page where you approve the access - it asks for, and no token needs to be copied around. - </Alert> - </div> - {:else} - <p class="text-xs text-tertiary">Pick a workspace to get its MCP URL.</p> - {/if} - - {#if !mcpOnly} - <div class="mt-4 flex justify-end gap-2 flex-row"> - <Button onClick={exitMcpMode} variant="default">Cancel</Button> - </div> - {/if} + {#if !mcpOnly} + <div class="flex justify-end"> + <Button onClick={exitMcpMode} variant="default">Cancel</Button> + </div> + {/if} + </div> {:else} {#if scopes != undefined} <div class="mb-4"> @@ -370,7 +445,7 @@ {#if !scopes || scopes.length === 0} <ScopesPicker - mode={mcpCreationMode ? 'mcp' : 'standard'} + mode="standard" workspaceId={scopeWorkspaceId} bind:value={pickedScopes} bind:readOnly @@ -378,31 +453,6 @@ {/if} <div class="mt-2 grid grid-cols-1 md:grid-cols-2 gap-4"> - {#if mcpCreationMode} - {#if !lockWorkspace} - <div> - <span class="block mb-1 text-emphasis text-xs font-semibold">Workspace</span> - <Select - bind:value={newTokenWorkspace} - items={[ - { - label: 'All workspaces', - value: ALL_WORKSPACES, - subtitle: 'Multi-workspace' - }, - ...workspaces.map((w) => ({ label: w.name, value: w.id, subtitle: w.id })) - ]} - /> - {#if isAllWorkspaces} - <p class="mt-1 text-xs text-tertiary"> - This token works across every workspace you can access. Tools take a - <code>workspace_id</code> argument; call <code>list_workspaces</code> to discover them. - </p> - {/if} - </div> - {/if} - {/if} - {#if !mcpOnly} <div> <span class="block mb-1 text-emphasis text-xs font-semibold" @@ -412,27 +462,25 @@ </div> {/if} - {#if !mcpCreationMode || maxExpirationSecs != undefined} - <div> - <span class="block mb-1 text-xs text-emphasis font-semibold"> - Expires In - {#if maxExpirationSecs == undefined} - <span class="text-xs text-primary">(optional)</span> - {/if} - </span> - <Select - bind:value={() => newTokenExpiration, (v) => (pickedExpiration = v)} - placeholder={maxExpirationSecs == undefined ? 'No expiration' : 'Pick an expiration'} - inputClass="w-full" - items={expirationItems} - /> - {#if maxExpirationSecs != undefined} - <p class="mt-1 text-xs text-tertiary"> - This instance limits tokens to {maxExpirationLabel}. - </p> + <div> + <span class="block mb-1 text-xs text-emphasis font-semibold"> + Expires In + {#if maxExpirationSecs == undefined} + <span class="text-xs text-primary">(optional)</span> {/if} - </div> - {/if} + </span> + <Select + bind:value={() => newTokenExpiration, (v) => (pickedExpiration = v)} + placeholder={maxExpirationSecs == undefined ? 'No expiration' : 'Pick an expiration'} + inputClass="w-full" + items={expirationItems} + /> + {#if maxExpirationSecs != undefined} + <p class="mt-1 text-xs text-tertiary"> + This instance limits tokens to {maxExpirationLabel}. + </p> + {/if} + </div> </div> <div class="mt-4 flex justify-end gap-2 flex-row"> @@ -446,13 +494,7 @@ Cancel </Button> {/if} - <Button - on:click={() => createToken(mcpCreationMode)} - disabled={mcpCreationMode && (newTokenWorkspace == undefined || !pickedScopes)} - variant="accent" - > - {mcpCreationMode ? 'Generate MCP URL' : 'New token'} - </Button> + <Button on:click={() => createToken()} variant="accent">New token</Button> </div> {/if} </div> @@ -462,6 +504,6 @@ {/if} {#if newMcpToken && displayCreateToken} - <TokenDisplay token={newMcpToken} mcpUrl={`${mcpBaseUrl}${newMcpToken}`} /> + <TokenDisplay token={newMcpToken} mcpUrl={newMcpTokenUrl} /> {/if} </div> diff --git a/frontend/src/lib/components/settings/EditTokenScopesModal.svelte b/frontend/src/lib/components/settings/EditTokenScopesModal.svelte index 44a44a7937..7125093749 100644 --- a/frontend/src/lib/components/settings/EditTokenScopesModal.svelte +++ b/frontend/src/lib/components/settings/EditTokenScopesModal.svelte @@ -6,6 +6,7 @@ import Modal from '../common/modal/Modal.svelte' import TextInput from '../text_input/TextInput.svelte' import ScopesPicker from './ScopesPicker.svelte' + import McpAccessPicker, { mcpAccessOf } from '../mcp/McpAccessPicker.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() @@ -41,8 +42,26 @@ let labelValue = $state('') let pickedScopes = $state<string[] | null>(null) + let mcpScopes = $state<string[]>([]) let saving = $state(false) + // The access picker re-derives a token's scopes from what it can show, which drops what it + // does not model (an old token's endpoint list, say). So they are rewritten only once the + // user has used the picker; until then the token keeps exactly the scopes it had. + let mcpTouched = $state(false) + // Carried over as is, the picker knowing nothing of them: dropping the workspace list + // would widen the token to every workspace. + const isWorkspaceList = (s: string) => s.startsWith('mcp:workspaces:') + const keptAside = (s: string) => + isWorkspaceList(s) || (mcpAccessOf(mcpScopes) !== 'selected' && s.startsWith('mcp:hub:')) + const scopesToSave = $derived( + isMcp + ? mcpTouched + ? [...mcpScopes, ...(initialScopes ?? []).filter(keptAside)] + : (initialScopes ?? []) + : pickedScopes + ) + // Seed the label field from the token whenever the modal opens or the token // identity changes (untracked read so typing doesn't reset it). ScopesPicker // seeds itself via the `{#key tokenPrefix}` remount below. @@ -51,6 +70,7 @@ if (open) { untrack(() => { labelValue = initialLabel ?? '' + mcpTouched = false }) } }) @@ -69,7 +89,7 @@ try { const trimmed = labelValue.trim() const labelChanged = labelEditable && trimmed !== (initialLabel ?? '') - const scopesChanged = !scopesEqual(pickedScopes, initialScopes ?? null) + const scopesChanged = !scopesEqual(scopesToSave, initialScopes ?? null) if (labelChanged) { await UserService.updateTokenLabel({ @@ -80,7 +100,7 @@ if (scopesChanged) { await UserService.updateTokenScopes({ tokenPrefix, - requestBody: { scopes: pickedScopes } + requestBody: { scopes: scopesToSave } }) } if (labelChanged || scopesChanged) { @@ -95,7 +115,7 @@ } } - const saveDisabled = $derived(saving || !tokenPrefix || (isMcp && !pickedScopes)) + const saveDisabled = $derived(saving || !tokenPrefix || (isMcp && mcpScopes.length === 0)) </script> <Modal bind:open title="Edit token" class="!max-w-3xl"> @@ -117,14 +137,30 @@ </div> <div class="flex flex-col gap-1"> - <span class="text-xs font-semibold text-emphasis">Scopes</span> + {#if !isMcp} + <span class="text-xs font-semibold text-emphasis">Scopes</span> + {/if} {#key tokenPrefix} - <ScopesPicker - mode={isMcp ? 'mcp' : 'standard'} - workspaceId={mcpWorkspaceId} - {initialScopes} - bind:value={pickedScopes} - /> + {#if isMcp} + <div + role="group" + onpointerdown={() => (mcpTouched = true)} + onkeydown={() => (mcpTouched = true)} + > + <McpAccessPicker + workspaceId={mcpWorkspaceId} + initialScopes={(initialScopes ?? []).filter((s) => !isWorkspaceList(s))} + bind:scopes={mcpScopes} + /> + </div> + {:else} + <ScopesPicker + mode="standard" + workspaceId={mcpWorkspaceId} + {initialScopes} + bind:value={pickedScopes} + /> + {/if} {/key} </div> </div> diff --git a/frontend/src/lib/components/settings/ForkMemberSettings.svelte b/frontend/src/lib/components/settings/ForkMemberSettings.svelte index 69d911dffc..8603980bb5 100644 --- a/frontend/src/lib/components/settings/ForkMemberSettings.svelte +++ b/frontend/src/lib/components/settings/ForkMemberSettings.svelte @@ -7,7 +7,8 @@ import Cell from '$lib/components/table/Cell.svelte' import Popover from '$lib/components/meltComponents/Popover.svelte' import Select from '$lib/components/select/Select.svelte' - import type { User } from '$lib/gen' + import type { EffectiveRole, User } from '$lib/gen' + import { loadWorkspaceRoles, pickerValue, roleLabel } from '$lib/workspaceRoles' import { UserService, WorkspaceService } from '$lib/gen' import { userStore, userWorkspaces, workspaceStore } from '$lib/stores' import { sendUserToast } from '$lib/toast' @@ -32,10 +33,16 @@ .map((c) => ({ value: c.email, label: `${c.username} (${c.email})` })) ) + let roles: EffectiveRole[] | undefined = $state(undefined) + async function loadMembers(workspace: string): Promise<void> { - const loaded = await UserService.listUsers({ workspace }) + const [loaded, workspaceRoles] = await Promise.all([ + UserService.listUsers({ workspace }), + loadWorkspaceRoles(workspace).catch(() => undefined) + ]) if (workspace !== $workspaceStore) return members = loaded + roles = workspaceRoles?.roles } async function loadCandidates(workspace: string, parent: string): Promise<void> { @@ -167,7 +174,7 @@ <tbody> {#if members} {#each members as member (member.email)} - {@const { email, username, is_admin, operator } = member} + {@const { email, username, is_admin } = member} <tr class="bg-surface"> <Cell first> {#if member.is_service_account} @@ -184,7 +191,7 @@ </Cell> <Cell> <Badge color="blue"> - {is_admin ? 'Admin' : operator ? 'Operator' : 'Developer'} + {roleLabel(roles, pickerValue(member))} </Badge> </Cell> <Cell last actions> diff --git a/frontend/src/lib/components/settings/RoleCapabilitiesEditor.svelte b/frontend/src/lib/components/settings/RoleCapabilitiesEditor.svelte new file mode 100644 index 0000000000..e9823aecde --- /dev/null +++ b/frontend/src/lib/components/settings/RoleCapabilitiesEditor.svelte @@ -0,0 +1,226 @@ +<script lang="ts"> + 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 Toggle from '$lib/components/Toggle.svelte' + import type { + AuthorLevel, + RoleCapabilities, + RoleCapabilitiesPatch, + RoleVisibility + } from '$lib/gen' + import { + applyCapabilitiesPatch, + capabilitiesError, + isEmptyPatch, + VISIBILITY_KEYS + } from '$lib/workspaceRoles' + import { RotateCcw } from 'lucide-svelte' + + interface Props { + /** The layer an unset field falls through to. */ + base: RoleCapabilities + /** Fields whose `base` value is not known here, as `code` or `visibility.runs`. */ + unknownBase?: string[] + patch: RoleCapabilitiesPatch + /** What the layer below is called: "default" on the instance, "instance" in a workspace. */ + baseName: string + /** Marks a field this layer sets, and one it leaves to `base`. */ + setText: string + inheritedText: string + disabled?: boolean + onchange?: (patch: RoleCapabilitiesPatch) => void + } + + let { + base, + unknownBase = [], + patch, + baseName, + setText, + inheritedText, + disabled = false, + onchange + }: Props = $props() + + const effective = $derived(applyCapabilitiesPatch(base, patch)) + const error = $derived(capabilitiesError(effective)) + + type BoolKey = 'code' | 'manage_schedules' | 'manage_triggers' + type LevelKey = 'flows' | 'apps' + + function boolValue(path: string): boolean { + if (path.startsWith('visibility.')) { + return effective.visibility[path.slice('visibility.'.length) as keyof RoleVisibility] + } + return effective[path as BoolKey] + } + + function isSet(path: string): boolean { + if (path.startsWith('visibility.')) { + return patch.visibility?.[path.slice('visibility.'.length)] != null + } + return patch[path as keyof RoleCapabilitiesPatch] != null + } + + function update(path: string, value: boolean | AuthorLevel | undefined) { + const next: RoleCapabilitiesPatch = structuredClone($state.snapshot(patch)) + if (path.startsWith('visibility.')) { + const vis = { ...(next.visibility ?? {}) } + const k = path.slice('visibility.'.length) + if (value === undefined) delete vis[k] + else vis[k] = value as boolean + next.visibility = Object.keys(vis).length > 0 ? vis : undefined + if (next.visibility === undefined) delete next.visibility + } else if (value === undefined) { + delete next[path as keyof RoleCapabilitiesPatch] + } else { + ;(next as Record<string, unknown>)[path] = value + } + onchange?.(next) + } + + function resetAll() { + onchange?.({}) + } + + const authoring: { key: BoolKey | LevelKey; label: string; description: string }[] = [ + { + key: 'code', + label: 'Write code', + description: + 'Author scripts and run code of their own: previews, dependency jobs, dynamic selects. Required for full flow or app authoring.' + }, + { + key: 'flows', + label: 'Flows', + description: 'Compose assembles deployed scripts and flows without code of their own.' + }, + { + key: 'apps', + label: 'Apps', + description: + 'Compose builds full-code apps out of deployed runnables, always sandboxed. Low-code apps need full.' + } + ] + + const management: { key: BoolKey; label: string; description: string }[] = [ + { + key: 'manage_schedules', + label: 'Manage schedules', + description: 'Create, edit and delete schedules where folder permissions allow.' + }, + { + key: 'manage_triggers', + label: 'Manage triggers', + description: 'Create, edit and delete triggers where folder permissions allow.' + } + ] +</script> + +{#snippet status(path: string)} + <div class="flex items-center justify-end gap-1 w-32 shrink-0"> + {#if isSet(path)} + <Badge color="blue" verySmall>{setText}</Badge> + <Button + variant="subtle" + unifiedSize="xs" + iconOnly + startIcon={{ icon: RotateCcw }} + title="Reset to the {baseName} value" + aria-label="Reset to the {baseName} value" + {disabled} + onclick={() => update(path, undefined)} + /> + {:else if unknownBase.includes(path)} + <span class="text-2xs text-hint text-right">{inheritedText}, shown after saving</span> + {:else} + <span class="text-2xs text-hint">{inheritedText}</span> + {/if} + </div> +{/snippet} + +{#snippet row(label: string, description: string | undefined, path: string)} + <div class="flex items-center gap-4 py-1.5"> + <div class="flex flex-col grow min-w-0"> + <span class="text-xs font-semibold text-emphasis">{label}</span> + {#if description} + <span class="text-2xs text-secondary">{description}</span> + {/if} + </div> + <div class="flex justify-end w-60 shrink-0"> + {#if path === 'flows' || path === 'apps'} + {@const key = path as LevelKey} + <ToggleButtonGroup + {disabled} + selected={effective[key]} + onSelected={(v) => update(key, v as AuthorLevel)} + > + {#snippet children({ item })} + <ToggleButton value="none" label="None" small {item} /> + <ToggleButton value="compose" label="Compose" small {item} /> + <ToggleButton + value="full" + label="Full" + small + disabled={!effective.code} + tooltip={effective.code ? undefined : 'Requires the code capability'} + {item} + /> + {/snippet} + </ToggleButtonGroup> + {:else} + <Toggle + size="xs" + {disabled} + checked={boolValue(path)} + on:change={(e) => update(path, e.detail)} + /> + {/if} + </div> + {@render status(path)} + </div> +{/snippet} + +<div class="flex flex-col gap-4"> + <div class="flex flex-col divide-y"> + <span class="text-xs font-semibold text-secondary pb-1">Authoring</span> + {#each authoring as f (f.key)} + {@render row(f.label, f.description, f.key)} + {/each} + </div> + {#if error} + <span class="text-xs text-red-600 dark:text-red-400">{error}</span> + {/if} + + <div class="flex flex-col divide-y"> + <span class="text-xs font-semibold text-secondary pb-1">Management</span> + {#each management as f (f.key)} + {@render row(f.label, f.description, f.key)} + {/each} + </div> + + <div class="flex flex-col divide-y"> + <span class="text-xs font-semibold text-secondary pb-1"> + Pages in their menu + <span class="font-normal">(hides a page, does not block the API)</span> + </span> + {#each VISIBILITY_KEYS as v (v.key)} + {@render row(v.label, undefined, `visibility.${v.key}`)} + {/each} + </div> + + {#if !isEmptyPatch(patch)} + <div> + <Button + variant="subtle" + unifiedSize="sm" + startIcon={{ icon: RotateCcw }} + {disabled} + onclick={resetAll} + > + Reset all to the {baseName} values + </Button> + </div> + {/if} +</div> diff --git a/frontend/src/lib/components/settings/RolePicker.svelte b/frontend/src/lib/components/settings/RolePicker.svelte new file mode 100644 index 0000000000..f8bb3d444a --- /dev/null +++ b/frontend/src/lib/components/settings/RolePicker.svelte @@ -0,0 +1,79 @@ +<script lang="ts"> + import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' + import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' + import Select from '$lib/components/select/Select.svelte' + import type { EffectiveRole } from '$lib/gen' + import { roleItems } from '$lib/workspaceRoles' + + interface Props { + /** The workspace's roles; undefined while loading. */ + roles: EffectiveRole[] | undefined + /** A role name, or `admin`. */ + value: string | undefined + /** May return a promise; the picker shows `value` again once it settles. */ + onchange: (value: string) => unknown + /** Whether Admin is offered next to the roles. */ + admin?: boolean + disabled?: boolean + disablePortal?: boolean + } + + let { + roles, + value, + onchange, + admin = true, + disabled = false, + disablePortal = false + }: Props = $props() + + const known = $derived(roleItems(roles, { admin })) + // A role the workspace no longer defines grants nothing; it stays visible rather than showing + // nothing selected. + const missing = $derived(value && !known.some((i) => i.value === value) ? value : undefined) + const undefinedValue = $derived(roles ? missing : undefined) + const items = $derived( + missing + ? [...known, { label: undefinedValue ? `${missing} (inactive)` : missing, value: missing }] + : known + ) + + let picks = $state(0) + + async function pick(v: string) { + if (v === value) return + try { + await onchange(v) + } finally { + picks++ + } + } + + const TOOLTIPS: Record<string, string> = { + admin: + 'Full control over the workspace: members, settings, every item. Holds everything whatever the role.' + } +</script> + +{#if items.length <= 4 && !undefinedValue} + <!-- The group keeps its own selection, so it is remounted after each pick: a pick the parent + refuses then shows `value` again instead of the refused one. --> + {#key picks} + <ToggleButtonGroup selected={value} {disabled} onSelected={pick}> + {#snippet children({ item })} + {#each items as i (i.value)} + <ToggleButton value={i.value} label={i.label} tooltip={TOOLTIPS[i.value]} small {item} /> + {/each} + {/snippet} + </ToggleButtonGroup> + {/key} +{:else} + <Select + {items} + {disabled} + {disablePortal} + size="sm" + class="min-w-36" + bind:value={() => value, (v) => v && pick(v)} + /> +{/if} diff --git a/frontend/src/lib/components/settings/TokenDisplay.svelte b/frontend/src/lib/components/settings/TokenDisplay.svelte index 5697b55b1b..238e7faa00 100644 --- a/frontend/src/lib/components/settings/TokenDisplay.svelte +++ b/frontend/src/lib/components/settings/TokenDisplay.svelte @@ -74,8 +74,8 @@ <!-- Additional info using alert info styling --> <div class="mt-1 {alertStyles.bgClass} rounded-md p-2"> <p class="text-xs {alertStyles.descriptionClass}"> - <strong>Next steps:</strong> Use this URL in your MCP-compatible client (like Claude Desktop) - to access your Windmill scripts and flows as tools. + <strong>Next steps:</strong> Add this URL to your MCP-compatible client (like Claude Desktop) + as a remote MCP server. </p> </div> {/if} diff --git a/frontend/src/lib/components/settings/TokensTable.svelte b/frontend/src/lib/components/settings/TokensTable.svelte index 1024aefa72..a13c06312b 100644 --- a/frontend/src/lib/components/settings/TokensTable.svelte +++ b/frontend/src/lib/components/settings/TokensTable.svelte @@ -129,7 +129,7 @@ editModalOpen = true } - async function listTokens(): Promise<void> { + export async function listTokens(): Promise<void> { tokens = await UserService.listTokens({ excludeEphemeral: true, page: tokenPage, diff --git a/frontend/src/lib/components/settings/WorkspaceOperatorSettings.svelte b/frontend/src/lib/components/settings/WorkspaceOperatorSettings.svelte deleted file mode 100644 index b9ad80f63e..0000000000 --- a/frontend/src/lib/components/settings/WorkspaceOperatorSettings.svelte +++ /dev/null @@ -1,295 +0,0 @@ -<script lang="ts"> - import { 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 DataTable from '$lib/components/table/DataTable.svelte' - import Section from '$lib/components/Section.svelte' - import Head from '$lib/components/table/Head.svelte' - import Cell from '$lib/components/table/Cell.svelte' - import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' - import Toggle from '$lib/components/Toggle.svelte' - import { WorkspaceService } from '$lib/gen' - import { workspaceStore } from '$lib/stores' - import { sendUserToast } from '$lib/toast' - import { SaveIcon, EyeIcon, EyeOffIcon } from 'lucide-svelte' - import { untrack } from 'svelte' - - const defaultVisibility = { - runs: true, - schedules: true, - resources: true, - variables: true, - assets: true, - triggers: true, - audit_logs: true, - groups: true, - folders: true, - workers: true - } - let operatorWorkspaceSettings = $state({ ...defaultVisibility }) - - // Kept out of `operatorWorkspaceSettings` so the visibility table's "Enable all" never flips a - // write right, and so these rows stay out of that table. - let builderFlows = $state(false) - let builderApps = $state(false) - // Withdrawable rather than granted: operators hold these until an admin turns them off. - let manageSchedules = $state(true) - let manageTriggers = $state(true) - - let originalSettings = $state({ - ...untrack(() => operatorWorkspaceSettings), - builder_flows: false, - builder_apps: false, - manage_schedules: true, - manage_triggers: true - }) - let isChanged = $state(false) - let currentWorkspace: string | null = $state(null) - let confirmBuilderOpen = $state(false) - // Saving sends every key, so saving before the load, or over a late response for another - // workspace, would write defaults over the rights stored there. - let loadedWorkspace: string | null = $state(null) - - const settingsPayload = $derived({ - ...operatorWorkspaceSettings, - builder_flows: builderFlows, - builder_apps: builderApps, - manage_schedules: manageSchedules, - manage_triggers: manageTriggers - }) - - // The seat cost lands on the first right granted, so confirm only when going from neither to - // either. Adding the second one later is already paid for. - const grantsFirstBuilderRight = $derived( - (builderFlows || builderApps) && - !(originalSettings.builder_flows || originalSettings.builder_apps) - ) - - const grantedBuilderKinds = $derived( - [builderFlows && 'flows', builderApps && 'full-code apps'].filter(Boolean).join(' and ') - ) - - function onSaveClicked() { - if (grantsFirstBuilderRight) { - confirmBuilderOpen = true - } else { - saveSettings() - } - } - - async function saveSettings() { - try { - await WorkspaceService.updateOperatorSettings({ - workspace: $workspaceStore!, - requestBody: settingsPayload - }) - originalSettings = { ...settingsPayload } - isChanged = false - sendUserToast('Operator settings saved successfully!', false) - } catch (error) { - console.error('Error updating operator settings:', error) - sendUserToast(`Failed to save operator settings: ${error?.body ?? error}`, true) - } - } - - const descriptions = { - runs: { title: 'Runs', description: 'View runs' }, - schedules: { title: 'Schedules', description: 'View schedules' }, - resources: { title: 'Resources', description: 'View resources' }, - variables: { title: 'Variables', description: 'View variables' }, - assets: { title: 'Assets', description: 'View assets' }, - triggers: { title: 'Triggers', description: 'View all triggers (HTTP, Websocket, Kafka)' }, - audit_logs: { title: 'Audit Logs', description: 'View audit logs' }, - groups: { title: 'Groups', description: 'View groups and group members' }, - folders: { title: 'Folders', description: 'View folders' }, - workers: { title: 'Workers', description: 'View workers and worker groups' } - } - - $effect(() => { - if ($workspaceStore && $workspaceStore !== currentWorkspace) { - ;(async () => { - const ws = $workspaceStore - currentWorkspace = ws - const settings = await WorkspaceService.getSettings({ workspace: ws }) - if (ws !== currentWorkspace) return - // Every value is reset, null settings included: one kept from the previous workspace - // would be saved here with the next unrelated change, builder rights among them. - const { - builder_flows: remoteFlows, - builder_apps: remoteApps, - manage_schedules: remoteSchedules, - manage_triggers: remoteTriggers, - ...remoteVisibility - } = settings.operator_settings ?? {} - operatorWorkspaceSettings = { ...defaultVisibility, ...remoteVisibility } - builderFlows = remoteFlows ?? false - builderApps = remoteApps ?? false - manageSchedules = remoteSchedules ?? true - manageTriggers = remoteTriggers ?? true - originalSettings = { - ...operatorWorkspaceSettings, - builder_flows: builderFlows, - builder_apps: builderApps, - manage_schedules: manageSchedules, - manage_triggers: manageTriggers - } - loadedWorkspace = ws - })() - } - }) - - $effect(() => { - isChanged = JSON.stringify(settingsPayload) !== JSON.stringify(originalSettings) - }) - - const allDisabled = $derived( - Object.values(operatorWorkspaceSettings).every((value) => value === false) - ) - const allEnabled = $derived( - Object.values(operatorWorkspaceSettings).every((value) => value === true) - ) -</script> - -<Section - label="Operator settings" - collapsable={true} - tooltip="Operators run what is shared with them. Choose what else they can change and see in this workspace." - description="Operators run what is shared with them. Choose what else they can change and see in this workspace." -> - {#snippet action()} - <Button - on:click={onSaveClicked} - startIcon={{ icon: SaveIcon }} - disabled={!isChanged || loadedWorkspace !== $workspaceStore} - variant="accent" - > - Save operator settings - </Button> - {/snippet} - - <Section - small - label="Build flows and apps" - description="Let operators build flows out of deployed scripts and flows, and full-code apps whose frontend code they write. Apps always run sandboxed, and operators still cannot write backend scripts. Granting either right makes each operator consume a full seat instead of half a seat." - wrapperClass="mb-6" - class="flex flex-col gap-y-1" - > - <Toggle - bind:checked={builderFlows} - options={{ right: 'Operators can build flows' }} - size="xs" - /> - <Toggle - bind:checked={builderApps} - options={{ right: 'Operators can build full-code apps' }} - size="xs" - /> - </Section> - - <Section - small - label="Change schedules and triggers" - description="Operators can create, edit and delete schedules and triggers wherever their folder permissions let them write. When turned off, the server refuses these changes, including through the API and the CLI." - wrapperClass="mb-6" - class="flex flex-col gap-y-1" - > - <Toggle - bind:checked={manageSchedules} - options={{ right: 'Operators can manage schedules' }} - size="xs" - /> - <Toggle - bind:checked={manageTriggers} - options={{ right: 'Operators can manage triggers' }} - size="xs" - /> - </Section> - - <Section - small - label="Pages in their menu" - description="Hides pages from an operator's menu. It does not block access through the API: use folder and item permissions to restrict what they can read." - > - <DataTable tableFixed={true} size="xs"> - <Head> - <tr> - <Cell head first>Section</Cell> - <Cell head>Description</Cell> - <Cell head last> - <ToggleButtonGroup - bind:selected={ - () => (allDisabled ? 'false' : allEnabled ? 'true' : ''), - (v) => { - Object.keys(operatorWorkspaceSettings).forEach((key) => { - if (v === 'true') operatorWorkspaceSettings[key] = true - if (v === 'false') operatorWorkspaceSettings[key] = false - }) - } - } - > - {#snippet children({ item })} - <ToggleButton - icon={EyeIcon} - small={true} - value={'true'} - label="Enable All" - {item} - /> - <ToggleButton - icon={EyeOffIcon} - small={true} - value={'false'} - label="Disable All" - {item} - /> - {/snippet} - </ToggleButtonGroup> - </Cell> - </tr> - </Head> - <tbody class="divide-y bg-surface"> - {#each Object.entries(descriptions) as [key, { title, description }]} - <tr> - <Cell first>{title}</Cell> - <Cell>{description}</Cell> - <Cell last class="pl-8"> - <ToggleButtonGroup - selected={operatorWorkspaceSettings[key] ? 'on' : 'off'} - on:selected={({ detail }) => (operatorWorkspaceSettings[key] = detail === 'on')} - > - {#snippet children({ item })} - <ToggleButton icon={EyeIcon} small={true} value={'on'} label="On" {item} /> - <ToggleButton icon={EyeOffIcon} small={true} value={'off'} label="Off" {item} /> - {/snippet} - </ToggleButtonGroup> - </Cell> - </tr> - {/each} - </tbody> - </DataTable> - </Section> -</Section> - -<ConfirmationModal - open={confirmBuilderOpen} - title="Give operators builder rights" - type="info" - confirmationText="Enable builder rights" - onCanceled={() => (confirmBuilderOpen = false)} - onConfirmed={async () => { - confirmBuilderOpen = false - await saveSettings() - }} -> - <div class="flex flex-col gap-2 text-sm"> - <span>This applies to every operator of this workspace, not to a chosen few.</span> - <span> - Each of them then consumes a full seat instead of half a seat, which changes what this - instance is billed. - </span> - <span> - They can create, edit and delete {grantedBuilderKinds} wherever their folder permissions already - let them write. Review those permissions before enabling. - </span> - </div> -</ConfirmationModal> diff --git a/frontend/src/lib/components/settings/WorkspaceRoleOverrides.svelte b/frontend/src/lib/components/settings/WorkspaceRoleOverrides.svelte new file mode 100644 index 0000000000..12e1b3e68e --- /dev/null +++ b/frontend/src/lib/components/settings/WorkspaceRoleOverrides.svelte @@ -0,0 +1,393 @@ +<script lang="ts"> + import { Alert, Badge, Button, Section } from '$lib/components/common' + import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' + import TextInput from '$lib/components/text_input/TextInput.svelte' + import RoleCapabilitiesEditor from './RoleCapabilitiesEditor.svelte' + import { + WorkspaceService, + type EffectiveRole, + type RoleCapabilities, + type RoleOverride, + type RoleOverrides, + type WorkspaceRoles + } from '$lib/gen' + import { sendUserToast } from '$lib/toast' + import { + applyCapabilitiesPatch, + authorLevelLabel, + baseCapabilitiesFor, + capabilitiesError, + isEmptyPatch, + roleNameError, + VISIBILITY_KEYS + } from '$lib/workspaceRoles' + import { ChevronDown, ChevronRight, Plus, RotateCcw, SaveIcon, Trash2 } from 'lucide-svelte' + import { SvelteMap } from 'svelte/reactivity' + + interface Props { + workspace: string + /** The workspace's roles as last read; undefined while loading. */ + data: WorkspaceRoles | undefined + onSaved: () => void + } + + let { workspace, data, onSaved }: Props = $props() + + // Maps, not objects: a role name is any slug, `constructor` and `__proto__` included. + const draft = new SvelteMap<string, RoleOverride>() + let loadedFrom: WorkspaceRoles | undefined = undefined + const open = new SvelteMap<string, boolean>() + let newRoleName = $state('') + let confirmSeatOpen = $state(false) + let saving = $state(false) + + $effect(() => { + if (data && data !== loadedFrom) { + loadedFrom = data + draft.clear() + for (const [name, o] of Object.entries(data.overrides ?? {})) { + draft.set(name, structuredClone($state.snapshot(o))) + } + } + }) + + const instanceRoles = $derived((data?.roles ?? []).filter((r) => r.source === 'instance')) + const workspaceOnlyNames = $derived( + [...draft.keys()].filter((n) => !instanceRoles.some((r) => r.name === n)).sort() + ) + + function overriddenKeys(o: RoleOverride | undefined): string[] { + const c = o?.capabilities ?? {} + const keys = ['code', 'flows', 'apps', 'manage_schedules', 'manage_triggers'].filter( + (k) => c[k] != null + ) + for (const { key } of VISIBILITY_KEYS) { + if (c.visibility?.[key] != null) keys.push(`visibility.${key}`) + } + return keys + } + + /** + * The instance layer of a role. The listing gives only capabilities with the stored override + * applied, so a field that override sets has no known instance value: `unknown` lists them. + */ + function baseFor(name: string): { base: RoleCapabilities; unknown: string[] } { + const role = instanceRoles.find((r) => r.name === name) + if (!role) return { base: baseCapabilitiesFor(name), unknown: [] } + const stored = data && Object.hasOwn(data.overrides, name) ? data.overrides[name] : undefined + return { base: role.capabilities, unknown: overriddenKeys(stored) } + } + + function effectiveFor(name: string): RoleCapabilities { + return applyCapabilitiesPatch(baseFor(name).base, draft.get(name)?.capabilities) + } + + const AUTHORING_KEYS = ['code', 'flows', 'apps'] + + /** Whether saving makes an authoring field of `name` fall back to an unknown instance value. */ + function authoringUnknown(name: string): boolean { + const set = overriddenKeys(draft.get(name)) + return baseFor(name).unknown.some((k) => AUTHORING_KEYS.includes(k) && !set.includes(k)) + } + + function isAuthor(c: RoleCapabilities): boolean { + return c.code || c.flows !== 'none' || c.apps !== 'none' + } + + function summary(c: RoleCapabilities): string { + return [ + c.code ? 'Code' : 'No code', + `Flows: ${authorLevelLabel(c.flows).toLowerCase()}`, + `Apps: ${authorLevelLabel(c.apps).toLowerCase()}` + ].join(' · ') + } + + /** The overrides to store: instance roles left as the instance has them are dropped. */ + function cleaned(entries: Iterable<[string, RoleOverride]>): RoleOverrides { + const out: [string, RoleOverride][] = [] + for (const [name, v] of entries) { + const label = v.label?.trim() || undefined + const isInstance = instanceRoles.some((r) => r.name === name) + if (isInstance && !label && isEmptyPatch(v.capabilities)) continue + out.push([ + name, + { + ...(label ? { label } : {}), + capabilities: isEmptyPatch(v.capabilities) ? {} : v.capabilities + } + ]) + } + return Object.fromEntries(out) + } + + const cleanedDraft = $derived(cleaned(draft)) + + const dirty = $derived( + data != undefined && + JSON.stringify(cleanedDraft) !== JSON.stringify(cleaned(Object.entries(data.overrides))) + ) + + const errors = $derived.by(() => { + const e: Record<string, string> = Object.create(null) + for (const name of [...instanceRoles.map((r) => r.name), ...workspaceOnlyNames]) { + // Left to the server, which knows the instance value. + if (authoringUnknown(name)) continue + const err = capabilitiesError(effectiveFor(name)) + if (err) e[name] = err + } + return e + }) + + // A role that starts authoring moves each member from half a seat to a full one. One that + // falls back to an unknown instance value may, so it is confirmed too. + const newAuthorRoles = $derived( + [ + ...instanceRoles.map((r) => r.name), + ...(data?.custom_roles_enabled ? workspaceOnlyNames : []) + ].filter((name) => { + const before = data?.roles.find((r) => r.name === name)?.capabilities + if (before && isAuthor(before)) return false + return isAuthor(effectiveFor(name)) || authoringUnknown(name) + }) + ) + + function setPatch(name: string, capabilities: RoleOverride['capabilities']) { + draft.set(name, { ...(draft.get(name) ?? {}), capabilities }) + } + + function setLabel(name: string, label: string) { + draft.set(name, { ...(draft.get(name) ?? {}), label }) + } + + function resetRole(name: string) { + draft.delete(name) + } + + const newRoleError = $derived.by(() => { + const name = newRoleName.trim() + if (!name) return undefined + if (instanceRoles.some((r) => r.name === name) || draft.has(name)) { + return 'This role already exists' + } + return roleNameError(name) + }) + + function addRole() { + const name = newRoleName.trim() + if (!name || newRoleError) return + draft.set(name, { capabilities: {} }) + open.set(name, true) + newRoleName = '' + } + + function onSaveClicked() { + if (newAuthorRoles.length > 0) confirmSeatOpen = true + else save() + } + + async function save() { + saving = true + try { + await WorkspaceService.updateWorkspaceRoleOverrides({ + workspace, + requestBody: cleanedDraft + }) + sendUserToast('Workspace roles saved') + onSaved() + } catch (e) { + sendUserToast(`Failed to save workspace roles: ${e?.body ?? e}`, true) + } finally { + saving = false + } + } +</script> + +{#snippet roleCard(name: string, role: EffectiveRole | undefined)} + {@const { base, unknown } = baseFor(name)} + {@const caps = applyCapabilitiesPatch(base, draft.get(name)?.capabilities)} + {@const overridden = Object.hasOwn(cleanedDraft, name)} + {@const frozen = !role && !data?.custom_roles_enabled} + <div class="flex flex-col rounded-md border bg-surface-tertiary"> + <div class="flex items-center gap-2 p-2"> + <div class="flex flex-col w-40 shrink-0 min-w-0"> + <span class="text-xs font-semibold text-emphasis truncate"> + {draft.get(name)?.label?.trim() || role?.label || name} + </span> + <span class="text-2xs text-secondary font-mono truncate">{name}</span> + </div> + {#if role} + <Badge color="gray" verySmall>Instance</Badge> + {#if overridden} + <Badge color="blue" verySmall>Overridden here</Badge> + {/if} + {:else if frozen} + <Badge color="orange" verySmall>Read-only without a license</Badge> + {:else} + <Badge color="indigo" verySmall>This workspace only</Badge> + {/if} + <span class="text-2xs text-secondary truncate grow"> + {summary(caps)} + </span> + <Button + variant="subtle" + unifiedSize="sm" + startIcon={{ icon: open.get(name) ? ChevronDown : ChevronRight }} + onclick={() => open.set(name, !open.get(name))} + > + Capabilities + </Button> + {#if role} + <Button + variant="subtle" + unifiedSize="sm" + startIcon={{ icon: RotateCcw }} + disabled={!overridden} + title="Use the instance definition of this role" + onclick={() => resetRole(name)} + > + Reset role + </Button> + {:else} + <Button + variant="subtle" + unifiedSize="sm" + destructive + iconOnly + startIcon={{ icon: Trash2 }} + aria-label="Remove role" + title="Remove role" + onclick={() => resetRole(name)} + /> + {/if} + </div> + {#if errors[name] && !open.get(name)} + <span class="text-2xs text-red-600 dark:text-red-400 px-2 pb-2">{errors[name]}</span> + {/if} + {#if open.get(name)} + <div class="border-t p-3 flex flex-col gap-3"> + <div class="flex items-center gap-2"> + <span class="text-xs font-semibold text-emphasis w-24">Label</span> + <div class="w-60"> + <TextInput + size="sm" + value={draft.get(name)?.label ?? ''} + inputProps={{ + placeholder: role?.label ?? name, + disabled: frozen, + 'aria-label': 'Label in this workspace', + oninput: (e) => setLabel(name, e.currentTarget.value) + }} + /> + </div> + </div> + <RoleCapabilitiesEditor + {base} + unknownBase={unknown} + patch={draft.get(name)?.capabilities ?? {}} + baseName={role ? 'instance' : 'default'} + setText={role ? 'Overridden' : 'Set'} + inheritedText={role ? 'From instance' : 'Default'} + disabled={frozen} + onchange={(p) => setPatch(name, p)} + /> + </div> + {/if} + </div> +{/snippet} + +<Section + label="Workspace roles" + collapsable={true} + description="The roles members of this workspace can hold, as the instance defines them. Override what a role allows in this workspace; a field you do not override keeps following the instance. Workspace admins hold everything regardless of their role." +> + {#snippet action()} + <Button + onclick={onSaveClicked} + startIcon={{ icon: SaveIcon }} + disabled={!dirty || saving || Object.keys(errors).length > 0} + variant="accent" + > + Save workspace roles + </Button> + {/snippet} + + {#if !data} + <span class="text-xs text-secondary">Loading roles...</span> + {:else} + <div class="flex flex-col gap-3"> + {#each instanceRoles as role (role.name)} + {@render roleCard(role.name, role)} + {/each} + {#each workspaceOnlyNames as name (name)} + {@render roleCard(name, undefined)} + {/each} + + {#if data.custom_roles_enabled} + <div class="flex items-start gap-2"> + <div class="flex flex-col gap-0.5 w-60"> + <TextInput + size="sm" + bind:value={newRoleName} + error={newRoleError} + inputProps={{ + placeholder: 'new role name', + 'aria-label': 'New role name', + onkeydown: (e) => e.key === 'Enter' && addRole() + }} + /> + {#if newRoleError} + <span class="text-2xs text-red-600 dark:text-red-400">{newRoleError}</span> + {/if} + </div> + <Button + variant="default" + unifiedSize="sm" + startIcon={{ icon: Plus }} + disabled={!newRoleName.trim() || !!newRoleError} + onclick={addRole} + > + Add workspace role + </Button> + </div> + <span class="text-2xs text-secondary"> + A workspace role exists only here. Its unset capabilities default to the operator rights. + One still held by a member, invite, auto-invite or instance group mapping cannot be + removed. + </span> + {:else} + <Alert + type="info" + size="xs" + title="Adding roles requires a valid Windmill Enterprise Edition license" + > + This workspace can still override what the instance roles allow. Roles added here earlier + keep applying but are read-only; they can still be removed. + </Alert> + {/if} + </div> + {/if} +</Section> + +<ConfirmationModal + open={confirmSeatOpen} + title="Roles that now author consume a full seat" + type="info" + confirmationText="Save" + onCanceled={() => (confirmSeatOpen = false)} + onConfirmed={async () => { + confirmSeatOpen = false + await save() + }} +> + <div class="flex flex-col gap-2 text-sm"> + <span> + With this change, members holding + <b>{newAuthorRoles.join(', ')}</b> can write code or build flows or apps in this workspace, or + may again if the instance definition allows it. + </span> + <span> + Each of them then consumes a full seat instead of half a seat, which changes what this + instance is billed. They can author wherever their folder permissions already let them write. + </span> + </div> +</ConfirmationModal> diff --git a/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte b/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte index 8f5db5f2d9..19ca67a5f7 100644 --- a/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte +++ b/frontend/src/lib/components/settings/WorkspaceUserSettings.svelte @@ -4,7 +4,17 @@ import Popover from '$lib/components/meltComponents/Popover.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' - import WorkspaceOperatorSettings from '$lib/components/settings/WorkspaceOperatorSettings.svelte' + import WorkspaceRoleOverrides from '$lib/components/settings/WorkspaceRoleOverrides.svelte' + import RolePicker from '$lib/components/settings/RolePicker.svelte' + import { + defaultRoleName, + loadWorkspaceRoles, + OPERATOR_ROLE, + pickerValue, + roleLabel, + roleRequestFields, + validRoleOr + } from '$lib/workspaceRoles' import InviteUser from '$lib/components/InviteUser.svelte' import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte' @@ -12,7 +22,7 @@ import Head from '$lib/components/table/Head.svelte' import Toggle from '$lib/components/Toggle.svelte' import Tooltip from '$lib/components/Tooltip.svelte' - import type { CancelablePromise, User, UserUsage } from '$lib/gen' + import type { CancelablePromise, User, UserUsage, WorkspaceRoles } from '$lib/gen' import { UserService, WorkspaceService, GroupService, type WorkspaceInvite } from '$lib/gen' import { userStore, @@ -39,12 +49,28 @@ let offboardingUser: string | undefined = $state(undefined) let offboardingReassignOnly = $state(false) - let users: User[] | undefined = $state(undefined) + // `role` is the member's workspace role, which `listUsers` returns but the spec's `User` omits. + type Member = User & { role?: string } + + let users: Member[] | undefined = $state(undefined) let invites: WorkspaceInvite[] = $state([]) - let filteredUsers: User[] | undefined = $state(undefined) + let filteredUsers: Member[] | undefined = $state(undefined) let userFilter = $state('') let auto_invite_domain: string | undefined = $state() - let operatorOnly: boolean | undefined = $state(undefined) + // Unset until picked or stored; the pickers and payloads fall back to the default role. + let autoInviteRole: string | undefined = $state(undefined) + let workspaceRoles = $state<WorkspaceRoles | undefined>(undefined) + const roles = $derived(workspaceRoles?.roles) + + async function loadRoles(): Promise<void> { + const workspace = $workspaceStore! + try { + const r = await loadWorkspaceRoles(workspace) + if (workspace === $workspaceStore) workspaceRoles = r + } catch (e) { + sendUserToast(`Failed to load the workspace roles: ${e?.body ?? e}`, true) + } + } let autoAdd: boolean | undefined = $state(false) let addAdminsAndDevelopersToForks = $state(false) let nbDisplayed = $state(30) @@ -56,7 +82,7 @@ // Add new instance group form state let selectedNewInstanceGroup: string | undefined = $state(undefined) - let selectedNewRole: string | undefined = $state('developer') + let selectedNewRole: string | undefined = $state(undefined) // Service account creation @@ -113,13 +139,14 @@ enabled?: boolean domain?: string operator?: boolean + role?: string mode?: string instance_groups?: string[] instance_groups_roles?: Record<string, string> } | undefined auto_invite_domain = autoInvite?.enabled ? (autoInvite?.domain ?? '*') : undefined - operatorOnly = autoInvite?.operator ?? false + autoInviteRole = autoInvite?.role ?? (autoInvite?.operator ? OPERATOR_ROLE : undefined) autoAdd = autoInvite?.mode === 'add' autoAddInstanceGroups = autoInvite?.instance_groups || [] autoAddInstanceGroupsRoles = autoInvite?.instance_groups_roles || {} @@ -190,11 +217,11 @@ let lastSeen: { workspace: string; signature: string; seq: number } | undefined = undefined let usersIssued = 0 - // Every field a paid seat count depends on — the developer/operator split, and the - // two categories billing excludes. - function membershipSignature(list: User[] | undefined): string { + // Every field a paid seat count depends on — the role, and the two categories billing + // excludes. + function membershipSignature(list: Member[] | undefined): string { return (list ?? []) - .map((u) => `${u.email}:${u.operator}:${u.disabled}:${u.is_service_account}`) + .map((u) => `${u.email}:${u.operator}:${u.role}:${u.disabled}:${u.is_service_account}`) .sort() .join(',') } @@ -218,29 +245,25 @@ } } + // Throws, so each caller rolls back its optimistic change. async function saveInstanceGroupSettings(): Promise<void> { - try { - await WorkspaceService.editInstanceGroups({ - workspace: $workspaceStore ?? '', - requestBody: { - groups: autoAddInstanceGroups, - roles: autoAddInstanceGroupsRoles - } - }) - sendUserToast('Instance group settings saved') - // Refresh user list to show newly auto-added users - listUsers() - } catch (e) { - console.error('Failed to save instance group settings:', e) - sendUserToast('Failed to save settings', true) - } + await WorkspaceService.editInstanceGroups({ + workspace: $workspaceStore ?? '', + requestBody: { + groups: autoAddInstanceGroups, + roles: autoAddInstanceGroupsRoles + } + }) + sendUserToast('Instance group settings saved') + // Refresh user list to show newly auto-added users + listUsers() } async function addInstanceGroup(): Promise<void> { - if (!selectedNewInstanceGroup || !selectedNewRole) return + if (!selectedNewInstanceGroup) return const groupToAdd = selectedNewInstanceGroup - const roleToAdd = selectedNewRole + const roleToAdd = validRoleOr(roles, selectedNewRole) try { autoAddInstanceGroups = [...autoAddInstanceGroups, groupToAdd] @@ -248,14 +271,14 @@ // Reset form selectedNewInstanceGroup = undefined - selectedNewRole = 'developer' + selectedNewRole = undefined await saveInstanceGroupSettings() } catch (e) { // Rollback on error autoAddInstanceGroups = autoAddInstanceGroups.filter((g) => g !== groupToAdd) delete autoAddInstanceGroupsRoles[groupToAdd] - sendUserToast('Failed to add instance group', true) + sendUserToast(`Failed to add instance group: ${e?.body ?? e}`, true) } } @@ -273,7 +296,7 @@ if (previousRole) { autoAddInstanceGroupsRoles[groupName] = previousRole } - sendUserToast('Failed to remove instance group', true) + sendUserToast(`Failed to remove instance group: ${e?.body ?? e}`, true) } } @@ -286,7 +309,7 @@ } catch (e) { // Rollback on error autoAddInstanceGroupsRoles[groupName] = previousRole - sendUserToast('Failed to update role', true) + sendUserToast(`Failed to update role: ${e?.body ?? e}`, true) } } @@ -315,6 +338,8 @@ listInvites() loadSettings() loadInstanceGroups() + workspaceRoles = undefined + loadRoles() }) } }) @@ -356,20 +381,16 @@ let nbInviteDisplayed = $state(50) - async function inviteUser(email: string, selected: 'operator' | 'developer' | 'admin') { + async function inviteUser(email: string, selected: string) { try { await WorkspaceService.inviteUser({ workspace: $workspaceStore!, - requestBody: { - email, - is_admin: selected == 'admin', - operator: selected == 'operator' - } + requestBody: { email, ...roleRequestFields(selected) } }) sendUserToast(`Invited ${email}`) } catch (e) { console.error('Failed to invite user:', e) - sendUserToast('Failed to invite user', true) + sendUserToast(`Failed to invite user: ${e?.body ?? e}`, true) } if (!(await UserService.existsEmail({ email }))) { @@ -412,7 +433,7 @@ workspace: $workspaceStore ?? '', requestBody: enable ? { - operator: operatorOnly ?? false, + ...roleRequestFields(validRoleOr(roles, autoInviteRole)), invite_all: !isCloudHosted(), auto_add: autoAdd } @@ -430,7 +451,7 @@ sendUserToast(message) } catch (e) { console.error('Failed to update auto invite:', e) - sendUserToast('Failed to update auto invite', true) + sendUserToast(`Failed to update auto invite: ${e?.body ?? e}`, true) } loadSettings() listInvites() @@ -548,32 +569,18 @@ {/if} <span class="text-xs mb-1">Role <Tooltip>Role of the auto-added users</Tooltip></span> - <ToggleButtonGroup - selected={operatorOnly ? 'operator' : 'developer'} - on:selected={async (e) => { - operatorOnly = e.detail === 'operator' + <RolePicker + {roles} + admin={false} + disablePortal + value={validRoleOr(roles, autoInviteRole)} + onchange={async (v) => { + autoInviteRole = v if (auto_invite_domain != undefined) { await updateAutoInvite(true) } }} - > - {#snippet children({ item })} - <ToggleButton - value="operator" - small - label="Operator" - tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on." - {item} - /> - <ToggleButton - value="developer" - small - label="Developer" - tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)." - {item} - /> - {/snippet} - </ToggleButtonGroup> + /> <div class="mt-6"> <Toggle @@ -660,36 +667,12 @@ <div class="flex flex-col gap-1"> <span class="text-xs text-primary">Role</span> - <ToggleButtonGroup - selected={selectedNewRole} - on:selected={(e) => { - selectedNewRole = e.detail - }} - > - {#snippet children({ item })} - <ToggleButton - value="operator" - small - label="Operator" - tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on." - {item} - /> - <ToggleButton - value="developer" - small - label="Developer" - tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)." - {item} - /> - <ToggleButton - value="admin" - small - label="Admin" - tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace." - {item} - /> - {/snippet} - </ToggleButtonGroup> + <RolePicker + {roles} + disablePortal + value={validRoleOr(roles, selectedNewRole)} + onchange={(v) => (selectedNewRole = v)} + /> </div> </div> @@ -697,7 +680,7 @@ color="blue" size="xs" startIcon={{ icon: Plus }} - disabled={!selectedNewInstanceGroup || !selectedNewRole} + disabled={!selectedNewInstanceGroup} onclick={addInstanceGroup} > Add @@ -730,38 +713,15 @@ </td> <td class="py-2"> <div> - <ToggleButtonGroup - selected={autoAddInstanceGroupsRoles[groupName] || - 'developer'} - on:selected={async (e) => { - autoAddInstanceGroupsRoles[groupName] = e.detail - await updateGroupRole(groupName, e.detail) + <RolePicker + {roles} + disablePortal + value={autoAddInstanceGroupsRoles[groupName] || + defaultRoleName(roles)} + onchange={async (v) => { + await updateGroupRole(groupName, v) }} - > - {#snippet children({ item })} - <ToggleButton - value="operator" - small - label="Operator" - tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on." - {item} - /> - <ToggleButton - value="developer" - small - label="Developer" - tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)." - {item} - /> - <ToggleButton - value="admin" - small - label="Admin" - tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace." - {item} - /> - {/snippet} - </ToggleButtonGroup> + /> </div> </td> <td class="py-2"> @@ -800,10 +760,11 @@ {/if} {#if showAutoInviteToggle} - <InviteUser {inviteUser} /> + <InviteUser {inviteUser} {roles} /> {/if} <AddUser + {roles} on:new={() => { listUsers() listInvites() @@ -851,7 +812,7 @@ <tbody> {#if filteredUsers} {#each sortedUsers().slice(0, nbDisplayed) as user, index (user.email)} - {@const { email, username, is_admin, operator, disabled, added_via } = user} + {@const { email, username, is_admin, disabled, added_via } = user} <!-- Add separator between manual users and instance group users --> {#if hasNonManualUsers && index > 0 && sortedUsers()[index - 1]?.added_via?.source !== 'instance_group' && added_via?.source === 'instance_group'} <tr class="bg-surface-secondary"> @@ -901,63 +862,35 @@ {#if added_via?.source === 'instance_group'} <div class="flex items-center gap-1"> <span class="rounded-md text-xs px-2 py-1 bg-surface shadow-md font-bold"> - {is_admin ? 'Admin' : operator ? 'Operator' : 'Developer'} + {roleLabel(roles, pickerValue(user))} </span> <Tooltip>Role is managed through instance group configuration above.</Tooltip> </div> {:else} - <ToggleButtonGroup - selected={is_admin ? 'admin' : operator ? 'operator' : 'developer'} - on:selected={async (e) => { - if (is_admin && email == $userStore?.email && e.detail != 'admin') { + <RolePicker + {roles} + value={pickerValue(user)} + onchange={async (v) => { + if (is_admin && email == $userStore?.email && v != 'admin') { sendUserToast( 'Admins cannot be demoted by themselves, ask another admin to demote you', true ) - e.preventDefault() listUsers() return } - const body = - e.detail == 'admin' - ? { is_admin: true, operator: false } - : e.detail == 'operator' - ? { is_admin: false, operator: true } - : { is_admin: false, operator: false } - await UserService.updateUser({ - workspace: $workspaceStore ?? '', - username, - requestBody: body - }) - listUsers() + try { + await UserService.updateUser({ + workspace: $workspaceStore ?? '', + username, + requestBody: roleRequestFields(v) + }) + } catch (e) { + sendUserToast(`Failed to change role: ${e?.body ?? e}`, true) + } + await listUsers() }} - > - {#snippet children({ item })} - <ToggleButton - value="operator" - small - label="Operator" - tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on." - {item} - /> - - <ToggleButton - value="developer" - small - label="Developer" - tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)." - {item} - /> - - <ToggleButton - value="admin" - small - label="Admin" - tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace." - {item} - /> - {/snippet} - </ToggleButtonGroup> + /> {/if} </div> </Cell> @@ -1083,7 +1016,9 @@ <div class="pt-12"></div> -<WorkspaceOperatorSettings /> +{#if $workspaceStore} + <WorkspaceRoleOverrides workspace={$workspaceStore} data={workspaceRoles} onSaved={loadRoles} /> +{/if} <div class="pt-12"></div> @@ -1118,7 +1053,7 @@ {#snippet action()} {#if showAutoInviteToggle && !isAdminsWorkspaceWithoutEE} <div class="flex gap-2 items-center"> - <InviteUser {inviteUser} /> + <InviteUser {inviteUser} {roles} /> </div> {/if} {/snippet} @@ -1132,56 +1067,27 @@ </Head> <tbody class="divide-y bg-surface"> {#if invites?.length > 0} - {#each invites.slice(0, nbInviteDisplayed) as { email, is_admin, operator }} + {#each invites.slice(0, nbInviteDisplayed) as invite} + {@const { email, is_admin, operator } = invite} <Row> <Cell first>{email}</Cell> <Cell> <div> - <ToggleButtonGroup - selected={is_admin ? 'admin' : operator ? 'operator' : 'developer'} - on:selected={async (e) => { - const body = - e.detail == 'admin' - ? { is_admin: true, operator: false } - : e.detail == 'operator' - ? { is_admin: false, operator: true } - : { is_admin: false, operator: false } - await WorkspaceService.inviteUser({ - workspace: $workspaceStore ?? '', - requestBody: { - email, - ...body - } - }) - listUsers() + <RolePicker + {roles} + value={pickerValue(invite)} + onchange={async (v) => { + try { + await WorkspaceService.inviteUser({ + workspace: $workspaceStore ?? '', + requestBody: { email, ...roleRequestFields(v) } + }) + } catch (e) { + sendUserToast(`Failed to change role: ${e?.body ?? e}`, true) + } + await listInvites() }} - > - {#snippet children({ item })} - <ToggleButton - value="operator" - small - label="Operator" - tooltip="An operator can only execute and view scripts/flows/apps from your workspace, and only those that he has visibility on." - {item} - /> - - <ToggleButton - value="developer" - small - label="Developer" - tooltip="A Developer can execute and view scripts/flows/apps, but they can also create new ones and edit those they are allowed to by their path (either u/ or Writer or Admin of their folder found at /f)." - {item} - /> - - <ToggleButton - value="admin" - small - label="Admin" - tooltip="An admin has full control over a specific Windmill workspace, including the ability to manage users, edit entities, and control permissions within the workspace." - {item} - /> - {/snippet} - </ToggleButtonGroup> + /> </div> </Cell> <Cell last actions> diff --git a/frontend/src/lib/components/sidebar/FavoriteMenu.svelte b/frontend/src/lib/components/sidebar/FavoriteMenu.svelte index 1c3191f0cb..448d7644bc 100644 --- a/frontend/src/lib/components/sidebar/FavoriteMenu.svelte +++ b/frontend/src/lib/components/sidebar/FavoriteMenu.svelte @@ -10,6 +10,7 @@ flow: `/flows/get/${path}`, app: `/apps/get/${path}`, raw_app: `/apps_raw/get/${path}`, + agent: `/agents/get/${path}`, asset: '#' }[kind] } @@ -29,6 +30,9 @@ href: string kind: FavoriteKind }[] = $state([]) + /** The workspace `current` was last loaded for, so a reader can tell "none yet" from + * "none". */ + loadedFor: string | undefined = $state(undefined) async unstar(path: string, favorite_kind: FavoriteKind, workspaceId?: string) { this.current = this.current.filter( @@ -71,7 +75,7 @@ </script> <script lang="ts"> - import { CodeXml, LayoutDashboard, Table2, Star, Trash2 } from 'lucide-svelte' + import { Bot, CodeXml, LayoutDashboard, Table2, Star, Trash2 } from 'lucide-svelte' import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte' import { Menu, MenuItem } from '$lib/components/meltComponents' import MenuButton from '$lib/components/sidebar/MenuButton.svelte' @@ -141,6 +145,8 @@ <BarsStaggered size={16} class="text-teal-500" /> {:else if favorite.kind == 'app' || favorite.kind == 'raw_app'} <LayoutDashboard size={16} class="text-orange-500" /> + {:else if favorite.kind == 'agent'} + <Bot size={16} class="text-violet-500" /> {:else if favorite.kind == 'asset'} <Table2 size={16} class="text-primary" /> {/if} diff --git a/frontend/src/lib/components/sidebar/SettingsMenu.svelte b/frontend/src/lib/components/sidebar/SettingsMenu.svelte index 95dc03992c..13f38840d4 100644 --- a/frontend/src/lib/components/sidebar/SettingsMenu.svelte +++ b/frontend/src/lib/components/sidebar/SettingsMenu.svelte @@ -38,7 +38,6 @@ import { USER_SETTINGS_HASH, SUPERADMIN_SETTINGS_HASH } from './settings' import { accountSetup } from './accountSetup.svelte' import { EXECUTIONS_HINT } from './executionsHint' - import { sidebarPageAllowed } from './operatorRoutes' import { TOUR_PARAM, TOUR_PARAM_VALUE } from '$lib/components/tutorials/operatorTour' import { userWorkspaces, @@ -49,7 +48,8 @@ enterpriseLicense, isCriticalAlertsUIOpen, usageStore, - isPremiumStore + isPremiumStore, + roleCapabilities } from '$lib/stores' let { @@ -250,7 +250,7 @@ icon: Settings, action: () => goto(USER_SETTINGS_HASH) }, - ...($userStore?.operator + ...(!$roleCapabilities.code ? [ { displayName: 'Take the tour', @@ -318,7 +318,7 @@ label="Workers" href="/workers" icon={ServerCog} - disabled={!sidebarPageAllowed($userStore?.operator, currentWs, 'workers')} + disabled={!$roleCapabilities.visibility.workers} {isCollapsed} aiId="sidebar-menu-link-workers" aiDescription="Button to navigate to workers" @@ -364,7 +364,7 @@ label="Audit logs" href="/audit_logs" icon={Eye} - disabled={!sidebarPageAllowed($userStore?.operator, currentWs, 'audit_logs')} + disabled={!$roleCapabilities.visibility.audit_logs} {isCollapsed} aiId="sidebar-menu-link-audit-logs" aiDescription="Button to navigate to audit logs" diff --git a/frontend/src/lib/components/sidebar/SidebarContent.svelte b/frontend/src/lib/components/sidebar/SidebarContent.svelte index 624fdff527..f08446d4ed 100644 --- a/frontend/src/lib/components/sidebar/SidebarContent.svelte +++ b/frontend/src/lib/components/sidebar/SidebarContent.svelte @@ -8,7 +8,8 @@ workspaceStore, isCriticalAlertsUIOpen, enterpriseLicense, - devopsRole + devopsRole, + roleCapabilities } from '$lib/stores' import { isForkOwner } from '$lib/utils/workspaceHierarchy' import { SIDEBAR_SHOW_SCHEDULES } from '$lib/consts' @@ -65,7 +66,7 @@ import GoogleCloudIcon from '../icons/triggers/GoogleCloudIcon.svelte' import AzureIcon from '../icons/triggers/AzureIcon.svelte' import { leaveCurrentWorkspace } from './leaveWorkspace' - import { sidebarPageAllowed, type OperatorPageKey } from './operatorRoutes' + import type { VisibilityKey } from '$lib/roleCapabilities' import { markChangelogsOpened, readRecentChangelogs } from './changelogs' const { recent: recentChangelogs, hasNew } = readRecentChangelogs() @@ -183,8 +184,8 @@ } const currentWs = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore)) - function allows(key: OperatorPageKey) { - return sidebarPageAllowed($userStore?.operator, currentWs, key) + function allows(key: VisibilityKey) { + return $roleCapabilities.visibility[key] } const itemClass = twMerge( @@ -228,7 +229,7 @@ label: p.label, href: p.path === '/' ? `/` : `${p.path}`, icon: p.icon, - disabled: p.operatorKey ? !allows(p.operatorKey) : false, + disabled: p.visibilityKey ? !allows(p.visibilityKey) : false, ...MAIN_MENU_EXTRAS[p.label] })) .filter((l) => !excludeMainLabels.includes(l.label)) @@ -340,7 +341,7 @@ label: config?.serviceDisplayName || service, href: `/native_triggers/${service}`, icon: icon, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: service, aiId: `sidebar-menu-link-${service}`, aiDescription: `Button to navigate to ${config?.serviceDisplayName || service} triggers` @@ -436,7 +437,7 @@ ] : []) ], - disabled: $userStore?.operator + disabled: !$roleCapabilities.code }, { label: 'Workers', diff --git a/frontend/src/lib/components/sidebar/UserMenu.svelte b/frontend/src/lib/components/sidebar/UserMenu.svelte index ff302d3c5e..40c64d5621 100644 --- a/frontend/src/lib/components/sidebar/UserMenu.svelte +++ b/frontend/src/lib/components/sidebar/UserMenu.svelte @@ -6,8 +6,11 @@ usageStore, workspaceUsageStore, isPremiumStore, - workspaceStore + workspaceStore, + userWorkspaces, + roleCapabilities } from '$lib/stores' + import { isRestricted } from '$lib/roleCapabilities' import { USER_SETTINGS_HASH } from './settings' import { isCloudHosted } from '$lib/cloud' import { twMerge } from 'tailwind-merge' @@ -32,6 +35,12 @@ // An account entered through an invite link that still has no credentials of its own. let pendingSetup = $derived(accountSetup.pending) + // A server that predates roles sends no role name, and its only restricted role is operator. + const roleName = $derived( + $userWorkspaces.find((w) => w.id === $workspaceStore)?.role ?? 'operator' + ) + const roleLabel = $derived(roleName.charAt(0).toUpperCase() + roleName.slice(1)) + const itemClass = twMerge( 'text-secondary text-left font-normal text-xs ', 'flex flex-row items-center gap-2 px-4 py-3 w-full', @@ -62,8 +71,8 @@ Superadmin, not a member of this workspace <Crown size={14} /> {:else if $userStore?.is_admin} Admin of this workspace <Crown size={14} /> - {:else if $userStore?.operator} - Operator in this workspace <ServerCog size={14} /> + {:else if isRestricted($roleCapabilities)} + {roleLabel} in this workspace <ServerCog size={14} /> {/if} </span> {#if $userStore?.non_member} diff --git a/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte b/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte index 3719e35d35..97385c7e9d 100644 --- a/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte +++ b/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte @@ -2,8 +2,10 @@ import { tick, untrack } from 'svelte' import type { MenubarMenuElements } from '@melt-ui/svelte' import { workspaceMenuHref } from './workspaceMenuHref' - import { isOperatorInWorkspace } from './operatorRoutes' + import { capabilitiesIn, hidesPages, isRestricted } from '$lib/roleCapabilities' + import type { RoleCapabilities } from '$lib/gen' import { + roleCapabilities, isPremiumStore, maybePremium, superadmin, @@ -13,9 +15,12 @@ workspaceUsageStore, clearWorkspaceFromStorage, globalForkModal, - enterpriseLicense + enterpriseLicense, + type UserWorkspace } from '$lib/stores' - import { Check, ChevronDown, ChevronRight, Plus, Settings } from 'lucide-svelte' + import { Check, ChevronDown, ChevronRight, Plus, Search, Settings } from 'lucide-svelte' + import TextInput from '$lib/components/text_input/TextInput.svelte' + import SearchItems from '$lib/components/SearchItems.svelte' import { forkAccentStyle } from '$lib/utils/forkColor' import { SvelteSet } from 'svelte/reactivity' import { CopyButton } from '$lib/components/common' @@ -82,16 +87,15 @@ await fixupUrlAfterWorkspaceSwitch(id, { landOnHome }) } - // Operator page access is granted per workspace, so a switch lands on home rather than - // on a page either side may refuse. Both signals are positive: `userStore.operator` is - // an explicit flag for the workspace being left, and `isOperatorInWorkspace` only ever - // confirms the target (its NULL is ambiguous, so a `false` from it is never taken as - // proof of a developer on its own). Leaving one developer workspace for another keeps - // the page, as it always has. + // What a role may see and do is per workspace, so a switch that leaves or enters a restricted + // role lands on home rather than on a page either side may refuse. Leaving one unrestricted + // workspace for another keeps the page. function landsOnHome(id: string): boolean { if (keepPageOnSwitch) return false - if ($userStore?.operator) return true - return isOperatorInWorkspace(($userWorkspaces ?? []).find((w) => w.id === id)) + const gated = (caps: RoleCapabilities) => isRestricted(caps) || hidesPages(caps) + if (gated($roleCapabilities)) return true + const target = ($userWorkspaces ?? []).find((w) => w.id === id) + return gated(capabilitiesIn({ is_super_admin: $userStore?.is_super_admin }, target)) } // Href for the item's navigation (including modifier/middle clicks opening a @@ -136,25 +140,28 @@ // (standalone pages such as svix webhook creation), so // there forks must stay directly selectable — list the full hierarchy // unconditionally. - const hierarchy = $derived($userWorkspaces ? buildWorkspaceHierarchy($userWorkspaces) : []) + const hierarchy = $derived( + $userWorkspaces ? buildWorkspaceHierarchy($userWorkspaces, $userStore?.email) : [] + ) const expandedFamilies = new SvelteSet<string>() // Root ids with at least one fork — only they get the expand chevron. // hierarchy is a DFS (parent before child), so a depth>0 row belongs to the // last depth-0 row seen. - const familiesWithForks = $derived.by(() => { + function rootsWithForks(rows: typeof hierarchy): Set<string> { const withForks = new Set<string>() let rootId: string | undefined - for (const h of hierarchy) { + for (const h of rows) { if (h.depth === 0) rootId = h.workspace.id else if (rootId) withForks.add(rootId) } return withForks - }) + } + const familiesWithForks = $derived(rootsWithForks(hierarchy)) // Gate for the "Workspace fork" entry pinned below the list (the global fork // modal carries its own base-workspace picker). Hidden on non-premium cloud, // in the admins workspace, or when forking is disabled. const canForkHere = $derived( - (!isCloudHosted() || $maybePremium) && $workspaceStore !== 'admins' && canCreateFork($userStore) + (!isCloudHosted() || $maybePremium) && $workspaceStore !== 'admins' && canCreateFork($userStore, $roleCapabilities) ) // The row mechanics below — which family to expand, which collapsed row carries the tick — key on // a depth-0 row of the list, which `buildWorkspaceHierarchy` puts at the highest workspace the @@ -167,16 +174,54 @@ return ancestors.at(-1) ?? $userWorkspaces?.find((w) => w.id === id) }) + const SEARCH_THRESHOLD = 8 + const showSearch = $derived(($userWorkspaces?.length ?? 0) > SEARCH_THRESHOLD) + let searchFilter = $state('') + let searchMatches: (UserWorkspace & { marked?: string })[] | undefined = $state() + const searching = $derived(showSearch && searchFilter.trim() !== '') + const markedNames = $derived( + new Map(searching ? searchMatches?.map((w) => [w.id, w.marked]) : undefined) + ) + + // While searching, every family is open: a match is listed under the ancestors that lead to + // it, so a fork still reads as part of its family. + const searchedRows = $derived.by(() => { + const matched = new Set(searchMatches?.map((w) => w.id)) + const keep = new Set<string>() + const path: string[] = [] + for (const h of hierarchy) { + path.length = h.depth + path.push(h.workspace.id) + if (matched.has(h.workspace.id)) path.forEach((id) => keep.add(id)) + } + return hierarchy.filter((h) => keep.has(h.workspace.id)) + }) + + // A search opens every family that has a matching fork; the chevron can still close one. Kept + // apart from expandedFamilies so clearing the search restores the families as they were. + const collapsedInSearch = new SvelteSet<string>() + function isExpanded(rootId: string): boolean { + return searching ? !collapsedInSearch.has(rootId) : expandedFamilies.has(rootId) + } + function toggleFamily(rootId: string) { + const set = searching ? collapsedInSearch : expandedFamilies + if (set.has(rootId)) set.delete(rootId) + else set.add(rootId) + } + // Roots with at least one fork among the rows the list can show right now. + const expandableFamilies = $derived(searching ? rootsWithForks(searchedRows) : familiesWithForks) + const familyWorkspaces = $derived.by(() => { let rootId: string | undefined + const rows = searching ? searchedRows : hierarchy const visible = strictWorkspaceSelect - ? hierarchy - : hierarchy.filter((h) => { + ? rows + : rows.filter((h) => { if (h.depth === 0) { rootId = h.workspace.id return true } - return !!rootId && expandedFamilies.has(rootId) + return !!rootId && isExpanded(rootId) }) // The family the user is standing in leads the list, its forks travelling with it: the // workspace they are in is the one they look for first, and the one the tick is on. @@ -193,12 +238,112 @@ return [active, ...families].flatMap((f) => f.rows) }) + // With the search field shown, focus stays in it: the arrows move this highlight rather than + // melt's roving focus, which would take the keys away from the field. Until the user arrows or + // hovers, it rests on the best-ranked visible match (not the first row, which may only be a + // match's ancestor), so Enter's target is always the highlighted row. The highlight walks every + // element carrying `data-nav-id` — workspace rows, then the actions below them — in DOM order. + const ACTION_PREFIX = 'action:' + let rootEl: HTMLDivElement | undefined = $state() + let pickedId: string | undefined = $state() + const navIds = $derived( + familyWorkspaces.filter((h) => !h.workspace.disabled).map((h) => h.workspace.id) + ) + const highlightedId = $derived( + !showSearch + ? undefined + : pickedId && (pickedId.startsWith(ACTION_PREFIX) || navIds.includes(pickedId)) + ? pickedId + : searching + ? searchMatches?.find((w) => navIds.includes(w.id))?.id + : undefined + ) + + function actionNavId(action: string): string | undefined { + return showSearch ? ACTION_PREFIX + action : undefined + } + + function actionClass(action: string): string { + return twMerge(itemClass, highlightedId === ACTION_PREFIX + action && 'bg-surface-hover') + } + + function navElement(id: string): HTMLElement | null | undefined { + return rootEl?.querySelector<HTMLElement>(`[data-nav-id="${CSS.escape(id)}"]`) + } + + function moveHighlight(step: 1 | -1) { + const order = Array.from(rootEl?.querySelectorAll('[data-nav-id]') ?? []).map( + (el) => el.getAttribute('data-nav-id')! + ) + if (order.length === 0) return + const at = highlightedId ? order.indexOf(highlightedId) : -1 + const next = + at === -1 + ? step === 1 + ? 0 + : order.length - 1 + : Math.min(order.length - 1, Math.max(0, at + step)) + pickedId = order[next] + navElement(order[next])?.scrollIntoView({ block: 'nearest' }) + } + + function onSearchKeydown(e: KeyboardEvent) { + // An IME uses arrows, Enter and Escape to pick and confirm a candidate; those keys are its own. + if (e.isComposing || e.keyCode === 229) { + e.stopPropagation() + return + } + if (e.key === 'Escape') return + e.stopPropagation() + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault() + moveHighlight(e.key === 'ArrowDown' ? 1 : -1) + } else if (e.key === 'Enter') { + const target = highlightedId + if (!target) return + e.preventDefault() + if (target.startsWith(ACTION_PREFIX)) { + navElement(target)?.querySelector<HTMLElement>('[role="menuitem"]')?.click() + return + } + closeMenu?.() + void tick().then(() => toggleSwitchWorkspace(target)) + } + } + + // A new query starts from its own best match with every matching family open. + function onSearchInput() { + pickedId = undefined + collapsedInSearch.clear() + } + + // melt focuses an item on pointermove and its menu on pointerleave; stopped here, before they + // reach the item, so hovering the list leaves focus in the search field. + function keepSearchFocus(e: PointerEvent) { + if (!showSearch) return + e.stopPropagation() + if (e.type === 'pointermove') { + const id = (e.target as HTMLElement | null) + ?.closest('[data-nav-id]') + ?.getAttribute('data-nav-id') + if (id) pickedId = id + } + } + + // melt still moves focus onto an item in other cases, the first one when the menu is opened from + // the keyboard among them; with the search shown, focus belongs to the field. + let searchWrapEl: HTMLDivElement | undefined = $state() + function returnFocusToSearch(e: FocusEvent) { + if (!showSearch || !(e.target as HTMLElement | null)?.closest('[role="menuitem"]')) return + searchWrapEl?.querySelector('input')?.focus() + } + // ArrowRight/ArrowLeft expand/collapse the keyboard-highlighted family (melt // stamps data-highlighted on the item; the row wrapper carries the workspace // id). Capture phase, so melt's menubar left/right (adjacent-menu switching) // doesn't fire when the keypress means expansion here. function onExpandKeydown(e: KeyboardEvent) { - if (strictWorkspaceSelect) return + if (strictWorkspaceSelect || e.target instanceof HTMLInputElement) return if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return // Scoped inside a row wrapper: the menubar trigger also carries // data-highlighted while its menu is open. @@ -234,212 +379,288 @@ // font-normal is explicit: href-less MenuItems render as <button>, which the // global stylesheet makes semibold, unlike the <a> the href entries get. const itemClass = - 'text-primary font-normal w-full flex flex-row gap-2 px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary' + 'text-primary font-normal w-full flex flex-row gap-2 px-4 py-2 text-xs focus:outline-none hover:bg-surface-hover hover:text-primary data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary' </script> <svelte:window onkeydowncapture={onExpandKeydown} /> -<div class="divide-y" role="none"> - <!-- The list scrolls internally so the sections below (+ Workspace, +<div + bind:this={rootEl} + role="none" + onpointermovecapture={keepSearchFocus} + onpointerleavecapture={keepSearchFocus} + onfocusincapture={returnFocusToSearch} +> + <div class="divide-y" role="none"> + {#if showSearch} + <SearchItems + filter={searchFilter} + items={$userWorkspaces} + bind:filteredItems={searchMatches} + f={(w) => w.name + ' (' + w.id + ')'} + /> + <!-- Keys typed here must not reach melt: its menu typeahead would focus the first item + starting with the typed letter. A native listener, because Svelte delegates `onkeydown` + to the root, which runs after melt's listener on the menu element. --> + <div + bind:this={searchWrapEl} + class="px-2 pt-1 pb-2" + role="none" + {@attach (el) => { + el.addEventListener('keydown', onSearchKeydown) + return () => el.removeEventListener('keydown', onSearchKeydown) + }} + > + <div class="relative"> + <TextInput + bind:value={searchFilter} + size="sm" + inputProps={{ + placeholder: 'Search workspaces...', + autofocus: true, + oninput: onSearchInput + }} + class="!pr-8" + /> + <Search + size={14} + class="text-secondary absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none" + /> + </div> + </div> + {/if} + <!-- The list scrolls internally so the sections below (+ Workspace, Workspace fork, All workspaces) stay visible however long it gets. --> - <div class="py-1 overflow-y-auto max-h-[min(50vh,26rem)]"> - {#each familyWorkspaces as { workspace, depth, isForked, parentName }} - {@const isActive = $workspaceStore === workspace.id} - {@const forkAccent = isForked ? forkAccentStyle(workspace.color) : undefined} - <!-- Selection is a trailing tick (single-choice picker convention). A + <div class="py-1 overflow-y-auto max-h-[min(50vh,26rem)]"> + {#if searching && familyWorkspaces.length === 0} + <div class="px-3 py-2 text-xs text-secondary">No workspaces match your search</div> + {/if} + {#each familyWorkspaces as { workspace, depth, isForked, parentName, separatorBefore } (workspace.id)} + {@const isActive = $workspaceStore === workspace.id} + {@const forkAccent = isForked ? forkAccentStyle(workspace.color) : undefined} + <!-- A search filters out sibling forks, so the divider between the user's own forks + and the others would mark the wrong boundary. --> + {#if separatorBefore && !searching} + <div + class="my-1 border-t border-border-light" + style:margin-left={`${(depth - 1) * 16}px`} + role="separator" + ></div> + {/if} + <!-- Selection is a trailing tick (single-choice picker convention). A collapsed root carries the tick while one of its forks is active — the fork row is hidden and its family is the only trace of the selection; expanding moves the tick to the fork itself. --> - {@const isSelected = - isActive || - (!strictWorkspaceSelect && - depth === 0 && - lineageRoot?.id === workspace.id && - !expandedFamilies.has(workspace.id))} - {@const expandable = - !strictWorkspaceSelect && depth === 0 && familiesWithForks.has(workspace.id)} - <!-- The expand chevron sits OUTSIDE the melt item: melt activates items + {@const isSelected = + isActive || + (!strictWorkspaceSelect && + depth === 0 && + lineageRoot?.id === workspace.id && + !isExpanded(workspace.id) && + (!searching || searchedRows.some((h) => h.workspace.id === $workspaceStore)))} + {@const expandable = + !strictWorkspaceSelect && depth === 0 && expandableFamilies.has(workspace.id)} + <!-- The expand chevron sits OUTSIDE the melt item: melt activates items via document-level handlers, so a nested button can't stop the row's navigate-and-close with stopPropagation. As a sibling it toggles the family without selecting the row or closing the menu. --> - <!-- Hover/keyboard-highlight backgrounds live on the wrapper (the + <!-- Hover/keyboard-highlight backgrounds live on the wrapper (the highlight via :has(), since melt puts data-highlighted on the item) so they span the full row, chevron included; the chevron keeps only its own hover tint. The active workspace is marked by its row's check icon, not a background or text accent. --> - <div - data-workspace-id={workspace.id} - class={twMerge( - 'group flex items-center min-w-0 w-full', - workspace.disabled - ? '' - : 'hover:bg-surface-hover [&:has([data-highlighted])]:bg-surface-hover' - )} - > - <MenuItem + <div + data-workspace-id={workspace.id} + data-nav-id={showSearch && !workspace.disabled ? workspace.id : undefined} class={twMerge( - 'text-xs min-w-0 flex-1 overflow-hidden flex flex-col py-1.5 px-3', - workspace.disabled && 'opacity-50 cursor-not-allowed', - isActive ? 'cursor-default' : workspace.disabled ? '' : 'cursor-pointer' + 'group flex items-center min-w-0 w-full', + workspace.disabled + ? '' + : showSearch + ? highlightedId === workspace.id && 'bg-surface-hover' + : 'hover:bg-surface-hover [&:has([data-highlighted])]:bg-surface-hover' )} - href={workspace.disabled ? undefined : workspaceHref(workspace.id)} - onClick={(e) => onWorkspaceItemClick(e, workspace)} - {item} > - <div class="flex items-center justify-between gap-2 min-w-0 w-full"> - <div class="flex items-center gap-2 min-w-0" style:padding-left={`${depth * 16}px`}> - <WorkspaceIcon - workspaceColor={workspace.color} - {isForked} - isDevWorkspace={workspace.is_dev_workspace} - devWorkspaceLabel={workspace.dev_workspace_label} - {parentName} - padding="p-1" - /> - <div class="min-w-0 flex-1"> - <div class="flex items-center gap-1 min-w-0"> - <div - class="truncate text-left text-xs font-normal {forkAccent - ? 'text-[color:var(--fork-accent-text)] dark:text-[color:var(--fork-accent-text-dark)]' - : 'text-primary'}" - style={forkAccent} - title={workspace.name} - > - {workspace.name}{workspace.disabled ? ' (user disabled)' : ''} + <MenuItem + class={twMerge( + 'text-xs min-w-0 flex-1 overflow-hidden flex flex-col py-1.5 px-3 focus:outline-none', + workspace.disabled && 'opacity-50 cursor-not-allowed', + isActive ? 'cursor-default' : workspace.disabled ? '' : 'cursor-pointer' + )} + href={workspace.disabled ? undefined : workspaceHref(workspace.id)} + onClick={(e) => onWorkspaceItemClick(e, workspace)} + {item} + > + <div class="flex items-center justify-between gap-2 min-w-0 w-full"> + <div class="flex items-center gap-2 min-w-0" style:padding-left={`${depth * 16}px`}> + <WorkspaceIcon + workspaceColor={workspace.color} + {isForked} + isDevWorkspace={workspace.is_dev_workspace} + devWorkspaceLabel={workspace.dev_workspace_label} + {parentName} + padding="p-1" + /> + <div class="min-w-0 flex-1"> + <div class="flex items-center gap-1 min-w-0"> + <div + class="truncate text-left text-xs font-normal {forkAccent + ? 'text-[color:var(--fork-accent-text)] dark:text-[color:var(--fork-accent-text-dark)]' + : 'text-primary'}" + style={forkAccent} + title={workspace.name} + > + {#if markedNames.get(workspace.id)} + <!-- SearchItems escapes the text; only its <mark> tags are markup. --> + {@html markedNames.get(workspace.id)} + {:else} + {workspace.name} + {/if}{workspace.disabled ? ' (user disabled)' : ''} + </div> + {#if workspace.is_dev_workspace} + <DevWorkspaceBadge + label={workspace.dev_workspace_label} + color={workspace.color} + fallbackClass="dark:bg-surface-accent-primary text-white dark:text-white" + class="text-3xs px-1 py-0" + /> + {/if} </div> - {#if workspace.is_dev_workspace} - <DevWorkspaceBadge - label={workspace.dev_workspace_label} - color={workspace.color} - fallbackClass="dark:bg-surface-accent-primary text-white dark:text-white" - class="text-3xs px-1 py-0" - /> + {#if ambiguousNames.has(workspace.name) && !markedNames.has(workspace.id)} + <div class="truncate text-left text-2xs text-tertiary" title={workspace.id}> + {workspace.id} + </div> {/if} </div> - {#if ambiguousNames.has(workspace.name)} - <div class="truncate text-left text-2xs text-tertiary" title={workspace.id}> - {workspace.id} - </div> - {/if} </div> + {#if isSelected} + <Check size={14} class="shrink-0 ml-2 text-accent" /> + {/if} </div> - {#if isSelected} - <Check size={14} class="shrink-0 ml-2 text-accent" /> - {/if} - </div> - </MenuItem> - <!-- Hover-revealed like the fork picker's rows; a flex sibling of the melt + </MenuItem> + <!-- Hover-revealed like the fork picker's rows; a flex sibling of the melt item (nested buttons are invalid HTML) that always occupies its slot so nothing shifts on hover. --> - <div - class="shrink-0 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity" - > - <CopyButton value={workspace.id} title={`Copy id: ${workspace.id}`} /> - </div> - {#if expandable} - {@const expanded = expandedFamilies.has(workspace.id)} - <button - type="button" - title={expanded ? 'Hide forks' : 'Show forks'} - aria-expanded={expanded} - class="shrink-0 mr-1 px-2 self-stretch flex items-center rounded text-tertiary hover:bg-surface-hover hover:text-primary" - onclick={() => { - if (expanded) expandedFamilies.delete(workspace.id) - else expandedFamilies.add(workspace.id) - }} + <div + class="shrink-0 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity" > - {#if expanded} - <ChevronDown size={14} /> - {:else} - <ChevronRight size={14} /> - {/if} - </button> - {:else} - <!-- Reserve the expand-chevron's slot on fork-less rows so the hover copy + <CopyButton value={workspace.id} title={`Copy id: ${workspace.id}`} /> + </div> + {#if expandable} + {@const expanded = isExpanded(workspace.id)} + <!-- mousedown is cancelled so the click leaves focus in the search field. --> + <button + type="button" + title={expanded ? 'Hide forks' : 'Show forks'} + aria-expanded={expanded} + class="shrink-0 mr-1 px-2 self-stretch flex items-center rounded text-tertiary hover:bg-surface-hover hover:text-primary" + onmousedown={(e) => showSearch && e.preventDefault()} + onclick={() => toggleFamily(workspace.id)} + > + {#if expanded} + <ChevronDown size={14} /> + {:else} + <ChevronRight size={14} /> + {/if} + </button> + {:else} + <!-- Reserve the expand-chevron's slot on fork-less rows so the hover copy button lines up with the forked rows' and never sits flush against the menu's right edge. Matches the chevron's px-2 (30px) + mr-1. --> - <div class="shrink-0 mr-1 w-[30px]" aria-hidden="true"></div> + <div class="shrink-0 mr-1 w-[30px]" aria-hidden="true"></div> + {/if} + </div> + {/each} + </div> + {#if (isCloudHosted() || $superadmin || canForkHere) && !strictWorkspaceSelect} + <div class="py-1" role="none"> + {#if isCloudHosted() || $superadmin} + <div role="none" data-nav-id={actionNavId('create')}> + <MenuItem href="/user/create_workspace" class={actionClass('create')} {item}> + <Plus size={16} /> + Workspace + </MenuItem> + </div> + {/if} + {#if canForkHere} + <div role="none" data-nav-id={actionNavId('fork')}> + <MenuItem + class={actionClass('fork')} + onClick={() => (globalForkModal.val = { opened: true })} + {item} + > + <Plus size={16} /> + Workspace fork + </MenuItem> + </div> {/if} </div> - {/each} + {/if} + {#if canManageWorkspace && !strictWorkspaceSelect} + <div class="py-1" role="none" data-nav-id={actionNavId('settings')}> + <MenuItem + href="/workspace_settings?workspace={$workspaceStore}" + class={actionClass('settings')} + {item} + > + <Settings size={16} /> + {(activeWorkspace && ambiguousNames.has(activeWorkspace.name) + ? activeWorkspace.id + : activeWorkspace?.name) ?? $workspaceStore} settings + </MenuItem> + </div> + {/if} + {#if !strictWorkspaceSelect} + <div class="py-1" role="none" data-nav-id={actionNavId('all')}> + <MenuItem + href="/user/workspaces" + onClick={() => clearWorkspaceFromStorage()} + class={actionClass('all')} + {item} + > + All workspaces + </MenuItem> + </div> + {/if} </div> - {#if (isCloudHosted() || $superadmin || canForkHere) && !strictWorkspaceSelect} + {#if isCloudHosted() && $isPremiumStore === false && !strictWorkspaceSelect} <div class="py-1" role="none"> - {#if isCloudHosted() || $superadmin} - <MenuItem href="/user/create_workspace" class={itemClass} {item}> - <Plus size={16} /> - Workspace - </MenuItem> + {#if $workspaceStore != 'demo'} + <span class="text-secondary block w-full text-left px-4 py-2 text-xs"> + {$workspaceUsageStore ?? '—'}/1000 free workspace execs + <Tooltip small> + {#snippet text()} + {EXECUTIONS_HINT} + {/snippet} + </Tooltip> + </span> + <div class="w-full bg-gray-200 h-1"> + <div + class="bg-blue-400 h-1" + style="width: {Math.min($workspaceUsageStore ?? 0, 1000) / 10}%" + ></div> + </div> {/if} - {#if canForkHere} - <MenuItem class={itemClass} onClick={() => (globalForkModal.val = { opened: true })} {item}> - <Plus size={16} /> - Workspace fork - </MenuItem> + {#if $userStore?.is_admin} + <div role="none" data-nav-id={actionNavId('upgrade')}> + <MenuItem + class={twMerge( + 'text-secondary block font-normal w-full text-left px-4 py-2 text-sm focus:outline-none hover:bg-gray-100 hover:text-gray-900', + 'data-[highlighted]:bg-gray-100 data-[highlighted]:text-gray-900', + highlightedId === ACTION_PREFIX + 'upgrade' && 'bg-gray-100 text-gray-900' + )} + onClick={() => { + goto('/workspace_settings?tab=premium') + }} + {item} + > + Upgrade + </MenuItem> + </div> {/if} </div> {/if} - {#if canManageWorkspace && !strictWorkspaceSelect} - <div class="py-1" role="none"> - <MenuItem - href="/workspace_settings?workspace={$workspaceStore}" - class={itemClass} - {item} - > - <Settings size={16} /> - {(activeWorkspace && ambiguousNames.has(activeWorkspace.name) - ? activeWorkspace.id - : activeWorkspace?.name) ?? $workspaceStore} settings - </MenuItem> - </div> - {/if} - {#if !strictWorkspaceSelect} - <div class="py-1" role="none"> - <MenuItem - href="/user/workspaces" - onClick={() => clearWorkspaceFromStorage()} - class={itemClass} - {item} - > - All workspaces - </MenuItem> - </div> - {/if} </div> -{#if isCloudHosted() && $isPremiumStore === false && !strictWorkspaceSelect} - <div class="py-1" role="none"> - {#if $workspaceStore != 'demo'} - <span class="text-secondary block w-full text-left px-4 py-2 text-xs"> - {$workspaceUsageStore ?? '—'}/1000 free workspace execs - <Tooltip small> - {#snippet text()} - {EXECUTIONS_HINT} - {/snippet} - </Tooltip> - </span> - <div class="w-full bg-gray-200 h-1"> - <div - class="bg-blue-400 h-1" - style="width: {Math.min($workspaceUsageStore ?? 0, 1000) / 10}%" - ></div> - </div> - {/if} - {#if $userStore?.is_admin} - <MenuItem - class={twMerge( - 'text-secondary block font-normal w-full text-left px-4 py-2 text-sm hover:bg-gray-100 hover:text-gray-900', - 'data-[highlighted]:bg-gray-100 data-[highlighted]:text-gray-900' - )} - onClick={() => { - goto('/workspace_settings?tab=premium') - }} - {item} - > - Upgrade - </MenuItem> - {/if} - </div> -{/if} {#if $enterpriseLicense && !strictWorkspaceSelect} <MultiplayerMenu /> {/if} diff --git a/frontend/src/lib/components/sidebar/navDetached.svelte.ts b/frontend/src/lib/components/sidebar/navDetached.svelte.ts index 19568eaddc..e53f4e7cbb 100644 --- a/frontend/src/lib/components/sidebar/navDetached.svelte.ts +++ b/frontend/src/lib/components/sidebar/navDetached.svelte.ts @@ -1,5 +1,5 @@ import { fromStore } from 'svelte/store' -import { userStore } from '$lib/stores' +import { roleCapabilities } from '$lib/stores' import { useLocalStorageValue } from '$lib/svelte5Utils.svelte' // One preference per role rather than one per account. The same person is both — an operator in @@ -8,21 +8,21 @@ import { useLocalStorageValue } from '$lib/svelte5Utils.svelte' // Empty until that role presses detach or attach once. const operatorPref = useLocalStorageValue<string>('nav_detached_operator', '', 'string') const devPref = useLocalStorageValue<string>('nav_detached_dev', '', 'string') -const user = fromStore(userStore) +const caps = fromStore(roleCapabilities) /** Whether the sidebar is detached: hidden behind a floating button that slides it in over the page. */ export const navDetached = { get val(): boolean { // Unasked, operators start detached: many of them navigate through a workspace's own app, // and a docked sidebar would take its width. - const operator = !!user.current?.operator + const operator = !caps.current.code const pref = operator ? operatorPref : devPref if (pref.val === 'true') return true if (pref.val === 'false') return false return operator }, set val(detached: boolean) { - const pref = user.current?.operator ? operatorPref : devPref + const pref = caps.current.code ? devPref : operatorPref pref.val = String(detached) } } diff --git a/frontend/src/lib/components/sidebar/navPages.ts b/frontend/src/lib/components/sidebar/navPages.ts index 385b93abcc..2461ba6e1a 100644 --- a/frontend/src/lib/components/sidebar/navPages.ts +++ b/frontend/src/lib/components/sidebar/navPages.ts @@ -11,7 +11,7 @@ import { Unplug, Users } from 'lucide-svelte' -import type { OperatorPageKey } from './operatorRoutes' +import type { VisibilityKey } from '$lib/roleCapabilities' /** * The names the page header's breadcrumb gives the workspace's own pages, and the rows the @@ -20,7 +20,7 @@ import type { OperatorPageKey } from './operatorRoutes' * Every entry names a page for the breadcrumb, and its `icon` goes there too. Only the entries * `SidebarContent`'s `MAIN_MENU_EXTRAS` also lists become sidebar links from here — Schedules, * HTTP routes, Workers and Audit logs are grouped differently there and carry their own row, so - * for those four the `operatorKey` below goes unread. Keep the labels in step with those rows by + * for those four the `visibilityKey` below goes unread. Keep the labels in step with those rows by * hand. */ export type NavPage = { @@ -28,23 +28,23 @@ export type NavPage = { /** Path under `base`, matched against the current route by longest prefix. */ path: string icon: any - /** The `operator_settings` key that admits an operator, when the page has one. */ - operatorKey?: OperatorPageKey + /** The role visibility flag that shows the page, when the page has one. */ + visibilityKey?: VisibilityKey } export const NAV_PAGES: NavPage[] = [ { label: 'Home', path: '/', icon: Home }, - { label: 'Runs', path: '/runs', icon: Play, operatorKey: 'runs' }, - { label: 'Variables', path: '/variables', icon: DollarSign, operatorKey: 'variables' }, - { label: 'Resources', path: '/resources', icon: Boxes, operatorKey: 'resources' }, - { label: 'Assets', path: '/assets', icon: Pyramid, operatorKey: 'assets' }, - { label: 'Folders', path: '/folders', icon: FolderOpen, operatorKey: 'folders' }, - { label: 'Groups', path: '/groups', icon: Users, operatorKey: 'groups' }, - { label: 'Schedules', path: '/schedules', icon: Calendar, operatorKey: 'schedules' }, + { label: 'Runs', path: '/runs', icon: Play, visibilityKey: 'runs' }, + { label: 'Variables', path: '/variables', icon: DollarSign, visibilityKey: 'variables' }, + { label: 'Resources', path: '/resources', icon: Boxes, visibilityKey: 'resources' }, + { label: 'Assets', path: '/assets', icon: Pyramid, visibilityKey: 'assets' }, + { label: 'Folders', path: '/folders', icon: FolderOpen, visibilityKey: 'folders' }, + { label: 'Groups', path: '/groups', icon: Users, visibilityKey: 'groups' }, + { label: 'Schedules', path: '/schedules', icon: Calendar, visibilityKey: 'schedules' }, // "Triggers" is the group the sidebar files this under, not the page: the row there reads HTTP. - { label: 'HTTP routes', path: '/routes', icon: Unplug, operatorKey: 'triggers' }, - { label: 'Workers', path: '/workers', icon: HardHat, operatorKey: 'workers' }, - { label: 'Audit logs', path: '/audit_logs', icon: Eye, operatorKey: 'audit_logs' } + { label: 'HTTP routes', path: '/routes', icon: Unplug, visibilityKey: 'triggers' }, + { label: 'Workers', path: '/workers', icon: HardHat, visibilityKey: 'workers' }, + { label: 'Audit logs', path: '/audit_logs', icon: Eye, visibilityKey: 'audit_logs' } ] /** diff --git a/frontend/src/lib/components/sidebar/operatorRoutes.test.ts b/frontend/src/lib/components/sidebar/operatorRoutes.test.ts deleted file mode 100644 index bf14926d00..0000000000 --- a/frontend/src/lib/components/sidebar/operatorRoutes.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { sidebarPageAllowed } from './operatorRoutes' -import type { UserWorkspace } from '$lib/stores' - -const ws = (operator_settings: any): UserWorkspace => - ({ id: 'w', name: 'w', username: 'u', operator_settings }) as UserWorkspace - -describe('sidebarPageAllowed', () => { - it('shows every page to anyone who is not an operator', () => { - expect(sidebarPageAllowed(false, ws(null), 'runs')).toBe(true) - expect(sidebarPageAllowed(undefined, undefined, 'runs')).toBe(true) - }) - - it('admits an operator only where the workspace turned the page on', () => { - expect(sidebarPageAllowed(true, ws({ runs: true }), 'runs')).toBe(true) - expect(sidebarPageAllowed(true, ws({ runs: false }), 'runs')).toBe(false) - }) - - it('withholds a page the settings do not mention', () => { - // `=== true`, not truthiness: a right absent from the jsonb, or settings that were never - // written, must not read as granted. - expect(sidebarPageAllowed(true, ws({ variables: true }), 'runs')).toBe(false) - expect(sidebarPageAllowed(true, ws(null), 'runs')).toBe(false) - expect(sidebarPageAllowed(true, undefined, 'runs')).toBe(false) - }) -}) diff --git a/frontend/src/lib/components/sidebar/operatorRoutes.ts b/frontend/src/lib/components/sidebar/operatorRoutes.ts deleted file mode 100644 index 6824c36c62..0000000000 --- a/frontend/src/lib/components/sidebar/operatorRoutes.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { OperatorSettings } from '$lib/gen' -import type { UserWorkspace } from '$lib/stores' - -/** - * An `operator_settings` key that admits an operator to one page (`triggers` to all of them). - * - * Only the visibility flags: the same object also carries the write rights `builder_flows`, - * `manage_schedules` and `manage_triggers`, which the `=== true` rule below answers wrongly. A - * write right is granted unless withdrawn, so its absence means "never configured" rather than - * "no" — see `docs/operator-write-rights.md`. - */ -export type OperatorPageKey = Extract< - keyof NonNullable<OperatorSettings>, - | 'runs' - | 'schedules' - | 'resources' - | 'variables' - | 'assets' - | 'audit_logs' - | 'triggers' - | 'groups' - | 'folders' - | 'workers' -> - -/** - * Whether the sidebar shows the page behind `key`. Everyone but an operator sees every page - * (other gates, such as admin-only entries, still apply); an operator sees only the pages the - * workspace's `operator_settings` turn on. - */ -export function sidebarPageAllowed( - isOperator: boolean | undefined, - workspace: UserWorkspace | undefined, - key: OperatorPageKey -): boolean { - return !isOperator || workspace?.operator_settings?.[key] === true -} - -/** - * Whether the user is an operator in `workspace`. The server nulls - * `operator_settings` for a non-operator, so a non-null value narrows an operator - * correctly; the converse does not hold — the column is itself nullable, so a - * genuine operator whose workspace never had settings written also reads as NULL. - * Only rely on `true`, never on `false` meaning "not an operator". - */ -export function isOperatorInWorkspace(workspace: UserWorkspace | undefined): boolean { - return workspace?.operator_settings != null -} diff --git a/frontend/src/lib/components/triggers/TriggerList.svelte b/frontend/src/lib/components/triggers/TriggerList.svelte index 1ccbd21d0e..3931d9660c 100644 --- a/frontend/src/lib/components/triggers/TriggerList.svelte +++ b/frontend/src/lib/components/triggers/TriggerList.svelte @@ -22,7 +22,7 @@ } from '$lib/utils' import { withForkConflictRetry } from '$lib/utils/forkConflict' import { getLocalDraftHint } from '$lib/localDraftHints.svelte' - import { enterpriseLicense, usedTriggerKinds, userWorkspaces, workspaceStore } from '$lib/stores' + import { enterpriseLicense, usedTriggerKinds, workspaceStore } from '$lib/stores' import { goto, setQuery } from '$lib/navigation' import { isCloudHosted } from '$lib/cloud' import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable' @@ -519,10 +519,7 @@ } const operatorBlocked = $derived( - config.operatorGate && - !!operatingUser.current?.operator && - !!$operatingWorkspace && - !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.triggers + config.operatorGate && !!$operatingWorkspace && !editRights.capabilities.visibility.triggers ) const canCreate = $derived(!config.adminOnly || isAdmin) </script> @@ -883,7 +880,7 @@ {#if operatorBlocked} <div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert"> <p class="font-bold">Unauthorized</p> - <p>Page not available for operators</p> + <p>Page not available for your role</p> </div> {:else} <CenteredPage> diff --git a/frontend/src/lib/components/triggers/TriggerRunnablePicker.svelte b/frontend/src/lib/components/triggers/TriggerRunnablePicker.svelte index c6b0986226..9a954b211e 100644 --- a/frontend/src/lib/components/triggers/TriggerRunnablePicker.svelte +++ b/frontend/src/lib/components/triggers/TriggerRunnablePicker.svelte @@ -15,7 +15,8 @@ scriptPath: string initialScriptPath: string canWrite: boolean - isOperator: boolean + /** Whether to link to the picked runnable's editor: the role must author its kind. */ + allowEdit: boolean promptText?: string promptClass?: string workspace?: string @@ -30,7 +31,7 @@ scriptPath = $bindable(), initialScriptPath, canWrite, - isOperator, + allowEdit, promptText = 'Pick a script or flow to be triggered', promptClass = 'text-xs mb-1 text-primary', workspace = undefined, @@ -53,7 +54,7 @@ bind:itemKind bind:scriptPath allowRefresh={canWrite} - allowEdit={!isOperator} + {allowEdit} {workspace} clearable /> diff --git a/frontend/src/lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte index 7295ca0c35..c0f4803339 100644 --- a/frontend/src/lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte @@ -91,7 +91,6 @@ }: Props = $props() const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() - const actingUser = $derived(operatingUser.current) const wsId = $derived($operatingWorkspace) let amqp_resource_path: string = $state('') @@ -586,7 +585,7 @@ bind:scriptPath={script_path} {initialScriptPath} canWrite={can_write} - isOperator={!!actingUser?.operator} + allowEdit={editRights.roleCanAuthor(itemKind)} /> </Section> {/if} diff --git a/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte index e0c2008be8..6b52a05428 100644 --- a/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/azure/AzureTriggerEditorInner.svelte @@ -54,7 +54,6 @@ let mode = $state<TriggerMode>('enabled') let dirtyPath = $state(false) const operatingUser = useOperatingUser() - const actingUser = $derived(operatingUser.current) let permsPath = $state<string | undefined>(undefined) let permsForWrite = $state<Record<string, boolean> | undefined>(undefined) // The acting user in the operating workspace arrives asynchronously, and an unknown user @@ -526,7 +525,7 @@ bind:itemKind bind:scriptPath={script_path} allowRefresh={can_write} - allowEdit={!actingUser?.operator} + allowEdit={editRights.roleCanAuthor(itemKind)} clearable /> {#if emptyString(script_path)} diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte index 38a8243855..9de47ba600 100644 --- a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte @@ -455,7 +455,7 @@ bind:scriptPath={script_path} {initialScriptPath} canWrite={can_write} - isOperator={!!actingUser?.operator} + allowEdit={editRights.roleCanAuthor(itemKind)} promptClass="text-xs mt-3 mb-1 text-primary" > {#snippet createButton()} diff --git a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte index 486711bd6c..04052a54d6 100644 --- a/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/gcp/GcpTriggerEditorInner.svelte @@ -63,7 +63,6 @@ let mode = $state<TriggerMode>('enabled') let dirtyPath = $state(false) const operatingUser = useOperatingUser() - const actingUser = $derived(operatingUser.current) let permsPath = $state<string | undefined>(undefined) let permsForWrite = $state<Record<string, boolean> | undefined>(undefined) // The acting user in the operating workspace arrives asynchronously, and an unknown user @@ -599,7 +598,7 @@ bind:scriptPath={script_path} {initialScriptPath} canWrite={can_write} - isOperator={!!actingUser?.operator} + allowEdit={editRights.roleCanAuthor(itemKind)} promptText="Pick a script or flow to be triggered " > {#snippet createButton()} diff --git a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte index 786137eab4..fe94787167 100644 --- a/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte +++ b/frontend/src/lib/components/triggers/http/RouteEditorInner.svelte @@ -778,7 +778,7 @@ bind:itemKind bind:scriptPath={script_path} allowRefresh={can_write} - allowEdit={!actingUser?.operator} + allowEdit={editRights.roleCanAuthor(itemKind)} clearable /> diff --git a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte index 17a17f98e8..80be6049d3 100644 --- a/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte @@ -88,7 +88,6 @@ }: Props = $props() const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() - const actingUser = $derived(operatingUser.current) const operatingHref = useOperatingWorkspaceHref() const wsId = $derived($operatingWorkspace) @@ -609,7 +608,7 @@ bind:scriptPath={script_path} {initialScriptPath} canWrite={can_write} - isOperator={!!actingUser?.operator} + allowEdit={editRights.roleCanAuthor(itemKind)} > {#snippet createButton()} {#if emptyString(script_path)} diff --git a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte index 5c1fb4aaf0..d58abebe3b 100644 --- a/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte @@ -96,7 +96,6 @@ }: Props = $props() const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() - const actingUser = $derived(operatingUser.current) const operatingHref = useOperatingWorkspaceHref() const wsId = $derived($operatingWorkspace) @@ -582,7 +581,7 @@ bind:scriptPath={script_path} {initialScriptPath} canWrite={can_write} - isOperator={!!actingUser?.operator} + allowEdit={editRights.roleCanAuthor(itemKind)} > {#snippet createButton()} {#if emptyString(script_path)} diff --git a/frontend/src/lib/components/triggers/native/NativeConnectionPicker.svelte b/frontend/src/lib/components/triggers/native/NativeConnectionPicker.svelte new file mode 100644 index 0000000000..ad7d0badb2 --- /dev/null +++ b/frontend/src/lib/components/triggers/native/NativeConnectionPicker.svelte @@ -0,0 +1,245 @@ +<script lang="ts"> + import { WorkspaceIntegrationService } from '$lib/gen' + import type { NativeServiceName, NativeTriggerConnection } from '$lib/gen/types.gen' + import { Button } from '$lib/components/common' + import Alert from '$lib/components/common/alert/Alert.svelte' + import Select from '$lib/components/select/Select.svelte' + import AppConnectDrawer from '$lib/components/AppConnectDrawer.svelte' + import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' + import { createAsyncConfirmationModal } from '$lib/components/common/confirmationModal/asyncConfirmationModal.svelte' + import { sendUserToast } from '$lib/toast' + import { Plus, RefreshCw, Unplug } from 'lucide-svelte' + import { userStore } from '$lib/stores' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { connectNativeAccount, getServiceConfig } from './utils' + + interface Props { + service: NativeServiceName + value: string | undefined + /** An existing trigger keeps the connection its webhook was registered under. */ + locked?: boolean + disabled?: boolean + /** Select the first connection when none is chosen yet. */ + autoSelect?: boolean + clearable?: boolean + /** Suffix the workspace connection's label, where it sits among personal ones. */ + labelWorkspace?: boolean + placeholder?: string + /** Called instead of setting `value` directly when the user picks or clears a connection. */ + onSelect?: (path: string | undefined) => void + /** Called after an account is connected or disconnected from the picker. */ + onConnectionsChange?: () => void + } + + let { + service, + value = $bindable(), + locked = false, + disabled = false, + autoSelect = true, + clearable = false, + labelWorkspace = true, + placeholder, + onSelect, + onConnectionsChange + }: Props = $props() + + const operatingWorkspace = useOperatingWorkspace() + const displayName = $derived(getServiceConfig(service)?.serviceDisplayName ?? service) + const confirmationModal = createAsyncConfirmationModal() + + let connections = $state<NativeTriggerConnection[] | undefined>(undefined) + let connecting = $state(false) + let connectDrawer: AppConnectDrawer | undefined = $state(undefined) + + async function load() { + try { + connections = await WorkspaceIntegrationService.listNativeTriggerConnections({ + workspace: $operatingWorkspace!, + serviceName: service + }) + } catch (err: any) { + connections = [] + sendUserToast(`Failed to load ${displayName} connections: ${err.body ?? err.message}`, true) + } + if (autoSelect && !locked && value === undefined && connections.length > 0) { + select(connections[0].path) + } + } + + $effect(() => { + service + $operatingWorkspace + load() + }) + + function select(path: string | undefined) { + if (onSelect) onSelect(path) + else value = path + } + + async function connect(path: string) { + connecting = true + try { + path = await connectNativeAccount($operatingWorkspace!, service, path) + await load() + sendUserToast(`${displayName} account connected`) + onConnectionsChange?.() + select(path) + } catch (err: any) { + sendUserToast(`Could not connect ${displayName}: ${err.body ?? err.message}`, true) + throw err + } finally { + connecting = false + } + } + + async function reconnect(c: NativeTriggerConnection) { + if (c.owner && c.owner !== $userStore?.username) { + const confirmed = await confirmationModal.ask({ + title: `Reconnect ${c.path} with your account?`, + confirmationText: 'Reconnect', + children: `${c.path} will act as the ${displayName} account you sign in with, instead of ${c.owner}'s. Every trigger using it will act as you.` + }) + if (!confirmed) return + } + await connect(c.path).catch(() => {}) + } + + async function disconnect(path: string) { + const confirmed = await confirmationModal.ask({ + title: `Disconnect ${path}?`, + confirmationText: 'Disconnect', + children: `This deletes every ${displayName} trigger that uses this account, including ones other members created. This action cannot be undone.` + }) + if (!confirmed) return + try { + await WorkspaceIntegrationService.deleteNativeTriggerConnection({ + workspace: $operatingWorkspace!, + serviceName: service, + path + }) + sendUserToast(`${path} disconnected`) + if (value === path) select(undefined) + await load() + onConnectionsChange?.() + } catch (err: any) { + sendUserToast(`Failed to disconnect ${path}: ${err.body ?? err.message}`, true) + } + } + + // Members cannot write the workspace connection; the server refuses anyone else it does not own. + function canDisconnect(c: NativeTriggerConnection): boolean { + if ($userStore?.is_admin) return true + return !c.workspace && !!c.owner && c.owner === $userStore?.username + } + + // The server lets only the connection's owner or an admin replace it. + function canReconnect(c: NativeTriggerConnection): boolean { + return !!$userStore?.is_admin || (!!c.owner && c.owner === $userStore?.username) + } + + const items = $derived.by(() => { + const list = (connections ?? []).map((c) => ({ + label: c.workspace && labelWorkspace ? `${c.path} (shared account)` : c.path, + value: c.path + })) + if (value && !list.some((i) => i.value === value)) { + list.push({ label: value, value }) + } + return list + }) + const unusable = $derived( + connections !== undefined && !!value && !connections.some((c) => c.path === value) + ) +</script> + +<div class="flex flex-col gap-2"> + <Select + {items} + bind:value={() => value, (v) => select(v)} + onClear={() => select(undefined)} + {clearable} + disabled={disabled || locked} + loading={connections === undefined || connecting} + placeholder={placeholder ?? `Connect a ${displayName} account to create this trigger`} + noItemsMsg="No {displayName} account connected yet" + > + {#snippet endSnippet({ item, close })} + {@const connection = connections?.find((c) => c.path === item.value)} + {#if connection && canReconnect(connection)} + <Button + unifiedSize="xs" + variant="subtle" + startIcon={{ icon: RefreshCw }} + disabled={connecting} + onclick={() => (close(), reconnect(connection))} + > + Reconnect + </Button> + {/if} + {#if connection && canDisconnect(connection)} + <Button + unifiedSize="xs" + variant="subtle" + destructive + startIcon={{ icon: Unplug }} + onclick={() => (close(), disconnect(connection.path))} + > + Disconnect + </Button> + {/if} + {/snippet} + {#snippet bottomSnippet({ close })} + <div class="flex bg-surface border-t"> + <Button + unifiedSize="md" + variant="subtle" + wrapperClasses="flex-1" + btnClasses="rounded-none" + startIcon={{ icon: Plus }} + disabled={disabled || connecting} + onclick={() => (close(), connectDrawer?.open(getServiceConfig(service)?.resourceType))} + > + Connect a {displayName} account + </Button> + </div> + {/snippet} + </Select> + {#if locked} + {@const current = connections?.find((c) => c.path === value)} + <div class="flex items-center justify-between gap-2"> + <p class="text-2xs text-secondary"> + The webhook is registered under this account. To use another one, create a new trigger. + </p> + {#if current && canReconnect(current)} + <Button + unifiedSize="xs" + variant="subtle" + startIcon={{ icon: RefreshCw }} + disabled={connecting} + onclick={() => reconnect(current)} + > + Reconnect + </Button> + {/if} + </div> + {/if} + {#if unusable} + <Alert type="warning" title="You cannot use this connection" size="xs"> + Saving acts as the account at {value}. Ask its owner for read access to it, or create a new + trigger with your own account. + </Alert> + {/if} +</div> + +<AppConnectDrawer + bind:this={connectDrawer} + native={{ + title: `Connect a ${displayName} account`, + defaultName: `native_${service}`, + connect + }} +/> + +<ConfirmationModal {...confirmationModal.props} /> diff --git a/frontend/src/lib/components/triggers/native/NativeOAuthCallback.svelte b/frontend/src/lib/components/triggers/native/NativeOAuthCallback.svelte new file mode 100644 index 0000000000..18c9428d76 --- /dev/null +++ b/frontend/src/lib/components/triggers/native/NativeOAuthCallback.svelte @@ -0,0 +1,69 @@ +<script lang="ts"> + import { onMount } from 'svelte' + import { page } from '$app/state' + import { WorkspaceIntegrationService } from '$lib/gen' + import type { NativeServiceName } from '$lib/gen/types.gen' + import Alert from '$lib/components/common/alert/Alert.svelte' + import { Loader2 } from 'lucide-svelte' + import { postNativeOAuthResult } from './utils' + + interface Props { + /** The exact redirect URI the authorization request was made with. */ + redirectUri: string + } + + let { redirectUri }: Props = $props() + + let outcome = $state<{ ok: true; path: string } | { ok: false; error: string } | undefined>() + + // The signed state carries the workspace and service it was issued for, so this page works + // whatever workspace is selected and wherever the provider sends the user back. + function decodeState(state: string): { workspace: string; service: NativeServiceName } { + const payload = atob(state.split(':')[0].replace(/-/g, '+').replace(/_/g, '/')) + const [workspace, service] = payload.split(':') + return { workspace, service: service as NativeServiceName } + } + + async function finish(): Promise<{ ok: true; path: string } | { ok: false; error: string }> { + const error = page.url.searchParams.get('error') + const code = page.url.searchParams.get('code') + const state = page.url.searchParams.get('state') + if (error || !code || !state) { + return { ok: false, error: error ?? 'The provider returned no authorization code' } + } + try { + const { workspace, service } = decodeState(state) + const path = await WorkspaceIntegrationService.nativeTriggerServiceCallback({ + workspace, + serviceName: service, + requestBody: { code, state, redirect_uri: redirectUri } + }) + return { ok: true, path } + } catch (err: any) { + return { ok: false, error: err.body ?? err.message ?? String(err) } + } + } + + onMount(async () => { + outcome = await finish() + postNativeOAuthResult(outcome) + if (window.opener) { + window.close() + } + }) +</script> + +<div class="max-w-lg mx-auto mt-16 px-4"> + {#if outcome === undefined} + <div class="flex items-center gap-2 text-sm text-secondary"> + <Loader2 class="animate-spin" size={16} /> + Finishing the connection... + </div> + {:else if outcome.ok} + <Alert type="success" title="Account connected"> + Saved as {outcome.path}. You can close this window. + </Alert> + {:else} + <Alert type="error" title="Could not connect the account">{outcome.error}</Alert> + {/if} +</div> diff --git a/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte b/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte index 9e8dc592e9..bf8fe0f7d1 100644 --- a/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte +++ b/frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte @@ -6,8 +6,10 @@ validateCommonFields, getServiceConfig, getTemplatePath, - saveNativeTriggerFromCfg + saveNativeTriggerFromCfg, + setNativeConnectionContext } from './utils' + import NativeConnectionPicker from './NativeConnectionPicker.svelte' import { usedTriggerKinds } from '$lib/stores' import { emptyString, sendUserToast } from '$lib/utils' import { useEditRights, useTriggerLock } from '$lib/operatorWriteRights' @@ -28,15 +30,10 @@ import { deepEqual } from 'fast-equals' import type { Snippet } from 'svelte' import Alert from '$lib/components/common/alert/Alert.svelte' - import { - useOperatingUser, - useOperatingWorkspace - } from '$lib/components/operatingWorkspace.svelte' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const triggerLock = useTriggerLock() const operatingWorkspace = useOperatingWorkspace() - const operatingUser = useOperatingUser() - const actingUser = $derived(operatingUser.current) const editRights = useEditRights() interface Props { @@ -130,6 +127,8 @@ let externalError = $state<string | undefined>(undefined) let retryEdit = $state<(() => void) | undefined>(undefined) let enabled = $state(true) + let connectionPath = $state<string | undefined>(undefined) + setNativeConnectionContext(() => connectionPath) export function openNew( nis_flow?: boolean, @@ -161,6 +160,7 @@ externalError = undefined retryEdit = undefined enabled = true + connectionPath = undefined } export function openRecreate(nativeTrigger: ExtendedNativeTrigger) { @@ -190,6 +190,7 @@ externalError = undefined retryEdit = undefined enabled = nativeTrigger.enabled + connectionPath = nativeTrigger.connection_path ?? undefined } export async function openEdit( @@ -224,6 +225,7 @@ initialScriptPath = '' summary = '' enabled = true + connectionPath = undefined try { const fullTrigger = await NativeTriggerService.getNativeTrigger({ @@ -241,6 +243,7 @@ externalData = fullTrigger.external_data externalError = fullTrigger.external_error ?? undefined enabled = fullTrigger.enabled + connectionPath = fullTrigger.connection_path ?? undefined // Apply default values if provided (for draft triggers) if (defaultValues) { @@ -268,7 +271,8 @@ script_path: scriptPath, is_flow: isFlow, service_config: serviceConfig, - summary: summary !== '' ? summary : undefined + summary: summary !== '' ? summary : undefined, + connection_path: connectionPath } } @@ -313,7 +317,8 @@ loadingForm || !can_write || !hasChanged || - loadError !== undefined + loadError !== undefined || + (isNew && !connectionPath) ) const saveCfg = $derived.by(getSaveCfg) @@ -540,7 +545,7 @@ bind:itemKind kinds={['script']} allowFlow={true} - allowEdit={!actingUser?.operator} + allowEdit={editRights.roleCanAuthor(itemKind)} clearable /> {#if emptyString(scriptPath)} @@ -562,6 +567,17 @@ </Section> {/if} + {#if !loadingConfig} + <Section label="{serviceInfo?.serviceDisplayName} account"> + <NativeConnectionPicker + {service} + bind:value={connectionPath} + locked={!isNew} + disabled={loading || !can_write} + /> + </Section> + {/if} + {#if loadingConfig} <Section label="{serviceInfo?.serviceDisplayName} configuration"> <div class="flex items-center gap-2 text-secondary text-xs"> @@ -569,34 +585,39 @@ Loading configuration from {serviceInfo?.serviceDisplayName}... </div> </Section> + {:else if isNew && !connectionPath} + <!-- The pickers browse the service as the chosen account, so there is nothing to + configure until one is chosen. --> {:else if ServiceFormComponent} - <ServiceFormComponent - bind:loading={loadingForm} - bind:this={serviceFormRef} - bind:serviceConfig - bind:errors - {externalData} - disabled={loading || loadingConfig || !can_write} - path={scriptPath} - {isFlow} - token="" - triggerTokens={undefined} - scopes={[]} - /> + {#key connectionPath} + <ServiceFormComponent + bind:loading={loadingForm} + bind:this={serviceFormRef} + bind:serviceConfig + bind:errors + {externalData} + disabled={loading || loadingConfig || !can_write} + path={scriptPath} + {isFlow} + token="" + triggerTokens={undefined} + scopes={[]} + /> + {/key} {:else} <Section label="{serviceInfo?.serviceDisplayName} configuration"> <div class="text-red-500 text-xs space-y-2"> <div>Failed to load service configuration component for {service}.</div> <div class="text-secondary"> - Ensure your workspace has a connected {serviceInfo?.serviceDisplayName} integration. + Check that {serviceInfo?.serviceDisplayName} is configured in the native trigger settings. </div> <Button unifiedSize="xs" variant="default" - href="/workspace_settings?tab=integrations" + href="/workspace_settings?tab=native_triggers" target="_blank" > - Manage integrations + Native trigger settings </Button> </div> </Section> diff --git a/frontend/src/lib/components/triggers/native/services/github/GitHubTriggerForm.svelte b/frontend/src/lib/components/triggers/native/services/github/GitHubTriggerForm.svelte index 27add7f797..c058a41560 100644 --- a/frontend/src/lib/components/triggers/native/services/github/GitHubTriggerForm.svelte +++ b/frontend/src/lib/components/triggers/native/services/github/GitHubTriggerForm.svelte @@ -7,8 +7,10 @@ import { sendUserToast } from '$lib/toast' import { Loader2 } from 'lucide-svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { useNativeConnection } from '../../utils' const operatingWorkspace = useOperatingWorkspace() + const connectionPath = useNativeConnection() interface Props { serviceConfig: Record<string, any> @@ -66,7 +68,8 @@ loading = true try { repos = await NativeTriggerService.listGithubRepos({ - workspace: $operatingWorkspace + workspace: $operatingWorkspace, + connectionPath: connectionPath() }) } catch (err: any) { console.error('Failed to load GitHub repositories:', err) diff --git a/frontend/src/lib/components/triggers/native/services/google/GoogleCalendarPicker.svelte b/frontend/src/lib/components/triggers/native/services/google/GoogleCalendarPicker.svelte index f4c6d9894b..3252d7c6aa 100644 --- a/frontend/src/lib/components/triggers/native/services/google/GoogleCalendarPicker.svelte +++ b/frontend/src/lib/components/triggers/native/services/google/GoogleCalendarPicker.svelte @@ -6,8 +6,10 @@ import { Loader2, X, RefreshCw } from 'lucide-svelte' import GoogleCalendarIcon from '$lib/components/icons/GoogleCalendarIcon.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { useNativeConnection } from '../../utils' const operatingWorkspace = useOperatingWorkspace() + const connectionPath = useNativeConnection() interface Props { calendarId: string @@ -26,7 +28,8 @@ loading = true try { calendars = await NativeTriggerService.listGoogleCalendars({ - workspace: $operatingWorkspace + workspace: $operatingWorkspace, + connectionPath: connectionPath() }) if (calendarId && !calendarName) { const found = calendars.find((c) => c.id === calendarId) diff --git a/frontend/src/lib/components/triggers/native/services/google/GoogleDrivePicker.svelte b/frontend/src/lib/components/triggers/native/services/google/GoogleDrivePicker.svelte index 597eb7d514..26e82dbdde 100644 --- a/frontend/src/lib/components/triggers/native/services/google/GoogleDrivePicker.svelte +++ b/frontend/src/lib/components/triggers/native/services/google/GoogleDrivePicker.svelte @@ -9,8 +9,10 @@ import { Debounced, watch } from 'runed' import { untrack } from 'svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { useNativeConnection } from '../../utils' const operatingWorkspace = useOperatingWorkspace() + const connectionPath = useNativeConnection() interface Props { resourceId: string @@ -53,6 +55,7 @@ try { const params: Parameters<typeof NativeTriggerService.listGoogleDriveFiles>[0] = { workspace: $operatingWorkspace, + connectionPath: connectionPath(), pageToken } @@ -98,7 +101,8 @@ try { sharedDrives = await NativeTriggerService.listGoogleSharedDrives({ - workspace: $operatingWorkspace + workspace: $operatingWorkspace, + connectionPath: connectionPath() }) } catch (err: any) { sendUserToast(`Failed to load shared drives: ${err.body || err.message}`, true) diff --git a/frontend/src/lib/components/triggers/native/services/nextcloud/NextcloudTriggerForm.svelte b/frontend/src/lib/components/triggers/native/services/nextcloud/NextcloudTriggerForm.svelte index 780ccde9e8..d4f01478ef 100644 --- a/frontend/src/lib/components/triggers/native/services/nextcloud/NextcloudTriggerForm.svelte +++ b/frontend/src/lib/components/triggers/native/services/nextcloud/NextcloudTriggerForm.svelte @@ -6,10 +6,11 @@ import SchemaForm from '$lib/components/SchemaForm.svelte' import Section from '$lib/components/Section.svelte' import { Loader2 } from 'lucide-svelte' - import { getNextcloudSchema } from '../../utils' + import { getNextcloudSchema, useNativeConnection } from '../../utils' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() + const connectionPath = useNativeConnection() interface Props { serviceConfig: Record<string, any> @@ -49,7 +50,8 @@ eventsError = undefined try { const events = await NativeTriggerService.listNextCloudEvents({ - workspace: $operatingWorkspace! + workspace: $operatingWorkspace!, + connectionPath: connectionPath() }) availableEvents = events serviceSchema = getNextcloudSchema(events) @@ -116,16 +118,14 @@ {#if eventsError} <div class="break-words">Could not load the available events: {eventsError}</div> {:else} - <div - >No events available. Please ensure your workspace has a connected Nextcloud integration.</div - > + <div>No events available. Check that the selected Nextcloud account can reach the server.</div> {/if} <div class="flex gap-2"> <Button variant="default" on:click={loadAvailableEvents} {disabled}> Retry loading events </Button> - <Button variant="subtle" href="/workspace_settings?tab=integrations" target="_blank"> - Manage integrations + <Button variant="subtle" href="/workspace_settings?tab=native_triggers" target="_blank"> + Native trigger settings </Button> </div> </div> @@ -140,8 +140,7 @@ /> {:else} <div class="text-secondary text-xs"> - Please ensure Nextcloud workspace integration is connected to load available configuration - options. + Select a Nextcloud account to load the available configuration options. </div> {/if} </Section> diff --git a/frontend/src/lib/components/triggers/native/utils.ts b/frontend/src/lib/components/triggers/native/utils.ts index b411429d74..5d0a7f0fca 100644 --- a/frontend/src/lib/components/triggers/native/utils.ts +++ b/frontend/src/lib/components/triggers/native/utils.ts @@ -2,11 +2,97 @@ import type { NativeServiceName, NativeTrigger, NativeTriggerData } from '$lib/g import { isCloudHosted } from '$lib/cloud' import { NativeTriggerService, WorkspaceIntegrationService } from '$lib/gen' import { get, type Writable } from 'svelte/store' +import { getContext, setContext } from 'svelte' import { sendUserToast } from '$lib/toast' +const CONNECTION_CONTEXT = Symbol('nativeTriggerConnection') + +/** Makes the connection a trigger editor acts through available to the pickers of its form. */ +export function setNativeConnectionContext(connectionPath: () => string | undefined) { + setContext(CONNECTION_CONTEXT, connectionPath) +} + +/** The connection the service pickers browse through; undefined lets the server pick. */ +export function useNativeConnection(): () => string | undefined { + return getContext<(() => string | undefined) | undefined>(CONNECTION_CONTEXT) ?? (() => undefined) +} + +const CONNECT_MESSAGE = 'native_trigger_oauth' + +/** Turns the resource connect drawer into the connect step of a native trigger account. */ +export type NativeConnect = { + title: string + defaultName: string + /** Rejects when the connection was not saved; the drawer then stays open on the path. */ + connect: (path: string) => Promise<void> +} + +export type NativeOAuthMessage = + | { type: typeof CONNECT_MESSAGE; ok: true; path: string } + | { type: typeof CONNECT_MESSAGE; ok: false; error: string } + +/** The page the provider redirects to, as registered on the workspace's OAuth apps. */ +export function nativeOAuthRedirectUri(service: NativeServiceName): string { + return `${window.location.origin}/workspace_settings?tab=native_triggers&service=${service}` +} + +/** Hands the outcome of a connect popup back to the page that opened it. */ +export function postNativeOAuthResult( + result: { ok: true; path: string } | { ok: false; error: string } +) { + const message = { type: CONNECT_MESSAGE, ...result } as NativeOAuthMessage + window.opener?.postMessage(message, window.location.origin) +} + +/** + * Connect an account in a popup and resolve with the path of the saved connection. The + * connection is saved at `resourcePath`, or at the caller's default path when omitted. + */ +export async function connectNativeAccount( + workspace: string, + service: NativeServiceName, + resourcePath?: string +): Promise<string> { + const authUrl = await WorkspaceIntegrationService.generateNativeTriggerServiceConnectUrl({ + workspace, + serviceName: service, + requestBody: { redirect_uri: nativeOAuthRedirectUri(service), resource_path: resourcePath } + }) + const popup = window.open(authUrl, '_blank', 'popup,width=600,height=750') + if (!popup) { + throw new Error('The connection window was blocked. Allow popups for this site and retry.') + } + return new Promise((resolve, reject) => { + const onMessage = (event: MessageEvent<NativeOAuthMessage>) => { + if (event.origin !== window.location.origin || event.data?.type !== CONNECT_MESSAGE) return + cleanup() + if (event.data.ok) resolve(event.data.path) + else reject(new Error(event.data.error)) + } + // The callback page posts its result and then closes itself, so a closed window may still + // have a message in flight: give it a moment before treating the close as a cancel. + const closedPoll = setInterval(() => { + if (popup.closed) { + clearInterval(closedPoll) + setTimeout(() => { + cleanup() + reject(new Error('The connection window was closed before finishing')) + }, 1000) + } + }, 500) + function cleanup() { + window.removeEventListener('message', onMessage) + clearInterval(closedPoll) + } + window.addEventListener('message', onMessage) + }) +} + export interface NativeTriggerConfig { readonly serviceDisplayName: string readonly serviceKey: NativeServiceName + /** The type of the resource a connection is saved as. */ + readonly resourceType: string readonly supportsSync: boolean readonly isCloudCompatible: boolean readonly templates?: { @@ -19,6 +105,7 @@ export const NATIVE_TRIGGER_SERVICES: Record<NativeServiceName, NativeTriggerCon nextcloud: { serviceDisplayName: 'Nextcloud', serviceKey: 'nextcloud', + resourceType: 'nextcloud', supportsSync: true, isCloudCompatible: true, templates: { @@ -30,6 +117,7 @@ export const NATIVE_TRIGGER_SERVICES: Record<NativeServiceName, NativeTriggerCon google: { serviceDisplayName: 'Google', serviceKey: 'google', + resourceType: 'gworkspace', supportsSync: true, isCloudCompatible: true, templates: { @@ -40,6 +128,7 @@ export const NATIVE_TRIGGER_SERVICES: Record<NativeServiceName, NativeTriggerCon github: { serviceDisplayName: 'GitHub', serviceKey: 'github', + resourceType: 'github', supportsSync: true, isCloudCompatible: true, templates: { @@ -216,8 +305,13 @@ export async function saveNativeTriggerFromCfg( service_config: triggerCfg.service_config, summary: triggerCfg.summary } - // Only a create can set it: an update ignores the field, and `setenabled` owns it thereafter. - const createBody: NativeTriggerData = { ...requestBody, enabled: triggerCfg.enabled ?? true } + // Only a create can set these: an update ignores both, `setenabled` owns `enabled` thereafter, + // and the connection stays the one the webhook was registered under. + const createBody: NativeTriggerData = { + ...requestBody, + enabled: triggerCfg.enabled ?? true, + connection_path: triggerCfg.connection_path + } const serviceName = NATIVE_TRIGGER_SERVICES[service].serviceDisplayName diff --git a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte index e0ba992e12..db95d73e7f 100644 --- a/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/nats/NatsTriggerEditorInner.svelte @@ -84,7 +84,6 @@ }: Props = $props() const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() - const actingUser = $derived(operatingUser.current) const operatingHref = useOperatingWorkspaceHref() const wsId = $derived($operatingWorkspace) @@ -578,7 +577,7 @@ bind:scriptPath={script_path} {initialScriptPath} canWrite={can_write} - isOperator={!!actingUser?.operator} + allowEdit={editRights.roleCanAuthor(itemKind)} > {#snippet createButton()} {#if emptyString(script_path)} diff --git a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte index 68ee8816b7..402cca53fb 100644 --- a/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte @@ -103,7 +103,6 @@ }: Props = $props() const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() - const actingUser = $derived(operatingUser.current) const operatingHref = useOperatingWorkspaceHref() const wsId = $derived($operatingWorkspace) @@ -760,7 +759,7 @@ bind:scriptPath={script_path} {initialScriptPath} canWrite={can_write} - isOperator={!!actingUser?.operator} + allowEdit={editRights.roleCanAuthor(itemKind)} promptText="Pick a script or flow to be triggered " promptClass="text-xs text-primary" > diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index df09a81671..5e167a4337 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -103,7 +103,7 @@ let loading = $state(false) let drawerLoading = $state(true) let showLoading = $state(false) - let initialConfig: Record<string, any> | undefined = undefined + let initialConfig = $state<Record<string, any> | undefined>(undefined) let extraPerms: Record<string, boolean> = $state({}) // Path the permissions above were loaded for — the verdict is about the schedule as // stored, not about a rename being typed into the form. `undefined` until a config has @@ -141,9 +141,9 @@ ? editRights.roleCanAuthor('schedule') : editRights.canEditItem('schedule', permsPath, extraPerms) ) - // Editing the runnable is closed to operators, and an unresolved acting user is no - // evidence that this one isn't. - const canEditRunnable = $derived(actingUser !== undefined && !actingUser.operator) + // Editing the runnable needs a role that authors its kind; an unresolved acting user is no + // evidence of that role. + const canEditRunnable = $derived(actingUser !== undefined && editRights.roleCanAuthor(itemKind)) const saveDisabled = $derived( !allowSchedule || pathError != '' || @@ -281,7 +281,7 @@ schedule = s?.schedule ?? '0 0 12 * *' initialSchedule = schedule timezone = s?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone - paused_until = s?.paused_until ?? undefined + paused_until = asPicked(s?.paused_until) showPauseUntil = paused_until !== undefined summary = s?.summary ?? '' labels = s?.labels ?? undefined @@ -372,6 +372,12 @@ } } + // The draft check compares as text and the picker writes `.000Z`: spell the stored time + // the same, or a time changed and changed back reads as unsaved (`…:00Z` vs `…:00.000Z`). + function asPicked(time: string | null | undefined): string | undefined { + return time ? new Date(time).toISOString() : undefined + } + async function loadScheduleCfg(cfg: Record<string, any>): Promise<void> { loading = true @@ -382,7 +388,7 @@ schedule = cfg.schedule initialSchedule = schedule timezone = cfg.timezone - paused_until = cfg.paused_until + paused_until = asPicked(cfg.paused_until) showPauseUntil = paused_until !== undefined summary = cfg.summary ?? '' labels = cfg.labels ?? undefined diff --git a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte index 5593dd121f..77f9478026 100644 --- a/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/sqs/SqsTriggerEditorInner.svelte @@ -88,7 +88,6 @@ }: Props = $props() const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() - const actingUser = $derived(operatingUser.current) const operatingHref = useOperatingWorkspaceHref() const wsId = $derived($operatingWorkspace) @@ -561,7 +560,7 @@ bind:scriptPath={script_path} {initialScriptPath} canWrite={can_write} - isOperator={!!actingUser?.operator} + allowEdit={editRights.roleCanAuthor(itemKind)} promptText="Pick a script or flow to be triggered " > {#snippet createButton()} diff --git a/frontend/src/lib/components/triggers/triggers.svelte.ts b/frontend/src/lib/components/triggers/triggers.svelte.ts index 7fe40d111d..8ee12aaaea 100644 --- a/frontend/src/lib/components/triggers/triggers.svelte.ts +++ b/frontend/src/lib/components/triggers/triggers.svelte.ts @@ -32,6 +32,7 @@ import type { UserExt } from '$lib/stores' import type { ScheduleTrigger } from '../triggers' import { formatCron, generateRandomString } from '$lib/utils' import { canEditItem } from '$lib/editRights' +import { capabilitiesIn } from '$lib/roleCapabilities' export class Triggers { #triggers = $state<Trigger[]>([]) @@ -135,9 +136,10 @@ export class Triggers { user: UserExt | undefined = undefined ): number { const currentTriggers = this.#triggers - const operatorSettings = get(userWorkspaces).find( - (w) => w.id === workspaceId - )?.operator_settings + const caps = capabilitiesIn( + user, + get(userWorkspaces).find((w) => w.id === workspaceId) + ) const kind = type === 'schedule' ? 'schedule' : 'trigger' // Identify triggers with draftConfig to preserve const configuredTriggers = currentTriggers.filter( @@ -157,7 +159,7 @@ export class Triggers { path: trigger.path, isPrimary: type === 'schedule' && trigger.path === trigger.script_path, isDraft: false, - canWrite: canEditItem(kind, trigger.path, trigger.extra_perms, user, operatorSettings), + canWrite: canEditItem(kind, trigger.path, trigger.extra_perms, user, caps), draftConfig: draftConfig, lightConfig: getLightConfig(type, trigger) } diff --git a/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts b/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts index abe2e287bd..daaab5a6f5 100644 --- a/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts +++ b/frontend/src/lib/components/triggers/useTriggerDraftSync.svelte.ts @@ -47,6 +47,8 @@ export interface TriggerDraftSyncOptions { /** * The deployed baseline the editor's dirty check compares against — * `originalConfig` for TriggerCrud editors, `initialConfig` for Schedule. + * Must read `$state`: the baseline is cached, so a plain `let` set by a later + * open stays stale and the "unsaved changes" banner never shows. */ deployed: () => Cfg | undefined } @@ -61,8 +63,8 @@ export interface TriggerDraftSync { */ readonly hasDraft: boolean /** - * Whether a deployed baseline exists (banner can appear). Reactive, unlike - * reading `deployed` directly (a plain `let`), so callers can reserve its slot. + * Whether a deployed baseline exists (banner can appear), so callers can + * reserve its slot. */ readonly hasBaseline: boolean /** The deployed baseline the dirty check compares against. */ @@ -172,8 +174,7 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft !opts.drawerLoading() && baseline != null && cfgDiffers(opts.getCfg() as Cfg, baseline as Cfg) ) - // Reactive "banner is possible" — depends on `drawerLoading()` so it - // re-evaluates (and re-reads the plain-`let` baseline) once the load settles. + // "Banner is possible" — false until the load settles. const hasBaseline = $derived(!opts.drawerLoading() && opts.deployed() != null) /** `auto: true` marks a discard from the reactive persist-effect (not an @@ -313,8 +314,7 @@ export function useTriggerDraftSync(opts: TriggerDraftSyncOptions): TriggerDraft const deployed = opts.deployed() if (deployed == null) return // The deployed object too, so `resetToDeployed` restores the new value; - // `settledBaseline` because it shadows the deployed object and is the - // reactive side (an editor's `deployed` may be a plain `let`). + // `settledBaseline` because it shadows the deployed object. Object.assign(deployed, patch) settledBaseline = { ...snapshotCfg(settledBaseline ?? deployed), ...patch } } diff --git a/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte index b011d6fee1..5d19535b01 100644 --- a/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte @@ -105,7 +105,6 @@ }: Props = $props() const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() - const actingUser = $derived(operatingUser.current) const operatingHref = useOperatingWorkspaceHref() const wsId = $derived($operatingWorkspace) @@ -637,7 +636,7 @@ bind:itemKind bind:scriptPath={script_path} allowRefresh={can_write} - allowEdit={!actingUser?.operator} + allowEdit={editRights.roleCanAuthor(itemKind)} clearable /> {#if emptyString(script_path)} diff --git a/frontend/src/lib/components/variables/VariablesList.svelte b/frontend/src/lib/components/variables/VariablesList.svelte index 3805b51bbb..e3d996804e 100644 --- a/frontend/src/lib/components/variables/VariablesList.svelte +++ b/frontend/src/lib/components/variables/VariablesList.svelte @@ -36,7 +36,7 @@ import Tooltip from '$lib/components/Tooltip.svelte' import VariableEditor from '$lib/components/VariableEditor.svelte' import type { ContextualVariable, ListableVariable, WorkspaceDeployUISettings } from '$lib/gen' - import { enterpriseLicense, userWorkspaces } from '$lib/stores' + import { enterpriseLicense } from '$lib/stores' import { sendUserToast } from '$lib/toast' import { isOwner, truncate } from '$lib/utils' import { useEditRights } from '$lib/operatorWriteRights' @@ -317,10 +317,10 @@ <DeployWorkspaceDrawer bind:this={deploymentDrawer} /> -{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.variables} +{#if $operatingWorkspace && !editRights.capabilities.visibility.variables} <div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert"> <p class="font-bold">Unauthorized</p> - <p>Page not available for operators</p> + <p>Page not available for your role</p> </div> {:else} <CenteredPage> diff --git a/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte b/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte index 959b666363..d6a43364f0 100644 --- a/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte +++ b/frontend/src/lib/components/workspaceSettings/CreateWorkspaceInner.svelte @@ -11,6 +11,12 @@ type CompletedJob } from '$lib/gen' import { validateUsername } from '$lib/utils' + import { + devWorkspaceTagSuffix, + devWorkspaceSkipNonDevForks, + isForkOfIsolated, + loadDevWorkspaceTagSuffix + } from '$lib/devWorkspaceTags' import { logoutWithRedirect } from '$lib/logoutKit' import { page } from '$app/state' import { @@ -28,12 +34,15 @@ devWorkspacesInChainAbove } from '$lib/utils/workspaceHierarchy' import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte' - import { isOperatorInWorkspace } from '$lib/components/sidebar/operatorRoutes' + import { capabilitiesIn, isRestricted } from '$lib/roleCapabilities' import { + canUserBypassRuleKindInRulesets, fetchProtectionRulesForWorkspace, + isRuleActiveInRulesets, isRuleUnconditionallyActiveInRulesets } from '$lib/workspaceProtectionRules.svelte' import { resource } from 'runed' + import { getUserExt } from '$lib/user' import { Button } from '$lib/components/common' import { DEV_WORKSPACE_LABELS, @@ -70,6 +79,7 @@ import WorkspaceScopeTrigger from '../WorkspaceScopeTrigger.svelte' import DarkModeToggle from '../sidebar/DarkModeToggle.svelte' import ForkDucklakeSection from './ForkDucklakeSection.svelte' + import ForkStorageSection from './ForkStorageSection.svelte' import { createWorkspaceForkAndWait } from '$lib/utils/forkCreation' interface Props { @@ -128,14 +138,14 @@ let forkableWorkspaces = $derived(forkable.current) // Base candidates are the current workspace's family: its root first, then every fork/dev under it. - // Never one the user is an operator in: a fork does not carry the role, so forking such a base + // Never one the user holds a restricted role in: a fork does not carry the role, so forking such a base // would hand them a full-rights copy of it. let familyRoot = $derived(findWorkspaceRoot($workspaceStore, forkableWorkspaces)) let baseCandidates = $derived( (familyRoot ? [familyRoot, ...findWorkspaceDescendants(familyRoot.id, forkableWorkspaces)] : [] - ).filter((w) => !isOperatorInWorkspace(w)) + ).filter((w) => !isRestricted(capabilitiesIn(undefined, w))) ) let baseItems = $derived( baseCandidates.map((w) => ({ @@ -162,6 +172,12 @@ // read as a root (it would offer invalid dev creation while the workspace list is still loading). // `workspaceIsFork` (prefix OR parent) also excludes an orphaned `wm-fork-` workspace, whose parent // FK was set null — it has no parent but is still a fork, so it can't host a dev workspace. + let baseIsRoot = $derived( + !!baseWorkspaceEntry && !workspaceIsFork(baseWorkspaceId, forkableWorkspaces) + ) + $effect(() => { + isFork && loadDevWorkspaceTagSuffix() + }) let baseCanHostDev = $derived( !!baseWorkspaceEntry && (!workspaceIsFork(baseWorkspaceId, forkableWorkspaces) || @@ -265,6 +281,35 @@ let forkDatatableSection: ReturnType<typeof ForkDatatableSection> | undefined = $state(undefined) let forkDucklakeSection: ReturnType<typeof ForkDucklakeSection> | undefined = $state(undefined) + let forkStorageSection: ReturnType<typeof ForkStorageSection> | undefined = $state(undefined) + let datatableCount = $state(0) + let ducklakeCount = $state(0) + let storageCount = $state(0) + let forkDataCount = $derived(datatableCount + ducklakeCount + storageCount) + + // The base's `RequireIsolatedForks` rule leaves no data shared with it: the sections below drop + // their sharing options. The server enforces it; this only keeps the form from offering a + // choice it would refuse. Bypass is judged with the user's identity in the BASE, like + // ParentWorkspaceProtectionAlert. + const baseRuleContext = resource( + () => (isFork ? baseWorkspaceId : undefined), + async (ws) => { + if (!ws) return undefined + const [rules, user] = await Promise.all([ + fetchProtectionRulesForWorkspace(ws), + getUserExt(ws) + ]) + return { rules, user } + } + ) + let requireIsolation = $derived.by(() => { + const ctx = baseRuleContext.current + return ( + !!ctx && + isRuleActiveInRulesets(ctx.rules, 'RequireIsolatedForks') && + !canUserBypassRuleKindInRulesets(ctx.rules, 'RequireIsolatedForks', ctx.user) + ) + }) let workspaceColor: string | undefined = $state(undefined) let colorEnabled = $state(false) @@ -421,9 +466,11 @@ id: prefixed_id, name, color: colorEnabled && workspaceColor ? workspaceColor : undefined, - // Sent in this first phase too, so a clone the fork would refuse is refused before - // any branch is created. + // Sent in this first phase too, so a clone or a sharing choice the fork would refuse + // is refused before any branch is created. forked_datatables: forkedDatatables, + shared_ducklakes: forkDucklakeSection?.getSharedDucklakes() ?? [], + shared_storages: forkStorageSection?.getSharedStorages() ?? [], is_dev_workspace: createAsDevWorkspace, dev_workspace_label: createAsDevWorkspace ? devWorkspaceLabel : undefined, // Send the lock intent in this first phase too so the backend can reject a non-admin's @@ -487,6 +534,7 @@ color: colorEnabled && workspaceColor ? workspaceColor : undefined, forked_datatables: forkedDatatables, shared_ducklakes: forkDucklakeSection?.getSharedDucklakes() ?? [], + shared_storages: forkStorageSection?.getSharedStorages() ?? [], is_dev_workspace: createAsDevWorkspace, dev_workspace_label: createAsDevWorkspace ? devWorkspaceLabel : undefined, lock_prod_deploy: createAsDevWorkspace && effectiveLockProdDeploy, @@ -963,19 +1011,77 @@ there. </Alert> {/if} + {#if baseWorkspaceId && createAsDevWorkspace} + <Alert + type="info" + size="xs" + title="Resources and variables are copied from {baseWorkspaceId}" + > + The dev workspace starts with copies of {baseWorkspaceId}'s resources and variables, + credentials included. Point them at staging before developers use it. + {#if !forkingLocked && !lockProdForking} + Turn on <b>Prevent forking</b> above so developers fork this dev workspace, which will + hold staging credentials, rather than {baseWorkspaceId}. + {/if} + {#if $devWorkspaceTagSuffix} + Its jobs run on dev workers (tags suffixed with <code>{$devWorkspaceTagSuffix}</code + >). + {/if} + </Alert> + {:else if baseWorkspaceId && $devWorkspaceTagSuffix && !isForkOfIsolated(baseWorkspaceId, $userWorkspaces, $devWorkspaceSkipNonDevForks)} + <Alert type="info" size="xs" title="Runs on {baseWorkspaceId}'s workers"> + Forks of non-dev workspaces are not isolated on this instance: jobs of this fork run on + the same workers as {baseWorkspaceId}. + </Alert> + {:else if baseWorkspaceId && $devWorkspaceTagSuffix} + <Alert type="info" size="xs" title="Runs on dev workers"> + Jobs of this fork run on the worker groups serving dev workspaces (tags suffixed with + <code>{$devWorkspaceTagSuffix}</code>). + {#if baseIsRoot} + It copies {baseWorkspaceId}'s resources and variables, so its scripts use + {baseWorkspaceId}'s credentials from those workers and reach what their network + allows. Forking a dev workspace instead gives a fork its staging credentials. + {/if} + </Alert> + {/if} {/if} {#if isFork} - <ForkDatatableSection - bind:this={forkDatatableSection} - sourceWorkspace={baseWorkspaceId} - onAllDone={() => { - completeFork(effectiveForkId) - }} - onCanceled={() => { - forkCreationLoading = false - }} - /> - <ForkDucklakeSection bind:this={forkDucklakeSection} sourceWorkspace={baseWorkspaceId} /> + <!-- Hidden rather than unmounted while empty: the sections load their own lists. --> + <div class={forkDataCount > 0 ? 'flex flex-col gap-3' : 'hidden'}> + <Label label="Fork data"> + <span class="text-xs text-secondary"> + Isolated or cloned: the fork writes only its own data, and this workspace's is never + changed. Shared: the fork reads and writes this workspace's data directly. + {#if requireIsolation} + A protection rule on this workspace requires isolation. + {/if} + </span> + </Label> + <ForkDatatableSection + bind:this={forkDatatableSection} + bind:itemCount={datatableCount} + sourceWorkspace={baseWorkspaceId} + {requireIsolation} + onAllDone={() => { + completeFork(effectiveForkId) + }} + onCanceled={() => { + forkCreationLoading = false + }} + /> + <ForkDucklakeSection + bind:this={forkDucklakeSection} + bind:itemCount={ducklakeCount} + sourceWorkspace={baseWorkspaceId} + {requireIsolation} + /> + <ForkStorageSection + bind:this={forkStorageSection} + bind:itemCount={storageCount} + sourceWorkspace={baseWorkspaceId} + {requireIsolation} + /> + </div> {/if} {#if !automateUsernameCreation} <Label label="Your username in that workspace"> @@ -1006,7 +1112,7 @@ <span class="text-2xs text-secondary">(optional but recommended)</span> </label> - <ToggleButtonGroup bind:selected> + <ToggleButtonGroup bind:selected wrap> {#snippet children({ item })} <ToggleButton value="openai" label="OpenAI" {item} /> <ToggleButton value="anthropic" label="Anthropic" {item} /> @@ -1017,6 +1123,7 @@ <div class="flex flex-row gap-1"> <input id="ai-key" + class="flex-1 min-w-0" type="password" autocomplete="new-password" bind:value={aiKey} @@ -1101,11 +1208,8 @@ </div> <div class="flex flex-wrap flex-row {inModal ? 'justify-end' : 'justify-between'} gap-4 pt-4"> {#if !inModal} - <Button - disabled={forkCreationLoading} - variant="default" - size="sm" - href="/user/workspaces">← Back to workspaces</Button + <Button disabled={forkCreationLoading} variant="default" size="sm" href="/user/workspaces" + >← Back to workspaces</Button > {/if} {#if !forkCreationLoading} diff --git a/frontend/src/lib/components/workspaceSettings/DataTableMigrationsButton.svelte b/frontend/src/lib/components/workspaceSettings/DataTableMigrationsButton.svelte index f9dd3dda02..eb7e22c858 100644 --- a/frontend/src/lib/components/workspaceSettings/DataTableMigrationsButton.svelte +++ b/frontend/src/lib/components/workspaceSettings/DataTableMigrationsButton.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import { Button } from '../common' + import { Badge, Button } from '../common' import Modal2 from '../common/modal/Modal2.svelte' import Tabs from '../common/tabs/Tabs.svelte' import Tab from '../common/tabs/Tab.svelte' @@ -29,6 +29,7 @@ import { superadmin } from '$lib/stores' import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte' import { parseMigrationRole, withMigrationRole } from '../datatableMigrationRole' + import { scopesOverlap } from '../datatableMigrationSchemas' const operatingUser = useOperatingUser() const actingUser = $derived(operatingUser.current) @@ -228,17 +229,56 @@ } } - // Run a single migration. Warn first if earlier migrations haven't run, since - // this one might depend on them. + async function runForSchema(schema: string) { + busy = true + try { + const res = await WorkspaceService.runDatatableMigrations({ + workspace, + datatableName: datatable, + schema + }) + sendUserToast( + res.applied.length > 0 + ? `Applied ${res.applied.length} migration(s) on ${schema}` + : `No pending migrations on ${schema}` + ) + await loadMigrations() + if (res.applied.length > 0) onSchemaChanged?.() + } catch (e: any) { + sendUserToast(`Failed to run migrations: ${e?.body ?? e?.message ?? e}`, true) + } finally { + busy = false + } + } + + // The schemas that have pending migrations declaring them, each runnable on its own. + const pendingSchemas = $derived([ + ...new Set(migrations.filter((m) => m.status !== 'ran').flatMap((m) => m.schemas ?? [])) + ]) + + // What makes two migrations depend on each other, for the out-of-order warnings. + function overlap(a: DatatableMigrationWithStatus, b: DatatableMigrationWithStatus): string { + if (!b.schemas) return 'it applies to the whole database' + if (!a.schemas) return `"${a.name}" applies to the whole database` + return `both touch schema ${a.schemas.filter((s) => b.schemas?.includes(s)).join(', ')}` + } + + // Run a single migration. Warn first if an earlier migration touching the same schemas hasn't + // run, since this one might depend on it. async function runMigration(m: DatatableMigrationWithStatus) { - const earlierPending = migrations.filter( - (x) => x.timestamp < m.timestamp && x.status !== 'ran' - ).length - if (earlierPending > 0) { + // `blocked_by` is only known when the applied versions could be read: when they could not, + // an earlier overlapping migration of unknown status may still be pending. + const blocker = + migrations.find((x) => x.timestamp === m.blocked_by) ?? + migrations.find( + (x) => + x.timestamp < m.timestamp && x.status === 'unknown' && scopesOverlap(x.schemas, m.schemas) + ) + if (blocker) { const confirmed = await confirmationModal.ask({ title: 'Run migration out of order', confirmationText: 'Run anyway', - children: `${earlierPending} earlier migration(s) have not been run yet. "${m.name}" might depend on them. Run it anyway?` + children: `"${blocker.name}" ${blocker.status === 'unknown' ? 'may not have been run yet' : 'has not been run yet'} and ${overlap(m, blocker)}, so "${m.name}" might depend on it. Run it anyway?` }) if (!confirmed) return } @@ -267,17 +307,15 @@ } } - // Revert a single migration. Warn first if later migrations have run, since - // they might depend on this one. + // Revert a single migration. Warn first if a later migration touching the same schemas has + // run, since it might depend on this one. async function revertMigration(m: DatatableMigrationWithStatus) { - const laterApplied = migrations.filter( - (x) => x.timestamp > m.timestamp && x.status === 'ran' - ).length - if (laterApplied > 0) { + const blocker = migrations.find((x) => x.timestamp === m.blocked_by) + if (blocker) { const confirmed = await confirmationModal.ask({ title: 'Revert migration out of order', confirmationText: 'Revert anyway', - children: `${laterApplied} later migration(s) have already been run and might depend on "${m.name}". Reverting it may break them. Revert anyway?` + children: `"${blocker.name}" has already been run and ${overlap(m, blocker)}, so it might depend on "${m.name}". Reverting it may break it. Revert anyway?` }) if (!confirmed) return } @@ -535,6 +573,21 @@ <span class="text-sm text-primary truncate">{m.name}</span> <span class="text-xs text-hint">{m.timestamp}</span> </button> + <div class="flex flex-wrap justify-end gap-1 shrink-0 max-w-48"> + {#if m.schemas} + {#each m.schemas as s (s)} + <Badge small>{s}</Badge> + {/each} + {:else} + <Badge + small + color="yellow" + title="Declares no schemas: every later migration waits for it" + > + whole database + </Badge> + {/if} + </div> <Button variant="subtle" size="xs" @@ -579,10 +632,16 @@ > <Button variant="accent" - size="sm" + unifiedSize="sm" startIcon={{ icon: Play }} disabled={busy || !hasPending} on:click={() => runUpTo(undefined)} + dropdownItems={pendingSchemas.length > 0 + ? pendingSchemas.map((s) => ({ + label: `Run pending on ${s} only`, + onClick: () => runForSchema(s) + })) + : undefined} > Run all </Button> diff --git a/frontend/src/lib/components/workspaceSettings/ForkDatatableSection.svelte b/frontend/src/lib/components/workspaceSettings/ForkDatatableSection.svelte index 9627e4a026..b7d67cd80b 100644 --- a/frontend/src/lib/components/workspaceSettings/ForkDatatableSection.svelte +++ b/frontend/src/lib/components/workspaceSettings/ForkDatatableSection.svelte @@ -13,7 +13,7 @@ import { isCloudHosted } from '$lib/cloud' import { resource } from 'runed' import Select from '../select/Select.svelte' - import Label from '../Label.svelte' + import { TriangleAlert } from 'lucide-svelte' import Alert from '../common/alert/Alert.svelte' import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte' @@ -21,11 +21,20 @@ // Workspace whose datatables are cloned into the fork (the fork's base). Falls back to the // current workspace so existing callers keep working. sourceWorkspace?: string + // A protection rule on the base forbids `keep_original` for the base's own datatables. + requireIsolation?: boolean + itemCount?: number onAllDone?: () => void onCanceled?: () => void } - let { sourceWorkspace, onAllDone, onCanceled }: Props = $props() + let { + sourceWorkspace, + requireIsolation = false, + itemCount = $bindable(), + onAllDone, + onCanceled + }: Props = $props() let effectiveSource = $derived(sourceWorkspace ?? $workspaceStore ?? undefined) @@ -34,8 +43,27 @@ async (ws) => (ws ? WorkspaceService.listDataTables({ workspace: ws }) : undefined) ) - let datatableBehaviors: Record<string, 'schema_only' | 'schema_and_data' | 'keep_original'> = - $state({}) + $effect(() => { + itemCount = allDatatables.current?.length ?? 0 + }) + + type Behavior = 'schema_only' | 'schema_and_data' | 'keep_original' + let datatableBehaviors: Record<string, Behavior> = $state({}) + + // An entry whose database another workspace owns is not the base's data: the rule leaves it + // alone, as the server does. + function mustClone(dt: { database_workspace_id?: string }): boolean { + return requireIsolation && !dt.database_workspace_id + } + + function behaviorOf(dt: { name: string; database_workspace_id?: string }): Behavior { + const chosen = datatableBehaviors[dt.name] ?? 'keep_original' + return mustClone(dt) && chosen === 'keep_original' ? 'schema_only' : chosen + } + + let anyShared = $derived( + (allDatatables.current ?? []).some((dt) => behaviorOf(dt) === 'keep_original') + ) let cloneModalOpen = $state(false) let currentCloneJob: DatatableCloneJob | undefined = $state(undefined) @@ -49,14 +77,11 @@ // A fork attempt starts here: what an earlier attempt confirmed is not this one's to send. confirmedJobs = [] return (allDatatables.current ?? []) - .filter((dt) => { - const behavior = datatableBehaviors[dt.name] ?? 'keep_original' - return behavior !== 'keep_original' - }) + .filter((dt) => behaviorOf(dt) !== 'keep_original') .map((dt) => ({ name: dt.name, resourceType: dt.resource_type, - behavior: datatableBehaviors[dt.name] as 'schema_only' | 'schema_and_data', + behavior: behaviorOf(dt) as 'schema_only' | 'schema_and_data', // A dev workspace's id has no `wm-fork-` prefix; its copies are named like a fork's _newDbName: `wm_fork_${targetWorkspaceId.replace(/^wm-fork-/, '').replace(/-/g, '_')}__${dt.name}` })) @@ -94,10 +119,10 @@ </script> {#if allDatatables.current && allDatatables.current.length > 0} - <Label label="Data table behavior"> - <span class="text-xs text-secondary"> Choose how to handle each datatable when forking </span> + <div class="flex flex-col gap-1"> + <span class="text-xs font-medium text-secondary">Data tables</span> <div class="border rounded-md divide-y"> - {#each allDatatables.current as dt} + {#each allDatatables.current as dt (dt.name)} <div class="flex items-center gap-2 justify-between px-4 py-1.5"> <div class="flex flex-col"> <span class="text-xs font-medium">{dt.name}</span> @@ -107,15 +132,18 @@ </div> <Select dropdownClass="max-w-96" - bind:value={ - () => datatableBehaviors[dt.name] ?? 'keep_original', - (v) => (datatableBehaviors[dt.name] = v) - } + bind:value={() => behaviorOf(dt), (v) => (datatableBehaviors[dt.name] = v)} items={[ - { - value: 'keep_original', - label: dt.permissioned ? 'Keep original (under roles)' : 'Keep original' - }, + ...(mustClone(dt) + ? [] + : [ + { + value: 'keep_original' as const, + label: dt.permissioned + ? 'Shared with parent (under roles)' + : 'Shared with parent' + } + ]), // A copy of a data table under roles carries the source's owners and grants, // which only the Enterprise Edition replays: elsewhere the server refuses it. ...(dt.permissioned && !$enterpriseLicense @@ -131,7 +159,13 @@ </div> {/each} </div> - </Label> + {#if anyShared} + <span class="flex items-center gap-1 text-2xs text-amber-700 dark:text-amber-300"> + <TriangleAlert size={12} class="shrink-0" /> + Scripts in the fork will read and write this workspace's rows. + </span> + {/if} + </div> {/if} {#if cloneModalOpen && currentCloneJob} diff --git a/frontend/src/lib/components/workspaceSettings/ForkDucklakeSection.svelte b/frontend/src/lib/components/workspaceSettings/ForkDucklakeSection.svelte index 24d3da404c..41813fa552 100644 --- a/frontend/src/lib/components/workspaceSettings/ForkDucklakeSection.svelte +++ b/frontend/src/lib/components/workspaceSettings/ForkDucklakeSection.svelte @@ -3,13 +3,16 @@ import { workspaceStore } from '$lib/stores' import { resource } from 'runed' import Select from '../select/Select.svelte' - import Label from '../Label.svelte' import { TriangleAlert } from 'lucide-svelte' // The workspace the fork is created from (matches ForkDatatableSection): a // fork-of-fork bases off the selected base, not the navigation workspace, so // the lakes listed here must match the ones the create-fork call submits to. - let { sourceWorkspace }: { sourceWorkspace?: string } = $props() + let { + sourceWorkspace, + requireIsolation = false, + itemCount = $bindable() + }: { sourceWorkspace?: string; requireIsolation?: boolean; itemCount?: number } = $props() let effectiveSource = $derived(sourceWorkspace ?? $workspaceStore ?? undefined) // Keyed on the source so a direct page load (store hydrates after mount) still fetches. @@ -18,34 +21,42 @@ async (ws) => (ws ? WorkspaceService.listDucklakes({ workspace: ws }) : undefined) ) + $effect(() => { + itemCount = allDucklakes.current?.length ?? 0 + }) + let lakeBehaviors: Record<string, 'isolated' | 'shared'> = $state({}) // Lakes the user explicitly opted out of isolation for — sent as // `shared_ducklakes` on the create-fork request. Unlisted lakes default to the // isolated fork namespace with read-defer, so nothing needs sending for them. export function getSharedDucklakes(): string[] { + if (requireIsolation) return [] return (allDucklakes.current ?? []).filter((name) => lakeBehaviors[name] === 'shared') } - let anyShared = $derived((allDucklakes.current ?? []).some((n) => lakeBehaviors[n] === 'shared')) + let anyShared = $derived( + !requireIsolation && (allDucklakes.current ?? []).some((n) => lakeBehaviors[n] === 'shared') + ) </script> <!-- Self-hides when the workspace has no ducklake configured, like the datatable section. --> {#if allDucklakes.current && allDucklakes.current.length > 0} - <Label label="Ducklake data environment"> - <span class="text-xs text-secondary"> - By default each lake is isolated in the fork: materializations write a fork-scoped namespace - and reads of not-yet-materialized tables defer to this workspace's current data. - </span> + <div class="flex flex-col gap-1"> + <span class="text-xs font-medium text-secondary">Ducklakes</span> <div class="border rounded-md divide-y"> {#each allDucklakes.current as name (name)} <div class="flex items-center gap-2 justify-between px-4 py-1.5"> <span class="text-xs font-medium">{name}</span> <Select dropdownClass="max-w-96" - bind:value={() => lakeBehaviors[name] ?? 'isolated', (v) => (lakeBehaviors[name] = v)} + disabled={requireIsolation} + bind:value={ + () => (requireIsolation ? 'isolated' : (lakeBehaviors[name] ?? 'isolated')), + (v) => (lakeBehaviors[name] = v) + } items={[ - { value: 'isolated', label: 'Isolated (defer reads to parent)' }, + { value: 'isolated', label: 'Isolated' }, { value: 'shared', label: 'Shared with parent' } ]} /> @@ -53,15 +64,10 @@ {/each} </div> {#if anyShared} - <div - class="flex items-start gap-2 px-3 py-2 text-xs rounded-md border border-amber-200 dark:border-amber-900/60 bg-amber-50 dark:bg-amber-950/40 text-amber-700 dark:text-amber-300" - > - <TriangleAlert size={14} class="shrink-0 mt-0.5" /> - <span> - A shared lake is NOT isolated: pipeline runs in the fork read and write this workspace's - tables directly. - </span> - </div> + <span class="flex items-center gap-1 text-2xs text-amber-700 dark:text-amber-300"> + <TriangleAlert size={12} class="shrink-0" /> + Pipeline runs in the fork will write this workspace's tables. + </span> {/if} - </Label> + </div> {/if} diff --git a/frontend/src/lib/components/workspaceSettings/ForkStorageSection.svelte b/frontend/src/lib/components/workspaceSettings/ForkStorageSection.svelte new file mode 100644 index 0000000000..01287a72b1 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/ForkStorageSection.svelte @@ -0,0 +1,76 @@ +<script lang="ts"> + import { SettingService } from '$lib/gen' + import { workspaceStore } from '$lib/stores' + import { resource } from 'runed' + import Select from '../select/Select.svelte' + import { TriangleAlert } from 'lucide-svelte' + + // The workspace the fork is created from, like ForkDucklakeSection: the storages + // listed here must match the ones the create-fork call submits to. + let { + sourceWorkspace, + requireIsolation = false, + itemCount = $bindable() + }: { sourceWorkspace?: string; requireIsolation?: boolean; itemCount?: number } = $props() + let effectiveSource = $derived(sourceWorkspace ?? $workspaceStore ?? undefined) + + let storages = resource( + () => effectiveSource, + async (ws) => + ws + ? SettingService.getSecondaryStorageNames({ workspace: ws, includeDefault: true }) + : undefined + ) + + $effect(() => { + itemCount = storages.current?.length ?? 0 + }) + + let behaviors: Record<string, 'isolated' | 'shared'> = $state({}) + + // Sent as `shared_storages` on the create-fork request; unlisted storages are isolated. + export function getSharedStorages(): string[] { + if (requireIsolation) return [] + return (storages.current ?? []).filter((name) => behaviors[name] === 'shared') + } + + let anyShared = $derived( + !requireIsolation && (storages.current ?? []).some((n) => behaviors[n] === 'shared') + ) + + function storageLabel(name: string): string { + return name === '_default_' ? 'Primary storage' : name + } +</script> + +<!-- Self-hides when the workspace has no object storage configured. --> +{#if storages.current && storages.current.length > 0} + <div class="flex flex-col gap-1"> + <span class="text-xs font-medium text-secondary">Object storage</span> + <div class="border rounded-md divide-y"> + {#each storages.current as name (name)} + <div class="flex items-center gap-2 justify-between px-4 py-1.5"> + <span class="text-xs font-medium">{storageLabel(name)}</span> + <Select + dropdownClass="max-w-96" + disabled={requireIsolation} + bind:value={ + () => (requireIsolation ? 'isolated' : (behaviors[name] ?? 'isolated')), + (v) => (behaviors[name] = v) + } + items={[ + { value: 'isolated', label: 'Isolated' }, + { value: 'shared', label: 'Shared with parent' } + ]} + /> + </div> + {/each} + </div> + {#if anyShared} + <span class="flex items-center gap-1 text-2xs text-amber-700 dark:text-amber-300"> + <TriangleAlert size={12} class="shrink-0" /> + Scripts in the fork will overwrite and delete this workspace's files. + </span> + {/if} + </div> +{/if} diff --git a/frontend/src/lib/components/workspaceSettings/NewDataTableMigrationModal.svelte b/frontend/src/lib/components/workspaceSettings/NewDataTableMigrationModal.svelte index 04cf841b28..09a7ffaac8 100644 --- a/frontend/src/lib/components/workspaceSettings/NewDataTableMigrationModal.svelte +++ b/frontend/src/lib/components/workspaceSettings/NewDataTableMigrationModal.svelte @@ -17,6 +17,13 @@ import { fetchPendingMigrations, outOfOrderRunMessage } from './datatableMigrationUtils' import Select from '../select/Select.svelte' import { parseMigrationRole, withMigrationRole } from '../datatableMigrationRole' + import { + isAnnotatableSchemaName, + parseMigrationSchemas, + suggestMigrationSchemas, + withMigrationSchemas + } from '../datatableMigrationSchemas' + import MultiSelect from '../select/MultiSelect.svelte' import { ADMIN_DATATABLE_ROLE } from '../dbTypes' import { resource } from 'runed' @@ -95,12 +102,57 @@ const roleOf = (d: typeof declaredUp) => (d.kind === 'role' ? d.role : undefined) // A rollback runs as the role its own SQL names: under another role than the up migration it // typically cannot touch what the up created. + // The scope lives in the SQL too, as its `-- schemas` annotation; the picker is a view onto it. + let declaredSchemas = $derived(parseMigrationSchemas(codeUp)) + let selectedSchemas = $derived(declaredSchemas.kind === 'schemas' ? declaredSchemas.schemas : []) + let suggestedSchemas = $derived(suggestMigrationSchemas(codeUp)) + // Schemas other migrations of this data table already declare, offered next to the suggestion. + const knownSchemas = resource( + () => [workspace, datatable] as const, + async ([ws, dt]) => { + try { + const { migrations } = await WorkspaceService.getDatatableMigrationsStatus({ + workspace: ws, + datatableName: dt + }) + return migrations.flatMap((m) => m.schemas ?? []) + } catch { + return [] + } + } + ) + let schemaItems = $derived( + [ + ...new Set([ + ...selectedSchemas, + ...suggestedSchemas, + ...(knownSchemas.current ?? []), + 'public' + ]) + ].map((s) => ({ value: s, label: s })) + ) + let unsuggested = $derived( + suggestedSchemas.length > 0 && + (selectedSchemas.length !== suggestedSchemas.length || + suggestedSchemas.some((s) => !selectedSchemas.includes(s))) + ) + + function setSchemas(schemas: string[]) { + codeUp = withMigrationSchemas( + codeUp, + schemas.map((s) => s.trim().toLowerCase()).filter(isAnnotatableSchemaName) + ) + upEditor?.setCode(codeUp) + } + let sqlProblem = $derived( malformedLine !== undefined ? malformedMessage(malformedLine) - : declaredDown !== undefined && roleOf(declaredDown) !== roleOf(declaredUp) - ? `The down migration runs as ${roleOf(declaredDown) ?? 'admin (no role)'} but the up migration as ${roleOf(declaredUp) ?? 'admin (no role)'}: make their role annotations match` - : undefined + : declaredSchemas.kind === 'malformed' + ? `Malformed scope annotation \`${declaredSchemas.line}\`: write it as \`-- schemas <name>, <name>\`, or pick the schemas above` + : declaredDown !== undefined && roleOf(declaredDown) !== roleOf(declaredUp) + ? `The down migration runs as ${roleOf(declaredDown) ?? 'admin (no role)'} but the up migration as ${roleOf(declaredUp) ?? 'admin (no role)'}: make their role annotations match` + : undefined ) let selectedRole = $derived(declaredUp.kind === 'role' ? declaredUp.role : NO_ROLE) let permissioned = $derived(!!usableRoles.current?.permissioned) @@ -181,17 +233,29 @@ // Start from the transaction template; when prefilled from detected DDL, // wrap that DDL in the same BEGIN; ... END; frame. A role the prefill declares is taken // out first and put back on top: below `BEGIN;` it would not be read. + knownSchemas.refetch() const prefillRole = prefill?.codeUp ? parseMigrationRole(prefill.codeUp) : undefined if (prefill?.codeUp) { + // The scope is handled like the role. One the prefill does not declare is offered as a + // suggestion for the author to accept, never written for them. + const prefillSchemas = parseMigrationSchemas(prefill.codeUp) const wrapped = wrapInTransaction( - ensureTrailingSemicolon(withMigrationRole(prefill.codeUp, undefined)) + ensureTrailingSemicolon( + withMigrationSchemas(withMigrationRole(prefill.codeUp, undefined), []) + ) ) - codeUp = + const withRole = prefillRole?.kind === 'role' ? withMigrationRole(wrapped, prefillRole.role) : prefillRole?.kind === 'malformed' ? `${prefillRole.line}\n${wrapped}` : wrapped + codeUp = + prefillSchemas.kind === 'schemas' + ? withMigrationSchemas(withRole, prefillSchemas.schemas) + : prefillSchemas.kind === 'malformed' + ? `${prefillSchemas.line}\n${withRole}` + : withRole } else { codeUp = PLACEHOLDER } @@ -224,7 +288,7 @@ // status can't be fetched, fall through and let the run/rollback handle it. let pending: Awaited<ReturnType<typeof fetchPendingMigrations>> = [] try { - pending = await fetchPendingMigrations(workspace, datatable) + pending = await fetchPendingMigrations(workspace, datatable, codeUp) } catch {} if (pending.length > 0) { const confirmed = await confirmationModal.ask({ @@ -315,6 +379,35 @@ /> {/if} </div> + <div class="flex flex-col gap-1"> + <MultiSelect + items={schemaItems} + bind:value={() => selectedSchemas, (v) => setSchemas(v)} + placeholder="Schemas: whole database" + searchPlaceholder="Schema name" + createText="Declare schema" + onCreateItem={(s) => setSchemas([...selectedSchemas, s])} + reorderable={false} + size="sm" + /> + <div class="flex items-center gap-2 text-xs text-secondary"> + {#if selectedSchemas.length === 0} + <span> + Declares no schemas, so it applies to the whole database and every later migration waits + for it. Declare the schemas it changes. + </span> + {:else} + <span> + Waits only on earlier migrations that touch {selectedSchemas.join(', ')} or the whole database. + </span> + {/if} + {#if unsuggested} + <Button variant="subtle" unifiedSize="xs" on:click={() => setSchemas(suggestedSchemas)}> + Use {suggestedSchemas.join(', ')} + </Button> + {/if} + </div> + </div> {#if sqlProblem !== undefined} <p class="text-xs text-red-500">{sqlProblem}</p> {:else if permissioned && usableRoles.current?.roles.length === 0} diff --git a/frontend/src/lib/components/workspaceSettings/OAuthClientConfig.svelte b/frontend/src/lib/components/workspaceSettings/OAuthClientConfig.svelte index 1a6f15345f..86d224160c 100644 --- a/frontend/src/lib/components/workspaceSettings/OAuthClientConfig.svelte +++ b/frontend/src/lib/components/workspaceSettings/OAuthClientConfig.svelte @@ -1,11 +1,14 @@ <script lang="ts"> + import type { Snippet } from 'svelte' import { sendUserToast } from '$lib/utils' import { Button, Alert } from '$lib/components/common' - import Label from '$lib/components/Label.svelte' + import Modal from '$lib/components/common/modal/Modal.svelte' + import TextInput from '$lib/components/text_input/TextInput.svelte' import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte' - import { Save, ExternalLink } from 'lucide-svelte' + import { ExternalLink } from 'lucide-svelte' interface Props { + open: boolean serviceName: string serviceDisplayName: string existingConfig?: { @@ -16,178 +19,147 @@ redirectUri: string onConfigSaved: (oauthData: any) => Promise<void> requiresBaseUrl?: boolean - baseUrlLabel?: string baseUrlPlaceholder?: string baseUrlSettingsPath?: string clientIdPlaceholder?: string clientSecretPlaceholder?: string setupInstructions?: string[] + /** Shown above the form, for what the service needs before it can be connected. */ + requirements?: Snippet } let { + open = $bindable(), serviceName, serviceDisplayName, existingConfig, onConfigSaved, redirectUri, requiresBaseUrl = true, - baseUrlLabel = `${serviceDisplayName} base URL`, baseUrlPlaceholder = `https://your-${serviceName}.example.com`, baseUrlSettingsPath = '/settings/admin/security', clientIdPlaceholder = `Generated by ${serviceDisplayName} OAuth app`, clientSecretPlaceholder = `Generated by ${serviceDisplayName} OAuth app`, - setupInstructions + setupInstructions, + requirements }: Props = $props() let saving = $state(false) let clientId = $state('') let clientSecret = $state('') let baseUrl = $state('') + $effect(() => { - if (existingConfig) { - clientId = existingConfig.client_id - clientSecret = existingConfig.client_secret - baseUrl = existingConfig.base_url + if (open) { + clientId = existingConfig?.client_id ?? '' + clientSecret = existingConfig?.client_secret ?? '' + baseUrl = existingConfig?.base_url ?? '' } }) - let canSave = $derived( - !saving && - !!clientId.trim() && - !!clientSecret.trim() && - (!requiresBaseUrl || !!baseUrl.trim()) + const baseUrlValid = $derived.by(() => { + try { + return ['http:', 'https:'].includes(new URL(baseUrl.trim()).protocol) + } catch { + return false + } + }) + + const canSave = $derived( + !saving && !!clientId.trim() && !!clientSecret.trim() && (!requiresBaseUrl || baseUrlValid) ) - async function saveConfig() { - if (!canSave) { - sendUserToast('Please fill in all required fields', true) - return - } - + async function save() { + if (!canSave) return saving = true try { - const oauthData: Record<string, string> = { + await onConfigSaved({ client_id: clientId.trim(), client_secret: clientSecret.trim(), base_url: requiresBaseUrl ? baseUrl.trim().replace(/\/+$/, '') : '', redirect_uri: redirectUri.trim() - } - - await onConfigSaved(oauthData) - resetForm() + }) } catch (err: any) { - sendUserToast(`Failed to configure OAuth client: ${err.message}`, true) + sendUserToast(`Failed to save the OAuth app: ${err.message}`, true) } finally { saving = false } } - - function resetForm() { - clientId = '' - clientSecret = '' - } - - </script> -<div class="space-y-4"> - <div class="flex justify-between items-center"> - <h4 class="text-md font-medium text-primary">{serviceDisplayName} OAuth Client Configuration</h4 - > - </div> +<Modal + bind:open + title="{serviceDisplayName} OAuth app" + description="Members connect their {serviceDisplayName} accounts through this app." + cancelText="Cancel" +> + <div class="flex flex-col gap-6"> + {@render requirements?.()} - {#if !existingConfig} - <Alert type="warning" title="OAuth client required"> - <p class="text-sm mb-2"> - Before you can connect to {serviceDisplayName}, you need to configure an OAuth client. - </p> - {#if setupInstructions} - <ol class="list-decimal list-inside space-y-1 text-sm"> + {#if !existingConfig && setupInstructions} + <Alert type="info" title="Create an OAuth app in {serviceDisplayName}"> + <ol class="list-decimal list-inside flex flex-col gap-1"> {#each setupInstructions as instruction} <li>{@html instruction}</li> {/each} </ol> - {/if} - </Alert> - {/if} + </Alert> + {/if} - <div class="space-y-4"> - <div class="space-y-4"> - {#if requiresBaseUrl} - <div class="flex flex-col gap-1"> - <Label label={baseUrlLabel} /> - <p class="text-xs font-normal text-secondary" - >The base URL of your {serviceDisplayName} instance (without trailing slash)</p - > - <input - type="url" - bind:value={baseUrl} - placeholder={baseUrlPlaceholder} - class="windmill-input" - disabled={saving} - /> - {#if baseUrl && baseUrlSettingsPath} + {#if requiresBaseUrl} + <div class="flex flex-col gap-1"> + <div class="text-xs font-semibold text-emphasis">{serviceDisplayName} URL</div> + <div class="text-xs text-secondary">The address of your {serviceDisplayName} server</div> + <TextInput + bind:value={baseUrl} + error={!!baseUrl.trim() && !baseUrlValid} + inputProps={{ type: 'url', placeholder: baseUrlPlaceholder, disabled: saving }} + /> + {#if baseUrl.trim() && !baseUrlValid} + <div class="text-2xs text-red-500">Enter a full address, starting with https://</div> + {/if} + {#if baseUrlValid && baseUrlSettingsPath} + <div class="flex"> <Button - wrapperClasses="self-start" - href={`${baseUrl}${baseUrlSettingsPath}`} + unifiedSize="sm" + variant="subtle" + href={`${baseUrl.replace(/\/+$/, '')}${baseUrlSettingsPath}`} target="_blank" startIcon={{ icon: ExternalLink }} > - {serviceDisplayName} OAuth Settings + Open the {serviceDisplayName} OAuth settings </Button> - {/if} - </div> - {/if} - - <div class="flex flex-col gap-1"> - <Label label="Redirect URI" /> - <p class="text-xs font-normal text-secondary" - >Use this URL when creating the OAuth app in {serviceDisplayName}</p - > - <ClipboardPanel content={redirectUri} size="sm" /> + </div> + {/if} </div> + {/if} - <div class="flex flex-col gap-1"> - <Label label="Client ID" /> - <input - type="text" - bind:value={clientId} - placeholder={clientIdPlaceholder} - class="windmill-input" - disabled={saving} - /> + <div class="flex flex-col gap-1"> + <div class="text-xs font-semibold text-emphasis">Redirect URI</div> + <div class="text-xs text-secondary"> + Register this URL when creating the OAuth app in {serviceDisplayName} </div> + <ClipboardPanel content={redirectUri} size="sm" /> + </div> - <div class="flex flex-col gap-1"> - <Label label="Client secret" /> - <input - type="password" - bind:value={clientSecret} - placeholder={clientSecretPlaceholder} - class="windmill-input" - disabled={saving} - /> - </div> + <div class="flex flex-col gap-1"> + <div class="text-xs font-semibold text-emphasis">Client ID</div> + <TextInput + bind:value={clientId} + inputProps={{ placeholder: clientIdPlaceholder, disabled: saving }} + /> + </div> - <div class="flex gap-2"> - <Button - color="blue" - onclick={saveConfig} - disabled={!canSave} - startIcon={{ icon: Save }} - > - {saving ? 'Saving...' : 'Save configuration'} - </Button> - </div> + <div class="flex flex-col gap-1"> + <div class="text-xs font-semibold text-emphasis">Client secret</div> + <TextInput + bind:value={clientSecret} + inputProps={{ type: 'password', placeholder: clientSecretPlaceholder, disabled: saving }} + /> </div> </div> - {#if existingConfig} - <Alert type="success" title="OAuth client configured"> - <p class="text-sm"> - OAuth client is configured for {serviceDisplayName}. You can now connect this workspace to - {serviceDisplayName}. - </p> - </Alert> - {/if} -</div> + {#snippet actions()} + <Button variant="accent" disabled={!canSave} loading={saving} onclick={save}>Save</Button> + {/snippet} +</Modal> diff --git a/frontend/src/lib/components/workspaceSettings/RulesetEditor.svelte b/frontend/src/lib/components/workspaceSettings/RulesetEditor.svelte index eefc1d5647..43f5434c4d 100644 --- a/frontend/src/lib/components/workspaceSettings/RulesetEditor.svelte +++ b/frontend/src/lib/components/workspaceSettings/RulesetEditor.svelte @@ -1,8 +1,8 @@ <script lang="ts"> - import { Section, Button, Badge } from '$lib/components/common' + import { Section, Button, DrawerContent } from '$lib/components/common' import Toggle from '$lib/components/Toggle.svelte' import Label from '$lib/components/Label.svelte' - import Select from '$lib/components/select/Select.svelte' + import MultiSelect from '$lib/components/select/MultiSelect.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' import { workspaceStore } from '$lib/stores' import { @@ -16,16 +16,17 @@ import { clone } from '$lib/utils' import { DEV_WORKSPACE_LOCK_RULE_NAME } from '$lib/workspaceProtectionRules.svelte' import { untrack } from 'svelte' - import { Save, X, Plus } from 'lucide-svelte' + import { Save } from 'lucide-svelte' import { safeSelectItems } from '$lib/components/select/utils.svelte' interface Props { rule?: ProtectionRuleset existingNames?: string[] onUpdate?: () => void + onClose: () => void } - let { rule, existingNames = [], onUpdate }: Props = $props() + let { rule, existingNames = [], onUpdate, onClose }: Props = $props() // Create mode vs Edit mode const isCreateMode = $derived(!rule) @@ -44,6 +45,7 @@ let restrictAnonymousAppDeployment = $state(hasRule('RestrictAnonymousAppDeployment')) let restrictPublicRunSharing = $state(hasRule('RestrictPublicRunSharing')) let restrictGuestAppDeployment = $state(hasRule('RestrictGuestAppDeployment')) + let requireIsolatedForks = $state(hasRule('RequireIsolatedForks')) let selectedGroups = $state<string[]>( untrack(() => rule)?.bypass_groups?.map((g) => g.replace('g/', '')) ?? [] ) @@ -59,6 +61,7 @@ let initialRestrictAnonymousAppDeployment = $state(hasRule('RestrictAnonymousAppDeployment')) let initialRestrictPublicRunSharing = $state(hasRule('RestrictPublicRunSharing')) let initialRestrictGuestAppDeployment = $state(hasRule('RestrictGuestAppDeployment')) + let initialRequireIsolatedForks = $state(hasRule('RequireIsolatedForks')) let initialSelectedGroups = $state<string[]>( untrack(() => rule)?.bypass_groups ? untrack(() => rule)!.bypass_groups.map((g) => g.replace('g/', '')) @@ -74,9 +77,7 @@ let availableGroups = $state<string[]>([]) let availableUsers = $state<string[]>([]) - // Temporary values for Select dropdowns - let selectedGroupToAdd = $state<string | undefined>(undefined) - let selectedUserToAdd = $state<string | undefined>(undefined) + let saving = $state(false) // Load available groups and users async function loadAvailableGroups() { @@ -98,26 +99,6 @@ } }) - // Effect to add selected group - $effect(() => { - if (selectedGroupToAdd && !selectedGroups.includes(selectedGroupToAdd)) { - selectedGroups = [...selectedGroups, selectedGroupToAdd] - untrack(() => { - selectedGroupToAdd = undefined // Reset for next selection - }) - } - }) - - // Effect to add selected user - $effect(() => { - if (selectedUserToAdd && !selectedUsers.includes(selectedUserToAdd)) { - selectedUsers = [...selectedUsers, selectedUserToAdd] - untrack(() => { - selectedUserToAdd = undefined // Reset for next selection - }) - } - }) - // Computed properties const hasUnsavedChanges = $derived( isCreateMode @@ -128,6 +109,7 @@ restrictAnonymousAppDeployment || restrictPublicRunSharing || restrictGuestAppDeployment || + requireIsolatedForks || selectedGroups.length > 0 || selectedUsers.length > 0 : name !== initialName || @@ -137,6 +119,7 @@ restrictAnonymousAppDeployment !== initialRestrictAnonymousAppDeployment || restrictPublicRunSharing !== initialRestrictPublicRunSharing || restrictGuestAppDeployment !== initialRestrictGuestAppDeployment || + requireIsolatedForks !== initialRequireIsolatedForks || JSON.stringify([...selectedGroups].sort()) !== JSON.stringify([...initialSelectedGroups].sort()) || JSON.stringify([...selectedUsers].sort()) !== @@ -155,17 +138,10 @@ const canSave = $derived(!nameError && hasUnsavedChanges) - function removeGroup(group: string) { - selectedGroups = selectedGroups.filter((g) => g !== group) - } - - function removeUser(user: string) { - selectedUsers = selectedUsers.filter((u) => u !== user) - } - async function create() { if (!canSave || !$workspaceStore) return + saving = true try { await WorkspaceService.createProtectionRule({ workspace: $workspaceStore, @@ -183,7 +159,8 @@ ...(restrictPublicRunSharing ? ['RestrictPublicRunSharing' as ProtectionRuleKind] : []), ...(restrictGuestAppDeployment ? ['RestrictGuestAppDeployment' as ProtectionRuleKind] - : []) + : []), + ...(requireIsolatedForks ? ['RequireIsolatedForks' as ProtectionRuleKind] : []) ], bypass_groups: selectedGroups, bypass_users: selectedUsers @@ -195,12 +172,15 @@ } catch (error) { console.error('Failed to create protection rule:', error) sendUserToast('Failed to create protection rule', true) + } finally { + saving = false } } async function save() { if (!canSave || !$workspaceStore) return + saving = true try { await WorkspaceService.updateProtectionRule({ workspace: $workspaceStore, @@ -219,7 +199,8 @@ ...(restrictPublicRunSharing ? ['RestrictPublicRunSharing' as ProtectionRuleKind] : []), ...(restrictGuestAppDeployment ? ['RestrictGuestAppDeployment' as ProtectionRuleKind] - : []) + : []), + ...(requireIsolatedForks ? ['RequireIsolatedForks' as ProtectionRuleKind] : []) ], bypass_groups: selectedGroups, bypass_users: selectedUsers @@ -236,6 +217,7 @@ initialRestrictAnonymousAppDeployment = restrictAnonymousAppDeployment initialRestrictPublicRunSharing = restrictPublicRunSharing initialRestrictGuestAppDeployment = restrictGuestAppDeployment + initialRequireIsolatedForks = requireIsolatedForks initialSelectedGroups = clone(selectedGroups) initialSelectedUsers = clone(selectedUsers) @@ -243,198 +225,138 @@ } catch (error) { console.error('Failed to save protection rule:', error) sendUserToast('Failed to save protection rule', true) + } finally { + saving = false } } + + // The required marker already says an empty name is missing; only a clash needs words. + const shownNameError = $derived(name.trim() ? nameError : undefined) </script> -<div class="flex flex-col gap-6 p-4"> - <!-- Name Section --> - <span class="text-secondary text-sm"> - Keep in mind that rulesets can take up to one minute to take effect - </span> - <Section label="Rule Name" class="space-y-2"> - <TextInput - size="md" - bind:value={name} - error={nameError} - inputProps={{ - placeholder: 'Enter rule name', - disabled: isNameLocked - }} - /> - {#if isNameLocked} - <div class="text-xs text-secondary"> - Managed by the dev workspace pairing, which locates this rule by name. Its restrictions and - bypassers below can still be changed. - </div> - {:else if nameError} - <div class="text-xs text-red-600">{nameError}</div> - {/if} - </Section> +<!-- `pl-11` lines the text up with the toggle's label: the `sm` track is 36px plus its 8px gap. --> +{#snippet description(text: string)} + <p class="text-xs text-secondary pl-11">{text}</p> +{/snippet} - <!-- Bypass Permissions Section --> - <Section - label="Bypass Permissions" - description="Select the groups and/or users who can bypass the restrictions defined in this rule. These users will be exempt from the rules configured below." - class="space-y-4" - > - <!-- Groups --> - <div class="flex flex-col gap-2"> - <Label class="text-xs">Groups</Label> - <Select - bind:value={selectedGroupToAdd} - items={safeSelectItems(availableGroups.filter((g) => !selectedGroups.includes(g)))} - placeholder="Select groups..." +<DrawerContent + title={rule ? `Protection rule ${rule.name}` : 'New protection rule'} + on:close={onClose} +> + <div class="flex flex-col gap-6"> + <p class="text-xs text-secondary">A rule can take up to one minute to take effect.</p> + + <Label label="Name" required> + <TextInput + bind:value={name} + error={shownNameError} + inputProps={{ + placeholder: 'Enter rule name', + disabled: isNameLocked + }} /> - {#if selectedGroups.length > 0} - <div class="flex flex-wrap gap-2 mt-2"> - {#each selectedGroups as group (group)} - <Badge color="blue" class="flex items-center gap-1"> - {group} - <button - type="button" - onclick={() => removeGroup(group)} - class="ml-1 hover:text-red-600" - > - <X size={14} /> - </button> - </Badge> - {/each} - </div> + {#if isNameLocked} + <p class="text-2xs text-hint"> + Managed by the dev workspace pairing, which locates this rule by name. Its restrictions + and bypassers can still be changed. + </p> + {:else if shownNameError} + <p class="text-2xs text-red-500">{shownNameError}</p> {/if} - </div> + </Label> - <!-- Users --> - <div class="flex flex-col gap-2"> - <Label class="text-xs">Users</Label> - <Select - bind:value={selectedUserToAdd} - items={safeSelectItems(availableUsers.filter((u) => !selectedUsers.includes(u)))} - placeholder="Select users..." - /> - {#if selectedUsers.length > 0} - <div class="flex flex-wrap gap-2 mt-2"> - {#each selectedUsers as user (user)} - <Badge color="indigo" class="flex items-center gap-1"> - {user} - <button - type="button" - onclick={() => removeUser(user)} - class="ml-1 hover:text-red-600" - > - <X size={14} /> - </button> - </Badge> - {/each} - </div> - {/if} - </div> - </Section> + <Section + label="Bypassers" + description="Groups and users who are exempt from the restrictions below." + class="flex flex-col gap-4" + > + <Label label="Groups"> + <MultiSelect + bind:value={selectedGroups} + items={safeSelectItems(availableGroups)} + placeholder="Select groups" + reorderable={false} + /> + </Label> + <Label label="Users"> + <MultiSelect + bind:value={selectedUsers} + items={safeSelectItems(availableUsers)} + placeholder="Select users" + reorderable={false} + /> + </Label> + </Section> - <!-- Protection Rules Section --> - <Section - label="Protection Rules" - description="Configure the rules that will be enforced" - class="space-y-4" - > - <div class="flex flex-col gap-4"> - <!-- Disable Direct Deployment --> - <div class="flex flex-col gap-2"> + <Section label="Restrictions" class="flex flex-col gap-4"> + <div class="flex flex-col gap-1"> <Toggle bind:checked={disableDirectDeployment} - options={{ - right: 'Disable direct deployment' - }} + options={{ right: 'Disable direct deployment' }} /> - <div class="text-xs text-secondary ml-6"> - Users must use a fork or git branch to make changes. Direct edits are not allowed. - </div> + {@render description( + 'Users must use a fork or git branch to make changes. Direct edits are not allowed.' + )} </div> - - <!-- Disable Fork --> - <div class="flex flex-col gap-2"> - <Toggle - bind:checked={disableFork} - options={{ - right: 'Disable workspace forking' - }} - /> - <div class="text-xs text-secondary ml-6">Users cannot create forks of this workspace.</div> + <div class="flex flex-col gap-1"> + <Toggle bind:checked={disableFork} options={{ right: 'Disable workspace forking' }} /> + {@render description('Users cannot create forks of this workspace.')} </div> - - <!-- Restrict deploy to deployers --> - <div class="flex flex-col gap-2"> + <div class="flex flex-col gap-1"> + <Toggle bind:checked={requireIsolatedForks} options={{ right: 'Require isolated forks' }} /> + {@render description( + "Forks of this workspace cannot share its data: every object storage and ducklake stays isolated, and every data table is cloned instead of pointing at this workspace's database." + )} + </div> + <div class="flex flex-col gap-1"> <Toggle bind:checked={restrictDeployToDeployers} - options={{ - right: 'Restrict deployment to wm_deployers' - }} + options={{ right: 'Restrict deployment to wm_deployers' }} /> - <div class="text-xs text-secondary ml-6"> - Only workspace admins and members of <code>wm_deployers</code> can deploy to this workspace. - Non-deployers can still fork, browse, and request a review. - </div> + {@render description( + 'Only workspace admins and members of wm_deployers can deploy to this workspace. Non-deployers can still fork, browse, and request a review.' + )} </div> - - <!-- Restrict anonymous app deployment --> - <div class="flex flex-col gap-2"> + <div class="flex flex-col gap-1"> <Toggle bind:checked={restrictAnonymousAppDeployment} - options={{ - right: 'Restrict public app access' - }} + options={{ right: 'Restrict public app access' }} /> - <div class="text-xs text-secondary ml-6"> - Only workspace admins and bypass users can make an app publicly accessible without login - (anonymous execution mode). Apps that are already public can still be redeployed. - </div> + {@render description( + 'Only workspace admins and bypassers can make an app publicly accessible without login (anonymous execution mode). Apps that are already public can still be redeployed.' + )} </div> - - <!-- Restrict guest app deployment --> - <div class="flex flex-col gap-2"> + <div class="flex flex-col gap-1"> <Toggle bind:checked={restrictGuestAppDeployment} - options={{ - right: 'Restrict guest app access' - }} + options={{ right: 'Restrict guest app access' }} /> - <div class="text-xs text-secondary ml-6"> - Only workspace admins and bypass users can open an app to guests (anyone who signs in, - with no workspace membership and no seat). Apps that already admit guests can still be - redeployed. - </div> + {@render description( + 'Only workspace admins and bypassers can open an app to guests (anyone who signs in, with no workspace membership and no seat). Apps that already admit guests can still be redeployed.' + )} </div> - - <!-- Restrict public run sharing --> - <div class="flex flex-col gap-2"> + <div class="flex flex-col gap-1"> <Toggle bind:checked={restrictPublicRunSharing} - options={{ - right: 'Restrict public run sharing' - }} + options={{ right: 'Restrict public run sharing' }} /> - <div class="text-xs text-secondary ml-6"> - Only workspace admins and bypass users can mint a public link to a run (readable without - login). The read-only link for workspace members stays available to everyone. - </div> + {@render description( + 'Only workspace admins and bypassers can mint a public link to a run (readable without login). The read-only link for workspace members stays available to everyone.' + )} </div> - </div> - </Section> + </Section> + </div> - <!-- Actions --> - <div class="flex items-center gap-4 pt-4 border-t"> + {#snippet actions()} <Button variant="accent" unifiedSize="md" disabled={!canSave} + loading={saving} + startIcon={{ icon: Save }} on:click={isCreateMode ? create : save} - startIcon={{ icon: isCreateMode ? Plus : Save }} > - {isCreateMode ? 'Create Rule' : 'Save Rule'} + {isCreateMode ? 'Create' : 'Save'} </Button> - - {#if hasUnsavedChanges && !isCreateMode} - <span class="text-xs text-secondary">You have unsaved changes</span> - {/if} - </div> -</div> + {/snippet} +</DrawerContent> diff --git a/frontend/src/lib/components/workspaceSettings/SlackChannelAgents.svelte b/frontend/src/lib/components/workspaceSettings/SlackChannelAgents.svelte new file mode 100644 index 0000000000..f8ccc93169 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/SlackChannelAgents.svelte @@ -0,0 +1,48 @@ +<script lang="ts"> + import { resource } from 'runed' + import { Badge } from '$lib/components/common' + import SettingCard from '$lib/components/instanceSettings/SettingCard.svelte' + import Cell from '$lib/components/table/Cell.svelte' + import DataTable from '$lib/components/table/DataTable.svelte' + import Head from '$lib/components/table/Head.svelte' + import Row from '$lib/components/table/Row.svelte' + import { SlackAgentsService } from '$lib/gen' + + /** The channels the workspace's agents are shared in, workspace-wide. */ + let { workspace }: { workspace: string } = $props() + + const channels = resource( + () => workspace, + async (workspace) => await SlackAgentsService.listSlackAgentChannels({ workspace }) + ) +</script> + +{#if channels.current?.length} + <SettingCard + label="Agents shared in Slack" + description="Bot mentions that name no agent go to the channel's default agent. Share an agent or change its channels from its settings." + > + <DataTable> + <Head> + <tr> + <Cell head first>Channel</Cell> + <Cell head>Agent</Cell> + <Cell head last></Cell> + </tr> + </Head> + <tbody class="divide-y bg-surface-tertiary"> + {#each channels.current as channel (channel.channel_id + channel.agent_path)} + <Row> + <Cell first>#{channel.channel_name}</Cell> + <Cell><a href="/agents/get/{channel.agent_path}">{channel.agent_path}</a></Cell> + <Cell last class="w-24"> + {#if channel.is_default} + <Badge small>Default</Badge> + {/if} + </Cell> + </Row> + {/each} + </tbody> + </DataTable> + </SettingCard> +{/if} diff --git a/frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte b/frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte index 622a9620a4..e1b8672e03 100644 --- a/frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte +++ b/frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte @@ -4,22 +4,25 @@ import { Button, Alert } from '$lib/components/common' import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte' import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte' - import { Check, X, ExternalLink, Cog, Plug } from 'lucide-svelte' + import { Check, ExternalLink, KeyRound, Pencil, Trash2 } from 'lucide-svelte' + import DropdownV2 from '$lib/components/DropdownV2.svelte' + import type { Item } from '$lib/utils' import NextcloudIcon from '$lib/components/icons/NextcloudIcon.svelte' import GithubIcon from '$lib/components/icons/GithubIcon.svelte' import GoogleIcon from '$lib/components/icons/GoogleIcon.svelte' - import { WorkspaceIntegrationService, type NativeServiceName } from '$lib/gen' - import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte' + import { + WorkspaceIntegrationService, + type NativeServiceName, + type NativeTriggerConnection + } from '$lib/gen' import OAuthClientConfig from './OAuthClientConfig.svelte' import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte' import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte' - import Path from '$lib/components/Path.svelte' - import { page } from '$app/state' - import { goto } from '$app/navigation' + import NativeConnectionPicker from '$lib/components/triggers/native/NativeConnectionPicker.svelte' + import { nativeOAuthRedirectUri } from '$lib/components/triggers/native/utils' interface WorkspaceIntegration { service_name: string - resource_path?: string oauth_data: { client_id: string client_secret: string @@ -44,7 +47,7 @@ nextcloud: { name: 'nextcloud', displayName: 'Nextcloud', - description: 'Connect to Nextcloud for file operations and webhook triggers', + description: 'File events', icon: NextcloudIcon, docsUrl: 'https://www.windmill.dev/docs/integrations/nextcloud', setupInstructions: [ @@ -56,7 +59,7 @@ google: { name: 'google', displayName: 'Google', - description: 'Connect to Google for Drive and Calendar triggers', + description: 'Drive and Calendar changes', icon: GoogleIcon, docsUrl: 'https://www.windmill.dev/docs/core_concepts/native_triggers#google-triggers', requiresBaseUrl: false, @@ -73,7 +76,7 @@ github: { name: 'github', displayName: 'GitHub', - description: 'Connect to GitHub for repository webhook triggers', + description: 'Repository events', icon: GithubIcon, docsUrl: 'https://www.windmill.dev/docs/core_concepts/native_triggers#github-triggers', requiresBaseUrl: false, @@ -89,18 +92,13 @@ } let integrations = $state<WorkspaceIntegration[]>([]) + let connections = $state<Record<string, NativeTriggerConnection[]>>({}) let loading = $state(false) - let connecting = $state<string | null>(null) - let showingConfig = $state<string | null>(null) + // The service whose OAuth app dialog is open. + let configOpen = $state<Record<string, boolean>>( + Object.fromEntries(Object.keys(supportedServices).map((s) => [s, false])) + ) let instanceSharingAvailable = $state<Record<string, boolean>>({}) - let pendingCallback = $state<{ - serviceName: NativeServiceName - code: string - state: string - workspace: string - } | null>(null) - let resourcePath = $state<string | undefined>(undefined) - let pathError = $state<string | undefined>(undefined) let confirmationModal = createAsyncConfirmationModal() async function loadIntegrations() { @@ -113,9 +111,9 @@ }) integrations = response.map((item) => ({ service_name: item.service_name, - resource_path: item.resource_path ?? undefined, oauth_data: item.oauth_data || null })) + await Promise.all(Object.keys(supportedServices).map(loadConnections)) } catch (err: any) { console.error('Failed to load workspace integrations:', err) sendUserToast(`Failed to load integrations: ${err.message}`, true) @@ -124,59 +122,132 @@ } } + async function loadConnections(serviceName: string) { + if (!$workspaceStore) return + try { + connections[serviceName] = await WorkspaceIntegrationService.listNativeTriggerConnections({ + workspace: $workspaceStore, + serviceName: serviceName as NativeServiceName + }) + } catch { + connections[serviceName] = [] + } + } + async function deleteIntegration(serviceName: string) { if (!$workspaceStore) return const displayName = supportedServices[serviceName]?.displayName ?? serviceName const confirmed = await confirmationModal.ask({ - title: `Disconnect ${displayName}?`, - confirmationText: 'Disconnect', - children: `This will delete all ${displayName} triggers associated with this integration. This action cannot be undone.` + title: `Remove the ${displayName} configuration?`, + confirmationText: 'Remove', + children: `This deletes the workspace's ${displayName} OAuth app, every ${displayName} account connected through it and all ${displayName} triggers, which stop receiving events. This action cannot be undone.` }) if (!confirmed) return try { await WorkspaceIntegrationService.deleteNativeTriggerService({ workspace: $workspaceStore, - serviceName: serviceName as any + serviceName: serviceName as NativeServiceName }) - sendUserToast(`${displayName} disconnected successfully`) + sendUserToast(`${displayName} integration removed`) loadIntegrations() } catch (err: any) { - sendUserToast(`Failed to disconnect ${displayName}: ${err.message}`, true) + sendUserToast(`Failed to remove ${displayName}: ${err.body ?? err.message}`, true) } } - async function connectService(serviceName: string, redirectUri: string) { - if (!$workspaceStore) return + async function setWorkspaceConnection( + serviceName: string, + path: string | undefined + ): Promise<boolean> { + if (!$workspaceStore) return false - connecting = serviceName - try { - const auth_url = await WorkspaceIntegrationService.generateNativeTriggerServiceConnectUrl({ - workspace: $workspaceStore, - serviceName: serviceName as any, - requestBody: { redirect_uri: redirectUri } + const displayName = supportedServices[serviceName]?.displayName ?? serviceName + if (path) { + const owner = connections[serviceName]?.find((c) => c.path === path)?.owner + const account = owner ? `this account, connected by ${owner}` : 'this account' + const browsable: string | undefined = ( + { google: 'its Drive files and calendars', github: 'its repositories' } as Record< + string, + string + > + )[serviceName] + const browse = browsable ? `, and browse ${browsable} in the trigger editor` : '' + const confirmed = await confirmationModal.ask({ + title: `Share ${path} with the workspace?`, + confirmationText: 'Share', + children: `Every member will be able to create and edit ${displayName} triggers that act as ${account}${browse}. They will not see its token. It replaces any connection shared before.` }) - - if (auth_url) { - window.location.href = auth_url - } - } catch (err: any) { - sendUserToast( - `Failed to connect ${supportedServices[serviceName]?.displayName}: ${err.message}`, - true - ) - connecting = null + if (!confirmed) return false + } else { + const confirmed = await confirmationModal.ask({ + title: 'Stop sharing this account?', + confirmationText: 'Stop sharing', + children: `Members will no longer be able to create or edit ${displayName} triggers through it, unless they can read it. Its triggers keep running.` + }) + if (!confirmed) return false } + + try { + await WorkspaceIntegrationService.setNativeTriggerWorkspaceConnection({ + workspace: $workspaceStore, + serviceName: serviceName as NativeServiceName, + requestBody: { path: path ?? null } + }) + sendUserToast(path ? `${path} is now the shared account` : 'Stopped sharing') + await loadConnections(serviceName) + return true + } catch (err: any) { + sendUserToast(`Failed to update the shared account: ${err.body ?? err.message}`, true) + return false + } + } + + function docsItem(config: ServiceConfig): Item[] { + return config.docsUrl + ? [{ displayName: 'Docs', icon: ExternalLink, href: config.docsUrl, hrefTarget: '_blank' }] + : [] + } + + async function openConfig(serviceName: string, leavingInstanceApp: boolean) { + const displayName = supportedServices[serviceName]?.displayName ?? serviceName + // A refresh token only works with the OAuth app that issued it. + if (leavingInstanceApp && (connections[serviceName]?.length ?? 0) > 0) { + const confirmed = await confirmationModal.ask({ + title: `Use your own ${displayName} OAuth app?`, + confirmationText: 'Continue', + children: `Accounts connected with the instance app will have to be connected again, at the same path, before their triggers work again.` + }) + if (!confirmed) return + } + configOpen[serviceName] = true } async function createOrUpdateIntegration(serviceName: string, oauthData: any) { if (!$workspaceStore) return + const displayName = supportedServices[serviceName]?.displayName ?? serviceName + const current = getIntegrationByService(serviceName)?.oauth_data + // Leaving the instance app is confirmed when the form opens; this covers editing our own app. + const clientChanges = + !!current?.client_id && + !current.instance_shared && + current.client_id !== (oauthData.client_id ?? '') + // A refresh token only works with the OAuth app that issued it. + if (clientChanges && (connections[serviceName]?.length ?? 0) > 0) { + const confirmed = await confirmationModal.ask({ + title: `Change the ${displayName} OAuth app?`, + confirmationText: 'Change', + children: `Accounts connected with the current app must be connected again, at the same path, before their triggers work again.` + }) + if (!confirmed) return + } + try { await WorkspaceIntegrationService.createNativeTriggerService({ workspace: $workspaceStore, - serviceName: serviceName as any, + serviceName: serviceName as NativeServiceName, requestBody: oauthData }) sendUserToast( @@ -207,33 +278,9 @@ } } - async function connectWithInstanceCredentials(serviceName: string) { - if (!$workspaceStore) return - - connecting = serviceName - try { - const redirectUri = getRedirectUri(serviceName) - const auth_url = await WorkspaceIntegrationService.generateInstanceConnectUrl({ - workspace: $workspaceStore, - serviceName: serviceName as NativeServiceName, - requestBody: { redirect_uri: redirectUri } - }) - - if (auth_url) { - window.location.href = auth_url - } - } catch (err: any) { - sendUserToast( - `Failed to connect ${supportedServices[serviceName]?.displayName}: ${err.message}`, - true - ) - connecting = null - } - } - - function isConfigured(integration: WorkspaceIntegration): boolean { - if (integration.oauth_data === null) return false - if (integration.oauth_data.instance_shared) return true + /** Whether the workspace has its own OAuth app for the service. */ + function hasWorkspaceApp(integration: WorkspaceIntegration | null): boolean { + if (!integration?.oauth_data || integration.oauth_data.instance_shared) return false const serviceConfig = supportedServices[integration.service_name] const needsBaseUrl = serviceConfig?.requiresBaseUrl !== false return ( @@ -243,82 +290,10 @@ ) } - function isConnected(integration: WorkspaceIntegration): boolean { - if (integration.oauth_data?.instance_shared) { - return !!integration.resource_path - } - return isConfigured(integration) && !!integration.resource_path - } - function getIntegrationByService(serviceName: string): WorkspaceIntegration | null { return integrations.find((integration) => integration.service_name === serviceName) || null } - function handleOAuthCallback( - workspace: string, - serviceName: NativeServiceName, - code: string, - state: string - ) { - // Phase 1: store callback params and clean URL — don't call backend yet - pendingCallback = { serviceName, code, state, workspace } - - // Pre-populate resource path from existing integration (for reconnect) - const integration = getIntegrationByService(serviceName) - resourcePath = integration?.resource_path ?? undefined - - const url = new URL(page.url) - url.searchParams.delete('code') - url.searchParams.delete('state') - url.searchParams.delete('service') - goto(url.toString(), { replaceState: true, noScroll: true, keepFocus: true }) - } - - async function finalizePendingCallback() { - if (!pendingCallback) return - - const { serviceName, code, state, workspace } = pendingCallback - try { - const redirectUri = getRedirectUri(serviceName) - await WorkspaceIntegrationService.nativeTriggerServiceCallback({ - serviceName, - workspace, - requestBody: { - code, - state, - redirect_uri: redirectUri, - resource_path: resourcePath - } - }) - sendUserToast(`${supportedServices[serviceName]?.displayName} connected successfully!`) - await loadIntegrations() - } catch (err: any) { - sendUserToast(`Failed to complete OAuth connection: ${err.message}`, true) - } finally { - pendingCallback = null - resourcePath = undefined - pathError = undefined - } - } - - $effect(() => { - if ( - page.url.searchParams.has('code') && - page.url.searchParams.has('state') && - page.url.searchParams.has('service') && - $workspaceStore - ) { - const service = page.url.searchParams.get('service')! as NativeServiceName - const code = page.url.searchParams.get('code')! - const state = page.url.searchParams.get('state')! - handleOAuthCallback($workspaceStore, service, code, state) - } - }) - - function getRedirectUri(serviceName: string): string { - return `${window.location.origin}/workspace_settings?tab=native_triggers&service=${serviceName}` - } - $effect(() => { if ($workspaceStore) { loadIntegrations() @@ -329,49 +304,12 @@ <div class="flex flex-col"> <SettingsPageHeader - title="Native Triggers" - description="Connect your workspace to external services for native triggers and enhanced functionality. These connections are shared across all workspace members and are required for native triggers to work." + title="Native triggers" + description="Configure the OAuth app of each service. Members connect their own account when creating a trigger, or use the shared account, and each trigger acts as the account it was created with." link="https://www.windmill.dev/docs/core_concepts/native_triggers" /> - {#if pendingCallback} - {@const serviceName = pendingCallback.serviceName} - {@const config = supportedServices[serviceName]} - <div class="border border-gray-200 dark:border-gray-700 rounded-md p-4 bg-surface-tertiary"> - <div class="text-sm font-semibold text-emphasis mb-2"> - Save {config?.displayName ?? serviceName} credentials as resource - </div> - <div class="text-xs text-secondary mb-3"> - Choose where to save the OAuth resource. This resource will store the access token for the - integration. - </div> - <Path - kind="resource" - initialPath={resourcePath ?? ''} - namePlaceholder={'native_' + serviceName} - bind:path={resourcePath} - bind:error={pathError} - /> - <div class="flex gap-2 mt-3"> - <Button - variant="accent" - disabled={!resourcePath || !!pathError} - onclick={finalizePendingCallback} - > - Save - </Button> - <Button - onclick={() => { - pendingCallback = null - resourcePath = undefined - pathError = undefined - }} - > - Cancel - </Button> - </div> - </div> - {:else if loading} + {#if loading} <div class="space-y-4"> {#each new Array(3) as _} <Skeleton layout={[[6], 0.4]} /> @@ -381,164 +319,144 @@ <div class="space-y-4"> {#each Object.entries(supportedServices) as [serviceName, config]} {@const integration = getIntegrationByService(serviceName)} - {@const isConnecting = connecting === serviceName} - {@const isOAuthConfigured = integration && isConfigured(integration)} - {@const isServiceConnected = integration && isConnected(integration)} - {@const isShowingConfig = showingConfig === serviceName} + {@const workspaceApp = hasWorkspaceApp(integration)} + {@const usesInstanceApp = !workspaceApp && instanceSharingAvailable[serviceName]} + {@const workspaceConnection = connections[serviceName]?.find((c) => c.workspace)?.path} - <div class="border border-gray-200 dark:border-gray-700 rounded-md p-4 bg-surface-tertiary"> - <div class="flex items-center justify-between"> - <div class="flex items-center gap-3"> - <div class="w-8 h-8 flex items-center justify-center"> - <config.icon class="w-6 h-6" /> - </div> - <div class="flex flex-col"> - <div class="text-sm font-semibold text-emphasis">{config.displayName}</div> - <div class="text-xs font-normal text-primary">{config.description}</div> - </div> - </div> - - <div class="flex items-center gap-2"> - {#if isServiceConnected} - <div class="flex items-center gap-1 text-green-600 text-xs"> - <Check size={16} /> - <span class="font-semibold">Connected</span> - </div> - <Button - onclick={() => - integration?.oauth_data?.instance_shared - ? connectWithInstanceCredentials(serviceName) - : connectService(serviceName, getRedirectUri(serviceName))} - disabled={isConnecting} - startIcon={{ icon: Plug }} - > - {isConnecting ? 'Reconnecting...' : 'Reconnect'} - </Button> - <Button - destructive - onclick={() => deleteIntegration(serviceName)} - startIcon={{ icon: X }} - > - Delete - </Button> - {:else if isOAuthConfigured} - <Button - variant="accent" - onclick={() => connectService(serviceName, getRedirectUri(serviceName))} - disabled={isConnecting} - startIcon={{ icon: Plug }} - > - {isConnecting ? 'Connecting...' : 'Connect'} - </Button> - <Button - destructive - onclick={() => deleteIntegration(serviceName)} - startIcon={{ icon: X }} - > - Delete - </Button> - {:else if instanceSharingAvailable[serviceName]} - <Button - variant="accent" - onclick={() => connectWithInstanceCredentials(serviceName)} - disabled={isConnecting} - startIcon={{ icon: Plug }} - > - {isConnecting ? 'Connecting...' : 'Connect'} - </Button> - {:else} - <Button - variant="accent" - onclick={() => - (showingConfig = showingConfig === serviceName ? null : serviceName)} - startIcon={{ icon: Cog }} - > - Configure OAuth - </Button> - {/if} - - {#if config.docsUrl} - <Button href={config.docsUrl} target="_blank" startIcon={{ icon: ExternalLink }}> - Docs - </Button> - {/if} + <div class="border border-light rounded-md p-4 bg-surface-tertiary flex flex-col gap-3"> + <div class="flex items-center gap-3"> + <config.icon class="w-6 h-6 shrink-0" /> + <div class="flex flex-col flex-1 min-w-0"> + <div class="text-sm font-semibold text-emphasis">{config.displayName}</div> + <div class="text-xs text-secondary">{config.description}</div> </div> + {#if workspaceApp || usesInstanceApp} + <DropdownV2 + items={workspaceApp + ? [ + { + displayName: 'Edit OAuth app', + icon: Pencil, + action: () => openConfig(serviceName, false) + }, + ...docsItem(config), + { + displayName: 'Remove configuration', + icon: Trash2, + type: 'delete', + action: () => deleteIntegration(serviceName) + } + ] + : [ + { + displayName: 'Use a workspace OAuth app', + icon: KeyRound, + action: () => openConfig(serviceName, true) + }, + ...docsItem(config) + ]} + /> + {:else} + <Button + unifiedSize="sm" + variant="default" + onclick={() => openConfig(serviceName, false)} + > + Set up + </Button> + {/if} </div> - {#if instanceSharingAvailable[serviceName] && !isOAuthConfigured} - <div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700"> - <Alert type="info" title="Redirect URI required"> - <p class="text-sm mb-2"> - Your instance admin has configured Google OAuth for native triggers. Before - connecting, ensure the following redirect URI has been added to the - <a - href="https://console.cloud.google.com/apis/credentials" - target="_blank" - rel="noopener noreferrer" - class="underline">Google Cloud Console</a - > by the instance admin: - </p> - <ClipboardPanel content={getRedirectUri(serviceName)} size="sm" /> + {#if !workspaceApp && !usesInstanceApp} + <div class="text-xs text-primary"> + Not configured. Members can't create {config.displayName} triggers yet. + </div> + {:else} + <div class="flex items-start gap-1.5 text-xs text-primary"> + <Check size={14} class="shrink-0 mt-0.5" /> + <span> + {workspaceApp ? 'Configured in workspace.' : 'Configured by instance.'} + Members connect their own {config.displayName} account when they create a trigger. + </span> + </div> + + {#if serviceName === 'nextcloud' && !workspaceConnection} + <Alert + type="info" + title="Creating Nextcloud triggers requires a Nextcloud admin account" + > + We recommend connecting one below and sharing it with the workspace. </Alert> + {/if} + + <div class="flex flex-col gap-1"> + <div class="text-xs font-semibold text-emphasis">Shared account</div> + <div class="text-xs text-secondary"> + Members can create triggers as this account and pick from what it can access, + without seeing its token. + </div> + <div class="max-w-md"> + <!-- Remounted so its list includes an account connected from elsewhere on the page. --> + {#key workspaceConnection} + <NativeConnectionPicker + service={serviceName as NativeServiceName} + value={workspaceConnection} + autoSelect={false} + clearable + labelWorkspace={false} + placeholder="None" + onSelect={(path) => setWorkspaceConnection(serviceName, path)} + onConnectionsChange={() => loadConnections(serviceName)} + /> + {/key} + </div> </div> {/if} - {#if isShowingConfig} - <div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700"> + <OAuthClientConfig + bind:open={configOpen[serviceName]} + {serviceName} + redirectUri={nativeOAuthRedirectUri(serviceName as NativeServiceName)} + serviceDisplayName={config.displayName} + existingConfig={workspaceApp ? integration?.oauth_data : null} + requiresBaseUrl={config.requiresBaseUrl !== false} + clientIdPlaceholder={config.clientIdPlaceholder} + clientSecretPlaceholder={config.clientSecretPlaceholder} + setupInstructions={config.setupInstructions} + onConfigSaved={async (oauthData) => { + await createOrUpdateIntegration(serviceName, oauthData) + configOpen[serviceName] = false + }} + > + {#snippet requirements()} {#if serviceName === 'nextcloud'} - <Alert type="info" title="Requirements" class="mb-4"> - <p>Nextcloud integration requires:</p> - <ul class="list-disc pl-4 mt-2 space-y-1"> + <Alert type="info" title="Nextcloud requirements"> + <ul class="list-disc pl-4 flex flex-col gap-1"> <li>Nextcloud 33 or later.</li> <li> The <a href="https://apps.nextcloud.com/apps/integration_windmill" target="_blank" rel="noopener noreferrer" - class="underline hover:text-blue-600" - > - Windmill integration app - </a> to be installed on your Nextcloud instance. + class="underline">Windmill integration app</a + > installed. </li> <li> <a href="https://docs.nextcloud.com/server/latest/admin_manual/installation/source_installation.html#pretty-urls" target="_blank" rel="noopener noreferrer" - class="underline hover:text-blue-600" - > - Pretty URLs - </a> - to be enabled on your Nextcloud instance. + class="underline">Pretty URLs</a + > enabled. </li> </ul> </Alert> {/if} - <OAuthClientConfig - {serviceName} - redirectUri={getRedirectUri(serviceName)} - serviceDisplayName={config.displayName} - existingConfig={integration?.oauth_data} - requiresBaseUrl={config.requiresBaseUrl !== false} - clientIdPlaceholder={config.clientIdPlaceholder} - clientSecretPlaceholder={config.clientSecretPlaceholder} - setupInstructions={config.setupInstructions} - onConfigSaved={async (oauthData) => { - await createOrUpdateIntegration(serviceName, oauthData) - showingConfig = null - }} - /> - </div> - {/if} + {/snippet} + </OAuthClientConfig> </div> {/each} </div> - - {#if integrations.length === 0} - <Alert type="warning" title="No Integrations Connected"> - Connect to external services above to enable native triggers for your workspace. - </Alert> - {/if} {/if} </div> diff --git a/frontend/src/lib/components/workspaceSettings/WorkspaceRulesets.svelte b/frontend/src/lib/components/workspaceSettings/WorkspaceRulesets.svelte index 142c2ad29f..805dc85764 100644 --- a/frontend/src/lib/components/workspaceSettings/WorkspaceRulesets.svelte +++ b/frontend/src/lib/components/workspaceSettings/WorkspaceRulesets.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import { Alert, Button, Drawer, DrawerContent, Skeleton } from '$lib/components/common' + import { Alert, Button, Drawer, Skeleton } from '$lib/components/common' import Dropdown from '$lib/components/DropdownV2.svelte' import DataTable from '$lib/components/table/DataTable.svelte' import Head from '$lib/components/table/Head.svelte' @@ -99,19 +99,15 @@ </script> <Drawer bind:this={ruleDrawer}> - <DrawerContent - title={selectedRule ? `Protection Rule: ${selectedRule.name}` : 'New Protection Rule'} - on:close={ruleDrawer?.closeDrawer} - > - <RulesetEditor - rule={selectedRule} - existingNames={existingRuleNames} - onUpdate={() => { - loadRules() - ruleDrawer?.closeDrawer() - }} - /> - </DrawerContent> + <RulesetEditor + rule={selectedRule} + existingNames={existingRuleNames} + onClose={() => ruleDrawer?.closeDrawer()} + onUpdate={() => { + loadRules() + ruleDrawer?.closeDrawer() + }} + /> </Drawer> {#if !$enterpriseLicense} diff --git a/frontend/src/lib/components/workspaceSettings/WorkspaceSettingsYaml.svelte b/frontend/src/lib/components/workspaceSettings/WorkspaceSettingsYaml.svelte new file mode 100644 index 0000000000..891ed55008 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/WorkspaceSettingsYaml.svelte @@ -0,0 +1,278 @@ +<script lang="ts"> + import { Alert, Skeleton } from '$lib/components/common' + import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' + import SettingsFooter from './SettingsFooter.svelte' + import { WorkspaceService } from '$lib/gen' + import type { ApplyWorkspaceSettingsDocumentResponse } from '$lib/gen' + import { workspaceStore } from '$lib/stores' + import { sendUserToast } from '$lib/toast' + import { Loader2 } from 'lucide-svelte' + import YAML from 'yaml' + import { untrack } from 'svelte' + import type SimpleEditor from '$lib/components/SimpleEditor.svelte' + + let { + onApplied + }: { + /** Called after the document was applied, so the form panes can reload what they show. */ + onApplied?: () => void | Promise<void> + } = $props() + + type Change = ApplyWorkspaceSettingsDocumentResponse['changes'][number] + + // Keys the server never applies from the document. `color` and `operator_settings` are applied + // when set but a null one is ignored, so deleting them does not clear them either. + const READ_ONLY_KEYS = ['name', 'slack_team_id', 'slack_name'] + const NOT_CLEARABLE_KEYS = ['color', 'operator_settings'] + + let loading = $state(true) + let code = $state('') + let initialCode = $state('') + let loadedKeys: string[] = [] + let editor: SimpleEditor | undefined = $state(undefined) + + let loadError = $state('') + let parseError = $state('') + let applyError = $state('') + let warnings: string[] = $state([]) + + // The workspace the editor's document was loaded from. Requests go there rather than to the + // workspace selected at call time, so a document is never applied to another workspace. + let documentWorkspace: string | undefined = undefined + let pendingWorkspace: string | undefined = undefined + let pendingDocument: Record<string, unknown> | undefined = undefined + let pendingChanges: Change[] = $state([]) + let confirmOpen = $state(false) + let applying = $state(false) + + function clearPending() { + confirmOpen = false + pendingDocument = undefined + pendingWorkspace = undefined + } + + async function load() { + const workspace = $workspaceStore! + if (workspace !== documentWorkspace) { + documentWorkspace = undefined + loadedKeys = [] + code = '' + initialCode = '' + editor?.setCode('') + applyError = '' + warnings = [] + clearPending() + } + loading = true + loadError = '' + let doc: Record<string, unknown> | undefined + try { + doc = await WorkspaceService.getWorkspaceSettingsDocument({ workspace }) + } catch (e) { + if (workspace === $workspaceStore) loadError = errorMessage(e) + } + if (workspace !== $workspaceStore) return + loadedKeys = doc ? Object.keys(doc) : [] + code = doc ? YAML.stringify(doc) : '' + initialCode = code + editor?.setCode(code) + parseError = '' + documentWorkspace = workspace + loading = false + } + + $effect(() => { + if ($workspaceStore) { + untrack(() => load()) + } + }) + + function errorMessage(e: unknown): string { + const body = (e as { body?: unknown })?.body + if (typeof body === 'string' && body) return body + return e instanceof Error ? e.message : String(e) + } + + /** The document to send: a key the editor no longer has becomes `null`, which clears it. */ + function documentFromEditor(): Record<string, unknown> | undefined { + const current = editor?.getCode() ?? code + code = current + let parsed: unknown + try { + parsed = YAML.parse(current) + } catch (e) { + parseError = String(e) + return undefined + } + if (parsed == null) parsed = {} + if (typeof parsed !== 'object' || Array.isArray(parsed)) { + parseError = 'The document must be a mapping (key: value)' + return undefined + } + parseError = '' + const doc = { ...(parsed as Record<string, unknown>) } + for (const key of loadedKeys) { + if (!(key in doc)) doc[key] = null + } + return doc + } + + async function review(): Promise<boolean> { + applyError = '' + warnings = [] + const workspace = documentWorkspace + if (!workspace) return false + const document = documentFromEditor() + if (!document) return false + try { + const res = await WorkspaceService.applyWorkspaceSettingsDocument({ + workspace, + requestBody: { document, replace: false, dry_run: true } + }) + if (workspace !== documentWorkspace) return false + warnings = res.warnings ?? [] + if (res.changes.length === 0) { + sendUserToast('No setting would change') + return false + } + pendingWorkspace = workspace + pendingDocument = document + pendingChanges = res.changes + confirmOpen = true + } catch (e) { + if (workspace === documentWorkspace) applyError = errorMessage(e) + } + return false + } + + async function apply() { + const workspace = pendingWorkspace + const document = pendingDocument + if (!document || !workspace || workspace !== documentWorkspace) { + clearPending() + return + } + applying = true + let ok = false + try { + const res = await WorkspaceService.applyWorkspaceSettingsDocument({ + workspace, + requestBody: { document, replace: false } + }) + warnings = res.warnings ?? [] + ok = true + sendUserToast(`Applied ${res.changes.length} setting change(s)`) + } catch (e) { + applyError = errorMessage(e) + } finally { + applying = false + clearPending() + } + // A failing write stops the apply but keeps the writes before it, so the form panes reload + // either way; the editor keeps the user's text on failure so it can be fixed and retried. + try { + await Promise.all([ok ? load() : undefined, onApplied?.()]) + } catch (e) { + sendUserToast(`Could not reload the settings: ${errorMessage(e)}`, true) + } + } + + export function discard() { + code = initialCode + editor?.setCode(initialCode) + parseError = '' + applyError = '' + warnings = [] + } + + export function unsavedChanges() { + return { + savedValue: { code: initialCode }, + modifiedValue: { code: editor?.getCode() ?? code } + } + } + + function formatValue(v: unknown): string { + if (v === null || v === undefined) return 'unset' + const s = typeof v === 'string' ? v : JSON.stringify(v) + return s.length > 200 ? s.slice(0, 200) + '…' : s + } +</script> + +<div class="flex flex-col gap-2 h-full"> + <p class="text-xs text-secondary"> + The workspace settings in the layout of the <code>settings.yaml</code> file that + <code>wmill sync</code> reads and writes. Deleting a key clears that setting. Secrets show as + <code>__SENSITIVE_AND_UNCHANGED__</code>: leave the placeholder to keep the stored value. + </p> + <Alert type="info" title="Read-only keys" size="xs"> + {#each READ_ONLY_KEYS as key, i (key)}<code>{key}</code>{i < READ_ONLY_KEYS.length - 1 + ? ', ' + : ''}{/each} are shown for reference and never applied: change the name from the General pane + and connect Slack from the Slack pane. + {#each NOT_CLEARABLE_KEYS as key, i (key)}<code>{key}</code>{i < NOT_CLEARABLE_KEYS.length - 1 + ? ' and ' + : ''}{/each} are applied when set, but deleting them does not clear them. + </Alert> + + <div class="border rounded w-full h-[calc(100vh-22rem)] min-h-64"> + {#if loading && !initialCode} + <Skeleton layout={[[20]]} /> + {:else} + {#await import('$lib/components/SimpleEditor.svelte')} + <Loader2 class="animate-spin" /> + {:then Module} + <Module.default + bind:this={editor} + class="h-full" + lang="yaml" + bind:code + leadingChangeSync + fixedOverflowWidgets={false} + /> + {/await} + {/if} + </div> + + {#if loadError} + <Alert type="error" title="The settings could not be loaded" size="xs">{loadError}</Alert> + {/if} + {#if parseError} + <Alert type="error" title="Invalid YAML" size="xs">{parseError}</Alert> + {/if} + {#if applyError} + <Alert type="error" title="The settings could not be applied" size="xs">{applyError}</Alert> + {/if} + {#each warnings as warning, i (i)} + <Alert type="warning" title="Warning" size="xs">{warning}</Alert> + {/each} + + <SettingsFooter + inline + hasUnsavedChanges={code !== initialCode} + saveLabel="Review and save" + onSave={review} + onDiscard={discard} + /> +</div> + +<ConfirmationModal + open={confirmOpen} + title="Apply these changes?" + confirmationText="Apply" + type="info" + loading={applying} + onConfirmed={apply} + onCanceled={clearPending} +> + <div class="flex flex-col gap-2 max-h-96 overflow-auto"> + {#each pendingChanges as change (change.key)} + <div class="flex flex-col gap-0.5"> + <span class="text-xs font-semibold text-emphasis font-mono">{change.key}</span> + <span class="text-2xs text-secondary font-mono break-all"> + {formatValue(change.before)} → {formatValue(change.after)} + </span> + </div> + {/each} + </div> +</ConfirmationModal> diff --git a/frontend/src/lib/components/workspaceSettings/datatableMigrationUtils.ts b/frontend/src/lib/components/workspaceSettings/datatableMigrationUtils.ts index ed046418f2..29e5c73b9a 100644 --- a/frontend/src/lib/components/workspaceSettings/datatableMigrationUtils.ts +++ b/frontend/src/lib/components/workspaceSettings/datatableMigrationUtils.ts @@ -1,30 +1,34 @@ import { WorkspaceService, type DatatableMigrationWithStatus } from '$lib/gen' +import { migrationScope, scopesOverlap } from '../datatableMigrationSchemas' /** - * Migrations that are defined but not yet applied. A newly-created migration - * always gets the highest timestamp, so every pending migration is "earlier": - * running the new one on its own would apply it ahead of them (out of order). + * The pending migrations a new migration declaring `sql`'s scope would run ahead of. A + * newly-created migration always gets the highest timestamp, so every pending migration is + * "earlier"; only those whose scope overlaps its own can be ones it depends on. */ export function pendingMigrations( - migrations: DatatableMigrationWithStatus[] + migrations: DatatableMigrationWithStatus[], + sql: string ): DatatableMigrationWithStatus[] { - return migrations.filter((m) => m.status !== 'ran') + const scope = migrationScope(sql) + return migrations.filter((m) => m.status !== 'ran' && scopesOverlap(m.schemas, scope)) } -/** Fetch the data table's migration status and return the pending ones. */ +/** Fetch the data table's migration status and return the pending ones `sql` would run ahead of. */ export async function fetchPendingMigrations( workspace: string, - datatableName: string + datatableName: string, + sql: string ): Promise<DatatableMigrationWithStatus[]> { const { migrations } = await WorkspaceService.getDatatableMigrationsStatus({ workspace, datatableName }) - return pendingMigrations(migrations) + return pendingMigrations(migrations, sql) } /** Confirmation copy shown before running a just-created migration ahead of * `count` still-pending earlier ones (mirrors the row-level Run warning). */ export function outOfOrderRunMessage(count: number): string { - return `${count} earlier migration(s) have not been run yet. This migration might depend on them. Run it anyway?` + return `${count} earlier migration(s) touching the same schemas have not been run yet. This migration might depend on them. Run it anyway?` } diff --git a/frontend/src/lib/components/workspaceSettings/projectBundle.ts b/frontend/src/lib/components/workspaceSettings/projectBundle.ts index 8adedeb559..047f7ee7fa 100644 --- a/frontend/src/lib/components/workspaceSettings/projectBundle.ts +++ b/frontend/src/lib/components/workspaceSettings/projectBundle.ts @@ -148,6 +148,8 @@ function runnableRef(node: Record<string, any>): Ref | undefined { if (!isRunnableByPath(node as any) || typeof node.path !== 'string') return undefined if (node.runType === 'flow') return { kind: 'flow', path: node.path } if (node.runType === 'script') return { kind: 'script', path: node.path } + // A saved agent is an `ai_agent` resource. + if (node.runType === 'agent') return { kind: 'resource', path: node.path } return undefined // hubscript -> external hub, ignored } diff --git a/frontend/src/lib/components/workspaceTree.test.ts b/frontend/src/lib/components/workspaceTree.test.ts index f9c00b4d7b..96d54bb8be 100644 --- a/frontend/src/lib/components/workspaceTree.test.ts +++ b/frontend/src/lib/components/workspaceTree.test.ts @@ -7,7 +7,7 @@ import { type WorkspaceItem, type WorkspaceItemKind } from './workspacePicker' -import type { DrillBranch, DrillLeaf, DrillNode } from './drillPicker' +import { resolveScope, type DrillBranch, type DrillLeaf, type DrillNode } from './drillPicker' const item = ( kind: WorkspaceItemKind, @@ -522,6 +522,17 @@ describe('legacyScopeToPath', () => { ]) }) + it('walks every ancestor folder, so a nested dir resolves in the built tree', () => { + const loaded = { flow: [item('flow', 'f/a/b/c')], script: [] } + for (const layout of ['flat', 'by-kind'] as const) { + const tree = buildWorkspaceTree({ loaded, kinds: ['flow', 'script'], loadingKind: {}, layout }) + const path = legacyScopeToPath({ kind: 'flow', dir: 'f/a/b' }, ['flow', 'script'], layout) + expect(resolveScope(tree, path)?.children.map((c) => c.key)).toEqual([ + leafKeyFor('flow', 'f/a/b/c') + ]) + } + }) + it('flat: returns [] without a dir', () => { expect(legacyScopeToPath({ kind: 'all' }, ['flow', 'script'], 'flat')).toEqual([]) }) diff --git a/frontend/src/lib/components/workspaceTree.ts b/frontend/src/lib/components/workspaceTree.ts index 9475b58af5..6c719dafb1 100644 --- a/frontend/src/lib/components/workspaceTree.ts +++ b/frontend/src/lib/components/workspaceTree.ts @@ -271,19 +271,20 @@ export function legacyScopeToPath( layout: 'by-kind' | 'flat' = 'by-kind' ): string[] { if (!scope) return [] + // A subfolder is a child of its parent's branch, not a root entry, so a scope into + // `f/a/b` must walk `f/a` first or it resolves to nothing. + const dirChain = (kind: WorkspaceItemKind | 'all') => { + if (!scope.dir) return [] + const parts = scope.dir.split('/') + return parts.slice(1).map((_, i) => dirKey(kind, parts.slice(0, i + 2).join('/'))) + } // Flat layout: no kind branches at all — scope dirs live at root and are // always keyed under the cross-kind 'all' namespace. - if (layout === 'flat') { - return scope.dir ? [dirKey('all', scope.dir)] : [] - } + if (layout === 'flat') return dirChain('all') // Single-kind mode: there's no kind branch at root; scope's `kind` is // implicit. Only the dir (if any) makes it to the path. - if (kinds.length === 1) { - return scope.dir ? [dirKey(scope.kind, scope.dir)] : [] - } - const path: string[] = [kindKey(scope.kind)] - if (scope.dir) path.push(dirKey(scope.kind, scope.dir)) - return path + if (kinds.length === 1) return dirChain(scope.kind) + return [kindKey(scope.kind), ...dirChain(scope.kind)] } /** Return `absolutePath` shortened to its segment relative to the deepest diff --git a/frontend/src/lib/consts.ts b/frontend/src/lib/consts.ts index bfa1d845b2..62108cf23b 100644 --- a/frontend/src/lib/consts.ts +++ b/frontend/src/lib/consts.ts @@ -10,6 +10,9 @@ export const DEFAULT_WEBHOOK_TYPE: 'async' | 'sync' = 'async' export const HOME_SHOW_HUB = true +/** The width of Home's one column: the composer and the items list below it. */ +export const HOME_COLUMN_WIDTH = 'max-w-[1120px]' + export const HOME_SHOW_CREATE_FLOW = true export const HOME_SEARCH_SHOW_FLOW = true @@ -48,6 +51,10 @@ export const DEFAULT_TAGS_WORKSPACES_SETTING = 'default_tags_workspaces' export const FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING = 'fork_workspace_tag_append_fork_suffix' export const PREVIEW_TAGS_OVERRIDE_SETTING = 'preview_tags_override' export const DEPENDENCY_JOB_TAG_SETTING = 'dependency_job_tag' +export const DEV_WORKSPACE_ISOLATION_SETTING = 'dev_workspace_isolation' +export const DEV_WORKSPACE_ISOLATION_SKIP_NON_DEV_FORKS_SETTING = + 'dev_workspace_isolation_skip_non_dev_forks' +export const DEV_WORKSPACE_TAG_SUFFIX_SETTING = 'dev_workspace_tag_suffix' export const WORKSPACE_FAIRNESS_ENABLED_SETTING = 'workspace_fairness_enabled' export const WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING = 'workspace_fairness_max_percent' diff --git a/frontend/src/lib/devWorkspaceTags.ts b/frontend/src/lib/devWorkspaceTags.ts new file mode 100644 index 0000000000..60352576eb --- /dev/null +++ b/frontend/src/lib/devWorkspaceTags.ts @@ -0,0 +1,141 @@ +import { derived, get, writable } from 'svelte/store' +import { WorkerService } from '$lib/gen' + +/** Mirrors `DEFAULT_DEV_WORKSPACE_TAG_SUFFIX` in `windmill-common/src/worker.rs`. */ +export const DEFAULT_DEV_WORKSPACE_TAG_SUFFIX = '-dev' +const WM_FORK_PREFIX = 'wm-fork-' + +/** Mirrors `is_valid_dev_workspace_tag_suffix`: the suffix ends up in SQL literals and tag lists. */ +export const DEV_WORKSPACE_TAG_SUFFIX_PATTERN = /^[A-Za-z0-9_.-]{1,32}$/ + +export type DevWorkspaceIsolationState = { + enabled: boolean + suffix: string + /** Forks of a workspace with no dev workspace in its lineage run on their parent's workers. */ + skip_non_dev_forks: boolean +} + +/** `undefined` until loaded. */ +export const devWorkspaceIsolation = writable<DevWorkspaceIsolationState | undefined>(undefined) +/** The suffix new dev workspace and fork jobs get: `null` while isolation is off. */ +export const devWorkspaceTagSuffix = derived(devWorkspaceIsolation, (i) => + i === undefined ? undefined : i.enabled ? i.suffix : null +) +/** Whether forks with no dev workspace in their lineage run on their parent's workers. */ +export const devWorkspaceSkipNonDevForks = derived( + devWorkspaceIsolation, + (i) => i?.skip_non_dev_forks ?? false +) +/** The suffix worker groups serving dev workspaces listen with, isolation on or off. */ +export const devWorkspaceListenSuffix = derived( + devWorkspaceIsolation, + (i) => i?.suffix ?? DEFAULT_DEV_WORKSPACE_TAG_SUFFIX +) + +let inflight: Promise<void> | undefined +export function loadDevWorkspaceTagSuffix(force = false): Promise<void> { + if (!force && get(devWorkspaceIsolation) !== undefined) return Promise.resolve() + if (!force && inflight) return inflight + // Started inside the chain so any failure lands in the catch: callers on a job's error path + // await this and must not fail because the setting could not be read. + inflight = Promise.resolve() + .then(() => WorkerService.getDevWorkspaceIsolation()) + .then((i) => devWorkspaceIsolation.set(i)) + .catch(() => + devWorkspaceIsolation.set({ + enabled: false, + suffix: DEFAULT_DEV_WORKSPACE_TAG_SUFFIX, + skip_non_dev_forks: false + }) + ) + .finally(() => (inflight = undefined)) + return inflight +} + +/** Mirrors `with_dev_workspace_suffix`: idempotent, as an inherited tag already carries it. */ +export function withDevSuffix(tag: string, suffix: string): string { + return tag.endsWith(suffix) ? tag : `${tag}${suffix}` +} + +/** The suffix `tag` carries as a dev workspace job's tag, if isolation is on and it does. */ +export function devSuffixOf(tag: string | undefined): string | undefined { + const suffix = get(devWorkspaceTagSuffix) + return suffix && tag?.endsWith(suffix) ? suffix : undefined +} + +/** What to do about a dev workspace tag no worker serves, empty for any other tag. */ +export function devTagNoWorkerHint(tag: string): string { + const suffix = devSuffixOf(tag) + if (!suffix) return '' + return ( + ` "${tag}" ends with "${suffix}", the suffix of jobs of dev workspaces and forks: only worker ` + + `groups flagged as serving dev workspaces listen to it. Flag a worker group as serving dev ` + + `workspaces (Workers page) whose tags include "${tag.slice(0, -suffix.length)}".` + ) +} + +type LineageWorkspace = { + id: string + parent_workspace_id?: string | null + is_dev_workspace?: boolean + deleted?: boolean +} + +/** Whether a workspace has a parent, or is (or was) a fork. */ +export function isDevOrForkWorkspace(w: LineageWorkspace): boolean { + return !!w.parent_workspace_id || w.id.startsWith(WM_FORK_PREFIX) +} + +/** Mirrors `in_dev_lineage` in `windmill-queue/src/tags.rs`: a dev workspace on the ancestor chain, + * `w` included, an orphaned fork or an archived ancestor with a parent counting as one (archiving a + * dev workspace clears its flag). The server reads the whole chain; here an + * ancestor missing from `workspaces` (one the user is not a member of) also counts as one, so a + * hint errs toward the dev workers rather than claiming a job reaches the parent's. */ +function isInDevLineage(w: LineageWorkspace, workspaces: LineageWorkspace[]): boolean { + let current: LineageWorkspace | undefined = w + for (let depth = 0; current && depth <= 20; depth++) { + const parentId = current.parent_workspace_id + if (current.is_dev_workspace || (current.deleted && parentId)) return true + if (!parentId) return current.id.startsWith(WM_FORK_PREFIX) + current = workspaces.find((x) => x.id === parentId) + } + return true +} + +/** Whether a workspace's jobs run on dev workers while isolation is on. Mirrors + * `is_isolated_workspace` in `windmill-common/src/worker.rs`. */ +export function isIsolatedWorkspace( + w: LineageWorkspace, + workspaces: LineageWorkspace[], + skipNonDevForks: boolean +): boolean { + return isDevOrForkWorkspace(w) && (!skipNonDevForks || isInDevLineage(w, workspaces)) +} + +/** The same check for a workspace id, looked up in `workspaces` (pass the store's value so a + * reactive caller recomputes once the list loads). */ +export function isIsolatedWorkspaceId( + workspaceId: string | undefined, + workspaces: LineageWorkspace[], + skipNonDevForks: boolean +): boolean { + if (!workspaceId) return false + return isIsolatedWorkspace( + workspaces.find((w) => w.id === workspaceId) ?? { id: workspaceId }, + workspaces, + skipNonDevForks + ) +} + +/** Whether a new throwaway fork of `baseId` would run on dev workers while isolation is on. */ +export function isForkOfIsolated( + baseId: string, + workspaces: LineageWorkspace[], + skipNonDevForks: boolean +): boolean { + return isIsolatedWorkspace( + { id: `${WM_FORK_PREFIX}new`, parent_workspace_id: baseId, is_dev_workspace: false }, + workspaces, + skipNonDevForks + ) +} diff --git a/frontend/src/lib/editRights.test.ts b/frontend/src/lib/editRights.test.ts index faa853e708..fa3366a284 100644 --- a/frontend/src/lib/editRights.test.ts +++ b/frontend/src/lib/editRights.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' +import type { RoleCapabilities } from '$lib/gen' import type { UserExt } from '$lib/stores' +import { FULL_CAPABILITIES, legacyOperatorCapabilities } from '$lib/roleCapabilities' import { canEditItem, roleCanAuthor, roleCanDraft } from './editRights' function user(overrides: Partial<UserExt>): UserExt { @@ -20,33 +22,49 @@ function user(overrides: Partial<UserExt>): UserExt { } as UserExt } +const role = (overrides: Partial<RoleCapabilities>): RoleCapabilities => ({ + ...legacyOperatorCapabilities(undefined), + ...overrides +}) + describe('edit rights', () => { - it('lets a builder operator author flows and nothing else', () => { - const builder = { builder_flows: true } - expect(roleCanAuthor('flow', user({}), builder)).toBe(true) - expect(roleCanAuthor('script', user({}), builder)).toBe(false) - expect(roleCanAuthor('flow', user({}), undefined)).toBe(false) - expect(roleCanDraft('raw_app', user({}), builder)).toBe(false) - expect(roleCanDraft('raw_app', user({}), { builder_apps: true })).toBe(true) + it('lets a compose-only flow role author flows and nothing else', () => { + const builder = role({ flows: 'compose' }) + expect(roleCanAuthor('flow', builder)).toBe(true) + expect(roleCanAuthor('script', builder)).toBe(false) + expect(roleCanAuthor('raw_app', builder)).toBe(false) + expect(roleCanDraft('raw_app', builder)).toBe(false) + expect(roleCanDraft('raw_app', role({ apps: 'compose' }))).toBe(true) }) - // drafts.rs admits an admin before its operator branch; the item handlers do not. - it('lets an admin who is also an operator save drafts, but not author code', () => { - const adminOperator = user({ is_admin: true }) - expect(roleCanDraft('script', adminOperator, undefined)).toBe(true) - expect(roleCanAuthor('script', adminOperator, undefined)).toBe(false) + // apps.rs `require_full_apps`: a composing role builds full-code apps, never low-code ones. + it('keeps low-code apps for full app authors', () => { + expect(roleCanAuthor('app', role({ apps: 'compose' }))).toBe(false) + expect(roleCanAuthor('raw_app', role({ apps: 'compose' }))).toBe(true) + const scripter = { ...FULL_CAPABILITIES, apps: 'none' as const } + expect(roleCanAuthor('script', scripter)).toBe(true) + expect(roleCanAuthor('app', scripter)).toBe(false) + expect(roleCanAuthor('raw_app', scripter)).toBe(false) }) - // drafts.rs refuses an operator every draft but a builder flow, rights to write directly or not. - it('refuses an operator drafts of what they may write directly', () => { - expect(roleCanAuthor('trigger', user({}), undefined)).toBe(true) - expect(roleCanDraft('trigger', user({}), undefined)).toBe(false) - expect(roleCanDraft('resource', user({}), undefined)).toBe(false) + // drafts.rs admits a restricted role's draft of a kind it could deploy, and no other. + it('lets a role draft schedules and triggers until the right is withdrawn', () => { + expect(roleCanDraft('schedule', role({}))).toBe(true) + expect(roleCanDraft('trigger', role({}))).toBe(true) + expect(roleCanDraft('schedule', role({ manage_schedules: false }))).toBe(false) + expect(roleCanDraft('trigger', role({ manage_triggers: false }))).toBe(false) + }) + + it('lets any role draft resources and variables, but scripts only with code', () => { + expect(roleCanDraft('resource', role({}))).toBe(true) + expect(roleCanDraft('variable', role({}))).toBe(true) + expect(roleCanDraft('script', role({}))).toBe(false) + expect(roleCanDraft('script', role({ code: true }))).toBe(true) }) // The role alone is not enough: a builder still cannot edit a flow in a folder they only read. it('requires write access to the item on top of the role', () => { - const builder = { builder_flows: true } + const builder = role({ flows: 'compose' }) expect(canEditItem('flow', 'f/demo/sum', {}, user({}), builder)).toBe(false) expect(canEditItem('flow', 'u/op/sum', {}, user({}), builder)).toBe(true) }) diff --git a/frontend/src/lib/editRights.ts b/frontend/src/lib/editRights.ts index 9073a03d28..48ec6c3ee2 100644 --- a/frontend/src/lib/editRights.ts +++ b/frontend/src/lib/editRights.ts @@ -1,16 +1,17 @@ -import type { OperatorSettings } from '$lib/gen' +import type { RoleCapabilities } from '$lib/gen' import type { UserExt } from '$lib/stores' import { canWrite } from '$lib/utils' +import { isRestricted } from '$lib/roleCapabilities' /** * The frontend's one copy of "may this user edit this item". The server decides — drafts.rs - * `require_can_write_path`, the item handlers' operator refusals, `gate_operator_writes`, and + * `require_can_write_path`, the item handlers' role refusals, `gate_operator_writes`, and * row-level security — and this mirrors it so a control the server would refuse is never * offered. Check edits through `canEditItem` (or `useEditRights` in a component), never by - * pairing `canWrite` with an operator test of your own. + * pairing `canWrite` with a role test of your own. */ -/** Kinds whose role half differs. Any other kind is code an operator never authors. */ +/** Kinds whose role half differs. Any other kind is code, authored under `code`. */ export type EditKind = | 'script' | 'flow' @@ -25,70 +26,45 @@ export type EditKind = | 'group' | (string & {}) -type OperatorRights = - | Pick< - NonNullable<OperatorSettings>, - 'builder_flows' | 'builder_apps' | 'manage_schedules' | 'manage_triggers' - > - | null - | undefined - /** - * Whether a user's role lets them author items of `kind` — create, edit, deploy or delete one — - * in a workspace with these `operator_settings`: 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. For an operator: - * - code (scripts, low-code apps, agents, pipelines): never; - * - flows and full-code apps: where the workspace grants `builder_flows` / `builder_apps` - * (`check_operator_can_build`); - * - schedules and triggers: unless the workspace set `manage_*` to false — a withdrawable - * right, so an unset key grants it (docs/operator-write-rights.md); + * 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`); + * - 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. */ -export function roleCanAuthor( - kind: EditKind, - user: Pick<UserExt, 'operator'> | undefined, - operatorSettings: OperatorRights -): boolean { - if (!user?.operator) return true +export function roleCanAuthor(kind: EditKind, caps: RoleCapabilities): boolean { switch (kind) { case 'flow': + return caps.flows !== 'none' case 'raw_app': - return builderGrants(kind, operatorSettings) + return caps.apps !== 'none' + case 'app': + return caps.apps === 'full' case 'schedule': - return operatorSettings?.manage_schedules !== false + return caps.manage_schedules case 'trigger': - return operatorSettings?.manage_triggers !== false + return caps.manage_triggers case 'resource': case 'variable': case 'group': return true default: - return false + return caps.code } } /** - * Whether the user may save drafts of `kind` — what every session editor writes. Mirrors drafts.rs - * `require_can_write_path`, which differs from the item handlers both ways: it admits an admin - * before its operator branch, and it refuses every operator draft but a flow or full-code app - * under its builder right — schedules, triggers, resources and variables included, though an operator may - * write those directly. + * Whether the role may save drafts of `kind`, which every session editor and the chat's draft + * tools write. Mirrors drafts.rs `operator_can_draft`: a restricted role drafts only the kinds it + * could deploy, and resources and variables always. An unrestricted role drafts every kind. */ -export function roleCanDraft( - kind: EditKind, - user: Pick<UserExt, 'operator' | 'is_admin' | 'is_super_admin'> | undefined, - operatorSettings: OperatorRights -): boolean { - if (user?.is_admin || user?.is_super_admin || !user?.operator) return true - return builderGrants(kind, operatorSettings) -} - -function builderGrants(kind: EditKind, operatorSettings: OperatorRights): boolean { - return ( - (kind === 'flow' && operatorSettings?.builder_flows === true) || - (kind === 'raw_app' && operatorSettings?.builder_apps === true) - ) +export function roleCanDraft(kind: EditKind, caps: RoleCapabilities): boolean { + if (!isRestricted(caps)) return true + return kind === 'group' ? false : roleCanAuthor(kind, caps) } /** `roleCanDraft` for the item at `path`, with its own permissions as in `canEditItem`. */ @@ -97,9 +73,9 @@ export function canDraftItem( path: string, extraPerms: Record<string, boolean> | undefined, user: UserExt | undefined, - operatorSettings: OperatorRights + caps: RoleCapabilities ): boolean { - return roleCanDraft(kind, user, operatorSettings) && canWrite(path, extraPerms ?? {}, user) + return roleCanDraft(kind, caps) && canWrite(path, extraPerms ?? {}, user) } /** Whether `user` may edit the item at `path`: their role authors `kind`, and the item's own @@ -109,7 +85,7 @@ export function canEditItem( path: string, extraPerms: Record<string, boolean> | undefined, user: UserExt | undefined, - operatorSettings: OperatorRights + caps: RoleCapabilities ): boolean { - return roleCanAuthor(kind, user, operatorSettings) && canWrite(path, extraPerms ?? {}, user) + return roleCanAuthor(kind, caps) && canWrite(path, extraPerms ?? {}, user) } diff --git a/frontend/src/lib/hubPaths.json b/frontend/src/lib/hubPaths.json index 7e1f965c36..74c2d6f9f4 100644 --- a/frontend/src/lib/hubPaths.json +++ b/frontend/src/lib/hubPaths.json @@ -1,6 +1,6 @@ { "gitSyncTest": "hub/28950/git-repo-test-read-write-windmill", - "gitInitRepo": "hub/29150/git-sync-init-repository-windmill", + "gitInitRepo": "hub/29784/git-sync-init-repository-windmill", "slackErrorHandler": "hub/28794/workspace-or-schedule-error-handler-slack", "emailErrorHandler": "hub/19795/workspace-or-error-handler-email", "slackRecoveryHandler": "hub/28791/slack/schedule-recovery-handler-slack", diff --git a/frontend/src/lib/infer.ts b/frontend/src/lib/infer.ts index 9e9cddeb00..3c1d30bc87 100644 --- a/frontend/src/lib/infer.ts +++ b/frontend/src/lib/infer.ts @@ -1,11 +1,14 @@ import { ScriptService, + ResourceService, type MainArgSignature, FlowService, type Script, type AssetUsageAccessType } from '$lib/gen' import { get, writable } from 'svelte/store' +import type { PathRunType } from './components/apps/inputType.js' +import { agentRunnableSchema } from './components/raw_apps/agentRunnable.js' import type { Schema, SupportedLanguage } from './common.js' import { type DynamicInput, @@ -658,9 +661,15 @@ async function inferSchemaIfNecessary(script: Script) { export async function loadSchema( workspace: string, path: string, - runType: 'script' | 'flow' | 'hubscript' + runType: PathRunType ): Promise<{ schema: Schema; summary: string | undefined }> { - if (runType === 'script') { + if (runType === 'agent') { + const agent = await ResourceService.getResource({ workspace, path }) + return { + schema: agentRunnableSchema((agent.value as any)?.memory), + summary: agent.description || undefined + } + } else if (runType === 'script') { const script = await ScriptService.getScriptByPath({ workspace, path diff --git a/frontend/src/lib/instanceRoutes.ts b/frontend/src/lib/instanceRoutes.ts new file mode 100644 index 0000000000..7d57dc1b81 --- /dev/null +++ b/frontend/src/lib/instanceRoutes.ts @@ -0,0 +1,10 @@ +/** + * Pages a devops user can open without belonging to any workspace. Devops grants no workspace + * access, so such a page renders with `$workspaceStore` and `$userStore` unset and must hide + * whatever needs them (a workspace-scoped call or a link into a workspace) rather than break. + */ +const INSTANCE_ROUTES = ['/workers', '/service_logs'] + +export function isInstanceRoute(pathname: string): boolean { + return INSTANCE_ROUTES.some((r) => pathname === r || pathname.startsWith(r + '/')) +} diff --git a/frontend/src/lib/mcpEndpointTools.ts b/frontend/src/lib/mcpEndpointTools.ts index e0c29f4d7b..e53beeb53b 100644 --- a/frontend/src/lib/mcpEndpointTools.ts +++ b/frontend/src/lib/mcpEndpointTools.ts @@ -65,6 +65,46 @@ export const mcpEndpointTools: EndpointTool[] = [ queryFieldRenames: undefined, bodyFieldRenames: undefined }, + { + name: "getWorkspaceSettingsDocument", + description: "get the workspace settings document: The workspace's settings in the layout of the settings.yaml that `wmill sync` writes. Admin only", + instructions: "", + path: "/w/{workspace}/workspaces/settings_document", + method: "GET", + pathParamsSchema: undefined, + queryParamsSchema: undefined, + bodySchema: undefined, + queryFieldRenames: undefined, + bodyFieldRenames: undefined + }, + { + name: "applyWorkspaceSettingsDocument", + description: "apply a workspace settings document: Applies a settings document, each setting through the endpoint that owns it. Admin only", + instructions: "Send only the top-level keys to change, each whole as getWorkspaceSettingsDocument returns it: a key the document omits is left as it is, and a key set to null clears it. Keep `__SENSITIVE_AND_UNCHANGED__` for a secret you were not given. Call with dry_run true first, show the user the changes and apply only once they agree.", + path: "/w/{workspace}/workspaces/settings_document", + method: "POST", + pathParamsSchema: undefined, + queryParamsSchema: undefined, + bodySchema: { + "type": "object", + "properties": { + "document": { + "type": "object", + "description": "The settings to apply, in the layout of the document. A secret given as `__SENSITIVE_AND_UNCHANGED__` keeps its stored value; one prefixed with `$encrypted:` is decrypted with the workspace key." + }, + "dry_run": { + "description": "Only compute the changes, without applying them.", + "type": "boolean" + } + }, + "required": [ + "document" + ], + "minProperties": 1 +}, + queryFieldRenames: undefined, + bodyFieldRenames: undefined + }, { name: "listDataMetrics", description: "list declared measures and dimensions on DuckLake tables: Call this before writing any aggregate query over a DuckLake table. A declared measure is the canonical definition of that number, and reproducing it yourself will silently disagree with it (a `revenue` measure typically excludes refunds or test rows). Filter by `table` for one table's declarations, or by `path_prefix` (e.g. `f/analytics`) for everything declared under a folder; omit both to browse the whole catalog. Results are keyset-paged: a full page may mean more remain, so continue with the `cursor_*` params rather than assuming a measure does not exist. Use each returned `expr` verbatim, and when a measure has a `filter` write it as `expr FILTER (WHERE filter)` so measures with different predicates can share one GROUP BY. If a number you need has no declared measure, write your own aggregate as usual. Results are limited to declarations whose producing script the caller can read", @@ -568,6 +608,10 @@ export const mcpEndpointTools: EndpointTool[] = [ "include_draft_only": { "type": "boolean", "description": "When true, append per-user draft resources whose path has\nno deployed resource. Synthesized rows carry\n`draft_only: true`.\n" + }, + "starred_only": { + "type": "boolean", + "description": "Only the `ai_agent` resources the caller starred" } }, "required": [] @@ -1303,7 +1347,7 @@ export const mcpEndpointTools: EndpointTool[] = [ "items": { "type": "string" }, - "description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true \u2014 an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write).\n" + "description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true \u2014 an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write). Every such token can call whoami without consent, whether `users:read` is declared or not.\n" } } } @@ -1420,7 +1464,7 @@ export const mcpEndpointTools: EndpointTool[] = [ "items": { "type": "string" }, - "description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true \u2014 an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write).\n" + "description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true \u2014 an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write). Every such token can call whoami without consent, whether `users:read` is declared or not.\n" } } }, @@ -1809,7 +1853,7 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "resolved": { "type": "boolean", - "description": "filter on whether a failure has been marked as handled. true keeps only resolved failures, false hides them" + "description": "filter on whether a failed or canceled run has been marked as handled. true keeps only resolved runs, false hides them" }, "is_flow_step": { "type": "boolean", diff --git a/frontend/src/lib/operatorWriteRights.ts b/frontend/src/lib/operatorWriteRights.ts index 916e39dce6..28a68784e6 100644 --- a/frontend/src/lib/operatorWriteRights.ts +++ b/frontend/src/lib/operatorWriteRights.ts @@ -1,29 +1,38 @@ import { derived, fromStore, get, readable, type Readable } from 'svelte/store' -import { userWorkspaces, usersWorkspaceStore, workspaceStore } from '$lib/stores' +import type { RoleCapabilities } from '$lib/gen' +import { userStore, userWorkspaces, usersWorkspaceStore, workspaceStore } from '$lib/stores' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' import { useActingUser } from '$lib/actingUser.svelte' import { canDraftItem, canEditItem, roleCanAuthor, roleCanDraft } from '$lib/editRights' +import { capabilitiesIn } from '$lib/roleCapabilities' /** * The `editRights` rules (`roleCanAuthor` / `canEditItem` for the item handlers, `roleCanDraft` / - * `canDraftItem` for drafts) answered for the user acting in the operating workspace, - * with that workspace's `operator_settings` — or in `workspace`, for a component that is handed - * one rather than sitting under a host that declares it. Reads context and registers an effect: - * call during component initialisation. + * `canDraftItem` for drafts) answered for the user acting in the operating workspace, with their + * role's capabilities there, or in `workspace`, for a component that is handed one rather than + * sitting under a host that declares it. Reads context and registers an effect: call during + * component initialisation. */ export function useEditRights(workspace?: () => string | undefined) { const operating = fromStore(useOperatingWorkspace()) const ws = workspace ?? (() => operating.current) const user = useActingUser(ws) const workspaces = fromStore(userWorkspaces) - const settings = () => workspaces.current.find((w) => w.id === ws())?.operator_settings + const caps = () => + capabilitiesIn( + user.current, + workspaces.current.find((w) => w.id === ws()) + ) return { - roleCanAuthor: (kind: string) => roleCanAuthor(kind, user.current, settings()), + get capabilities(): RoleCapabilities { + return caps() + }, + roleCanAuthor: (kind: string) => roleCanAuthor(kind, caps()), canEditItem: (kind: string, path: string, extraPerms: Record<string, boolean> | undefined) => - canEditItem(kind, path, extraPerms, user.current, settings()), - roleCanDraft: (kind: string) => roleCanDraft(kind, user.current, settings()), + canEditItem(kind, path, extraPerms, user.current, caps()), + roleCanDraft: (kind: string) => roleCanDraft(kind, caps()), canDraftItem: (kind: string, path: string, extraPerms: Record<string, boolean> | undefined) => - canDraftItem(kind, path, extraPerms, user.current, settings()), + 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() @@ -31,20 +40,42 @@ export function useEditRights(workspace?: () => string | undefined) { } /** - * Why writes of this kind are locked in the operating workspace, or `undefined` when they are not — + * The role's capabilities in `workspace`, from the workspace list alone: `$userStore` describes + * only the navigation workspace, so elsewhere only superadmin carries over. Admins get full + * capabilities from the server's list. + */ +function capabilitiesStore(workspace: Readable<string | undefined>): Readable<RoleCapabilities> { + return derived( + [userStore, workspaceStore, userWorkspaces, workspace], + ([$user, $nav, $workspaces, $ws]) => + capabilitiesIn( + $ws === $nav && $user?.workspace_id === $ws + ? $user + : { is_super_admin: $user?.is_super_admin }, + $workspaces.find((w) => w.id === $ws) + ) + ) +} + +/** The role's capabilities in the operating workspace. Reads context: call during component + * initialisation. Code outside any component reads `roleCapabilities` (navigation workspace) or + * `capabilitiesInWorkspace`. */ +export const useRoleCapabilities = () => capabilitiesStore(useOperatingWorkspace()) + +/** The role's capabilities in a given workspace, read once: for code acting on an explicit + * workspace rather than the nav's, such as the global AI chat. */ +export const capabilitiesInWorkspace = (workspace: string | undefined) => + get(capabilitiesStore(readable(workspace))) + +/** + * Why writes of this kind are locked in the operating workspace, or `undefined` when they are not: * the shape `title` and `disabled` both want. See `docs/operator-write-rights.md`. - * - * The role half of `roleCanAuthor`, phrased for the controls that explain it. `operator_settings` - * is non-null exactly for an operator (`isOperatorInWorkspace`), so it stands in for the user. */ function writeLock(kind: 'schedule' | 'trigger', noun: string) { - return derived([userWorkspaces, useOperatingWorkspace()], ([$userWorkspaces, $workspace]) => { - const settings = $userWorkspaces.find((w) => w.id === $workspace)?.operator_settings + return derived(useRoleCapabilities(), (caps) => // Worded as the server words its refusal of the same write. - return roleCanAuthor(kind, { operator: settings != null }, settings) - ? undefined - : `Operators cannot manage ${noun} in this workspace` - }) + roleCanAuthor(kind, caps) ? undefined : `Your role cannot manage ${noun} in this workspace` + ) } /** Reads context: call during component initialisation. */ @@ -52,55 +83,9 @@ export const useScheduleLock = () => writeLock('schedule', 'schedules') /** Reads context: call during component initialisation. */ export const useTriggerLock = () => writeLock('trigger', 'triggers') -/** - * True when the user is an operator of `workspace` and it granted operators the right to compose - * this kind out of runnables that are already deployed. They still author no code, and everywhere - * else `operator` keeps meaning read-only, so a gate on the operator role has to consult this before - * refusing. `operator_settings` is null for a non-operator, so this is false for them. - * - * The two rights are granted independently: anything that authors one kind must gate on that - * kind, or a flows-only workspace offers app affordances the backend then refuses. - */ -function builderRight( - key: 'builder_flows' | 'builder_apps', - workspace: Readable<string | undefined> -): Readable<boolean> { - return derived( - [userWorkspaces, workspace], - ([$userWorkspaces, $workspace]) => - $userWorkspaces.find((w) => w.id === $workspace)?.operator_settings?.[key] === true - ) -} - -/** Either right, only for surfaces that are not per-kind, such as the create menu's visibility. */ -function anyBuilderRight(workspace: Readable<string | undefined>): Readable<boolean> { - return derived( - [builderRight('builder_flows', workspace), builderRight('builder_apps', workspace)], - ([flows, apps]) => flows || apps - ) -} - -/** Builder rights in the operating workspace. Reads context: call during component - * initialisation. */ -export const useOperatorBuilderFlows = () => builderRight('builder_flows', useOperatingWorkspace()) -/** Reads context: call during component initialisation. */ -export const useOperatorBuilderApps = () => builderRight('builder_apps', useOperatingWorkspace()) -/** Reads context: call during component initialisation. */ -export const useOperatorBuilderRights = () => anyBuilderRight(useOperatingWorkspace()) - -/** Builder rights in the navigation workspace, for code outside any component: the legacy AI - * chat is one instance for the whole app and acts on the workspace the nav is on. */ -export const navigationOperatorBuilderFlows = builderRight('builder_flows', workspaceStore) -export const navigationOperatorBuilderApps = builderRight('builder_apps', workspaceStore) -export const navigationOperatorBuilderRights = anyBuilderRight(workspaceStore) - -/** The app builder right in a given workspace, read once: for code acting on an explicit - * workspace rather than the nav's, such as the global AI chat. */ -export const operatorBuilderAppsIn = (workspace: string | undefined) => - get(builderRight('builder_apps', readable(workspace))) - -/** Resolves once the workspace list has loaded. Every builder right reads false until then, so - * code that picks what a builder starts with on a fresh page load has to wait for it. */ +/** Resolves once the workspace list has loaded. Capabilities fall back to the user's legacy + * operator flag until then, so code that picks what a builder starts with on a fresh page load + * has to wait for it. */ export async function workspaceListLoaded(): Promise<void> { let unsubscribe = () => {} await new Promise<void>((resolve) => { diff --git a/frontend/src/lib/roleCapabilities.test.ts b/frontend/src/lib/roleCapabilities.test.ts new file mode 100644 index 0000000000..1962414042 --- /dev/null +++ b/frontend/src/lib/roleCapabilities.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import type { RoleCapabilities } from '$lib/gen' +import { capabilitiesIn, FULL_CAPABILITIES, hidesPages, isRestricted } from './roleCapabilities' + +const scripter: RoleCapabilities = { + ...FULL_CAPABILITIES, + apps: 'none', + visibility: { ...FULL_CAPABILITIES.visibility, workers: false } +} + +describe('capabilitiesIn', () => { + it("takes the server's capabilities, whatever the legacy operator flag says", () => { + const caps = capabilitiesIn({ operator: false }, { capabilities: scripter }) + expect(caps).toEqual(scripter) + expect(isRestricted(caps)).toBe(true) + }) + + it('gives an admin everything', () => { + expect(capabilitiesIn({ is_admin: true }, { capabilities: scripter })).toBe(FULL_CAPABILITIES) + }) + + // A server that predates roles sends `operator` and `operator_settings` only. + it('derives an operator from operator_settings on an older server', () => { + const caps = capabilitiesIn( + { operator: true }, + { operator_settings: { runs: true, builder_flows: true, manage_triggers: false } as any } + ) + expect(caps.code).toBe(false) + expect(caps.flows).toBe('compose') + expect(caps.apps).toBe('none') + expect(caps.manage_schedules).toBe(true) + expect(caps.manage_triggers).toBe(false) + expect(caps.visibility.runs).toBe(true) + // `=== true`: a flag the settings never mention, or settings never written, hide the page. + expect(caps.visibility.workers).toBe(false) + expect(capabilitiesIn({ operator: true }, undefined).visibility.runs).toBe(false) + expect(capabilitiesIn({ operator: false }, undefined)).toBe(FULL_CAPABILITIES) + }) + + // `RoleCapabilities::is_restricted` leaves visibility out, so a full author keeps forking. + it('does not restrict a full role that only hides pages', () => { + const hidden = { ...FULL_CAPABILITIES, visibility: { ...FULL_CAPABILITIES.visibility, workers: false } } + expect(isRestricted(hidden)).toBe(false) + expect(hidesPages(hidden)).toBe(true) + }) +}) diff --git a/frontend/src/lib/roleCapabilities.ts b/frontend/src/lib/roleCapabilities.ts new file mode 100644 index 0000000000..55beec9ee0 --- /dev/null +++ b/frontend/src/lib/roleCapabilities.ts @@ -0,0 +1,74 @@ +import type { OperatorSettings, RoleCapabilities, RoleVisibility } from '$lib/gen' +import type { UserExt, UserWorkspace } from '$lib/stores' +import { FULL_CAPABILITIES, VISIBILITY_KEYS } from '$lib/workspaceRoles' + +/** + * What a member's workspace role lets them author, manage and see: the frontend's copy of + * `windmill_common::roles`, see `docs/workspace-roles.md`. Gate on these, never on + * `UserExt.operator`, which only says "this role cannot write code" and so lets through a role + * that may code but not, say, build apps. + */ + +export type VisibilityKey = keyof RoleVisibility + +export { FULL_CAPABILITIES } + +const VISIBILITY = VISIBILITY_KEYS.map((v) => v.key) + +/** + * The operator role as a server that predates roles describes it, through `operator_settings`: + * builder rights are `compose`, the manage rights are granted unless withdrawn, and a page is + * visible only when its flag is `true` (absent settings hide every page, as they always did). + */ +export function legacyOperatorCapabilities( + settings: OperatorSettings | null | undefined +): RoleCapabilities { + const visibility = Object.fromEntries( + VISIBILITY.map((k) => [k, settings?.[k] === true]) + ) as RoleVisibility + return { + code: false, + flows: settings?.builder_flows === true ? 'compose' : 'none', + apps: settings?.builder_apps === true ? 'compose' : 'none', + manage_schedules: settings?.manage_schedules !== false, + manage_triggers: settings?.manage_triggers !== false, + visibility + } +} + +export type CapabilitiesUser = Partial<Pick<UserExt, 'is_admin' | 'is_super_admin' | 'operator'>> +export type CapabilitiesWorkspace = Pick<UserWorkspace, 'capabilities' | 'operator_settings'> + +/** + * The capabilities of `user` in `workspace` (its entry of `$userWorkspaces`). An admin holds + * everything. The server's `capabilities` win; an older server sends none, and the role is then + * derived from `operator` and `operator_settings` the way it read them. Without a user, a + * non-null `operator_settings` is what marks an operator, as the server sends it to them only. + */ +export function capabilitiesIn( + user: CapabilitiesUser | undefined, + workspace: CapabilitiesWorkspace | undefined +): RoleCapabilities { + if (user?.is_admin || user?.is_super_admin) return FULL_CAPABILITIES + if (workspace?.capabilities) return workspace.capabilities + const operator = user?.operator ?? workspace?.operator_settings != null + return operator ? legacyOperatorCapabilities(workspace?.operator_settings) : FULL_CAPABILITIES +} + +/** Whether the role is gated at all, as `RoleCapabilities::is_restricted`: visibility is left out, + * since hiding a page must not cost a full author what restricted roles are refused, such as + * forking. */ +export function isRestricted(caps: RoleCapabilities): boolean { + return ( + !caps.code || + caps.flows !== 'full' || + caps.apps !== 'full' || + !caps.manage_schedules || + !caps.manage_triggers + ) +} + +/** 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/scripts.ts b/frontend/src/lib/scripts.ts index f9f1fa775e..eb0b3c48a5 100644 --- a/frontend/src/lib/scripts.ts +++ b/frontend/src/lib/scripts.ts @@ -248,6 +248,8 @@ export async function getScriptByPath( lock?: string created_at?: string hash?: string + on_behalf_of?: string + on_behalf_of_email?: string }> { if (path.startsWith('hub/')) { const { content, language, schema, lockfile } = await ScriptService.getHubScriptByPath({ path }) @@ -277,7 +279,9 @@ export async function getScriptByPath( concurrency_time_window_s: script.concurrency_time_window_s, lock: script.lock, hash: script.hash, - created_at: script.created_at + created_at: script.created_at, + on_behalf_of: script.on_behalf_of, + on_behalf_of_email: script.on_behalf_of_email } } } diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index a3147b171c..16b62da863 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -5,6 +5,7 @@ import type { IntrospectionQuery } from 'graphql' import { CancelablePromise, type OperatorSettings, + type RoleCapabilities, type TokenResponse, type UserWorkspaceList, type Workspace, @@ -12,6 +13,7 @@ import { WorkspaceService } from './gen' import { getLocalSetting, type StateStore } from './utils' +import { capabilitiesIn } from './roleCapabilities' import { createState } from './svelte5Utils.svelte' import { DEFAULT_HUB_BASE_URL } from './hub' import type { DbManagerUriState } from './components/dbManagerDrawerModel.svelte' @@ -46,6 +48,9 @@ export interface UserWorkspace { username: string color?: string operator_settings?: OperatorSettings + /** Absent from a server that predates roles; read it through `capabilitiesIn`. */ + role?: string + capabilities?: RoleCapabilities parent_workspace_id?: string | null is_dev_workspace?: boolean dev_workspace_label?: string | null @@ -191,6 +196,18 @@ export const userWorkspaces: Readable<Array<UserWorkspace>> = derived( } ) +/** The user's role capabilities in the navigation workspace. A component acting on another + * workspace (an AI session) reads `useRoleCapabilities` instead. */ +export const roleCapabilities: Readable<RoleCapabilities> = derived( + [userStore, userWorkspaces, workspaceStore], + ([user, workspaces, workspace]) => + capabilitiesIn( + // Mid-switch `user` still describes the workspace being left: only superadmin carries over. + user?.workspace_id === workspace ? user : { is_super_admin: user?.is_super_admin }, + workspaces.find((w) => w.id === workspace) + ) +) + export const codeCompletionLoading = writable<boolean>(false) export const metadataCompletionEnabled = writable<boolean>(true) export const stepInputCompletionEnabled = writable<boolean>(true) diff --git a/frontend/src/lib/utils.test.ts b/frontend/src/lib/utils.test.ts index a37238f91c..07216d341f 100644 --- a/frontend/src/lib/utils.test.ts +++ b/frontend/src/lib/utils.test.ts @@ -13,17 +13,16 @@ import { apiErrorMessage } from './utils' -// Mirrors the backend invariant that only `status = 'failure'` rows can carry a -// resolution; loosening this to `!success` would offer the action on queued and -// canceled runs the API then silently skips. +// Mirrors the backend invariant that only failed or canceled rows can carry a resolution; +// offering the action anywhere else would send ids the API then silently skips. describe('isJobResolvable', () => { const completed = (o: object) => ({ type: 'CompletedJob', ...o }) as any - it('accepts a plain failure', () => { + it('accepts a plain failure and a cancellation', () => { expect(isJobResolvable(completed({ success: false, canceled: false }))).toBe(true) + expect(isJobResolvable(completed({ success: false, canceled: true }))).toBe(true) }) - it('rejects success, cancellation, flow steps and jobs still queued', () => { + it('rejects success, flow steps and jobs still queued', () => { expect(isJobResolvable(completed({ success: true, canceled: false }))).toBe(false) - expect(isJobResolvable(completed({ success: false, canceled: true }))).toBe(false) // a step resolved alone would show orange inside a flow that is still red expect( isJobResolvable(completed({ success: false, canceled: false, is_flow_step: true })) diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 8540b964e8..7341baef49 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -83,13 +83,13 @@ export function isJobReRunnable(j: Job): boolean { return (j.job_kind === 'script' || j.job_kind === 'flow') && j.parent_job === undefined } -// Only a top-level failure can carry a resolution: cancellations and skipped steps are not -// failures, and a flow step resolved on its own would render orange inside a flow whose status -// is still red. The resolve endpoint enforces the same three conditions. +// Only a top-level failed or canceled run can carry a resolution: successes and skipped steps +// have nothing to handle, and a flow step resolved on its own would render orange inside a flow +// whose status is still red. The resolve endpoint enforces the same conditions. // Runs obscured from another workspace stand in with `id: '-'`; they look like failures but // address nothing, and one reaching the endpoint fails the whole batch on UUID parsing. export function isJobResolvable(j: Job): j is CompletedJob { - return j.type === 'CompletedJob' && !j.success && !j.canceled && !j.is_flow_step && j.id !== '-' + return j.type === 'CompletedJob' && !j.success && !j.is_flow_step && j.id !== '-' } // Mirror the caps enforced by /jobs/completed/resolve, so the UI can chunk and validate @@ -1657,7 +1657,8 @@ export type Item = { href?: string hrefTarget?: '_blank' | '_self' | '_parent' | '_top' disabled?: boolean - type?: 'action' | 'delete' + /** `ai` sets the row in the AI colour, for an action that hands something to the assistant. */ + type?: 'action' | 'delete' | 'ai' hide?: boolean | undefined extra?: Snippet id?: string diff --git a/frontend/src/lib/utils/editInFork.ts b/frontend/src/lib/utils/editInFork.ts index d43c3644c9..9e74878f39 100644 --- a/frontend/src/lib/utils/editInFork.ts +++ b/frontend/src/lib/utils/editInFork.ts @@ -1,5 +1,8 @@ import { get } from 'svelte/store' +import { isRestricted } from '$lib/roleCapabilities' +import type { RoleCapabilities } from '$lib/gen' import { + roleCapabilities, userStore, userWorkspaces, workspaceStore, @@ -19,16 +22,17 @@ export type ItemType = 'script' | 'flow' | 'app' | 'raw_app' * Whether to show the "edit in fork / dev workspace" affordance. Allowed when the user may create a * fork, OR when the current workspace has a canonical dev to route to — routing into an existing dev * workspace creates no fork, so it survives a locked prod that has `DisableWorkspaceForking` set. - * Never for an operator, who has nothing to edit at the other end either way. User identity is read - * non-reactively (it's stable within a session); reactivity comes from the workspace args. + * Never for a role without `code`, which has nothing to edit at the other end either way. User + * identity is read non-reactively (it's stable within a session); reactivity comes from the + * workspace args. */ export function editInForkAllowed( currentWorkspaceId: string | undefined, allWorkspaces: UserWorkspace[] ): boolean { - const user = get(userStore) - if (user?.operator) return false - return canCreateFork(user) || !!findCanonicalDevWorkspace(currentWorkspaceId, allWorkspaces) + const caps = get(roleCapabilities) + if (!caps.code) return false + return canCreateFork(get(userStore), caps) || !!findCanonicalDevWorkspace(currentWorkspaceId, allWorkspaces) } /** Label for the affordance: "Edit in <dev name>" when routed to a canonical dev, else "Edit in fork". */ @@ -54,17 +58,17 @@ export function editInForkDescription( } /** - * Whether the user may CREATE a new fork of the current workspace: not an operator, and either - * forking isn't disabled or they can bypass the rule (workspace admins). Keeps the "Fork workspace" - * entry available to admins as the last-resort escape hatch on a locked prod. + * Whether the user may CREATE a new fork of the current workspace: their role is unrestricted, and + * either forking isn't disabled or they can bypass the rule (workspace admins). Keeps the "Fork + * workspace" entry available to admins as the last-resort escape hatch on a locked prod. * - * An operator is refused because a fork does not carry the role: the server writes the forker's - * membership row from the parent's `is_admin` and lets `operator` fall to its `false` default, so - * forking would hand an operator a full-rights copy of the workspace. The server does not refuse it - * — this hides the affordance, it does not enforce the rule. + * Any restricted role is refused because a fork does not carry the role: the server writes the + * forker's membership row from the parent's `is_admin` alone, so the forker gets the default role + * and forking would hand a restricted member a full-rights copy of the workspace. The server does + * not refuse it: this hides the affordance, it does not enforce the rule. */ -export function canCreateFork(user: UserExt | undefined): boolean { - if (user?.operator) return false +export function canCreateFork(user: UserExt | undefined, caps: RoleCapabilities): boolean { + if (isRestricted(caps)) return false return ( !isRuleActive('DisableWorkspaceForking') || canUserBypassRuleKind('DisableWorkspaceForking', user) diff --git a/frontend/src/lib/utils/preloadComponent.svelte.ts b/frontend/src/lib/utils/preloadComponent.svelte.ts new file mode 100644 index 0000000000..16ea5b5aca --- /dev/null +++ b/frontend/src/lib/utils/preloadComponent.svelte.ts @@ -0,0 +1,19 @@ +/** + * Starts loading a component's chunk when the calling module is evaluated and exposes it once + * loaded. Call it from a `<script module>` so a mount after the load renders the component in its + * first frame: an `{#await import()}` shows its pending branch for a tick even on a cached chunk. + * A failed load is left unhandled on purpose: the root layout reports chunk-load failures. + */ +export function preloadComponent<T>(load: () => Promise<{ default: T }>): { + readonly current: T | undefined +} { + let current = $state.raw<T | undefined>(undefined) + load().then((m) => { + current = m.default + }) + return { + get current() { + return current + } + } +} diff --git a/frontend/src/lib/utils/previewOnBehalfOf.ts b/frontend/src/lib/utils/previewOnBehalfOf.ts new file mode 100644 index 0000000000..3b28223b88 --- /dev/null +++ b/frontend/src/lib/utils/previewOnBehalfOf.ts @@ -0,0 +1,34 @@ +type ActingUser = + | { is_admin?: boolean; is_super_admin?: boolean; operator?: boolean; groups?: string[] } + | undefined + +/** + * The identity an editor preview should run as: the runnable's configured run-as identity, so a + * test behaves like the current deployed run. Only admins and `wm_deployers` may preview as + * someone else, and never a role without `code`, which is what `operator` carries (the server + * refuses everyone else); anyone else previews as themselves, which is also what their own save + * would record. + */ +export function previewOnBehalfOf( + user: ActingUser, + onBehalfOf: string | null | undefined, + onBehalfOfEmail?: string | null +): string | undefined { + const canPreserve = + !user?.operator && + (!!user?.is_admin || !!user?.is_super_admin || !!user?.groups?.includes('wm_deployers')) + if (!canPreserve) return undefined + return onBehalfOf || onBehalfOfEmail || undefined +} + +/** An app's runnables run as its policy identity unless it is in viewer mode, which runs them as + * whoever views it — the editor's user, in a preview. */ +export function appPreviewOnBehalfOf( + user: ActingUser, + policy: + | { execution_mode?: string; on_behalf_of?: string; on_behalf_of_email?: string } + | undefined +): string | undefined { + if (!policy || policy.execution_mode === 'viewer') return undefined + return previewOnBehalfOf(user, policy.on_behalf_of, policy.on_behalf_of_email) +} diff --git a/frontend/src/lib/utils/workspaceHierarchy.test.ts b/frontend/src/lib/utils/workspaceHierarchy.test.ts index 14e063cba8..6855effedc 100644 --- a/frontend/src/lib/utils/workspaceHierarchy.test.ts +++ b/frontend/src/lib/utils/workspaceHierarchy.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from 'vitest' import { + buildWorkspaceHierarchy, devWorkspacesInChainAbove, findDefaultForkBase, findWorkspaceAncestors, - findWorkspaceRoot + findWorkspaceRoot, + workspaceSubtreeRows } from './workspaceHierarchy' import type { UserWorkspace } from '../stores' @@ -75,3 +77,39 @@ describe('devWorkspacesInChainAbove', () => { expect(devWorkspacesInChainAbove('prod', nestedFamily)).toEqual([]) }) }) + +describe('buildWorkspaceHierarchy', () => { + const mine = { created_by: 'me@x.com' } + const theirs = { created_by: 'other@x.com' } + const forks = [ + ws('wm-fork-a', 'prod', theirs), + ws('wm-fork-b', 'prod', theirs), + ws('wm-fork-c', 'prod', theirs), + ws('wm-fork-y', 'prod', mine), + ws('wm-fork-z', 'prod', mine) + ] + const ids = (rows: ReturnType<typeof buildWorkspaceHierarchy>) => + rows.map((h) => (h.separatorBefore ? '| ' : '') + h.workspace.id) + + it("puts the user's own forks after the dev workspace, divided from the rest", () => { + const all = [root, ws('devws', 'prod', { is_dev_workspace: true, ...mine }), ...forks] + expect(ids(workspaceSubtreeRows('prod', buildWorkspaceHierarchy(all, 'me@x.com')))).toEqual([ + 'devws', + 'wm-fork-y', + 'wm-fork-z', + '| wm-fork-a', + 'wm-fork-b', + 'wm-fork-c' + ]) + }) + + it('draws no divider in a short list', () => { + const all = [root, ...forks.slice(2)] + expect(ids(buildWorkspaceHierarchy(all, 'me@x.com'))).toEqual([ + 'prod', + 'wm-fork-y', + 'wm-fork-z', + 'wm-fork-c' + ]) + }) +}) diff --git a/frontend/src/lib/utils/workspaceHierarchy.ts b/frontend/src/lib/utils/workspaceHierarchy.ts index 55d54e1b2c..c19eae3c74 100644 --- a/frontend/src/lib/utils/workspaceHierarchy.ts +++ b/frontend/src/lib/utils/workspaceHierarchy.ts @@ -6,14 +6,26 @@ export interface WorkspaceHierarchyItem { isForked: boolean parentName?: string hasChildren: boolean + /** First fork after the caller's own forks among its siblings; pickers draw a divider above it. */ + separatorBefore?: boolean } +/** Sibling forks below this count stay undivided: a divider in a short list is noise. */ +const OWNED_FORKS_SEPARATOR_MIN_SIBLINGS = 5 + /** * Builds a hierarchical structure from a flat array of workspaces. * Supports unlimited nesting levels (fork of fork of fork...). * Returns a flattened array with hierarchy metadata for easy rendering. + * + * With `currentUserEmail`, the forks that user created lead each sibling group (after the dev + * workspace). When every admin and developer is added to new forks, a family lists everyone's forks, + * and without this the user's own are scattered alphabetically among them. */ -export function buildWorkspaceHierarchy(workspaces: UserWorkspace[]): WorkspaceHierarchyItem[] { +export function buildWorkspaceHierarchy( + workspaces: UserWorkspace[], + currentUserEmail?: string | null +): WorkspaceHierarchyItem[] { if (!workspaces || workspaces.length === 0) { return [] } @@ -45,12 +57,15 @@ export function buildWorkspaceHierarchy(workspaces: UserWorkspace[]): WorkspaceH const result: WorkspaceHierarchyItem[] = [] + const isOwnedFork = (w: UserWorkspace) => !w.is_dev_workspace && isForkOwner(w, currentUserEmail) + // Recursively build the hierarchy function addWorkspaceAndChildren( workspace: UserWorkspace, depth: number, isForked: boolean, - parentName?: string + parentName?: string, + separatorBefore?: boolean ) { // Add the current workspace result.push({ @@ -58,19 +73,31 @@ export function buildWorkspaceHierarchy(workspaces: UserWorkspace[]): WorkspaceH depth, isForked, parentName, - hasChildren: hasChildrenSet.has(workspace.id) + hasChildren: hasChildrenSet.has(workspace.id), + separatorBefore }) - // Add its children: the canonical dev workspace first, then throwaway forks by name. - const children = childrenMap.get(workspace.id) || [] - children - .sort((a, b) => { - if (!!a.is_dev_workspace !== !!b.is_dev_workspace) return a.is_dev_workspace ? -1 : 1 - return a.name.localeCompare(b.name) - }) - .forEach((child) => { - addWorkspaceAndChildren(child, depth + 1, true, workspace.name) - }) + // Add its children: the canonical dev workspace first, then the user's own forks, then the + // other forks, each by name. + const children = (childrenMap.get(workspace.id) || []).sort((a, b) => { + if (!!a.is_dev_workspace !== !!b.is_dev_workspace) return a.is_dev_workspace ? -1 : 1 + if (isOwnedFork(a) !== isOwnedFork(b)) return isOwnedFork(a) ? -1 : 1 + return a.name.localeCompare(b.name) + }) + const forkCount = children.filter((c) => !c.is_dev_workspace).length + const firstOtherFork = + forkCount >= OWNED_FORKS_SEPARATOR_MIN_SIBLINGS && children.some(isOwnedFork) + ? children.find((c) => !c.is_dev_workspace && !isOwnedFork(c)) + : undefined + children.forEach((child) => { + addWorkspaceAndChildren( + child, + depth + 1, + true, + workspace.name, + child === firstOtherFork || undefined + ) + }) } // Process root workspaces (sorted by name for consistency) @@ -89,6 +116,21 @@ export function buildWorkspaceHierarchy(workspaces: UserWorkspace[]): WorkspaceH return result } +/** + * The hierarchy rows below `rootId` (the root itself excluded), in the order and with the depths + * `buildWorkspaceHierarchy` gives them. + */ +export function workspaceSubtreeRows( + rootId: string, + hierarchy: WorkspaceHierarchyItem[] +): WorkspaceHierarchyItem[] { + const at = hierarchy.findIndex((h) => h.workspace.id === rootId) + if (at < 0) return [] + const rootDepth = hierarchy[at].depth + const end = hierarchy.findIndex((h, i) => i > at && h.depth <= rootDepth) + return hierarchy.slice(at + 1, end < 0 ? undefined : end) +} + /** * Helper function to get the indentation padding based on depth. * Each level adds 24px of left padding. diff --git a/frontend/src/lib/utils_draft_deploy.test.ts b/frontend/src/lib/utils_draft_deploy.test.ts index 5f503989c8..c141af3a3b 100644 --- a/frontend/src/lib/utils_draft_deploy.test.ts +++ b/frontend/src/lib/utils_draft_deploy.test.ts @@ -122,6 +122,83 @@ describe('deployDraft preserves on_behalf_of', () => { }) ) }) + + it('agent: claims the deployed identity, which its draft never carries', async () => { + vi.mocked(ResourceService.getResource).mockResolvedValueOnce({ + path: 'f/support/triage_agent', + resource_type: 'ai_agent', + value: { system_prompt: 'deployed', on_behalf_of: 'u/alice' }, + draft: { path: 'f/support/triage_agent', args: { system_prompt: 'drafted' } } + } as any) + + expect(await deployDraft('resource', 'f/support/triage_agent', 'ws')).toEqual({ success: true }) + expect(ResourceService.updateResource).toHaveBeenCalledWith( + expect.objectContaining({ + requestBody: expect.objectContaining({ + value: { system_prompt: 'drafted', on_behalf_of: 'u/alice' }, + preserve_on_behalf_of: true + }) + }) + ) + }) +}) + +// 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', () => { + beforeEach(() => vi.clearAllMocks()) + + it('never sends the draft lock, so the backend relocks', async () => { + vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({ + hash: 'v1', + draft: { + path: 'f/admin/fetch', + content: 'import requests', + lock: 'certifi==2024.1.1', + lock_error_logs: 'old error' + } + } as any) + + await deployDraft('script', 'f/admin/fetch', 'ws') + const body = vi.mocked(ScriptService.createScript).mock.calls[0][0].requestBody + expect(body).not.toHaveProperty('lock') + expect(body).not.toHaveProperty('lock_error_logs') + }) + + it('flow: drops the lock of a step whose code differs from the deployed one only', async () => { + const step = (id: string, content: string) => ({ + id, + value: { type: 'rawscript', language: 'python3', content, lock: 'requests==2.0' } + }) + vi.mocked(FlowService.getFlowByPath).mockResolvedValueOnce({ + value: { modules: [step('edited', 'import requests'), step('kept', 'import requests')] }, + draft: { + path: 'f/admin/flow', + value: { modules: [step('edited', 'import yaml'), step('kept', 'import requests')] } + } + } as any) + + await deployDraft('flow', 'f/admin/flow', 'ws') + const [edited, kept] = vi.mocked(FlowService.updateFlow).mock.calls[0][0].requestBody.value + .modules as any[] + expect(edited.value).not.toHaveProperty('lock') + expect(kept.value.lock).toBe('requests==2.0') + }) + + // A never-deployed flow is often a copy or an import, whose locks match its code. + it('flow: keeps every lock when nothing is deployed to compare against', async () => { + const step = { id: 'copied', value: { type: 'rawscript', content: 'x', lock: 'requests==2.0' } } + vi.mocked(FlowService.getFlowByPath).mockResolvedValueOnce({ + no_deployed: true, + value: { modules: [step] }, + draft: { path: 'f/admin/copy', value: { modules: [step] } } + } as any) + + await deployDraft('flow', 'f/admin/copy', 'ws', { draftOnly: true }) + const [copied] = vi.mocked(FlowService.createFlow).mock.calls[0][0].requestBody.value + .modules as any[] + expect(copied.value.lock).toBe('requests==2.0') + }) }) // The resource branch reads the item again when the deploy lands, and falls back to the deployed diff --git a/frontend/src/lib/utils_draft_deploy.ts b/frontend/src/lib/utils_draft_deploy.ts index 611d5b96e8..96b83239b7 100644 --- a/frontend/src/lib/utils_draft_deploy.ts +++ b/frontend/src/lib/utils_draft_deploy.ts @@ -46,6 +46,7 @@ import { setLocalDraftHint } from '$lib/localDraftHints.svelte' import { userStore } from '$lib/stores' import { deployTriggers, type Trigger } from '$lib/components/triggers/utils' import { saveScheduleFromCfg } from '$lib/components/flows/scheduleUtils' +import { dropStaleStepLocks } from '$lib/components/flows/staleStepLocks' import { saveHttpRouteFromCfg } from '$lib/components/triggers/http/utils' import { saveWebsocketTriggerFromCfg } from '$lib/components/triggers/websocket/utils' import { savePostgresTriggerFromCfg } from '$lib/components/triggers/postgres/utils' @@ -233,8 +234,14 @@ function canonicalizeDraftDiffValue(kind: DraftKind, raw: any, isDraft: boolean) if (kind === 'resource') { // draft: { args, description, resource_type, labels, wsSpecific } // deployed row: { value, description, resource_type, labels, ws_specific } + // An agent's identity is in its deployed value and never in a draft, which is not an edit. + let value = (isDraft ? raw.args : raw.value) ?? {} + if (raw.resource_type === 'ai_agent') { + const { on_behalf_of: _, ...rest } = value + value = rest + } return { - value: (isDraft ? raw.args : raw.value) ?? {}, + value, description: raw.description ?? '', resource_type: raw.resource_type ?? undefined, labels: raw.labels ?? undefined, @@ -469,7 +476,15 @@ export async function deployDraft( const r = (await ScriptService.getScriptByPath({ workspace, path, getDraft: true })) as any const d = r.draft ?? r // Drop editor-only / server-managed keys; deploy as a real (non-draft) version. - const { draft_triggers: draftTriggers, draft_only: _o, ...rest } = d + // A draft seeded from the deployed script carries its `lock`, and the backend skips + // the dependency job whenever `lock` is set, so the new content would keep the old lock. + const { + draft_triggers: draftTriggers, + draft_only: _o, + lock: _lock, + lock_error_logs: _lockErrorLogs, + ...rest + } = d const scriptPath = d.path ?? path // Deploy at the draft's path so a rename in the draft is honored (same as // the editor: createScript at the new path with parent_hash links lineage). @@ -490,6 +505,9 @@ export async function deployDraft( } else if (kind === 'flow') { const r = (await FlowService.getFlowByPath({ workspace, path, getDraft: true })) as any const d = r.draft ?? r + const value = d.value ? structuredClone(d.value) : d.value + // With nothing deployed the server fills `r.value` from the draft itself. + if (value) dropStaleStepLocks(value, draftOnly || r.no_deployed ? undefined : r.value) const requestBody = { // Deploy at the draft's intended path: flow/app/raw-app drafts keep the // user-typed path in `draft_path` (a never-deployed item is parked at a @@ -498,7 +516,7 @@ export async function deployDraft( path: d.draft_path ?? d.path ?? path, summary: d.summary ?? '', description: d.description ?? '', - value: d.value, + value, schema: d.schema, tag: d.tag, dedicated_worker: d.dedicated_worker, @@ -551,7 +569,7 @@ export async function deployDraft( // undefined so the backend preserves the existing route. The draft has no // custom_path, so admins fall back to the deployed route (`''` when none). const isAdmin = !!(get(userStore)?.is_admin || get(userStore)?.is_super_admin) - const policy = r.policy ?? { execution_mode: 'publisher' } + const policy = r.policy ?? { execution_mode: 'publisher', sandbox: true } const requestBody = { value: appValue, summary: draftSummary ?? r.summary ?? '', @@ -646,12 +664,18 @@ export async function deployDraft( } }) } else { + // As for an app: an agent draft carries no identity, so the deployed one is kept (the + // backend resets it to the deploying user without the flag, gated by + // can_preserve_on_behalf_of). + const agentObo = + deployed.resource_type === 'ai_agent' ? deployed.value?.on_behalf_of : undefined await ResourceService.updateResource({ workspace, path, requestBody: { path: d.path ?? path, - value: d.args ?? {}, + value: agentObo ? { ...(d.args ?? {}), on_behalf_of: agentObo } : (d.args ?? {}), + preserve_on_behalf_of: agentObo ? true : undefined, description: d.description ?? '', labels: d.labels, ws_specific: d.wsSpecific diff --git a/frontend/src/lib/utils_workspace_deploy.test.ts b/frontend/src/lib/utils_workspace_deploy.test.ts index dfac557d20..5db05d041a 100644 --- a/frontend/src/lib/utils_workspace_deploy.test.ts +++ b/frontend/src/lib/utils_workspace_deploy.test.ts @@ -32,8 +32,8 @@ vi.mock('$lib/gen', async (importOriginal) => ({ } })) -// The deploy runs the published shared package, whose variable body has no `labels`: this -// pins that the adapter still carries them until a version that does is installed. +// The variable body is built by the installed shared package: this pins that a deploy from the +// app sends the source's labels on create and on update. describe('deploying a variable to another workspace', () => { it('carries its labels on create and on update', async () => { for (const workspaceTo of ['lacks-it', 'has-it']) { @@ -239,14 +239,17 @@ describe('workspace-level deploy permission', () => { expect(res.reason).toContain('Direct deployment') }) - // The server refuses an operator in the item handler regardless of their global role: a - // superadmin who is an operator in the workspace still gets 401 creating a script. So the - // operator term has to stay above the admin/superadmin short-circuit — hoisting the admin - // checks would offer a deploy the server refuses. - it('refuses an operator who is also a superadmin', async () => { + // Authentication merges superadmin into admin, and `caller_capabilities` gives an admin every + // capability, whatever the workspace role. + it('lets a superadmin whose role is operator deploy', async () => { const res = await permission({ ...member, operator: true, is_super_admin: true }, []) + expect(res.ok).toBe(true) + }) + + it('refuses a member whose role cannot code', async () => { + const res = await permission({ ...member, operator: true }, []) expect(res.ok).toBe(false) - expect(res.reason).toContain('operator') + expect(res.reason).toContain('role') }) }) diff --git a/frontend/src/lib/utils_workspace_deploy.ts b/frontend/src/lib/utils_workspace_deploy.ts index fa50805e3a..efa7f09115 100644 --- a/frontend/src/lib/utils_workspace_deploy.ts +++ b/frontend/src/lib/utils_workspace_deploy.ts @@ -22,6 +22,11 @@ import { type User } from '$lib/gen' import type { UserDraftItemKind } from '$lib/gen' +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 { fetchProtectionRulesForWorkspace, canUserBypassRuleKindInRulesets @@ -181,25 +186,13 @@ export type AppIdentity = { email: string; permissionedAs: string } export type DeployConflict = { hit: boolean } /** - * `deployItem` overrides only the email half of the identity, while the body it builds - * spreads the *source* item — which carries the source workspace's permissioned_as, valid - * nowhere else since usernames are per-workspace. The key is therefore always overwritten: - * with the picked user's principal for a custom choice, and cleared otherwise so the - * backend derives the target's own from the email it is given. The shared `deployItem` - * does the same, but this app consumes the published package, so the rewrite has to exist - * on both sides until that version ships. - * - * An app carries both halves inside its `policy` instead, so it needs both stamped here: - * the published package leaves the policy untouched, and a source principal beside a - * target address is rejected as a pair naming two different accounts. - * - * A group kept as the target identity travels as its synthetic `group-*@windmill.dev` - * address, which an admin-created account holding it would win on the backend: known and - * accepted, see `users::permissioned_as_from_email`. + * `deployItem` takes the identity only as an email and clears the source workspace's + * principal, leaving the backend to derive the target's own from that email. When the user + * picked a specific user, their principal is known here, so it is stamped on the body: on a + * flow or script directly, and inside the `policy` for an app, which holds both halves. */ function makeProvider( onBehalfOfPrincipal?: string, - onBehalfOf?: string, /** * Refuse the writes the shared `deployItem` reaches for only when the item already exists in * the target, turning its silent switch to an update into a failure the caller can act on. @@ -209,37 +202,16 @@ function makeProvider( */ conflict?: DeployConflict ): DeployProvider { - const withPermissionedAs = <T extends Record<string, any>>(requestBody: T): T => ({ - ...requestBody, - on_behalf_of: onBehalfOfPrincipal - }) + const withPermissionedAs = <T extends Record<string, any>>(requestBody: T): T => + onBehalfOfPrincipal ? { ...requestBody, on_behalf_of: onBehalfOfPrincipal } : requestBody const withPolicyIdentity = <T extends Record<string, any>>(app: T): T => - app.policy - ? { - ...app, - policy: { - ...app.policy, - on_behalf_of: onBehalfOfPrincipal, - on_behalf_of_email: onBehalfOf - } - } + onBehalfOfPrincipal && app.policy + ? { ...app, policy: { ...app.policy, on_behalf_of: onBehalfOfPrincipal } } : app const refuseUpdate = (): never => { if (conflict) conflict.hit = true throw new Error('item already exists in the target workspace') } - // The published package builds variable, resource and folder bodies field by field without - // `labels`, so carry the source item's across from the read `deployItem` does just before the - // write. One provider serves one deploy, so the read always belongs to the write that follows. - let sourceLabels: string[] | undefined - const readLabels = <T extends { labels?: string[] }>(item: T): T => { - sourceLabels = item.labels - return item - } - const withLabels = <T extends Record<string, any>>(requestBody: T): T => ({ - ...requestBody, - labels: sourceLabels - }) return { existsFlowByPath: (p) => FlowService.existsFlowByPath(p), existsScriptByPath: (p) => ScriptService.existsScriptByPath(p), @@ -284,31 +256,21 @@ function makeProvider( getPublicSecretOfLatestVersionOfApp: (p) => AppService.getPublicSecretOfLatestVersionOfApp(p), getRawAppData: (p) => AppService.getRawAppData(p), deleteApp: (p) => AppService.deleteApp(p), - getVariable: (p) => VariableService.getVariable(p).then(readLabels), - createVariable: (p) => - VariableService.createVariable({ ...p, requestBody: withLabels(p.requestBody) }), - updateVariable: (p) => - VariableService.updateVariable({ ...p, requestBody: withLabels(p.requestBody) }), + getVariable: (p) => VariableService.getVariable(p), + createVariable: (p) => VariableService.createVariable(p), + updateVariable: (p) => VariableService.updateVariable(p), deleteVariable: (p) => VariableService.deleteVariable(p), - getResource: (p) => ResourceService.getResource(p).then(readLabels), - createResource: (p) => - ResourceService.createResource({ ...p, requestBody: withLabels(p.requestBody) }), - updateResource: (p) => - ResourceService.updateResource({ ...p, requestBody: withLabels(p.requestBody) }), + getResource: (p) => ResourceService.getResource(p), + createResource: (p) => ResourceService.createResource(p), + updateResource: (p) => ResourceService.updateResource(p), deleteResource: (p) => ResourceService.deleteResource(p), getResourceType: (p) => ResourceService.getResourceType(p), createResourceType: (p) => ResourceService.createResourceType(p), updateResourceType: (p) => ResourceService.updateResourceType(p), deleteResourceType: (p) => ResourceService.deleteResourceType(p), - getFolder: (p) => FolderService.getFolder(p).then(readLabels), - createFolder: (p) => - FolderService.createFolder({ ...p, requestBody: withLabels(p.requestBody) }), - // A folder with no labels reads back without the field, so clearing them needs an explicit []. - updateFolder: (p) => - FolderService.updateFolder({ - ...p, - requestBody: { ...p.requestBody, labels: sourceLabels ?? [] } - }), + getFolder: (p) => FolderService.getFolder(p), + createFolder: (p) => FolderService.createFolder(p), + updateFolder: (p) => FolderService.updateFolder(p), deleteFolder: (p) => FolderService.deleteFolder(p), // Triggers existsTriggerByKind: (kind, p) => triggerServiceFor(kind).exists(p), @@ -450,7 +412,7 @@ export async function deployItem( const conflict: DeployConflict | undefined = createOnly ? { hit: false } : undefined const result = await sharedDeployItem( - makeProvider(onBehalfOfPrincipal, onBehalfOf, conflict), + makeProvider(onBehalfOfPrincipal, conflict), kind as DeployKind, path, workspaceFrom, @@ -591,6 +553,17 @@ export async function getOnBehalfOf( return undefined } } + if (kind === 'resource') { + // Only an agent runs as anyone, and like a trigger it records the principal. + try { + const resource = await ResourceService.getResource({ workspace, path }) + return resource.resource_type === 'ai_agent' + ? (resource.value as { on_behalf_of?: string } | undefined)?.on_behalf_of + : undefined + } catch { + return undefined + } + } return sharedGetOnBehalfOf(makeProvider(), kind as DeployKind, path, workspace) } @@ -797,6 +770,24 @@ export function kindGatedByDeployRules(kind: Kind | UserDraftItemKind): boolean return GATED_KIND_NAMES.has(kind) } +// The kinds whose item handlers refuse a role per kind (`check_role_can_code`, +// `check_operator_can_build`, `require_full_apps`). `checkDeployPermission` only asks for `code`, +// so a role that codes but cannot author flows or apps is caught here. +const ROLE_GATED_KINDS = new Set<string>(['script', 'flow', 'app', 'raw_app', 'data_pipeline']) + +function roleRefusal( + kind: Kind | UserDraftItemKind, + caps: RoleCapabilities +): DeployPermission | undefined { + if (!ROLE_GATED_KINDS.has(kind) || roleCanAuthor(kind === 'data_pipeline' ? 'script' : kind, caps)) + return undefined + return { + ok: false, + reason: `Your role in this workspace can't deploy ${kind.replace('_', ' ')}s`, + refusedBy: 'operator' + } +} + /** * Narrow a workspace-level refusal to one item kind. Only the direct-deployment term is scoped: * the deployers-only term over-reaches the same way, but it does so on `main` too, and loosening @@ -804,8 +795,11 @@ export function kindGatedByDeployRules(kind: Kind | UserDraftItemKind): boolean */ export function deployPermissionForKind( perm: DeployPermission, - kind: Kind | UserDraftItemKind + kind: Kind | UserDraftItemKind, + caps?: RoleCapabilities ): DeployPermission { + const refusal = caps && roleRefusal(kind, caps) + if (refusal) return refusal if (perm.ok) return perm if (perm.refusedBy === 'DisableDirectDeployment' && !kindGatedByDeployRules(kind)) { return { ok: true } @@ -816,8 +810,11 @@ export function deployPermissionForKind( /** The refusal a bulk action carries: it applies as soon as one selected kind is still refused. */ export function deployPermissionForKinds( perm: DeployPermission, - kinds: (Kind | UserDraftItemKind)[] + kinds: (Kind | UserDraftItemKind)[], + caps?: RoleCapabilities ): DeployPermission { + const refusal = caps && kinds.map((k) => roleRefusal(k, caps)).find((r) => r) + if (refusal) return refusal if (perm.ok) return perm // An empty selection keeps the workspace-level refusal, which is then the only thing left // to say why the action is unavailable. @@ -894,9 +891,9 @@ export async function checkDeployRules( /** * Whether the current user may deploy into `workspace` at all: the protection rules above, - * plus a refusal of every operator. + * plus a refusal of every role without `code`. * - * That refusal is not part of the `check_deploy_rules` mirror. The server refuses operators + * That refusal is not part of the `check_deploy_rules` mirror. The server refuses such roles * in the item handlers instead, and for fewer kinds, so refusing them for everything here is * deliberately stricter than the server rather than a faithful copy of it. A caller mirroring * one handler wants `checkDeployRules`. @@ -909,10 +906,10 @@ export async function checkDeployPermission( ): Promise<DeployPermission> { try { const me = whoami ?? (await UserService.whoami({ workspace })) - if (me.operator) { + if (!capabilitiesIn(me, get(userWorkspaces).find((w) => w.id === workspace)).code) { return { ok: false, - reason: "You're an operator in this workspace — operators can't deploy", + reason: "Your role in this workspace can't deploy", refusedBy: 'operator' } } diff --git a/frontend/src/lib/workspaceRoles.test.ts b/frontend/src/lib/workspaceRoles.test.ts new file mode 100644 index 0000000000..84f3ff8a71 --- /dev/null +++ b/frontend/src/lib/workspaceRoles.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest' +import type { EffectiveRole } from '$lib/gen' +import { + applyCapabilitiesPatch, + baseCapabilitiesFor, + FULL_CAPABILITIES, + validRoleOr, + workspaceRolesErrors +} from './workspaceRoles' + +// These mirror `windmill_common::roles`; the server refuses what they let through. +describe('role capability layering', () => { + it('falls back to full for developer and to the operator rights for any other name', () => { + expect(baseCapabilitiesFor('developer')).toEqual(FULL_CAPABILITIES) + const other = baseCapabilitiesFor('reviewer') + expect([other.code, other.flows, other.manage_schedules]).toEqual([false, 'none', true]) + }) + + it('applies only the keys a patch sets', () => { + const caps = applyCapabilitiesPatch(baseCapabilitiesFor('x'), { + flows: 'compose', + visibility: { workers: false } + }) + expect(caps.flows).toBe('compose') + expect(caps.apps).toBe('none') + expect(caps.visibility.workers).toBe(false) + expect(caps.visibility.runs).toBe(true) + }) + + it('treats a stored null as unset, as serde reads it', () => { + const caps = applyCapabilitiesPatch(baseCapabilitiesFor('developer'), { + code: null as any, + visibility: { runs: null as any } + }) + expect(caps).toEqual(FULL_CAPABILITIES) + expect( + workspaceRolesErrors([{ name: 'developer', capabilities: { code: null as any } }]) + ).toEqual({}) + }) + + it('refuses what parse_instance_roles refuses', () => { + expect(workspaceRolesErrors(undefined)).toEqual({}) + expect(workspaceRolesErrors([])).toHaveProperty('list') + expect(workspaceRolesErrors([{ name: 'admin' }])).toHaveProperty('0.name') + expect(workspaceRolesErrors([{ name: 'a' }, { name: 'a' }])).toHaveProperty('1.name') + expect(workspaceRolesErrors([{ name: 'x', capabilities: { flows: 'full' } }])).toHaveProperty( + '0.capabilities' + ) + expect( + workspaceRolesErrors([{ name: 'developer', capabilities: { apps: 'compose' } }]) + ).toEqual({}) + }) +}) + +describe('validRoleOr', () => { + const role = (name: string) => ({ name }) as EffectiveRole + + it('falls back to developer, or the first role when the instance dropped it', () => { + expect(validRoleOr([role('operator'), role('developer')], undefined)).toBe('developer') + expect(validRoleOr([role('builder'), role('viewer')], undefined)).toBe('builder') + expect(validRoleOr([role('builder'), role('viewer')], 'developer')).toBe('builder') + expect(validRoleOr([role('builder')], 'admin')).toBe('admin') + }) +}) diff --git a/frontend/src/lib/workspaceRoles.ts b/frontend/src/lib/workspaceRoles.ts new file mode 100644 index 0000000000..4cfb3a6089 --- /dev/null +++ b/frontend/src/lib/workspaceRoles.ts @@ -0,0 +1,218 @@ +import { + WorkspaceService, + type AuthorLevel, + type EffectiveRole, + type RoleCapabilities, + type RoleCapabilitiesPatch, + type RoleDefinition, + type RoleVisibility, + type WorkspaceRoles +} from '$lib/gen' + +/** Not a role: the `is_admin` flag, offered next to the roles wherever a member's access is picked. */ +export const ADMIN_ROLE = 'admin' + +export const DEVELOPER_ROLE = 'developer' +export const OPERATOR_ROLE = 'operator' + +export const VISIBILITY_KEYS: { key: keyof RoleVisibility; label: string }[] = [ + { key: 'runs', label: 'Runs' }, + { key: 'schedules', label: 'Schedules' }, + { key: 'resources', label: 'Resources' }, + { key: 'variables', label: 'Variables' }, + { key: 'assets', label: 'Assets' }, + { key: 'triggers', label: 'Triggers' }, + { key: 'audit_logs', label: 'Audit logs' }, + { key: 'groups', label: 'Groups' }, + { key: 'folders', label: 'Folders' }, + { key: 'workers', label: 'Workers' } +] + +const ALL_VISIBLE: RoleVisibility = { + runs: true, + schedules: true, + resources: true, + variables: true, + assets: true, + triggers: true, + audit_logs: true, + groups: true, + folders: true, + workers: true +} + +// Mirror `RoleCapabilities::FULL` and `RoleCapabilities::OPERATOR` in `windmill_common::roles`. +export const FULL_CAPABILITIES: RoleCapabilities = { + code: true, + flows: 'full', + apps: 'full', + manage_schedules: true, + manage_triggers: true, + visibility: ALL_VISIBLE +} + +const OPERATOR_CAPABILITIES: RoleCapabilities = { + code: false, + flows: 'none', + apps: 'none', + manage_schedules: true, + manage_triggers: true, + visibility: ALL_VISIBLE +} + +export const DEFAULT_INSTANCE_ROLES: RoleDefinition[] = [ + { name: DEVELOPER_ROLE, label: 'Developer', capabilities: {} }, + { name: OPERATOR_ROLE, label: 'Operator', capabilities: {} } +] + +/** What a role named `name` holds before any configuration: `RoleCapabilities::base_for`. */ +export function baseCapabilitiesFor(name: string): RoleCapabilities { + return applyCapabilitiesPatch( + name === DEVELOPER_ROLE ? FULL_CAPABILITIES : OPERATOR_CAPABILITIES, + undefined + ) +} + +export function applyCapabilitiesPatch( + base: RoleCapabilities, + patch: RoleCapabilitiesPatch | undefined +): RoleCapabilities { + // Spread rather than `structuredClone`: `base` may be a Svelte state proxy, which cannot be cloned. + const out: RoleCapabilities = { ...base, visibility: { ...base.visibility } } + if (!patch) return out + // `!= null`: a stored `null` deserializes to `None` server-side, so it is unset like `undefined`. + if (patch.code != null) out.code = patch.code + if (patch.flows != null) out.flows = patch.flows + if (patch.apps != null) out.apps = patch.apps + if (patch.manage_schedules != null) out.manage_schedules = patch.manage_schedules + if (patch.manage_triggers != null) out.manage_triggers = patch.manage_triggers + for (const { key } of VISIBILITY_KEYS) { + const v = patch.visibility?.[key] + if (v != null) out.visibility[key] = v + } + return out +} + +/** The error the server raises for these capabilities, if any (`RoleCapabilities::validate`). */ +export function capabilitiesError(caps: RoleCapabilities): string | undefined { + if (!caps.code && (caps.flows === 'full' || caps.apps === 'full')) { + return 'Full flow or app authoring requires the code capability' + } + return undefined +} + +/** `validate_role_name` in `windmill_common::roles`. */ +export function roleNameError(name: string): string | undefined { + if (name === ADMIN_ROLE) return 'The name admin is reserved' + if (!/^[a-z0-9_-]{1,50}$/.test(name)) { + return 'Use 1 to 50 lowercase letters, digits, underscores or hyphens' + } + return undefined +} + +/** A patch with no key set, so it is dropped rather than stored as `{}`. */ +export function isEmptyPatch(patch: RoleCapabilitiesPatch | undefined): boolean { + if (!patch) return true + const { visibility, ...rest } = patch + return ( + Object.values(rest).every((v) => v == null) && + Object.values(visibility ?? {}).every((v) => v == null) + ) +} + +/** + * What `parse_instance_roles` would refuse in a `workspace_roles` value, keyed `<index>.name`, + * `<index>.capabilities` or `list`. An unset value is the defaults and always valid. + */ +export function workspaceRolesErrors(value: unknown): Record<string, string> { + if (value == undefined) return {} + if (!Array.isArray(value)) return { list: 'Expected a list of roles' } + if (value.length === 0) return { list: 'Keep at least one role' } + const errors: Record<string, string> = {} + const seen = new Set<string>() + value.forEach((r: RoleDefinition, i) => { + const nameError = roleNameError(r?.name ?? '') + if (nameError) errors[`${i}.name`] = nameError + else if (seen.has(r.name)) errors[`${i}.name`] = 'Another role has this name' + seen.add(r?.name) + const capsError = capabilitiesError( + applyCapabilitiesPatch(baseCapabilitiesFor(r?.name ?? ''), r?.capabilities) + ) + if (capsError) errors[`${i}.capabilities`] = capsError + }) + return errors +} + +/** Whether `roles` is the default list, which is stored as no value at all. */ +export function isDefaultInstanceRoles(roles: RoleDefinition[]): boolean { + return ( + roles.length === DEFAULT_INSTANCE_ROLES.length && + roles.every( + (r, i) => + r.name === DEFAULT_INSTANCE_ROLES[i].name && + (r.label ?? '') === DEFAULT_INSTANCE_ROLES[i].label && + isEmptyPatch(r.capabilities) + ) + ) +} + +export function authorLevelLabel(level: AuthorLevel): string { + return level === 'none' ? 'None' : level === 'compose' ? 'Compose' : 'Full' +} + +export async function loadWorkspaceRoles(workspace: string): Promise<WorkspaceRoles> { + return await WorkspaceService.listWorkspaceRoles({ workspace }) +} + +/** + * The role a new member gets when none is picked: `developer` while the workspace defines it, + * the first role otherwise, as the server picks one. + */ +export function defaultRoleName(roles: EffectiveRole[] | undefined): string { + if (!roles?.length || roles.some((r) => r.name === DEVELOPER_ROLE)) return DEVELOPER_ROLE + return roles[0].name +} + +/** `value` while the workspace still defines it (or it is Admin), the default role otherwise. */ +export function validRoleOr(roles: EffectiveRole[] | undefined, value: string | undefined): string { + if (value === ADMIN_ROLE || (value && (!roles || roles.some((r) => r.name === value)))) { + return value + } + return defaultRoleName(roles) +} + +/** Role picker items: the workspace's roles in their order, then Admin. */ +export function roleItems( + roles: EffectiveRole[] | undefined, + opts: { admin?: boolean } = {} +): { label: string; value: string }[] { + const items = (roles ?? []).map((r) => ({ label: r.label, value: r.name })) + if (opts.admin ?? true) items.push({ label: 'Admin', value: ADMIN_ROLE }) + return items +} + +export function roleLabel(roles: EffectiveRole[] | undefined, name: string | undefined): string { + if (!name) return '' + if (name === ADMIN_ROLE) return 'Admin' + return roles?.find((r) => r.name === name)?.label ?? name +} + +/** The picker value of a member, invite or mapping: `admin` for an admin, its role otherwise. */ +export function pickerValue(m: { is_admin?: boolean; role?: string; operator?: boolean }): string { + if (m.is_admin) return ADMIN_ROLE + return m.role ?? (m.operator ? OPERATOR_ROLE : DEVELOPER_ROLE) +} + +/** + * The fields a picked value is sent as. An admin keeps a role underneath the flag, `developer` + * being what the server gives one when none is named; `operator` is set for servers that predate + * roles. + */ +export function roleRequestFields(value: string): { + is_admin: boolean + operator: boolean + role?: string +} { + if (value === ADMIN_ROLE) return { is_admin: true, operator: false } + return { is_admin: false, operator: value === OPERATOR_ROLE, role: value } +} diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 2dcdeab5dd..6b79582958 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -7,6 +7,7 @@ AssetService, FlowService, OpenAPI, + ResourceService, ScriptService, SettingService, UserService, @@ -53,10 +54,12 @@ nonMemberWorkspaces, setNonMemberWorkspaces, clearNonMemberWorkspaces, + roleCapabilities, type UserWorkspace } from '$lib/stores' import CenteredModal from '$lib/components/CenteredModal.svelte' - import { afterNavigate, beforeNavigate } from '$app/navigation' + import { afterNavigate, beforeNavigate, onNavigate } from '$app/navigation' + import { homeSessionsTransition } from '$lib/components/sessions/homeSessionsTransition.svelte' import { goto } from '$lib/navigation' import { registerToolDisplayActionHandler } from '$lib/components/copilot/chat/createdResourceActions.svelte' import type UserSettings from '$lib/components/UserSettings.svelte' @@ -78,8 +81,11 @@ Home, Play, Search, - WandSparkles + WandSparkles, + ArrowLeft } from 'lucide-svelte' + import { Button } from '$lib/components/common' + import { isInstanceRoute } from '$lib/instanceRoutes' import { getUserExt } from '$lib/user' import { confirmPendingLoginMethod } from '$lib/lastLoginMethod' import { deepEqual } from 'fast-equals' @@ -89,7 +95,6 @@ import { overlayStack } from '$lib/components/common/overlayHost.svelte' import PageHeaderBar from '$lib/components/PageHeaderBar.svelte' import { BAR_HEIGHT_PX, pageHeader } from '$lib/components/pageHeaderRegistry.svelte' - import { sidebarPageAllowed } from '$lib/components/sidebar/operatorRoutes' import type GlobalSearchModal from '$lib/components/search/GlobalSearchModal.svelte' import MenuButton from '$lib/components/sidebar/MenuButton.svelte' import MenuLink from '$lib/components/sidebar/MenuLink.svelte' @@ -162,7 +167,7 @@ let collapsedState = $state(collapsePref.val) // Operators never get the icon-only rail: their docked sidebar is always expanded (a sidebar // they want out of the way is what detaching is for). - let isCollapsed = $derived(collapsedState && !$userStore?.operator) + let isCollapsed = $derived(collapsedState && $roleCapabilities.code) // Resizable desktop rail, sized in REM so it scales with the root font-size the // same way the old `w-52`/`w-12` classes did — `:root` jumps to 18px on screens @@ -243,7 +248,7 @@ // instead: dragging the sidebar off the left edge puts it behind the handle. // Detaching is not a resize, so the width the drag started from is what gets // persisted — re-attaching restores the sidebar at the size it had. - if (collapse && $userStore?.operator) { + if (collapse && !$roleCapabilities.code) { sidebarWidth = widthAtStart stop() setDetached(true) @@ -302,7 +307,7 @@ // (SessionModeSwitch) just navigates in and out of that route. // An operator without the opt-in (`gate.ts`) is refused by /sessions, so their sidebar is not // handed over to a session list the page behind it declines, and they keep the docked chat. - const sessionsAllowed = $derived(sessionsAllowedFor($userStore?.operator)) + const sessionsAllowed = $derived(sessionsAllowedFor(!$roleCapabilities.code)) let sessionMode = $derived(page.url.pathname.startsWith('/sessions') && sessionsAllowed) // Session mode points the bottom settings entry at the open session's own // workspace. An unsent draft hasn't committed one yet, so it falls back to @@ -327,6 +332,8 @@ const sessionsSwitchShown = $derived( globalAiEnabled && sessionsAllowed && !$copilotInfo.workspaceDisabled ) + // The rail's top row; the nav below it pads to the page header's height when it is absent. + const switchRowShown = $derived(!embedded && sessionsSwitchShown) const askAiShown = $derived( (!globalAiEnabled || !sessionsAllowed) && !$copilotInfo.workspaceDisabled ) @@ -443,6 +450,8 @@ return kind ? { kind, path: route.itemPath } : undefined } + onNavigate(homeSessionsTransition) + beforeNavigate((navigation) => { if (preserveMenuOnNextNav) { preserveMenuOnNextNav = false @@ -655,22 +664,37 @@ } async function loadFavorites() { - const scripts = await ScriptService.listScripts({ - workspace: $workspaceStore ?? '', - starredOnly: true, - includeWithoutMain: true, - withoutDescription: true - }) - const flows = await FlowService.listFlows({ - workspace: $workspaceStore ?? '', - starredOnly: true, - withoutDescription: true - }) - const apps = await AppService.listApps({ - workspace: $workspaceStore ?? '', - starredOnly: true - }) - const assets = await AssetService.listFavoriteAssets({ workspace: $workspaceStore ?? '' }) + const workspace = $workspaceStore ?? '' + try { + await loadFavoritesFor(workspace) + } catch (e) { + // `current` still holds another workspace's favorites, which `loadedFor` is about to + // vouch for as this one's. + if (favoriteManager.loadedFor !== workspace && ($workspaceStore ?? '') === workspace) + favoriteManager.current = [] + throw e + } finally { + // Set on failure too: Home holds its shortcuts back until this says the favorites + // are in, and a failed read must not hold them back for good. Not once the workspace + // has changed: a slow load for the last one would name it and hide them in this one. + if (($workspaceStore ?? '') === workspace) favoriteManager.loadedFor = workspace + } + } + + async function loadFavoritesFor(workspace: string) { + const [scripts, flows, apps, agents, assets] = await Promise.all([ + ScriptService.listScripts({ + workspace, + starredOnly: true, + includeWithoutMain: true, + withoutDescription: true + }), + FlowService.listFlows({ workspace, starredOnly: true, withoutDescription: true }), + AppService.listApps({ workspace, starredOnly: true }), + ResourceService.listResource({ workspace, resourceType: 'ai_agent', starredOnly: true }), + AssetService.listFavoriteAssets({ workspace }) + ]) + if (($workspaceStore ?? '') !== workspace) return favoriteManager.current = [ ...scripts.map((s) => ({ label: s.summary || getFavoriteLabel(s.path, 'script'), @@ -690,6 +714,12 @@ href: getFavoriteHref(f.path, 'app'), kind: 'app' as const })), + ...agents.map((a) => ({ + label: a.description || getFavoriteLabel(a.path, 'agent'), + path: a.path, + href: getFavoriteHref(a.path, 'agent'), + kind: 'agent' as const + })), ...assets.map((a) => ({ label: getFavoriteLabel(a.path, 'asset'), path: a.path, @@ -784,6 +814,11 @@ } let devOnly = $derived(page.url.pathname.startsWith('/scripts/dev')) + // A devops user who belongs to no workspace has no `$userStore`, and would otherwise wait on + // "Loading user..." forever on the instance pages their role exists for. + let instanceOnlyView = $derived( + !$workspaceStore && !!$devopsRole && isInstanceRoute(page.url.pathname) + ) async function loadDefaultScripts(workspace: string, user: UserExt | undefined) { if (!user?.operator) { @@ -1098,6 +1133,14 @@ onblur={closeCardOnFrameFocus} /> +<!-- A manifest's start_url replaces the URL a home-screen shortcut was added from, so only the +console links it: shared pages (public apps, public runs, shared sessions, approval links) live +outside this layout, and their shortcut must reopen the URL that is their credential. --> +<svelte:head> + <link rel="manifest" href="/manifest.webmanifest" /> + <meta name="apple-mobile-web-app-title" content="Windmill" /> +</svelte:head> + <!-- Home + Runs lifted to the top of the workspace nav, sitting with Favorites and Search as the primary quick-access cluster. Excluded from SidebarContent (via excludeMainLabels) so they aren't rendered twice. --> @@ -1116,11 +1159,7 @@ label="Runs" href="/runs" icon={Play} - disabled={!sidebarPageAllowed( - $userStore?.operator, - $userWorkspaces?.find((w) => w.id === $workspaceStore), - 'runs' - )} + disabled={!$roleCapabilities.visibility.runs} isCollapsed={collapsed} aiId="sidebar-menu-link-runs" aiDescription="Button to navigate to runs" @@ -1152,7 +1191,7 @@ with a sidebar they cannot return. --> {#snippet sidebarToggle()} {@const hidden = navDetached.val} - {#if hidden || $userStore?.operator} + {#if hidden || !$roleCapabilities.code} <Tooltip class="flex" placement="bottom" small> <button class={SIDEBAR_ICON_BUTTON} @@ -1227,7 +1266,7 @@ <!-- Top row: the workspace ⇄ sessions switch, level with the page header's own row so the two read as one band. The workspace picker itself lives in that header, which is why nothing names the workspace here. --> - {#if !embedded && sessionsSwitchShown} + {#if switchRowShown} <div class="flex-shrink-0 px-2 gap-1 flex {collapsed ? 'flex-col items-center py-2' @@ -1256,8 +1295,10 @@ Search / workspace items / instance settings) are spaced by the scroll area's gap rather than each scrolling on its own. --> <SidebarScrollArea color={darkMode ? SIDEBAR_BG_DARK : SIDEBAR_BG}> - <!-- Section 1: Home/Runs + Favorites + Search --> - <div class="px-2 pt-1 flex flex-col gap-1"> + <!-- Section 1: Home/Runs + Favorites + Search. Without the switch row above, + Home is the top row and pt-2 centers its h-8 in the page header's h-12 + beside the docked rail. --> + <div class="px-2 flex flex-col gap-1 {switchRowShown ? 'pt-1' : 'pt-2'}"> {@render quickLinks(collapsed)} <Menubar class="flex flex-col gap-1"> {#snippet children({ createMenu })} @@ -1330,7 +1371,7 @@ {/if} <!-- Collapsing belongs to the rail: the panel is over the page already, and the way out of it is the handle in the band or a click outside. --> - {#if !panel && !$userStore?.operator} + {#if !panel && $roleCapabilities.code} <button class={SIDEBAR_ICON_BUTTON} title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} @@ -1546,6 +1587,27 @@ /> </div> </div> +{:else if instanceOnlyView} + <!-- No workspace means no sidebar: everything in it is workspace-scoped. The header bar + stays, without its workspace part: pages register their toolbars there. --> + <div class="flex flex-col h-screen w-full bg-surface"> + <div class="flex items-center pl-4 border-b shrink-0"> + <Button + variant="subtle" + unifiedSize="sm" + href="/user/workspaces" + startIcon={{ icon: ArrowLeft }} + > + Workspaces + </Button> + <div class="flex-1 min-w-0"> + <PageHeaderBar navHidden /> + </div> + </div> + <div class="flex-1 min-h-0 overflow-auto"> + {@render children?.()} + </div> + </div> {:else} <CenteredModal title="Loading user..." loading={true}></CenteredModal> {/if} diff --git a/frontend/src/routes/(root)/(logged)/+page.svelte b/frontend/src/routes/(root)/(logged)/+page.svelte index c20698fae0..d23f92de96 100644 --- a/frontend/src/routes/(root)/(logged)/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/+page.svelte @@ -1,6 +1,7 @@ <script lang="ts"> import { AppService, FlowService, type OpenFlow, type Script } from '$lib/gen' - import { userStore, workspaceStore } from '$lib/stores' + import { roleCapabilities, userStore, workspaceStore } from '$lib/stores' + import { HOME_COLUMN_WIDTH } from '$lib/consts' import { Alert, Button, Drawer, DrawerContent } from '$lib/components/common' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' @@ -97,6 +98,12 @@ // the mount animation below never covers. Replay it by hand instead of keying the page on the // workspace: a remount would drop the list's own state and refetch what it is already loading. let pageEl: HTMLDivElement | undefined = $state(undefined) + + // Back from AI Sessions the view transition (homeSessionsTransition.svelte.ts) carries the arrival, so + // the page skips its own fade-in. Decided once at mount: gating the class on the attribute + // instead would start the fade from transparent the moment the transition removes it. + const fadeIn = + typeof document === 'undefined' || !document.documentElement.dataset.homeSessionsTransition const reducedMotion = useReducedMotion() let animatedWorkspace = $workspaceStore $effect(() => { @@ -131,7 +138,7 @@ replaceState(url, page.state) // Gated here too: the parameter is part of a URL anyone can type, and the tour // describes a home page that only operators see. - if (user.operator) startTour() + if (!$roleCapabilities.code) startTour() }) }) @@ -139,7 +146,7 @@ // Operators get the tour once, and only when they have not been through it: they cannot // create anything, so the home page is the whole product to them and it is worth naming // its three tabs. Anyone who can build gets nothing — they have the create button. - if (!$userStore?.operator || page.url.searchParams.has(TOUR_PARAM)) return + if ($roleCapabilities.code || page.url.searchParams.has(TOUR_PARAM)) return if (await hasSeenOperatorTour()) return startTour() }) @@ -285,7 +292,7 @@ <div bind:this={pageEl} - class="wm-page-in flex flex-col w-full h-full overflow-y-auto items-center" + class="{fadeIn ? 'wm-page-in' : ''} flex flex-col w-full h-full overflow-y-auto items-center" style="scrollbar-gutter: stable both-edges;" > <!-- Where edits to this workspace are made is a fact about the workspace, so it sits in the @@ -294,7 +301,7 @@ <ForkWorkspaceBanner /> <WorkspaceDraftsBanner /> - <div class="max-w-7xl px-4 sm:px-8 md:px-8 h-fit w-full mb-6"> + <div class="{HOME_COLUMN_WIDTH} px-4 sm:px-8 md:px-8 h-fit w-full mb-6 shrink-0"> <!-- HomeAIChat carries both the AI composer and the AI-independent CLI/MCP connect row, so it shows whenever the sessions beta is on; the composer itself is gated on the workspace inside the component, which owns its own vertical spacing @@ -391,7 +398,7 @@ {/if} </div> -{#if $userStore?.operator} +{#if !$roleCapabilities.code} <OperatorTour bind:this={operatorTour} /> {/if} diff --git a/frontend/src/routes/(root)/(logged)/agents/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/agents/edit/[...path]/+page.svelte index a67ba2dcfa..7c6b5f9571 100644 --- a/frontend/src/routes/(root)/(logged)/agents/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/agents/edit/[...path]/+page.svelte @@ -5,6 +5,9 @@ import { copilotInfo } from '$lib/aiStore' import AgentEditorModal from '$lib/components/flows/content/AgentEditorModal.svelte' import { openAgentEditor } from '$lib/components/flows/agentEditorStore.svelte' + import { recentItems } from '$lib/components/home/recentItems.svelte' + import { workspaceStore } from '$lib/stores' + import { ResourceService } from '$lib/gen' let path = $derived(page.params.path ?? '') /** Set by `/agents/add` on the path it minted: a missing agent there is a new one, not an error. */ @@ -12,8 +15,27 @@ $effect(() => { const target = { path, isNew } - untrack(() => openAgentEditor(target)) + untrack(() => { + openAgentEditor(target) + if (!isNew) recordRecent(target.path) + }) }) + + // The agent loads inside the editor, so it is read again here. Only a deployed agent is + // recorded: a draft-only one 404s, and has no page for Home's card to open. + async function recordRecent(agentPath: string) { + const workspace = $workspaceStore + if (!workspace) return + try { + const agent = await ResourceService.getResource({ workspace, path: agentPath }) + recentItems.record(workspace, { + kind: 'agent', + path: agentPath, + summary: agent.description, + via: 'edit' + }) + } catch {} + } </script> <div class="h-full w-full"> @@ -21,6 +43,7 @@ layout="page" enableAi={$copilotInfo.enabled} owns={(t) => t.host === undefined} + startInSettings={page.url.searchParams.get('settings') === 'true'} onClose={(deployed) => goto(deployed ? `/agents/get/${path}` : `/?kind=agent`)} onDeployed={(saved) => // Replaced, not pushed, when this URL stopped naming what it opens: Back would otherwise 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 ceb37c0a10..0fcb6174e2 100644 --- a/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte @@ -1,11 +1,15 @@ <script lang="ts"> import { page } from '$app/state' import { FlaskConical, FormInput, MessageSquare, Pen } from 'lucide-svelte' + import { Pane, Splitpanes } from 'svelte-splitpanes' import { resource } from 'runed' import { goto } from '$lib/navigation' import { copilotInfo } from '$lib/aiStore' import AgentEvalsModal from '$lib/components/flows/content/AgentEvalsModal.svelte' + import AgentUsePanel from '$lib/components/flows/content/AgentUsePanel.svelte' + import { SlackAgentsService } from '$lib/gen' import DetailPageHeader from '$lib/components/details/DetailPageHeader.svelte' + import OnBehalfOfBadge from '$lib/components/details/OnBehalfOfBadge.svelte' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte' @@ -14,11 +18,13 @@ import AgentEditorHost from '$lib/components/flows/content/AgentEditorHost.svelte' import AgentConfigModal from '$lib/components/flows/content/AgentConfigModal.svelte' import RunForm from '$lib/components/RunForm.svelte' - import { Alert } from '$lib/components/common' + import { Alert, Tab, Tabs } from '$lib/components/common' 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 { userStore, workspaceStore } from '$lib/stores' + import { recentItems } from '$lib/components/home/recentItems.svelte' + import { roleCapabilities, userStore, workspaceStore } from '$lib/stores' import { sendUserToast } from '$lib/toast' /** @@ -35,13 +41,40 @@ 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) && !$userStore?.operator) + let canEdit = $derived( + config != undefined && (agent?.canWrite ?? false) && $roleCapabilities.code + ) + + // Recorded once the agent is in: a path with nothing deployed never loads one. + let loaded = $derived(config != undefined) + $effect(() => { + if (ws && loaded) { + recentItems.record(ws, { + kind: 'agent', + path, + summary: agent?.state?.description, + via: 'view' + }) + } + }) let configModal: AgentConfigModal | undefined = $state(undefined) let shareModal: ShareModal | undefined = $state(undefined) let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state(undefined) + let history: AgentHistory | undefined = $state(undefined) let deleteOpen = $state(false) let evalsModal: AgentEvalsModal | undefined = $state(undefined) + let clientWidth = $state(window.innerWidth) + let pickedMobileTab = $state('agent') + // The page is reused across agents, and an agent without managed memory has no Slack tab. + let mobileTab = $derived(pickedMobileTab === 'slack' && !chatAvailable ? 'api' : pickedMobileTab) + let wide = $derived(clientWidth >= 1024) + + const slack = resource( + () => ({ workspace: ws, path }), + async ({ workspace, path }) => + workspace ? await SlackAgentsService.getAgentSlackChannels({ workspace, path }) : undefined + ) let runForm: RunForm | undefined = $state(undefined) let isValid = $state(true) @@ -49,7 +82,7 @@ // while the chat shows, and the chat's composer owns the key then. function onKeyDown(event: KeyboardEvent) { if (event.defaultPrevented || event.key !== 'Enter' || !(event.ctrlKey || event.metaKey)) return - if (!runForm || testPane?.mode === 'chat') return + if (!runForm || testPane?.mode === 'chat' || (!wide && mobileTab !== 'agent')) return if (isValid) { event.preventDefault() runForm.run() @@ -67,9 +100,9 @@ () => ws, () => getDeployUiSettings() ) - // Operators get no menu, as on a flow's or a script's page. + // A role that cannot code gets no menu, as on a flow's or a script's page. let menuItems = $derived( - config != undefined && !$userStore?.operator + config != undefined && $roleCapabilities.code ? agentMenuItems({ path, canWrite: canEdit, @@ -77,6 +110,7 @@ deployUiSettings: deployUiSettings.current, onPermissions: () => shareModal?.openDrawer?.(path, 'resource'), onDeploy: () => deploymentDrawer?.openDrawer(path, 'resource'), + onHistory: () => history?.open(), onDelete: () => (deleteOpen = true) }).map((item) => ({ label: item.displayName, @@ -91,6 +125,7 @@ <ShareModal bind:this={shareModal} /> <DeployWorkspaceDrawer bind:this={deploymentDrawer} /> +<AgentHistory bind:this={history} {path} canRestore={canEdit} onRestored={() => reloaded++} /> <ConfirmationModal open={deleteOpen} title="Delete agent" @@ -152,6 +187,11 @@ : []) ]} > + <OnBehalfOfBadge + onBehalfOf={config?.run_on_behalf_of ? agent?.onBehalfOf : undefined} + onBehalfOfEmail={undefined} + kind="agent" + /> {#snippet leading_actions()} <!-- Only an agent that keeps the conversation can chat: without managed memory every message would be answered alone, so it is run from its inputs with nothing to switch. --> @@ -187,42 +227,97 @@ {/if} {/snippet} </DetailPageHeader> - <div class="flex-1 min-h-0"> - {#key `${ws}:${path}:${reloaded}`} - <AgentEditorHost - bind:this={host} - {path} - workspace={ws} - enableAi={$copilotInfo.enabled} - view - onOpenConfig={() => configModal?.open()} - > - {#snippet viewForm({ schema, run, loading, actions })} - {#if config && !chatAvailable} - <Alert type="info" size="xs" title="Managed memory is off" class="mb-4"> - Each run is answered on its own inputs, so this agent has no chat. That suits reusing - it as a step in a flow.{canEdit - ? ' Turn on managed memory in its configuration to chat with it.' - : ''} - </Alert> - {/if} - <RunForm - runnable={{ schema, path }} - runAction={run} - schedulable={false} - detailed={false} - autofocus - {loading} - {actions} - bind:isValid - bind:this={runForm} - /> - {/snippet} - </AgentEditorHost> - {/key} + <!-- Below `lg` the split would squeeze the chat, so the panel's sections become tabs of the + page, as on a script's or a flow's page. They cover the agent rather than replacing it: + unmounting the host destroys the chat and any turn in flight, so the agent pane keeps one + parent at every width. --> + {#if !wide && ws && config} + <Tabs + bind:selected={() => mobileTab, (t) => (pickedMobileTab = t)} + wrapperClass="flex-none no-scrollbar" + > + <Tab value="agent" label={testPane?.mode === 'chat' ? 'Chat' : 'Run form'} /> + <Tab value="api" label="API" /> + {#if chatAvailable} + <Tab value="slack" label="Slack" /> + {/if} + </Tabs> + {/if} + <div class="relative flex-1 min-h-0" bind:clientWidth> + <Splitpanes> + <Pane size={wide ? 65 : 100} minSize={50}> + <div class="h-full" inert={!wide && mobileTab !== 'agent'}> + {@render agentPane()} + </div> + </Pane> + {#if wide && ws && config} + <Pane size={35} minSize={15}> + {@render usePane(ws)} + </Pane> + {/if} + </Splitpanes> + {#if !wide && ws && config} + <div class={['absolute inset-0 z-10 bg-surface', mobileTab === 'agent' && 'hidden']}> + {@render usePane(ws, mobileTab === 'slack' ? 'slack' : 'api')} + </div> + {/if} </div> </main> +{#snippet agentPane()} + {#key `${ws}:${path}:${reloaded}`} + <AgentEditorHost + bind:this={host} + {path} + workspace={ws} + enableAi={$copilotInfo.enabled} + view + onOpenConfig={() => configModal?.open()} + > + {#snippet viewForm({ schema, run, loading, actions })} + {#if config && !chatAvailable} + <Alert type="info" size="xs" title="Managed memory is off" class="mb-4"> + Each run is answered on its own inputs, so this agent has no chat. That suits reusing it + as a step in a flow.{canEdit + ? ' Turn on managed memory in its configuration to chat with it.' + : ''} + </Alert> + {/if} + <RunForm + runnable={{ schema, path }} + runAction={run} + schedulable={false} + detailed={false} + autofocus + {loading} + {actions} + bind:isValid + bind:this={runForm} + /> + {/snippet} + </AgentEditorHost> + {/key} +{/snippet} + +{#snippet usePane(ws: string, section?: 'api' | 'slack')} + <AgentUsePanel + agentPath={path} + workspace={ws} + isAdmin={!!($userStore?.is_admin || $userStore?.is_super_admin)} + chat={chatAvailable} + slackLoading={slack.loading && !slack.current} + slackTeamName={slack.current?.slack_team_name} + botName={slack.current?.bot_name} + missingScopes={slack.current?.missing_scopes ?? []} + signingSecretSet={slack.current?.signing_secret_set ?? true} + runsAs={slack.current?.runs_as} + anyone={slack.current?.anyone ?? false} + canShare={slack.current?.can_share ?? false} + channels={slack.current?.channels ?? []} + {section} + /> +{/snippet} + {#if ws} <AgentEvalsModal bind:this={evalsModal} agentPath={path} workspace={ws} /> {/if} diff --git a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte index 974d1c7f64..d32c279802 100644 --- a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte @@ -17,6 +17,7 @@ import { importStore } from '$lib/components/apps/store' import { onDestroy, untrack } from 'svelte' import { page } from '$app/state' + import { recentItems } from '$lib/components/home/recentItems.svelte' import { UserDraft } from '$lib/userDraft.svelte' import { stripNewDraftFlag, stripNewDraftFlagOnSave, shouldSeedNewDraft } from '$lib/newDraftFlag' import { OtherUserDraftLoad } from '$lib/components/otherUserDraftLoad.svelte' @@ -108,7 +109,7 @@ itemKind: 'app', path }) - const emptyPolicy = { execution_mode: 'publisher' } as any + const emptyPolicy = { execution_mode: 'publisher', sandbox: true } as any // One-shot import handoff via $importStore (YAML/JSON or "Build app"). // Wrapped exports carry { summary, value, policy }; bare ones are the App value. const importRaw = $importStore @@ -206,6 +207,15 @@ getDraft }) if (tok !== loadAppToken) return + // A draft-only app has no page for Home's card to open. + if (!backendApp.no_deployed) { + recentItems.record($workspaceStore!, { + kind: 'app', + path: backendApp.path, + summary: backendApp.summary, + via: 'edit' + }) + } // Deployed App value for AppEditor's autosave `discardIf`, captured BEFORE // the draft swap below replaces `backendApp.value`. `undefined` when // there's no deployed row (draft-only path). @@ -246,7 +256,7 @@ summary: savedDraftApp?.summary ?? '', value: (savedDraftApp ?? {}) as App, path: page.params.path ?? '', - policy: { execution_mode: 'publisher' } as any, + policy: { execution_mode: 'publisher', sandbox: true } as any, custom_path: undefined, versions: undefined as any, id: 0 as any, diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.ts b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.ts index 8bd1ba1ba3..14bb56c86f 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.ts +++ b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.ts @@ -2,7 +2,7 @@ import { redirect } from '@sveltejs/kit' import { get } from 'svelte/store' import { makeDraftAddLoad } from '$lib/draftAddRedirect' import { mintDraftPath } from '$lib/mintDraftPath' -import { userStore, workspaceStore } from '$lib/stores' +import { roleCapabilities, userStore, workspaceStore } from '$lib/stores' import { copilotInfo, copilotWorkspace } from '$lib/aiStore' import { loadCopilot } from '$lib/components/copilot/loadCopilot' import { prefersSessionHandoff } from '$lib/components/copilot/chat/global/gate' @@ -26,7 +26,7 @@ async function newAppSessionHref(url: URL): Promise<string | undefined> { const user = get(userStore) const workspace = get(workspaceStore) // Neither is known yet on a cold load of this URL, which then opens the editor. - if (!user || !workspace || !prefersSessionHandoff(user.operator)) return undefined + if (!user || !workspace || !prefersSessionHandoff(!get(roleCapabilities).code)) return undefined if ((url.searchParams.get('workspace') ?? workspace) !== workspace) return undefined if (get(copilotWorkspace) !== workspace) await loadCopilot(workspace) if (get(copilotWorkspace) !== workspace || !get(copilotInfo).enabled) return undefined diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index ef1265c37a..aa51d62ea8 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -4,7 +4,7 @@ import { AppService } from '$lib/gen' import { userStore, workspaceStore } from '$lib/stores' - import { useOperatorBuilderApps, workspaceListLoaded } from '$lib/operatorWriteRights' + import { useRoleCapabilities, workspaceListLoaded } from '$lib/operatorWriteRights' import { readFieldsRecursively } from '$lib/utils' import { goto } from '$lib/navigation' import { sendUserToast } from '$lib/toast' @@ -15,6 +15,7 @@ import { openEditorInSession } from '$lib/components/sessions/sessionSwitch.svelte' import { stateSnapshot } from '$lib/svelte5Utils.svelte' import { page } from '$app/state' + import { recentItems } from '$lib/components/home/recentItems.svelte' import { type RawAppData, DEFAULT_DATA, @@ -102,7 +103,8 @@ // edits to the just-deployed app autosaved to a dead key (autosave appeared // broken). See /scripts/edit, which derives its draft path the same way. // effectivePath omitted: the live-editor-draft entry is owned by RawAppEditor. - const operatorBuilderApps = useOperatorBuilderApps() + const roleCaps = useRoleCapabilities() + const composeOnlyApps = $derived($roleCaps.apps === 'compose') const draftSync = usePageDraftSync<RawAppDraft>({ itemKind: 'raw_app', @@ -235,7 +237,8 @@ ? $userStore?.username : `u/${$userStore?.username}`, on_behalf_of_email: $userStore?.email, - execution_mode: 'publisher' + execution_mode: 'publisher', + sandbox: true } as any // Explicit path seed: the fork-a-draft handoff re-homes the source // path into the forker's namespace and passes it here. @@ -280,7 +283,7 @@ // Seed the React 19 template so the editor has a usable state even if the // user dismisses the picker without selecting. const seedFiles = { - ...($operatorBuilderApps ? BUILDER_FRAMEWORK_TEMPLATES.react19 : react19Template) + ...(composeOnlyApps ? BUILDER_FRAMEWORK_TEMPLATES.react19 : react19Template) } const seedRunnables = starterRunnables() savedApp = { @@ -310,6 +313,15 @@ rawApp: true })) as any if (tok !== loadAppToken) return + // A draft-only app has no page for Home's card to open. + if (!backendApp.no_deployed) { + recentItems.record($workspaceStore!, { + kind: 'raw_app', + path: backendApp.path, + summary: backendApp.summary, + via: 'edit' + }) + } // `other_drafts_users` only computed when `getDraft`; don't clobber the // known list on a `getDraft:false` reload. See /scripts/edit's loader. if (getDraft) { @@ -385,7 +397,7 @@ data: savedRawAppDraft?.data } backendApp.summary = savedRawAppDraft?.summary ?? '' - backendApp.policy = savedRawAppDraft?.policy ?? { execution_mode: 'publisher' } + backendApp.policy = savedRawAppDraft?.policy ?? { execution_mode: 'publisher', sandbox: true } backendApp.custom_path = savedRawAppDraft?.custom_path backendApp.path = page.params.path ?? '' } else if (savedRawAppDraft) { @@ -531,10 +543,10 @@ let rawAppEditor: RawAppEditor | undefined = $state() - // The starter runnables are inline scripts, which the backend refuses from an operator with - // builder rights: seeding them would make their very first deploy fail. + // The starter runnables are inline scripts, which the backend refuses from a role that only + // composes apps: seeding them would make their very first deploy fail. function starterRunnables() { - return $operatorBuilderApps ? {} : structuredClone(STARTER_RUNNABLES) + return composeOnlyApps ? {} : structuredClone(STARTER_RUNNABLES) } async function onTemplatePickerStart(result: RawAppTemplatePickerResult, mode: RawAppBuildMode) { @@ -546,7 +558,7 @@ policy: result.policy } const workspace = $workspaceStore - if (mode === 'ai' && prefersSessionHandoff($userStore?.operator) && workspace) { + if (mode === 'ai' && prefersSessionHandoff(!$roleCaps.code) && workspace) { // The session loads the app by path, so the picked setup is saved before it // opens. `new_draft` tells it the app is still the starter template. try { @@ -645,6 +657,7 @@ <RawAppEditor bind:this={rawAppEditor} ownsPageHeader + compact={page.url.searchParams.get('view') === 'preview'} onDetails={(e) => goto(`/apps_raw/get/${e.path}?workspace=${$workspaceStore}`)} onSavedNewAppPath={(savedPath) => { draftSync.remove() diff --git a/frontend/src/routes/(root)/(logged)/assets/+page.svelte b/frontend/src/routes/(root)/(logged)/assets/+page.svelte index b2f2f1b896..fec8bda036 100644 --- a/frontend/src/routes/(root)/(logged)/assets/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/assets/+page.svelte @@ -16,7 +16,7 @@ import { userStore, workspaceStore, - userWorkspaces, + roleCapabilities, globalDbManagerDrawer, superadmin } from '$lib/stores' @@ -120,7 +120,11 @@ // A `dbt://` asset's rows, previewed through the dbt project that writes it. // The producer is the asset's own WRITE usage — a table nobody writes has no // project to ask, and no `ref()` to resolve the relation with. - type AssetRow = { path: string; kind: string; usages: { path: string; kind: string; access_type?: string }[] } + type AssetRow = { + path: string + kind: string + usages: { path: string; kind: string; access_type?: string }[] + } function dbtProducerOf(asset: AssetRow): string | undefined { if (asset.kind !== 'dbt') return undefined // A DEFINITE writer. A bare `dbt://…` literal in a native script is stored @@ -168,7 +172,9 @@ ) } if (producer.language !== 'dbt') { - return fail(`${script} writes this table but is not a dbt script, so there is no model to preview.`) + return fail( + `${script} writes this table but is not a dbt script, so there is no model to preview.` + ) } // A project whose descriptor interpolates `{{ }}` needs those values to // run at all, and this page has nowhere to ask for them. Say so instead @@ -223,7 +229,6 @@ } } - let allS3Storages = resource( () => $workspaceStore, () => @@ -263,10 +268,10 @@ } </script> -{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.assets} +{#if $workspaceStore && !$roleCapabilities.visibility.assets} <div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert"> <p class="font-bold">Unauthorized</p> - <p>Page not available for operators</p> + <p>Page not available for your role</p> </div> {:else} <CenteredPage id="scrollable-container"> @@ -303,7 +308,7 @@ > See documentation </Button> - {#if !($userStore?.operator || (!$userStore?.is_admin && !$superadmin))} + {#if $roleCapabilities.code && ($userStore?.is_admin || $superadmin)} <Button wrapperClasses="h-fit" variant={props.data.current?.length === 0 && !props.data.loading @@ -496,10 +501,10 @@ </a> </Cell> <Cell class="w-24"> - {#if assetCanBeExplored(asset, asset.metadata) && !$userStore?.operator} + {#if assetCanBeExplored(asset, asset.metadata) && $roleCapabilities.code} <ExploreAssetButton {asset} {s3FilePicker} _resourceMetadata={asset.metadata} /> {/if} - {#if dbtProducerOf(asset) && !$userStore?.operator} + {#if dbtProducerOf(asset) && $roleCapabilities.code} <Button size="xs2" color="light" diff --git a/frontend/src/routes/(root)/(logged)/audit_logs/+page.svelte b/frontend/src/routes/(root)/(logged)/audit_logs/+page.svelte index 42d8ff996e..cbd615e62a 100644 --- a/frontend/src/routes/(root)/(logged)/audit_logs/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/audit_logs/+page.svelte @@ -15,7 +15,7 @@ import type { AuditLog } from '$lib/gen' import { AuditService } from '$lib/gen' - import { enterpriseLicense, userStore, workspaceStore, userWorkspaces } from '$lib/stores' + import { enterpriseLicense, workspaceStore, roleCapabilities } from '$lib/stores' import { Splitpanes, Pane } from 'svelte-splitpanes' import AuditLogsTimeline from '$lib/components/auditLogs/AuditLogsTimeline.svelte' import { useAuditLogsLoader } from '$lib/components/auditLogs/useAuditLogsLoader.svelte' @@ -128,10 +128,10 @@ } </script> -{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.audit_logs} +{#if $workspaceStore && !$roleCapabilities.visibility.audit_logs} <div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert"> <p class="font-bold">Unauthorized</p> - <p>Page not available for operators</p> + <p>Page not available for your role</p> </div> {:else} <!-- `h-full`, not `h-screen`: the band sits above this box, so a viewport floor would overhang 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 d724c4873d..cbaf2fd935 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -21,6 +21,7 @@ import { stripNewDraftFlag, stripNewDraftFlagOnSave, shouldSeedNewDraft } from '$lib/newDraftFlag' import type { stepState } from '$lib/components/stepHistoryLoader.svelte' import { page } from '$app/state' + import { recentItems } from '$lib/components/home/recentItems.svelte' import { UserDraft, draftValuesEqual } from '$lib/userDraft.svelte' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte' @@ -346,6 +347,15 @@ getDraft }) if (tok !== loadFlowToken) return + // A draft-only flow has no page for Home's card to open. + if (!backendFlow.no_deployed) { + recentItems.record($workspaceStore!, { + kind: 'flow', + path: backendFlow.path, + summary: backendFlow.summary, + via: 'edit' + }) + } // `other_drafts_users` only computed when `getDraft`; don't clobber the // known list on a `getDraft:false` reload. See /scripts/edit's loader. if (getDraft) { diff --git a/frontend/src/routes/(root)/(logged)/folders/+page.svelte b/frontend/src/routes/(root)/(logged)/folders/+page.svelte index b184e4aa4a..ab458f90de 100644 --- a/frontend/src/routes/(root)/(logged)/folders/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/folders/+page.svelte @@ -6,7 +6,7 @@ import Dropdown from '$lib/components/DropdownV2.svelte' import FolderEditorDrawer from '$lib/components/FolderEditorDrawer.svelte' import PageHeader from '$lib/components/PageHeader.svelte' - import { userStore, workspaceStore, userWorkspaces } from '$lib/stores' + import { userStore, workspaceStore, roleCapabilities } from '$lib/stores' import { Button, Drawer, DrawerContent, EmptyState, Skeleton } from '$lib/components/common' import FolderInfo from '$lib/components/FolderInfo.svelte' import FolderUsageInfo from '$lib/components/FolderUsageInfo.svelte' @@ -98,10 +98,10 @@ </DrawerContent> </Drawer> -{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.folders} +{#if $workspaceStore && !$roleCapabilities.visibility.folders} <div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert"> <p class="font-bold">Unauthorized</p> - <p>Page not available for operators</p> + <p>Page not available for your role</p> </div> {:else} <CenteredPage> diff --git a/frontend/src/routes/(root)/(logged)/groups/+page.svelte b/frontend/src/routes/(root)/(logged)/groups/+page.svelte index c17c6c188e..9d7a70ca7e 100644 --- a/frontend/src/routes/(root)/(logged)/groups/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/groups/+page.svelte @@ -11,7 +11,7 @@ import GroupInfo from '$lib/components/GroupInfo.svelte' import PageHeader from '$lib/components/PageHeader.svelte' import SharedBadge from '$lib/components/SharedBadge.svelte' - import { userStore, workspaceStore, userWorkspaces } from '$lib/stores' + import { userStore, workspaceStore, roleCapabilities } from '$lib/stores' import { useEditRights } from '$lib/operatorWriteRights' import { Pen, Plus, Trash } from 'lucide-svelte' import DataTable from '$lib/components/table/DataTable.svelte' @@ -70,10 +70,10 @@ </DrawerContent> </Drawer> -{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.groups} +{#if $workspaceStore && !$roleCapabilities.visibility.groups} <div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert"> <p class="font-bold">Unauthorized</p> - <p>Page not available for operators</p> + <p>Page not available for your role</p> </div> {:else} <CenteredPage> diff --git a/frontend/src/routes/(root)/(logged)/native_triggers/[service_name]/+page.svelte b/frontend/src/routes/(root)/(logged)/native_triggers/[service_name]/+page.svelte index 004799baed..769cc2d5fb 100644 --- a/frontend/src/routes/(root)/(logged)/native_triggers/[service_name]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/native_triggers/[service_name]/+page.svelte @@ -16,7 +16,7 @@ getLocalSetting, storeLocalSetting } from '$lib/utils' - import { userStore, workspaceStore, userWorkspaces, usedTriggerKinds } from '$lib/stores' + import { userStore, workspaceStore, roleCapabilities, usedTriggerKinds } from '$lib/stores' import CenteredPage from '$lib/components/CenteredPage.svelte' import PageHeader from '$lib/components/PageHeader.svelte' import { Button, Alert, EmptyState, Skeleton } from '$lib/components/common' @@ -110,7 +110,7 @@ serviceAvailable = await isServiceAvailable(serviceName, $workspaceStore) if (!serviceAvailable) { sendUserToast( - `${serviceName} integration not available, set it in the workspace settings`, + `${serviceConfig.serviceDisplayName} isn't configured in this workspace`, true ) } @@ -145,7 +145,10 @@ } if (!serviceAvailable) { - sendUserToast('Cannot sync triggers: workspace integration not connected', true) + sendUserToast( + `Cannot sync: ${serviceConfig.serviceDisplayName} isn't configured in this workspace`, + true + ) return } @@ -159,17 +162,7 @@ loadTriggers() } catch (err: any) { const errorMessage = err.body || err.message - if ( - errorMessage.includes('workspace integration') || - errorMessage.includes('not connected') - ) { - sendUserToast( - `Cannot sync: ${serviceConfig.serviceDisplayName} workspace integration not connected. Please connect it in workspace settings.`, - true - ) - } else { - sendUserToast(`Failed to sync triggers: ${errorMessage}`, true) - } + sendUserToast(`Failed to sync triggers: ${errorMessage}`, true) } } @@ -197,10 +190,10 @@ f={(trigger) => `${formatTriggerDisplayName(trigger)}`} /> -{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers} +{#if $workspaceStore && !$roleCapabilities.visibility.triggers} <div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert"> <p class="font-bold">Unauthorized</p> - <p>Page not available for operators</p> + <p>Page not available for your role</p> </div> {:else if !serviceSupported} <CenteredPage> @@ -237,8 +230,7 @@ type="warning" > <div class="flex flex-col gap-2 mt-2"> - The workspace doesn't have a {serviceConfig?.serviceDisplayName || serviceName} integration - connected + {serviceConfig?.serviceDisplayName || serviceName} isn't configured in this workspace yet. <div class="flex gap-2"> <Button variant="accent" href="/workspace_settings?tab=native_triggers"> Manage native triggers diff --git a/frontend/src/routes/(root)/(logged)/oauth/mcp_authorize/+page@(root).svelte b/frontend/src/routes/(root)/(logged)/oauth/mcp_authorize/+page@(root).svelte index 830e545719..a917de4d4a 100644 --- a/frontend/src/routes/(root)/(logged)/oauth/mcp_authorize/+page@(root).svelte +++ b/frontend/src/routes/(root)/(logged)/oauth/mcp_authorize/+page@(root).svelte @@ -6,10 +6,11 @@ import { page } from '$app/state' import CenteredModal from '$lib/components/CenteredModal.svelte' import { Button } from '$lib/components/common' - import Select from '$lib/components/select/Select.svelte' + import MultiSelect from '$lib/components/select/MultiSelect.svelte' + import Toggle from '$lib/components/Toggle.svelte' + import McpAccessPicker, { type McpAccess } from '$lib/components/mcp/McpAccessPicker.svelte' import { WorkspaceService } from '$lib/gen' import { sendUserToast } from '$lib/toast' - import McpScopeSelector from '$lib/components/mcp/McpScopeSelector.svelte' import Check from 'lucide-svelte/icons/check' // Get OAuth params from URL @@ -18,7 +19,12 @@ let clientId = page.url.searchParams.get('client_id') || '' let clientName = page.url.searchParams.get('client_name') || 'Unknown Client' let redirectUri = page.url.searchParams.get('redirect_uri') || '' - let scope = $state(page.url.searchParams.get('scope') || 'mcp:all') + // What the client asked for seeds the access picker; what is approved is the picker's. + const requestedScopes = (page.url.searchParams.get('scope') || 'mcp:all') + .split(' ') + .filter((s) => s.startsWith('mcp:')) + let scopes = $state<string[]>([]) + let access = $state<McpAccess>() let oauthState = page.url.searchParams.get('state') || '' let codeChallenge = page.url.searchParams.get('code_challenge') || '' let codeChallengeMethod = page.url.searchParams.get('code_challenge_method') || '' @@ -41,9 +47,21 @@ let success = $state(false) let successRedirectUrl = $state('') - // Gateway mode: fetch user's workspaces for the picker + // Gateway mode: fetch user's workspaces for the picker. One picked workspace binds the + // token to it; several, or all of them, leave it unbound and its tools name their workspace. let workspaces: Array<{ value: string; label: string }> = $state([]) + let pickedWorkspaces = $state<string[]>([]) + let allWorkspaces = $state(false) let loadingWorkspaces = $state(false) + const grantsWorkspace = $derived(!isGateway || allWorkspaces || pickedWorkspaces.length > 0) + // Selected scripts and flows are listed per workspace: across several, a path could be two. + const selectionNeedsOneWorkspace = $derived( + isGateway && access === 'selected' && (allWorkspaces || pickedWorkspaces.length !== 1) + ) + // The workspace the scope picker browses scripts and flows in. + const scopeWorkspaceId = $derived( + isGateway ? (pickedWorkspaces[0] ?? (allWorkspaces ? workspaces[0]?.value : '')) : workspaceId + ) if (isGateway) { loadingWorkspaces = true @@ -57,10 +75,12 @@ })) // Auto-select if only one workspace if (workspaces.length === 1) { - workspaceId = workspaces[0].value + pickedWorkspaces = [workspaces[0].value] } }) - .catch(() => { + .catch((e) => { + // Logged out: the root layout is already sending the browser to the login page. + if (e?.status === 401) return sendUserToast('Failed to load workspaces', true) }) .finally(() => { @@ -83,7 +103,7 @@ async function onApprove() { if (!redirectUriValid) return - if (!workspaceId) { + if (!grantsWorkspace) { sendUserToast('Please select a workspace', true) return } @@ -95,18 +115,19 @@ ? `/api/mcp/gateway/oauth/server/approve` : `/api/w/${workspaceId}/mcp/oauth/server/approve` - const body: Record<string, string> = { + const body: Record<string, unknown> = { client_id: clientId, redirect_uri: redirectUri, - scope: scope, + scope: scopes.join(' '), state: oauthState, code_challenge: codeChallenge, code_challenge_method: codeChallengeMethod } - // Gateway mode sends workspace_id in the body + // Gateway mode sends the workspaces in the body if (isGateway) { - body.workspace_id = workspaceId + body.workspace_ids = allWorkspaces ? [] : pickedWorkspaces + body.all_workspaces = allWorkspaces } const response = await fetch(approveUrl, { @@ -170,8 +191,8 @@ <p class="text-center text-sm text-primary mb-6"> <span class="font-semibold text-accent">{clientName}</span> is requesting access to - {#if isGateway && !workspaceId} - your Windmill workspace. + {#if isGateway} + your Windmill workspaces. {:else} your <span class="font-semibold text-accent">{workspaceId}</span> @@ -180,30 +201,53 @@ </p> {#if isGateway} - <div class="mb-6"> - <p class="text-xs font-semibold text-emphasis mb-2">Select a workspace:</p> + <div class="mb-6 flex flex-col gap-2"> + <p class="text-xs font-semibold text-emphasis">Workspaces it may work in:</p> {#if loadingWorkspaces} <p class="text-xs text-secondary">Loading workspaces...</p> {:else if workspaces.length === 0} <p class="text-xs text-secondary">No workspaces available.</p> {:else} - <Select items={workspaces} bind:value={workspaceId} placeholder="Choose a workspace" /> + <Toggle + bind:checked={allWorkspaces} + options={{ right: 'All my workspaces, including ones I join later' }} + size="xs" + /> + {#if !allWorkspaces} + <MultiSelect + items={workspaces} + bind:value={pickedWorkspaces} + placeholder="Choose workspaces" + reorderable={false} + /> + {/if} {/if} </div> {/if} - {#if workspaceId} - <div class="mb-6"> - <McpScopeSelector {workspaceId} bind:scope /> - </div> - {/if} + <div class="mb-6 flex flex-col gap-1"> + <McpAccessPicker + workspaceId={scopeWorkspaceId ?? ''} + initialScopes={requestedScopes} + bind:scopes + bind:access + /> + {#if selectionNeedsOneWorkspace} + <p class="text-2xs text-secondary"> + Selected scripts and flows come from one workspace: pick one. + </p> + {/if} + </div> <div class="flex flex-row justify-around gap-x-4"> <Button variant="border" size="lg" disabled={loading} onClick={onDeny}>Deny</Button> <Button variant="accent" size="lg" - disabled={loading || (isGateway && !workspaceId)} + disabled={loading || + !grantsWorkspace || + selectionNeedsOneWorkspace || + scopes.length === 0} {loading} onClick={onApprove}>Approve</Button > diff --git a/frontend/src/routes/(root)/(logged)/pipeline/+page.svelte b/frontend/src/routes/(root)/(logged)/pipeline/+page.svelte index d7972d3ef1..3caf6bcb84 100644 --- a/frontend/src/routes/(root)/(logged)/pipeline/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/pipeline/+page.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import { userStore, workspaceStore } from '$lib/stores' + import { roleCapabilities, workspaceStore } from '$lib/stores' import PipelineFolderList from '$lib/components/assets/AssetGraph/PipelineFolderList.svelte' import PipelineSetupSignpost from '$lib/components/assets/AssetGraph/PipelineSetupSignpost.svelte' import PipelineAlphaAckModal from '$lib/components/assets/AssetGraph/PipelineAlphaAckModal.svelte' @@ -64,12 +64,12 @@ </a> </div> - {#if !$userStore?.operator && $workspaceStore} + {#if $roleCapabilities.code && $workspaceStore} <PipelineSetupSignpost workspace={$workspaceStore} /> {/if} - <!-- Operators cannot pick an arbitrary folder, so the list is their only way in. --> - <PipelineFolderList hideExisting={!$userStore?.operator} /> + <!-- A role without code cannot pick an arbitrary folder, so the list is its only way in. --> + <PipelineFolderList hideExisting={$roleCapabilities.code} /> </div> </div> </div> diff --git a/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte b/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte index f5833527b0..224409c2b9 100644 --- a/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte @@ -1,8 +1,9 @@ <script lang="ts"> import pLimit from 'p-limit' - import { workspaceStore, userStore } from '$lib/stores' + import { roleCapabilities, workspaceStore, userStore } from '$lib/stores' import { PIPELINE_DRAFT_KIND, pipelineBundlePath } from '$lib/pipelinePaths' import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' + import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' import { page } from '$app/state' import Button from '$lib/components/common/button/Button.svelte' import DropdownV2 from '$lib/components/DropdownV2.svelte' @@ -202,11 +203,11 @@ // Page mode, URL-addressable via `?mode=`. No param = view (the // default): deployed-only graph focused on past/live executions. - // Operators are clamped to view — the derived ignores a hand-typed - // `?mode=edit` rather than redirecting. - let isOperator = $derived($userStore?.operator ?? false) + // A role without `code` (pipeline steps are scripts) is clamped to view: the + // derived ignores a hand-typed `?mode=edit` rather than redirecting. + let viewOnly = $derived(!$roleCapabilities.code) let urlMode = $derived(page.url.searchParams.get('mode')) - let mode = $derived<PipelineMode>(isOperator ? 'view' : urlMode === 'edit' ? 'edit' : 'view') + let mode = $derived<PipelineMode>(viewOnly ? 'view' : urlMode === 'edit' ? 'edit' : 'view') // The edit-mode pane, set aside while View shows the activity feed, so coming // back to Edit reopens it. let editPane: @@ -512,7 +513,7 @@ ensureEditable: () => { // Auto-enter edit so AI changes are visible/actionable, unless the user // is an operator (no edit permission) — then refuse with a clear error. - if (isOperator) { + if (viewOnly) { throw new Error('This pipeline is read-only for your role; AI edits are disabled.') } if (mode !== 'edit') setMode('edit') @@ -532,7 +533,8 @@ // autosave still inside its debounce has to land first. async function persistDraftsForSession(): Promise<void> { const workspace = $workspaceStore - if (!workspace) return + // A role that cannot code only views the pipeline, so there is no draft of theirs to save. + if (!workspace || viewOnly) return await tick() const draft = { workspace, itemKind: PIPELINE_DRAFT_KIND, path: pipelineDraftPath } await UserDraftDbSyncer.flush(draft) @@ -552,15 +554,11 @@ bypassNavigationGuard = true } - const sessionOpen: OpenInSessionSource | undefined = $derived( - isOperator - ? undefined - : { - target: { kind: 'pipeline', path: folder }, - workspaceId: $workspaceStore ?? undefined, - beforeOpen: persistDraftsForSession - } - ) + const sessionOpen: OpenInSessionSource = $derived({ + target: { kind: 'pipeline', path: folder }, + workspaceId: $workspaceStore ?? undefined, + beforeOpen: persistDraftsForSession + }) // Lets the navigation rail's session switch open this pipeline, with its drafts. setOpenInSessionHandoff({ source: () => sessionOpen }) @@ -1212,7 +1210,7 @@ let nodeFixes = $derived.by(() => { const fixes = new Map<string, NodeFixSpec>() - if (isOperator) return fixes + if (viewOnly) return fixes for (const [path, asset] of nodeErrors.waitsOn) { fixes.set(`script:${path}`, { explainer: [ @@ -1887,9 +1885,45 @@ // overflow (⋮) menu so the bar stays legible on small screens; only primary // actions stay inline. Recording lives here rather than on the bar at all // times — while armed it surfaces a compact inline pill (below) instead. + // The bar folds the actions (`actionFolds` below) once its breadcrumb has given all it can, + // moving them into the ⋮ menu rather than dropping their labels: first the view helpers, then + // the run and the drafts overlay. The mode toggle and Save all stay on the bar. + const viewHelpersInMenu = $derived(pageHeader.actionFold >= 1) + const runInMenu = $derived(pageHeader.actionFold >= 2) + let overflowMenuItems = $derived.by<Item[]>(() => { const items: Item[] = [] - if (!isOperator && allPipelineScripts.length > 0) { + if (runInMenu && !viewOnly && allPipelineScripts.length > 0) { + items.push({ + displayName: cascadeRunningRoot ? 'Running…' : 'Run pipeline', + icon: Play, + disabled: !!cascadeRunningRoot, + action: () => runWholePipeline() + }) + } + if (runInMenu && !viewOnly && mode === 'view' && pendingCount > 0) { + items.push({ + displayName: `${includeDrafts ? 'Hide' : 'Show'} ${pendingCount} draft${pendingCount === 1 ? '' : 's'}`, + icon: Telescope, + action: () => (includeDrafts = !includeDrafts) + }) + } + if (viewHelpersInMenu) { + if (mode === 'view') { + items.push({ + displayName: activityShowing ? 'Hide activity' : 'Activity', + icon: History, + action: () => toggleActivity() + }) + } + items.push({ + displayName: 'Refresh', + icon: RefreshCw, + disabled: graphRes.loading, + action: () => graphRes.refetch() + }) + } + if (!viewOnly && allPipelineScripts.length > 0) { items.push({ displayName: recordingMode ? 'Disarm recorder' : 'Record next run', icon: Circle, @@ -2300,6 +2334,10 @@ .filter((r) => r.usage_kind === 'script' && r.in_pipeline) .map((r) => r.path) ) + // What the bar shows of the page's main actions: the session button goes right before them. + const saveAllShown = $derived(mode === 'edit' && pendingCount > 0) + const runPipelineShown = $derived(!viewOnly && allPipelineScripts.length > 0 && !runInMenu) + // Run the whole pipeline: hand every script to the bounded-cascade engine, // which topo-orders them (roots first) and fans downstream — i.e. run + all // downstream from every source at once. Reuses the same per-hop launch/poll @@ -2709,7 +2747,7 @@ const f = folder if (!g || graphRes.loading || autoEditCheckedFolder === f) return autoEditCheckedFolder = f - if (untrack(() => urlMode) != null || untrack(() => isOperator)) return + if (untrack(() => urlMode) != null || untrack(() => viewOnly)) return if (g.runnables.length === 0 && g.assets.length === 0) { setMode('edit', { replace: true }) } @@ -2816,7 +2854,8 @@ <PageHeaderContent section={{ label: 'Pipelines', content: pipelineCrumb }} actions={pipelineActions} - actionsFlexible + sessionSource={sessionOpen} + actionFolds={2} /> {#snippet pipelineCrumb()} @@ -2866,19 +2905,16 @@ {/snippet} {#snippet pipelineActions()} - <!-- This page carries more actions than any other in the band, so the set scrolls rather than - being pushed off the end. `actionsFlexible` on the registration is what lets this box - shrink far enough for that to matter. --> - <div - class="flex flex-row items-center gap-2 min-w-0 overflow-x-auto scrollbar-hidden whitespace-nowrap" - > - {#if !isOperator} + <!-- This page carries more actions than any other in the band; what does not fit folds into + the ⋮ menu (see `viewHelpersInMenu`). --> + <div class="flex flex-row items-center gap-2 whitespace-nowrap"> + {#if !viewOnly} <!-- Center group: the mode toggle is the page's primary control — anchored between the two flex-1 side groups so it stays centered, with breathing room on both sides. --> <div class="flex flex-row items-center gap-2 shrink-0"> <PipelineModeToggle {mode} draftCount={pendingCount} onModeChange={(m) => setMode(m)} /> - {#if mode === 'view' && pendingCount > 0} + {#if mode === 'view' && pendingCount > 0 && !runInMenu} <!-- View variant: overlay the unsaved drafts onto the deployed graph — "what View will show once they're deployed". --> <Button @@ -2899,7 +2935,7 @@ {/if} </div> {/if} - {#if !isOperator && allPipelineScripts.length > 0} + {#if !viewOnly && allPipelineScripts.length > 0} <!-- Only the armed state surfaces on the bar (arming lives in the ⋮ menu) — a compact disarm pill. --> {#if recordingMode} @@ -2914,33 +2950,39 @@ Recording </button> {/if} - <!-- Pipeline-level run: always visible in both View and Edit so a + <!-- Pipeline-level run: in both View and Edit, on the bar or in the ⋮ menu, so a run never requires hunting for a specific node's play button (dbt `build` / Dagster "Materialize all"). Runs every script in dependency order (roots first, cascading downstream) over the displayed graph — deployed in View, draft-overlaid in Edit. --> - <Button - variant="accent-secondary" - unifiedSize="sm" - startIcon={{ - icon: cascadeRunningRoot ? Loader2 : Play, - classes: cascadeRunningRoot ? 'animate-spin' : undefined - }} - onclick={runWholePipeline} - disabled={!!cascadeRunningRoot} - title={cascadeRunningRoot - ? 'A pipeline run is already in progress' - : `Run all ${allPipelineScripts.length} script${ - allPipelineScripts.length === 1 ? '' : 's' - } in dependency order (roots first, cascading downstream)`} - > - {cascadeRunningRoot ? 'Running…' : 'Run pipeline'} - </Button> + {#if !runInMenu} + {#if !saveAllShown} + {@render sessionButton()} + {/if} + <Button + variant="accent-secondary" + unifiedSize="sm" + startIcon={{ + icon: cascadeRunningRoot ? Loader2 : Play, + classes: cascadeRunningRoot ? 'animate-spin' : undefined + }} + onclick={runWholePipeline} + disabled={!!cascadeRunningRoot} + title={cascadeRunningRoot + ? 'A pipeline run is already in progress' + : `Run all ${allPipelineScripts.length} script${ + allPipelineScripts.length === 1 ? '' : 's' + } in dependency order (roots first, cascading downstream)`} + > + {cascadeRunningRoot ? 'Running…' : 'Run pipeline'} + </Button> + {/if} {/if} {#if mode === 'edit' && saveErrors.size > 0} <PipelineDeployErrors errors={saveErrors} bind:open={saveErrorsOpen} /> {/if} - {#if mode === 'edit' && pendingCount > 0} + {#if saveAllShown} + {@render sessionButton()} <Button variant="accent" unifiedSize="sm" @@ -2952,7 +2994,7 @@ {savingAll ? 'Saving…' : `Save all (${pendingCount})`} </Button> {/if} - {#if mode === 'view'} + {#if mode === 'view' && !viewHelpersInMenu} <Button variant={activityShowing ? 'accent-secondary' : 'subtle'} unifiedSize="sm" @@ -2965,20 +3007,29 @@ Activity </Button> {/if} - <OpenInSessionButton source={sessionOpen} /> - <Button - variant="subtle" - unifiedSize="sm" - startIcon={{ icon: RefreshCw }} - onclick={() => graphRes.refetch()} - disabled={graphRes.loading} - iconOnly - title="Refresh" - /> + {#if !viewHelpersInMenu} + <Button + variant="subtle" + unifiedSize="sm" + startIcon={{ icon: RefreshCw }} + onclick={() => graphRes.refetch()} + disabled={graphRes.loading} + iconOnly + title="Refresh" + /> + {/if} <DropdownV2 size="sm" items={overflowMenuItems} /> + {#if !saveAllShown && !runPipelineShown} + {@render sessionButton()} + {/if} </div> {/snippet} +{#snippet sessionButton()} + <!-- Right before the page's main action: Save all while there are drafts, else Run pipeline. --> + <OpenInSessionButton source={sessionOpen} /> +{/snippet} + <div class="flex flex-col h-full"> <div class="flex-1 min-h-0"> {#if graphRes.loading && !graphRes.current} @@ -3013,8 +3064,8 @@ {runsRefreshKey} {runsPendingJobId} {boundPick} - validStartPaths={isOperator ? undefined : validStartPaths} - onStartBoundedRun={isOperator ? undefined : startBoundedRun} + validStartPaths={viewOnly ? undefined : validStartPaths} + onStartBoundedRun={viewOnly ? undefined : startBoundedRun} onPickEnd={pickBoundEnd} {panToNodeId} onCreateMissingTrigger={mode === 'edit' ? openMissingTriggerDrawer : undefined} @@ -3033,10 +3084,10 @@ onAddScriptForAsset={mode === 'edit' ? handleAddScriptForAsset : undefined} onAddPipelineScript={mode === 'edit' ? handleAddPipelineScript : undefined} onRunnableMenuRemove={mode === 'edit' ? handleRunnableMenuRemove : undefined} - onDeleteAssetUpstream={isOperator ? undefined : (t) => (assetDeleteTarget = t)} + onDeleteAssetUpstream={viewOnly ? undefined : (t) => (assetDeleteTarget = t)} {nodeFixes} assetDeleteVerb={canHardDeleteScripts ? 'Delete' : 'Archive'} - onEdgeAction={isOperator + onEdgeAction={viewOnly ? undefined : ({ action, @@ -3061,7 +3112,7 @@ } ) }} - onAddAssetTrigger={isOperator || mode !== 'edit' + onAddAssetTrigger={viewOnly || mode !== 'edit' ? undefined : ({ scripts, @@ -3076,7 +3127,7 @@ ) }} onRunProducer={mode === 'edit' ? handleRunProducer : undefined} - onRequestEdit={isOperator ? undefined : () => setMode('edit')} + onRequestEdit={viewOnly ? undefined : () => setMode('edit')} canRunByPath={openScriptHasDataUpload} onRunByPath={runByPathLegit} {selectionProducers} diff --git a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte index 6110af0fef..003f6c7752 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte @@ -18,6 +18,7 @@ import { onDestroy, untrack } from 'svelte' import { stripNewDraftFlag, stripNewDraftFlagOnSave, shouldSeedNewDraft } from '$lib/newDraftFlag' import { page } from '$app/state' + import { recentItems } from '$lib/components/home/recentItems.svelte' import { UserDraft, draftValuesEqual } from '$lib/userDraft.svelte' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { discardDraftAfterDeploy, runResetToDeployed } from '$lib/userDraftToast' @@ -311,6 +312,15 @@ getDraft }) if (tok !== loadScriptToken) return + // A draft-only script has no page for Home's card to open. + if (!backendScript.no_deployed) { + recentItems.record($workspaceStore!, { + kind: 'script', + path: backendScript.path, + summary: backendScript.summary, + via: 'edit' + }) + } // The editor re-infers the schema as soon as it mounts, so a baseline // taken from the raw stored schema would flag every unedited open of a // script whose stored schema differs from what this parser emits. diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index a1b2856323..b648bd4212 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -1,3 +1,13 @@ +<script module lang="ts"> + import { preloadComponent } from '$lib/utils/preloadComponent.svelte' + + // The preview tabs render whole items, through most of AIChat's code (Monaco among it): + // loaded beside the page, not as part of it, so the page shows without waiting on them. + const previewTabHostComponent = preloadComponent( + () => import('$lib/components/sessions/PreviewTabHost.svelte') + ) +</script> + <script lang="ts"> import { onDestroy, onMount, tick, untrack } from 'svelte' import { SvelteSet } from 'svelte/reactivity' @@ -25,10 +35,9 @@ type Scope } from '$lib/components/sessions/PreviewRouterPicker.svelte' import { goto } from '$lib/navigation' - import { resource } from 'runed' - import { WorkspaceService } from '$lib/gen' + import { useWorkspaceAiHidden } from '$lib/components/copilot/workspaceAiHidden.svelte' import SessionWrapper from '$lib/components/sessions/SessionWrapper.svelte' - import PreviewTabHost from '$lib/components/sessions/PreviewTabHost.svelte' + import type PreviewTabHost from '$lib/components/sessions/PreviewTabHost.svelte' import { useIsDarkMode } from '$lib/components/DarkModeObserver.svelte' import { createSession, @@ -44,7 +53,12 @@ import { withWorkspaceParam } from '$lib/components/sessions/sessionMode.svelte' import { enterSessionMode } from '$lib/components/sessions/sessionSwitch.svelte' import type { SessionPreviewTabs } from '$lib/components/sessions/sessionPreviewTabs.svelte' - import { userStore, userWorkspaces, usersWorkspaceStore, workspaceStore } from '$lib/stores' + import { + roleCapabilities, + userWorkspaces, + usersWorkspaceStore, + workspaceStore + } from '$lib/stores' import { getOrCreateRuntime, getRuntime, @@ -205,7 +219,7 @@ // load. `recovering` also guards re-entry: recovery mutates the session list // this effect tracks, while the URL that would stop it only updates on `goto`. $effect(() => { - if (embedded || !globalEnabled || !sessionsAllowedFor($userStore?.operator)) return + if (embedded || !globalEnabled || !sessionsAllowedFor(!$roleCapabilities.code)) return if (!sessionState.hydrated || recovering) return // A deliberate delete removes the open session ahead of its own navigation. // Claiming that gap would take over the URL and tell the user the session @@ -324,35 +338,16 @@ (activeSession ? getEffectiveWorkspaceId(activeSession) : undefined) ?? $workspaceStore ) - // Whether that workspace hid the AI assistant (`ai_config.copilot_disabled`). Read - // directly rather than from `copilotInfo`: that store holds whichever workspace loaded - // last, and the gate below unmounts the session wrapper that would refresh it, so a - // hidden verdict would stick across session and workspace switches. Tagged with its - // workspace and guarded against a superseded response like the protection-rules - // resource: runed keeps the previous `current` while a new source loads, so a switch - // would otherwise be judged on the previous workspace's verdict. A failed read leaves - // the page usable, as an unloaded config does everywhere else. - const workspaceAiHidden = resource( - () => previewWorkspace, - async (workspace, _prev, { signal }) => { - if (!workspace) return { workspace, hidden: false } - let hidden = false - try { - hidden = (await WorkspaceService.getCopilotInfo({ workspace })).copilot_disabled === true - } catch (e) { - console.error(`Failed to read the AI config of workspace ${workspace}:`, e) - } - // The generated client can't take an abort signal, so drop a superseded response here. - if (signal.aborted) throw new DOMException('superseded', 'AbortError') - return { workspace, hidden } - } + // Whether that workspace hid the AI assistant. The gate below unmounts the session wrapper + // that would refresh `copilotInfo`, so the verdict is read for the workspace itself. + const workspaceAiHidden = useWorkspaceAiHidden(() => previewWorkspace) + const aiHiddenVerdict = $derived(workspaceAiHidden.hidden) + // The full loader is for the page's first verdict only. Later, picking another workspace in + // "Acting on" leaves the session on screen, inert until its verdict lands: covering it for + // a request reads as the page reloading. + const firstVerdictPending = $derived( + aiHiddenVerdict === undefined && !workspaceAiHidden.settledOnce ) - // Only a verdict for the workspace currently judged counts; `undefined` means it has not - // landed yet. - const aiHiddenVerdict = $derived.by(() => { - const current = workspaceAiHidden.current - return current && current.workspace === previewWorkspace ? current.hidden : undefined - }) // Lazy-mount gate: a tab's content only renders once its key lands here (on // first activation) — so restoring a session with N saved tabs boots just @@ -966,7 +961,9 @@ so from then on the name comes from there, with its pen and its menu. --> <PageHeaderContent barRightInset={bandInset} - section={fullscreen ? { label: activeSession?.summary ?? 'Untitled session' } : undefined} + section={fullscreen + ? { label: activeSession?.summary ?? 'Untitled session', noNavPicker: true } + : undefined} actions={fullscreen ? fullscreenBand : sessionPageActions} actionsFill={fullscreen} separator={fullscreen ? 'always' : 'none'} @@ -1021,11 +1018,11 @@ }}>Open sessions</Button > </div> - {:else if !sessionsAllowedFor($userStore?.operator)} - <!-- Operators without the opt-in keep the legacy docked chat (see `gate.ts`); a direct - URL must not bypass that. --> + {:else if !sessionsAllowedFor(!$roleCapabilities.code)} + <!-- A role that cannot code keeps the legacy docked chat without the opt-in (see `gate.ts`); + a direct URL must not bypass that. --> <div class="p-8 flex flex-col items-start gap-3 text-secondary text-sm"> - <p class="text-primary font-medium">AI Sessions are not available for operators</p> + <p class="text-primary font-medium">AI Sessions are not available for your role</p> <p>Use the Ask AI chat instead.</p> <Button unifiedSize="xs" @@ -1117,7 +1114,11 @@ : 'z-0 opacity-0 pointer-events-none'}" aria-hidden={s.id !== activeSession?.id} > - <SessionWrapper sessionId={s.id} headerInPage={s.id === activeSession?.id} /> + <SessionWrapper + sessionId={s.id} + headerInPage={s.id === activeSession?.id} + covered={aiHiddenVerdict !== false} + /> </div> {/each} </div> @@ -1193,32 +1194,35 @@ content-gated by the shared mount MRU. Each host renders a live editor (script/flow/raw_app target) or an iframe fallback. --> <div class="relative flex-1 min-h-0"> - {#each warmSessions as s (s.id)} - {@const rt = getRuntime(s.id)} - {@const tabs = rt?.previewTabs} - {#each tabs?.tabs ?? [] as tab (tab.id)} - <!-- tabHosts is an imperative ref-bag (only tabHosts[key]?.reload() in + {#if previewTabHostComponent.current} + {@const TabHost = previewTabHostComponent.current} + {#each warmSessions as s (s.id)} + {@const rt = getRuntime(s.id)} + {@const tabs = rt?.previewTabs} + {#each tabs?.tabs ?? [] as tab (tab.id)} + <!-- tabHosts is an imperative ref-bag (only tabHosts[key]?.reload() in reloadTabs); it is intentionally a plain object so component instances aren't proxied. Nothing reads it reactively, so the non-reactive binding is fine. --> - <!-- svelte-ignore binding_property_non_reactive --> - <PreviewTabHost - bind:this={tabHosts[tabKey(s.id, tab.id)]} - {tab} - session={s} - runtime={rt} - active={s.id === activeSession?.id && tab.id === tabs?.activeId} - collapsed={(tabs?.collapsed ?? false) && !fullscreen} - mounted={mountedTabKeys.has(tabKey(s.id, tab.id))} - label={tabLabelFor(tab, s.workspace_id ?? '')} - darkMode={isDarkMode.val} - {fullscreen} - onNavigate={navigateEditorTo} - onSeeDetails={seeDetailsInPreview} - onLoad={(frame) => tabs && onTabLoad(tabs, tab, frame)} - /> + <!-- svelte-ignore binding_property_non_reactive --> + <TabHost + bind:this={tabHosts[tabKey(s.id, tab.id)]} + {tab} + session={s} + runtime={rt} + active={s.id === activeSession?.id && tab.id === tabs?.activeId} + collapsed={(tabs?.collapsed ?? false) && !fullscreen} + mounted={mountedTabKeys.has(tabKey(s.id, tab.id))} + label={tabLabelFor(tab, s.workspace_id ?? '')} + darkMode={isDarkMode.val} + {fullscreen} + onNavigate={navigateEditorTo} + onSeeDetails={seeDetailsInPreview} + onLoad={(frame) => tabs && onTabLoad(tabs, tab, frame)} + /> + {/each} {/each} - {/each} + {/if} {#if (owner?.tabs.length ?? 0) === 0} <!-- New session with nothing to preview: an empty state with a picker to open one, instead of defaulting to the home page. --> @@ -1269,15 +1273,16 @@ </Pane> </Splitpanes> </div> - {#if aiHiddenVerdict === undefined} + {#if firstVerdictPending} <div class="absolute inset-0 z-20 flex items-center justify-center bg-surface"> <Loader2 class="animate-spin" /> </div> {:else if aiHiddenVerdict} <!-- The workspace hid the assistant, and the sidebar switch with it, so only a direct URL or a session acting on such a workspace lands here. --> + <!-- Centred like the loader above: the page header overlays this pane's top edge. --> <div - class="absolute inset-0 z-20 bg-surface p-8 flex flex-col items-start gap-3 text-secondary text-sm" + class="absolute inset-0 z-20 bg-surface p-8 flex flex-col items-center justify-center gap-3 text-secondary text-sm text-center" > <p class="text-primary font-medium">AI Sessions are hidden in this workspace</p> <p>A workspace admin hid AI sessions in the workspace settings.</p> diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/fork_workspace/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/fork_workspace/+page.svelte index 3be417202c..bb72bc844e 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/fork_workspace/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/fork_workspace/+page.svelte @@ -1,16 +1,17 @@ <script lang="ts"> import CreateWorkspace from '$lib/components/workspaceSettings/CreateWorkspace.svelte' import { Alert } from '$lib/components/common' - import { userStore } from '$lib/stores' + import { roleCapabilities } from '$lib/stores' + import { isRestricted } from '$lib/roleCapabilities' </script> -<!-- The form is reachable by URL whatever the pickers show, and forking is what turns an operator - into a full member of the copy (the fork's membership row carries `is_admin` but not - `operator`). The server still accepts the call — this refuses the page, it does not enforce +<!-- The form is reachable by URL whatever the pickers show, and forking is what turns a restricted role + into a full member of the copy (the fork's membership row carries `is_admin` but not the + role). The server still accepts the call: this refuses the page, it does not enforce the rule; see docs/operator-write-rights.md. --> -{#if $userStore?.operator} +{#if isRestricted($roleCapabilities)} <div class="p-4 mt-12"> - <Alert type="error" title="Unauthorized">Page not available for operators</Alert> + <Alert type="error" title="Unauthorized">Page not available for your role</Alert> </div> {:else} <CreateWorkspace isFork={true} /> diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/login/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/login/+page.svelte index 8bcd94ce24..8500593592 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/login/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/login/+page.svelte @@ -8,7 +8,8 @@ workspaceStore, userStore, enterpriseLicense, - whitelabelNameStore + whitelabelNameStore, + roleCapabilities } from '$lib/stores' import { emptyString, parseQueryParams } from '$lib/utils' import { getUserExt } from '$lib/user' @@ -96,7 +97,7 @@ workspaceStore.set(allWorkspaces[0].id) $userStore = await getUserExt($workspaceStore!) - if (!$userStore?.is_super_admin && $userStore?.operator) { + if (!$roleCapabilities.code) { let defaultApp = await WorkspaceService.getWorkspaceDefaultApp({ workspace: $workspaceStore! }) diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte index 2c5e626578..4ed63729e4 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/workspaces/+page.svelte @@ -9,11 +9,13 @@ import { UserService, type WorkspaceInvite, WorkspaceService } from '$lib/gen' import { superadmin, + devopsRole, usersWorkspaceStore, userWorkspaces, workspaceStore, userStore, - getWorkspaceFromStorage + getWorkspaceFromStorage, + roleCapabilities } from '$lib/stores' import { Button, Skeleton } from '$lib/components/common' import Toggle from '$lib/components/Toggle.svelte' @@ -30,7 +32,9 @@ Search, ChevronsDownUp, ChevronsUpDown, - LogOut + LogOut, + Logs, + ServerCog } from 'lucide-svelte' import { isCloudHosted } from '$lib/cloud' import { isValidLogoutRedirect, toSameOriginRelativePath } from '$lib/logoutRedirect' @@ -169,17 +173,17 @@ let workspacesLoaded = $derived.by( () => workspaces !== undefined && $usersWorkspaceStore !== undefined ) - // Not for a superadmin: this page is also where they reach the instance settings and the - // list-all toggle, and standing the picker down takes both away — a superadmin with no - // membership of their own has business here besides creating a workspace. Waiting for the - // store to answer rather than reading `!$superadmin`, which is true while `globalWhoami` - // is still in flight. + // Not for a superadmin or devops user: this page is also where they reach the instance + // settings, the list-all toggle and the instance pages, and standing the picker down takes + // those away — they have business here besides creating a workspace. `$devopsRole` is truthy + // for a superadmin too. Waiting for the store to answer rather than reading `!$devopsRole`, + // which is true while `globalWhoami` is still in flight. let nothingToChoose = $derived( workspacesLoaded && invitesLoaded && createWorkspace && - $superadmin !== undefined && - !$superadmin && + $devopsRole !== undefined && + !$devopsRole && !list_all_as_super_admin && allWorkspaces.length === 0 && invites.length === 0 @@ -206,11 +210,11 @@ let showCreate = $state(false) $effect(() => { if (nothingToChoose) showCreate = true - // Except for a superadmin, whom `nothingToChoose` excludes — so this only ever undoes a - // latch that should not have happened: one taken on a stale `false` before the forced - // `refreshSuperadmin` above answered. Without it that superadmin would be stuck on the - // create form, instance settings and the list-all toggle gone with it, until a reload. - else if ($superadmin) showCreate = false + // Except for a superadmin or devops user, whom `nothingToChoose` excludes — so this only + // ever undoes a latch that should not have happened: one taken on a stale `false` before + // the forced `refreshSuperadmin` above answered. Without it they would be stuck on the + // create form, with what they came here for gone with it, until a reload. + else if ($devopsRole) showCreate = false }) async function speakFriendAndEnterWorkspace(workspaceId: string) { @@ -232,7 +236,7 @@ workspaceStore.set(undefined) workspaceStore.set(workspaceId) $userStore = await getUserExt($workspaceStore!) - if (!$userStore?.is_super_admin && $userStore?.operator) { + if (!$roleCapabilities.code) { let defaultApp = await WorkspaceService.getWorkspaceDefaultApp({ workspace: $workspaceStore! }) @@ -518,6 +522,20 @@ {/if} {/if} + <!-- A superadmin reaches these from the `admins` workspace; devops alone grants no + workspace, so without this row the role's pages have no way in. --> + {#if !showCreate && $devopsRole && !$superadmin} + <h2 class="text-sm font-semibold text-emphasis mt-10 mb-2">Instance</h2> + <div class="flex items-center flex-wrap gap-2"> + <Button variant="default" unifiedSize="sm" href="/workers" startIcon={{ icon: ServerCog }}> + Workers + </Button> + <Button variant="default" unifiedSize="sm" href="/service_logs" startIcon={{ icon: Logs }}> + Service logs + </Button> + </div> + {/if} + <!-- Settings are for someone who lives here; a user with no workspace yet has one thing to do, so this row stands down for the create state. Logging out is in this menu as well as on the subtitle line, which is the only one of the two the create state has. --> diff --git a/frontend/src/routes/(root)/(logged)/workers/+page.svelte b/frontend/src/routes/(root)/(logged)/workers/+page.svelte index 6c568b5b2d..93f0902800 100644 --- a/frontend/src/routes/(root)/(logged)/workers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workers/+page.svelte @@ -16,6 +16,8 @@ import Head from '$lib/components/table/Head.svelte' import Tooltip from '$lib/components/Tooltip.svelte' import WorkspaceGroup from '$lib/components/WorkerGroup.svelte' + import DevWorkspaceIsolation from '$lib/components/DevWorkspaceIsolation.svelte' + import { devWorkspaceListenSuffix, withDevSuffix } from '$lib/devWorkspaceTags' import { WorkerService, type WorkerPing, @@ -27,9 +29,8 @@ enterpriseLicense, superadmin, devopsRole, - userStore, workspaceStore, - userWorkspaces + roleCapabilities } from '$lib/stores' import { sendUserToast } from '$lib/toast' import { displayDate, groupBy, pluralize, retrieveCommonWorkerPrefix, truncate } from '$lib/utils' @@ -600,9 +601,12 @@ if (!config || !config.worker_tags || config.worker_tags.length === 0) cols += 1 // Worker tags if ((!config || config?.dedicated_worker == undefined) && ($superadmin || $devopsRole)) cols += 2 // Last job, Occupancy rate - if ($superadmin || $devopsRole) cols += 1 // Repl + if (replAvailable) cols += 1 // Repl return cols }) + // The REPL runs its commands as jobs in the selected workspace, which a devops user who + // belongs to no workspace does not have (see `isInstanceRoute`). + let replAvailable = $derived(($superadmin || $devopsRole) && !!$workspaceStore) </script> {#if $superadmin || $devopsRole} @@ -741,10 +745,10 @@ </DrawerContent> </Drawer> -{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.workers} +{#if $workspaceStore && !$roleCapabilities.visibility.workers} <div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert"> <p class="font-bold">Unauthorized</p> - <p>Page not available for operators</p> + <p>Page not available for your role</p> </div> {:else} <CenteredPage> @@ -904,6 +908,23 @@ </Alert> {/if} + {#if $superadmin || $devopsRole} + <DevWorkspaceIsolation + {workerGroups} + liveWorkers={Object.fromEntries( + groupedWorkers.map(([name, instances]) => [ + name, + instances.flatMap(([_, pings]) => pings.filter((p) => (p.last_ping ?? 0) < 15)).length + ]) + )} + canEdit={!!$superadmin} + onConfigureGroup={(name) => { + selectedTab = name + shouldAutoOpenDrawer = name + }} + /> + {/if} + {#if workers != undefined} {#if groupedWorkers.length == 0} <p>No workers seem to be available</p> @@ -1003,7 +1024,7 @@ <Cell head>Limits</Cell> <Cell head>Version</Cell> <Cell head>Status</Cell> - {#if $superadmin || $devopsRole} + {#if replAvailable} <Cell head> Repl <Tooltip> @@ -1046,7 +1067,13 @@ {@const isWorkerAlive = isWorkerMaybeAlive(last_ping)} {@const tagMismatchInfo = getTagMismatchInfo( custom_tags, - config?.worker_tags + config?.serves_dev_workspaces === true && + config?.dedicated_worker == undefined && + !config?.dedicated_workers?.length + ? config?.worker_tags?.map((t: string) => + withDevSuffix(t, $devWorkspaceListenSuffix) + ) + : config?.worker_tags )} <tr class={groupIdx % 2 == 1 ? 'bg-surface-secondary/50' : ''}> <Cell class="text-primary" first> @@ -1127,13 +1154,17 @@ <Cell class="text-secondary">{jobs_executed}</Cell> {#if (!config || config?.dedicated_worker == undefined) && ($superadmin || $devopsRole)} <Cell class="text-secondary"> - {#if last_job_id} + <!-- The run page switches to the job's workspace, which strands a + devops user who belongs to none of them. --> + {#if last_job_id && $workspaceStore} <a href={`/run/${last_job_id}?workspace=${last_job_workspace_id}`}> View last job <ExternalLink size={12} class="inline-block" /> </a> <br /> {last_job_workspace_id} + {:else if last_job_id} + {last_job_workspace_id} {/if} </Cell> <Cell class="text-secondary"> @@ -1252,7 +1283,7 @@ <div class="text-2xs text-hint">{pingText}</div> </div> </Cell> - {#if $superadmin || $devopsRole} + {#if replAvailable} <Cell class="text-secondary"> <Button unifiedSize="sm" @@ -1322,8 +1353,8 @@ {/if} </div> <div class="pb-8"></div> - <!-- Agent worker groups don't have autoscaling --> - {#if worker_group?.[2] === false} + <!-- An agent worker group is only autoscaled when it has a config row --> + {#if worker_group?.[2] === false || (workerGroups ?? {})[selectedTab]} <AutoscalingEvents worker_group={selectedTab} /> {/if} {:else} diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index d8e3d05d35..4cda55f662 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -18,6 +18,10 @@ type HandlerDraft } from '$lib/components/handlerDraft.svelte' import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' + import { WORKSPACE_SETTINGS_PATH } from '$lib/components/sessions/previewPaths' + import OpenInSessionButton, { + type OpenInSessionSource + } from '$lib/components/sessions/OpenInSessionButton.svelte' import ScriptPicker from '$lib/components/ScriptPicker.svelte' import Tooltip from '$lib/components/Tooltip.svelte' @@ -27,6 +31,7 @@ import { WORKSPACE_SHOW_SLACK_CMD, WORKSPACE_SHOW_WEBHOOK_CLI_SYNC } from '$lib/consts' import { OauthService, + SlackAgentsService, WorkspaceService, SettingService, type AIConfig, @@ -49,10 +54,13 @@ import { clone, emptyString, encodeState, hasUnsavedChanges } from '$lib/utils' import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile' import { ExternalLink, Slack, Target } from 'lucide-svelte' + import DropdownV2 from '$lib/components/DropdownV2.svelte' + import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' import SidebarNavigation from '$lib/components/common/sidebar/SidebarNavigation.svelte' import PremiumInfo from '$lib/components/settings/PremiumInfo.svelte' import Toggle from '$lib/components/Toggle.svelte' + import SlackChannelAgents from '$lib/components/workspaceSettings/SlackChannelAgents.svelte' import ChangeWorkspaceName from '$lib/components/settings/ChangeWorkspaceName.svelte' import ChangeWorkspaceId from '$lib/components/settings/ChangeWorkspaceId.svelte' @@ -70,6 +78,10 @@ import GitSyncSection from '$lib/components/git_sync/GitSyncSection.svelte' import Trashbin from '$lib/components/settings/Trashbin.svelte' import { untrack } from 'svelte' + import { resource } from 'runed' + import NativeOAuthCallback from '$lib/components/triggers/native/NativeOAuthCallback.svelte' + import { nativeOAuthRedirectUri } from '$lib/components/triggers/native/utils' + import type { NativeServiceName } from '$lib/gen' import DbtSettings, { convertDbtSettingsFromBackend, type DbtSettingsType @@ -88,6 +100,7 @@ } from '$lib/components/sessions/sessionState.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' import CollapseLink from '$lib/components/CollapseLink.svelte' + import SlackAppSetup from '$lib/components/common/slack/SlackAppSetup.svelte' import { validateWebhookUrl, validateEncryptionKey } from '$lib/validators/workspaceSettings' import DataTableSettings, { convertDataTableSettingsFromBackend, @@ -97,6 +110,8 @@ import SettingsFooter from '$lib/components/workspaceSettings/SettingsFooter.svelte' import WorkspaceRulesets from '$lib/components/workspaceSettings/WorkspaceRulesets.svelte' import SettingCard from '$lib/components/instanceSettings/SettingCard.svelte' + import WorkspaceSettingsYaml from '$lib/components/workspaceSettings/WorkspaceSettingsYaml.svelte' + import { loadCopilot } from '$lib/components/copilot/loadCopilot' let slackInitialPath: string = $state('') let slackScriptPath: string = $state('') @@ -165,7 +180,19 @@ await goto('/user/workspaces') } let useCustomSlackApp: boolean = $state(false) + // Opt-in: Slack refuses a connection asking for a scope its app doesn't declare. An agent's page + // links here with `agents=true`. + let slackAgents = $state($page.url.searchParams.get('agents') === 'true') let slackAppType: 'instance' | 'workspace' = $state('instance') + // Agents answer only once Windmill can verify Slack's requests with the signing secret of the + // app the workspace connects through. + let slackSigningSecret: string = $state('') + let slackSigningSecretSet: boolean = $state(false) + const slackAgentsStatus = resource( + () => (slackAgents ? $workspaceStore : undefined), + async (workspace) => + workspace ? await SlackAgentsService.getSlackAgentsStatus({ workspace }) : undefined + ) // Keep slackAppType and useCustomSlackApp in sync $effect(() => { @@ -383,7 +410,58 @@ // The deploy filters configure what may be promoted into the parent, so they only mean // something for a fork. A root workspace deploys nowhere by lineage. const showDeployToTab = $derived(Boolean(currentWorkspace?.parent_workspace_id)) + // The page native trigger OAuth providers redirect to. Anyone can connect an account, so the + // callback is handled before the admin gate. + const nativeOAuthService = $derived( + $page.url.searchParams.get('tab') === 'native_triggers' && $page.url.searchParams.has('state') + ? ($page.url.searchParams.get('service') as NativeServiceName | null) + : null + ) const canAdmin = $derived(($userStore?.is_admin ?? false) || Boolean($superadmin)) + let yamlMode = $state(false) + // A workspace where the user is not an admin hides the toggle, so it must not stay in YAML mode. + const showYaml = $derived(yamlMode && canAdmin) + let yamlEditor: WorkspaceSettingsYaml | undefined = $state(undefined) + let yamlConfirm: 'enter' | 'leave' | undefined = $state(undefined) + + function paneHasUnsavedChanges() { + const changes = getAllUnsavedChanges() + return hasUnsavedChanges(changes.savedValue ?? {}, changes.modifiedValue ?? {}) + } + + // Switching mode unmounts either the editor or the current pane, so the check runs in the + // binding's setter, before that happens. A refused change still leaves the toggle's checkbox + // flipped, so the toggle is remounted to show the mode again. + let yamlToggleKey = $state(0) + function setYamlMode(on: boolean) { + if (on === yamlMode) return + if (paneHasUnsavedChanges()) { + yamlToggleKey++ + yamlConfirm = on ? 'enter' : 'leave' + return + } + yamlMode = on + } + + function confirmYamlSwitch() { + if (yamlConfirm === 'enter') { + discardAllChanges() + yamlMode = true + } else { + yamlMode = false + } + yamlConfirm = undefined + } + + async function reloadAfterYamlApply() { + await Promise.all([ + loadSettings(), + loadSlackOAuthConfig(), + WorkspaceService.listUserWorkspaces().then((w) => usersWorkspaceStore.set(w)), + loadCopilot($workspaceStore!) + ]) + } + // The creator of a fork gets the fork members screen even when they are not an admin of it: // their `usr` row is copied from the parent, so forking as an ordinary developer leaves them // unable to bring anyone in to collaborate. Nothing else on this page opens up — the backend @@ -464,7 +542,8 @@ } const isSlackOAuthEnabled = $derived(isSlackOAuthConfigured(oauths?.slack)) - const isTeamsOAuthEnabled = $derived(isTeamsOAuthConfigured(oauths?.teams)) + // TODO: drop the pre-rename `teams` fallback with the backend one. + const isTeamsOAuthEnabled = $derived(isTeamsOAuthConfigured(oauths?.ms_teams ?? oauths?.teams)) async function editWorkspaceCommand(platform: 'slack' | 'teams'): Promise<void> { if (platform === 'slack') { @@ -676,12 +755,8 @@ slack_team_name = settings.slack_name teams_team_id = settings.teams_team_id teams_team_name = settings.teams_team_name - if (settings.slack_command_script) { - itemKind = settings.slack_command_script.split('/')[0] as 'flow' | 'script' - } - if (settings.teams_command_script) { - itemKind = settings.teams_command_script.split('/')[0] as 'flow' | 'script' - } + const commandScript = settings.teams_command_script || settings.slack_command_script + itemKind = commandScript ? (commandScript.split('/')[0] as 'flow' | 'script') : 'flow' slackScriptPath = (settings.slack_command_script ?? '').split('/').slice(1).join('/') teamsScriptPath = (settings.teams_command_script ?? '').split('/').slice(1).join('/') slackInitialPath = slackScriptPath @@ -764,6 +839,8 @@ triggers: (settings.deploy_ui.include_type?.indexOf('trigger') ?? -1) >= 0 } } + } else { + deployUiSettings = defaultDeployUiSettings() } // Store initial deploy settings state for unsaved changes detection @@ -798,6 +875,7 @@ slackOAuthClientId = config.slack_oauth_client_id || '' slackOAuthClientSecret = config.slack_oauth_client_secret || '' slackOAuthConfigLoaded = !!config.slack_oauth_client_id + slackSigningSecretSet = !!config.slack_signing_secret } catch (e) { console.error('Failed to load Slack OAuth config:', e) } @@ -833,15 +911,38 @@ slack_oauth_client_secret: slackOAuthClientSecret } }) + if (slackSigningSecret.trim()) { + await WorkspaceService.setWorkspaceSlackSigningSecret({ + workspace: $workspaceStore, + requestBody: { slack_signing_secret: slackSigningSecret } + }) + } // Redirect to OAuth flow - window.location.href = `/api/oauth/connect_slack?workspace=${$workspaceStore}` + window.location.href = `/api/oauth/connect_slack?workspace=${$workspaceStore}${slackAgents ? '&agents=true' : ''}` } catch (e) { sendUserToast('Failed to save Slack OAuth configuration', true) console.error(e) } } + async function saveSlackSigningSecret(): Promise<void> { + if (!$workspaceStore) return + const secret = slackSigningSecret.trim() || null + try { + await WorkspaceService.setWorkspaceSlackSigningSecret({ + workspace: $workspaceStore, + requestBody: { slack_signing_secret: secret } + }) + slackSigningSecret = '' + slackSigningSecretSet = !!secret + sendUserToast(secret ? 'Signing secret saved' : 'Signing secret removed') + } catch (e) { + sendUserToast('Failed to save the signing secret', true) + console.error(e) + } + } + async function deleteSlackOAuthConfig(): Promise<void> { if (!$workspaceStore) return @@ -859,6 +960,7 @@ slackOAuthClientId = '' slackOAuthClientSecret = '' slackOAuthConfigLoaded = false + slackSigningSecretSet = false await loadSettings() sendUserToast('Workspace Slack app deleted') } catch (e) { @@ -867,7 +969,7 @@ } } - let deployUiSettings: { + type DeployUiSettings = { include_path: string[] include_type: { scripts: boolean @@ -878,18 +980,22 @@ secrets: boolean triggers: boolean } - } = $state({ - include_path: [], - include_type: { - scripts: true, - flows: true, - apps: true, - resources: true, - variables: true, - secrets: true, - triggers: true + } + function defaultDeployUiSettings(): DeployUiSettings { + return { + include_path: [], + include_type: { + scripts: true, + flows: true, + apps: true, + resources: true, + variables: true, + secrets: true, + triggers: true + } } - }) + } + let deployUiSettings: DeployUiSettings = $state(defaultDeployUiSettings()) $effect(() => { // `canAdmin` is read as a dependency, not inside untrack: $userStore is repopulated @@ -1187,6 +1293,9 @@ // Combined function to check for unsaved changes across all tabs function getAllUnsavedChanges() { + if (showYaml) { + return yamlEditor?.unsavedChanges() ?? { savedValue: {}, modifiedValue: {} } + } switch (tab) { case 'windmill_data_tables': return dataTableSettingsComponent?.unsavedChanges() ?? { savedValue: {}, modifiedValue: {} } @@ -1235,6 +1344,10 @@ // Combined function to discard changes based on current tab function discardAllChanges() { + if (showYaml) { + yamlEditor?.discard() + return + } switch (tab) { case 'ai': aiSettingsComponent?.discard() @@ -1474,16 +1587,31 @@ const tabLabel = $derived( navigationGroups.flatMap((g) => g.items).find((i) => i.id === tab)?.label ) + + const sessionSource: OpenInSessionSource = $derived({ + // Read at click time: the tab lives in the query, so the preview opens on the tab being + // viewed. YAML mode is component state and does not carry over. + page: () => WORKSPACE_SETTINGS_PATH + window.location.search, + workspaceId: $workspaceStore ?? undefined + }) </script> <CenteredPage wrapperClasses="pb-0 h-full" handleOverflow={false} class="flex flex-col h-full"> - {#if canAdmin || isForkOwner} + {#if nativeOAuthService} + <NativeOAuthCallback redirectUri={nativeOAuthRedirectUri(nativeOAuthService)} /> + {:else if canAdmin || isForkOwner} <!-- No title of its own: the band already says which workspace's settings these are, and the two controls that sat beside the title are the page's actions. The panes below scroll inside themselves, so the bar keeps its edge at rest rather than only under a scroll. --> <PageHeaderContent - section={tabLabel ? { parent: 'Workspace settings', label: tabLabel } : undefined} + section={showYaml + ? { parent: 'Workspace settings', label: 'YAML' } + : tabLabel + ? { parent: 'Workspace settings', label: tabLabel } + : undefined} actions={settingsActions} + {sessionSource} + actionFolds={1} separator="always" /> @@ -1492,17 +1620,19 @@ <!-- The top padding belongs inside both scrollers, not above them: it is the gap under the separator at rest, and it has to travel with the content so what scrolls passes under the line rather than stopping short of it. --> - <div class="w-60 shrink-0 h-full overflow-auto pt-6 pb-4 pr-4 -ml-2"> - <SidebarNavigation - groups={navigationGroups} - selectedId={tab} - onNavigate={(id) => { - const params = new URLSearchParams($page.url.searchParams) - params.set('tab', id) - goto(`?${params.toString()}`) - }} - /> - </div> + {#if !showYaml} + <div class="w-60 shrink-0 h-full overflow-auto pt-6 pb-4 pr-4 -ml-2"> + <SidebarNavigation + groups={navigationGroups} + selectedId={tab} + onNavigate={(id) => { + const params = new URLSearchParams($page.url.searchParams) + params.set('tab', id) + goto(`?${params.toString()}`) + }} + /> + </div> + {/if} <!-- Main Content --> <div class="flex-1 min-w-0 h-full"> @@ -1510,6 +1640,8 @@ <div class="h-fit px-6 pt-6" style="scrollbar-gutter: stable both-edges;"> {#if !loadedSettings} <Skeleton layout={[1, [40]]} /> + {:else if showYaml} + <WorkspaceSettingsYaml bind:this={yamlEditor} onApplied={reloadAfterYamlApply} /> {:else if tab == 'users'} {#if canAdmin} <WorkspaceUserSettings /> @@ -1655,7 +1787,7 @@ } }} onSelect={editSlackCommand} - connectHref="/api/oauth/connect_slack" + connectHref="/api/oauth/connect_slack{slackAgents ? '?agents=true' : ''}" createScriptHref="/scripts/add?hub=hub%2F28071%2Fslack%2Fexample_of_responding_to_a_slack_command_slack" createFlowHref="/flows/add?hub=28" documentationLink="https://www.windmill.dev/docs/integrations/slack" @@ -1668,27 +1800,42 @@ {#snippet workspaceConfig()} <!-- Workspace OAuth Configuration Section --> {#if !slack_team_name} - <div class="flex flex-col gap-1"> - <!-- Show toggle buttons for app type selection --> - <ToggleButtonGroup bind:selected={slackAppType}> - {#snippet children({ item })} - <ToggleButton - {item} - value="instance" - label="Instance specific Slack app" - /> - <ToggleButton - {item} - value="workspace" - label="Workspace specific Slack app" - /> - {/snippet} - </ToggleButtonGroup> - <div class="text-2xs text-hint" - >Use the Slack app configured at the instance level if you want to use - the same Slack app for all workspaces. Configure your Slack app here if - you want to use a specific Slack app for this workspace.</div - > + <div class="flex flex-col gap-4 pb-3"> + <div class="flex flex-col gap-1"> + <ToggleButtonGroup bind:selected={slackAppType}> + {#snippet children({ item })} + <ToggleButton + {item} + value="instance" + label="Instance specific Slack app" + /> + <ToggleButton + {item} + value="workspace" + label="Workspace specific Slack app" + /> + {/snippet} + </ToggleButtonGroup> + <div class="text-2xs text-hint" + >The instance app is shared by all workspaces. A workspace app is your + own, for this workspace only.</div + > + </div> + <Toggle + size="xs" + bind:checked={slackAgents} + options={{ + right: 'Let AI agents answer in Slack', + rightTooltip: + 'Lets editors share agents in Slack channels, where people ask them by mentioning the bot. Needs users:read, users:read.email and channels:read on the Slack app.' + }} + /> + {#if slackAppType === 'instance' && slackAgentsStatus.current?.signing_secret_set === false} + <Alert type="warning" size="xs" title="Agents won't answer yet"> + An instance admin needs to set the Slack app's signing secret in the + instance settings. + </Alert> + {/if} </div> {/if} {#if slackOAuthConfigLoaded} @@ -1706,6 +1853,32 @@ >Client ID for the Slack app configured at the workspace level</div > </div> + <div class="flex flex-col gap-1"> + <div class="text-xs text-primary font-normal">Signing secret</div> + <div class="flex gap-2"> + <TextInput + inputProps={{ + type: 'password', + placeholder: slackSigningSecretSet + ? '••••••••••••••••••••••••••••••••' + : 'Enter signing secret' + }} + bind:value={slackSigningSecret} + /> + <Button + unifiedSize="md" + variant="default" + onclick={saveSlackSigningSecret} + disabled={!slackSigningSecret.trim() && !slackSigningSecretSet} + > + {slackSigningSecret.trim() || !slackSigningSecretSet ? 'Save' : 'Remove'} + </Button> + </div> + <div class="text-2xs text-hint" + >Lets Windmill check that requests come from Slack. Required for AI + agents; recommended for <code>/windmill</code> commands.</div + > + </div> {:else if slackAppType === 'workspace'} <div class="flex flex-col gap-6"> <label class="flex flex-col gap-1"> @@ -1730,21 +1903,23 @@ /> </label> + <label class="flex flex-col gap-1"> + <span class="text-primary font-semibold text-xs">Signing secret</span> + <TextInput + inputProps={{ + type: 'password', + placeholder: 'Enter signing secret' + }} + bind:value={slackSigningSecret} + /> + <span class="text-2xs text-hint" + >Lets Windmill check that requests come from Slack. Required for AI + agents; recommended for <code>/windmill</code> commands.</span + > + </label> + <CollapseLink text="Instructions"> - <div class="text-xs text-secondary"> - Create a Slack app at{' '} - <a - href="https://api.slack.com/apps" - target="_blank" - rel="noopener noreferrer" - class="text-blue-600 dark:text-blue-400 hover:underline" - > - Slack API - </a>. Set the redirect URI to:{' '} - <code class="bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded"> - {window.location.origin}/oauth/callback_slack - </code> - </div> + <SlackAppSetup /> </CollapseLink> <div class="pt-2"> @@ -1752,7 +1927,9 @@ size="xs" variant="accent" onclick={saveAndConnectSlack} - disabled={!slackOAuthClientId || !slackOAuthClientSecret} + disabled={!slackOAuthClientId || + !slackOAuthClientSecret || + (slackAgents && !slackSigningSecret.trim())} startIcon={{ icon: Slack }} btnClasses="w-fit" > @@ -1769,6 +1946,9 @@ {/if} {/snippet} </ConnectionSection> + {#if slack_team_name && $workspaceStore} + <SlackChannelAgents workspace={$workspaceStore} /> + {/if} {:else if slack_tabs === 'teams_commands'} {#if !$enterpriseLicense} <div class="pt-4"></div> @@ -2439,12 +2619,44 @@ export async function main( tabMode={true} /> +<ConfirmationModal + open={yamlConfirm !== undefined} + title="Unsaved changes detected" + confirmationText="Discard changes" + onConfirmed={confirmYamlSwitch} + onCanceled={() => { + yamlConfirm = undefined + }} +> + <span> + {yamlConfirm === 'enter' + ? 'Switching to YAML mode discards the changes you made in this pane.' + : 'Leaving YAML mode discards the changes you made in the editor.'} + </span> +</ConfirmationModal> + {#snippet settingsActions()} - {#if $superadmin} - <Button variant="default" unifiedSize="sm" on:click={() => goto('#superadmin-settings')}> - Instance settings - </Button> + {#if canAdmin} + {#key yamlToggleKey} + <Toggle bind:checked={() => yamlMode, setYamlMode} options={{ right: 'YAML' }} size="sm" /> + {/key} {/if} + {#if $superadmin} + <!-- Goes into a menu on a phone, where the AI session button that ends the row would + otherwise squeeze the breadcrumb under these controls. --> + {#if pageHeader.actionFold >= 1} + <DropdownV2 + size="sm" + items={[{ displayName: 'Instance settings', action: () => goto('#superadmin-settings') }]} + /> + {:else} + <Button variant="default" unifiedSize="sm" on:click={() => goto('#superadmin-settings')}> + Instance settings + </Button> + {/if} + {/if} + <!-- No main action on this page: the session button ends the row. --> + <OpenInSessionButton source={sessionSource} /> {/snippet} <style> diff --git a/frontend/src/routes/(root)/+layout.svelte b/frontend/src/routes/(root)/+layout.svelte index f14f9e1043..dddf1671ac 100644 --- a/frontend/src/routes/(root)/+layout.svelte +++ b/frontend/src/routes/(root)/+layout.svelte @@ -19,6 +19,7 @@ import { onDestroy, onMount } from 'svelte' import { refreshSuperadmin } from '$lib/refreshUser' + import { isInstanceRoute } from '$lib/instanceRoutes' // import EditorTheme from '$lib/components/EditorTheme.svelte' import { computeDrift } from '$lib/forLater' import { setLicense } from '$lib/enterpriseUtils' @@ -155,7 +156,8 @@ // The hub import wizard asks for the destination itself, and may end in a // workspace that does not exist yet — bouncing it to the picker would // force the very choice it exists to make. - !page.url.pathname.startsWith('/projects/import') + !page.url.pathname.startsWith('/projects/import') && + !((user.devops || user.super_admin) && isInstanceRoute(page.url.pathname)) ) { goto(`/user/workspaces?rd=${encodeURIComponent(page.url.href.replace(page.url.origin, ''))}`) } diff --git a/frontend/src/routes/(styled)/app_embed/[workspace]/[...path]/+page.svelte b/frontend/src/routes/(styled)/app_embed/[workspace]/[...path]/+page.svelte index 9b3a9c1e41..e2866f5674 100644 --- a/frontend/src/routes/(styled)/app_embed/[workspace]/[...path]/+page.svelte +++ b/frontend/src/routes/(styled)/app_embed/[workspace]/[...path]/+page.svelte @@ -58,7 +58,8 @@ console.warn('Anonymous user') } try { - app = await AppService.getAppByPath({ workspace, path }) + // The lite value, whose inline scripts carry their deployed rows' ids: see InWorkspaceAppViewer. + app = await AppService.getAppLiteByPath({ workspace, path }) noPermission = false notExists = false } catch (e: any) { diff --git a/frontend/src/routes/(styled)/shared_sessions/[workspace]/[id]/+page.svelte b/frontend/src/routes/(styled)/shared_sessions/[workspace]/[id]/+page.svelte new file mode 100644 index 0000000000..57e0ca654b --- /dev/null +++ b/frontend/src/routes/(styled)/shared_sessions/[workspace]/[id]/+page.svelte @@ -0,0 +1,157 @@ +<script lang="ts"> + import { page } from '$app/state' + import { resource } from 'runed' + import { Globe, Link2Off, LogIn, MessagesSquare, Users } from 'lucide-svelte' + import { Badge, Button, EmptyState, Skeleton } from '$lib/components/common' + import WindmillIcon from '$lib/components/icons/WindmillIcon.svelte' + import SharedSessionTranscript from '$lib/components/sessions/SharedSessionTranscript.svelte' + import { readSessionSnapshot } from '$lib/components/sessions/sessionSharing' + import { applyDarkModeVariant } from '$lib/darkModeVariant' + import { AiService } from '$lib/gen' + import { workspaceStore } from '$lib/stores' + import { sendUserToast } from '$lib/toast' + import { displayDate } from '$lib/utils' + + // Reached without signing in when the share is public, so this page sets up what the + // logged-in layout otherwise would. + const darkMode = + window.localStorage.getItem('dark-mode') ?? + (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') + document.documentElement.classList.toggle('dark', darkMode === 'dark') + applyDarkModeVariant() + + const workspace = $derived(page.params.workspace ?? '') + const id = $derived(page.params.id ?? '') + + $effect(() => { + $workspaceStore = workspace + }) + + type Loaded = + | { + state: 'found' + session: Awaited<ReturnType<typeof AiService.getSharedAiSession>> + snapshot: ReturnType<typeof readSessionSnapshot> + } + | { state: 'gone' } + | { state: 'signin' } + | { state: 'error'; message: string } + + const shared = resource( + () => ({ workspace, id }), + async ({ workspace, id }): Promise<Loaded | undefined> => { + if (!workspace || !id) return undefined + try { + const session = await AiService.getSharedAiSession({ workspace, id }) + return { state: 'found', session, snapshot: readSessionSnapshot(session.snapshot) } + } catch (err) { + const status = (err as { status?: number })?.status + if (status === 401) return { state: 'signin' } + // A malformed id is a 400 from the path extractor, and means the same to the reader. + if (status === 404 || status === 400) return { state: 'gone' } + return { state: 'error', message: String((err as { body?: unknown })?.body ?? err) } + } + } + ) + + const found = $derived(shared.current?.state === 'found' ? shared.current : undefined) + const loginHref = $derived( + `/user/login?rd=${encodeURIComponent(page.url.pathname + page.url.search)}` + ) + + let removing = $state(false) + async function stopSharing() { + if (!found) return + removing = true + try { + await AiService.unshareAiSession({ workspace, id: found.session.id }) + shared.mutate({ state: 'gone' }) + sendUserToast('Stopped sharing: the link no longer opens') + } catch (err) { + sendUserToast(`Could not stop sharing: ${(err as { body?: unknown })?.body ?? err}`, true) + } finally { + removing = false + } + } +</script> + +<!-- The transcript is its author's text, rendered to whoever holds the link: an image in it + that points elsewhere would report every reader to that host. A policy stays with the + document once set, so every link out of this page is a full load (`data-sveltekit-reload`). --> +<svelte:head> + <meta http-equiv="content-security-policy" content="img-src 'self' data: blob:" /> +</svelte:head> + +<div class="flex min-h-screen flex-col bg-surface" data-sveltekit-reload> + <div class="border-b bg-surface-secondary"> + <div class="mx-auto flex w-full max-w-3xl flex-wrap items-center gap-3 px-4 py-3"> + <WindmillIcon height="20px" width="20px" /> + {#if found} + <div class="flex min-w-0 flex-1 flex-col"> + <div class="flex min-w-0 items-center gap-2"> + <h1 class="truncate text-sm font-semibold text-emphasis" title={found.session.name}> + {found.session.name} + </h1> + {#if found.session.is_public} + <Badge color="blue" icon={{ icon: Globe, position: 'left' }}>Public</Badge> + {:else} + <Badge color="gray" icon={{ icon: Users, position: 'left' }}>{workspace}</Badge> + {/if} + </div> + <span class="text-xs font-normal text-secondary"> + Read-only AI session shared by {found.session.created_by} · {displayDate( + found.session.shared_at + )} + </span> + </div> + {#if found.session.can_unshare} + <Button + unifiedSize="sm" + variant="default" + destructive + loading={removing} + onClick={stopSharing} + > + Stop sharing + </Button> + {/if} + {:else} + <span class="text-sm font-semibold text-emphasis">Shared AI session</span> + {/if} + </div> + </div> + + <div class="mx-auto w-full max-w-3xl flex-1 px-4 py-6"> + {#if found} + {#if found.snapshot.messages.length > 0} + {#key found.session.id} + <SharedSessionTranscript snapshot={found.snapshot} {workspace} /> + {/key} + {:else} + <EmptyState icon={MessagesSquare} title="This session has no messages" /> + {/if} + {:else if shared.current?.state === 'signin'} + <EmptyState + icon={LogIn} + title="Sign in to read this session" + description="It is shared with the members of {workspace} only." + > + <Button variant="accent" unifiedSize="md" href={loginHref}>Sign in</Button> + </EmptyState> + {:else if shared.current?.state === 'gone'} + <EmptyState + icon={Link2Off} + title="This shared session is not available" + description="Its author stopped sharing it, or it is shared with another workspace's members only." + /> + {:else if shared.current?.state === 'error'} + <EmptyState + icon={Link2Off} + title="Could not load this shared session" + description={shared.current.message} + /> + {:else} + <Skeleton layout={[[3], 1, [30]]} /> + {/if} + </div> +</div> diff --git a/frontend/src/routes/oauth/callback/[client_name]/native_trigger/+page.svelte b/frontend/src/routes/oauth/callback/[client_name]/native_trigger/+page.svelte new file mode 100644 index 0000000000..9bc941520b --- /dev/null +++ b/frontend/src/routes/oauth/callback/[client_name]/native_trigger/+page.svelte @@ -0,0 +1,8 @@ +<script lang="ts"> + import { page } from '$app/state' + import NativeOAuthCallback from '$lib/components/triggers/native/NativeOAuthCallback.svelte' +</script> + +<!-- Where native trigger connections made with the instance GitHub OAuth app land: that app only + redirects below its registered callback, /oauth/callback/github. --> +<NativeOAuthCallback redirectUri="{page.url.origin}/oauth/callback/github/native_trigger" /> diff --git a/frontend/static/apple-touch-icon.png b/frontend/static/apple-touch-icon.png new file mode 100644 index 0000000000..211c5c1e81 Binary files /dev/null and b/frontend/static/apple-touch-icon.png differ diff --git a/frontend/static/manifest.webmanifest b/frontend/static/manifest.webmanifest new file mode 100644 index 0000000000..6ded55be6d --- /dev/null +++ b/frontend/static/manifest.webmanifest @@ -0,0 +1,22 @@ +{ + "id": "/", + "name": "Windmill", + "short_name": "Windmill", + "description": "Scripts, flows and apps for internal tools, workflows and background jobs", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#fbfbfd", + "theme_color": "#fbfbfd", + "icons": [ + { "src": "/pwa-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "/pwa-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { + "src": "/pwa-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + }, + { "src": "/logo.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" } + ] +} diff --git a/frontend/static/pwa-192.png b/frontend/static/pwa-192.png new file mode 100644 index 0000000000..ced36d0455 Binary files /dev/null and b/frontend/static/pwa-192.png differ diff --git a/frontend/static/pwa-512.png b/frontend/static/pwa-512.png new file mode 100644 index 0000000000..b188f4f45b Binary files /dev/null and b/frontend/static/pwa-512.png differ diff --git a/frontend/static/pwa-maskable-512.png b/frontend/static/pwa-maskable-512.png new file mode 100644 index 0000000000..c1c4de333f Binary files /dev/null and b/frontend/static/pwa-maskable-512.png differ diff --git a/integration_tests/test/git_sync_test.py b/integration_tests/test/git_sync_test.py index 3f761e36f5..aee7ea1088 100644 --- a/integration_tests/test/git_sync_test.py +++ b/integration_tests/test/git_sync_test.py @@ -215,17 +215,46 @@ class GitSyncTestBase(unittest.TestCase): return repo return None + def _new_sync_jobs(self, seen: set, path: str) -> list: + """Completed deploy callbacks not in `seen` whose items include `path`. + A callback debounced into another completes as skipped with its items + still in its args, so skipped ones are left out: only the survivor, + whose args carry the accumulated items, ran the push.""" + jobs = [] + for listed in self._client.get_completed_jobs(job_kinds="deploymentcallback"): + if listed["id"] in seen: + continue + job = self._client._client.get( + f"/api/w/{self._client._workspace}/jobs_u/get/{listed['id']}" + ).json() + if job.get("is_skipped"): + continue + items = (job.get("args") or {}).get("items") or [] + if any(item.get("path") == path for item in items): + jobs.append(job) + return jobs + def _deploy_seed_script(self, name_prefix: str) -> str: - """Deploy a script and wait for its push to git. Returns the script path.""" - initial_count = self._client.count_deployment_callback_jobs() + """Deploy a script and wait for its push to git. Returns the script path. + + Waits on the callback that carries this script, not on the workspace's + callback count: a callback still finishing from an earlier test raises + the count before this push lands.""" + seen = {j["id"] for j in self._client.get_completed_jobs(job_kinds="deploymentcallback")} script_path = f"u/admin/{unique_name(name_prefix)}" self._client.create_script( path=script_path, content=ts_script("return 'seed'"), language="bun", ) - self._client.wait_for_sync_jobs(initial_count, min_new=1) - time.sleep(3) + self._wait_until( + lambda: self._new_sync_jobs(seen, script_path), + timeout=90, + interval=2, + message=f"no push job for {script_path}", + ) + failed = [j for j in self._new_sync_jobs(seen, script_path) if not j.get("success")] + self.assertEqual(failed, [], f"the push of {script_path} failed") return script_path def _repo_script_file(self, repo_name: str, script_path: str, branch: str = None) -> str: @@ -773,17 +802,7 @@ class TestGitSync(GitSyncTestBase): def _new_sync_job_results(self, seen: set, path: str) -> list: """Results of the deploy callbacks not in `seen` that pushed `path`.""" - results = [] - for listed in self._client.get_completed_jobs(job_kinds="deploymentcallback"): - if listed["id"] in seen: - continue - job = self._client._client.get( - f"/api/w/{self._client._workspace}/jobs_u/get/{listed['id']}" - ).json() - items = (job.get("args") or {}).get("items") or [] - if any(item.get("path") == path for item in items): - results.append(job.get("result")) - return results + return [job.get("result") for job in self._new_sync_jobs(seen, path)] def test_promotion_push_reports_whether_it_pushed(self): """The push job's result says whether a commit was pushed, and the diff --git a/integration_tests/test/identity_script_test.py b/integration_tests/test/identity_script_test.py index 638919fb22..c4689e1874 100644 --- a/integration_tests/test/identity_script_test.py +++ b/integration_tests/test/identity_script_test.py @@ -24,12 +24,6 @@ func main(x int) (interface{}, error) { "python3": """ def main(x: int): return x -""", - "php": """<?php - -function main(int $x): int { - return $x; -} """, } @@ -76,8 +70,3 @@ class TestIdentityScript(unittest.TestCase): path = PATH_TEMPLATE.format(lang="python3") result = self._client.run_sync(path, {"x": 5}) self.assertEqual(result, 5) - - def test_php(self): - path = PATH_TEMPLATE.format(lang="php") - result = self._client.run_sync(path, {"x": 5}) - self.assertEqual(result, 5) diff --git a/lsp/Pipfile b/lsp/Pipfile index 80c03f06c6..cab32152b8 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.833.0" +wmill = ">=1.847.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 0242dc7f04..489888ee60 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.833.0 + version: 1.847.0 title: OpenFlow Spec contact: name: Ruben Fiszel @@ -112,7 +112,10 @@ components: description: Execution priority (higher numbers run first) early_return: type: string - description: JavaScript expression to return early from the flow + 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 @@ -1183,6 +1186,23 @@ components: description: | Number. Limits how many times the agent can loop through reasoning and tool use. Range: 1-1000. + model: + allOf: + - $ref: '#/components/schemas/InputTransform' + description: | + String. Only on a step linked to a saved agent (see `agent`): replaces the model + saved on the agent for this step's runs, on the agent's own provider resource. An + empty value keeps the saved model. Set it to an expression such as + `flow_input.model` to choose the model per run. + reasoning_effort: + allOf: + - $ref: '#/components/schemas/InputTransform' + description: | + String. Only on a step linked to a saved agent (see `agent`): replaces the reasoning + effort saved on the agent for this step's runs, as a provider-native token (e.g. + 'low', 'high', 'none'). An empty value sends no effort, so the model runs at its + default. Leave the input out to keep the saved effort, which holds only while the + step runs the saved model. A step that is not linked sets both inside `provider`. # Only the flow-local inputs are always present: a step linked to an `ai_agent` resource # (see `agent`) keeps just those and takes provider/output_type from the resource. Even # `user_message` may be absent, when memory is off and `previous_messages` is the prompt. @@ -1208,7 +1228,8 @@ components: Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain config (provider/model/system prompt/etc.) and tool set are resolved at runtime from that resource; the module's input_transforms then only carry the flow-local inputs - (user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages). + (user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages) + and, optionally, the `model` and `reasoning_effort` overrides of what the agent saved. tool_inputs: type: object description: | diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 86fc4553ff..713e75f667 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.833.0' + ModuleVersion = '1.847.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index a9ab5835de..2ef9c5923b 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.833.0" +version = "1.847.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/python-client/wmill/tests/test_s3_proxy_settings.py b/python-client/wmill/tests/test_s3_proxy_settings.py new file mode 100644 index 0000000000..52db4fc8a9 --- /dev/null +++ b/python-client/wmill/tests/test_s3_proxy_settings.py @@ -0,0 +1,68 @@ +"""The default S3 connection settings go through the S3 proxy only when the server can verify +the token's SigV4 signature and serves the bucket-addressed routes; otherwise they keep the +storage's own credentials.""" + +import pytest + +from wmill.client import Windmill + +JOB_TOKEN = "jwt_header.payload.signature" + + +class _Response: + def __init__(self, status_code, body): + self.status_code = status_code + self._body = body + + def json(self): + return self._body + + +def _client(token, status_code=200, body=None): + client = Windmill(base_url="https://wm.example", token=token, workspace="ws") + calls = [] + + def get(endpoint, raise_for_status=True, **kwargs): + calls.append(endpoint) + return _Response(status_code, body if body is not None else {"bucket": "data"}) + + def post(endpoint, raise_for_status=True, **kwargs): + calls.append(endpoint) + resource = {"endPoint": "s3.example", "useSSL": True, "region": "eu-west-1"} + return _Response(200, {**resource, "accessKey": "raw", "secretKey": "raw-secret"}) + + client.get = get + client.post = post + return client, calls + + +def test_job_token_goes_through_the_proxy(): + client, _ = _client(JOB_TOKEN) + settings = client.get_boto3_connection_settings() + assert settings["endpoint_url"] == "https://wm.example/api/w/ws/s3_proxy/_wm_bucket_/_default_" + assert settings["aws_access_key_id"] == "jwt_header.payload" + assert settings["aws_secret_access_key"] == "signature" + assert client.get_polars_connection_settings()["s3_bucket"] == "data" + + +@pytest.mark.parametrize( + "token,status_code,body", + [ + ("personaltokenwithoutdots", 200, None), + ("jwt_ext_header.payload.signature", 200, None), + (JOB_TOKEN, 404, None), + (JOB_TOKEN, 200, {"bucket": None}), + ], + ids=["personal token", "external jwt", "older server", "non-S3 storage"], +) +def test_falls_back_to_the_storage_credentials(token, status_code, body): + client, calls = _client(token, status_code, body) + settings = client.get_boto3_connection_settings() + assert settings["aws_access_key_id"] == "raw" + assert calls[-1].endswith("/job_helpers/v2/s3_resource_info") + + +def test_explicit_resource_path_never_asks_for_the_proxy(): + client, calls = _client(JOB_TOKEN) + client.get_boto3_connection_settings("u/admin/s3") + assert calls == ["/w/ws/job_helpers/v2/s3_resource_info"] diff --git a/python-client/wmill/uv.lock b/python-client/wmill/uv.lock index 1c366b42bd..c6ebad85d8 100644 --- a/python-client/wmill/uv.lock +++ b/python-client/wmill/uv.lock @@ -15,7 +15,9 @@ name = "anyio" version = "4.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } wheels = [ @@ -24,11 +26,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.2.25" +version = "2026.10.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" }, ] [[package]] @@ -40,6 +42,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -128,12 +142,95 @@ version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] + +[[package]] +name = "tomli" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/78/9ad63712633ed3ab5cc1a648d863d7e7da371e9425e209555a0fe711b695/tomli-2.5.0.tar.gz", hash = "sha256:264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6", size = 17662, upload-time = "2026-10-07T12:23:37.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/ab99b60ee52acd949684febabc3005d0045d0f66bebd9cdebd67372d26dd/tomli-2.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c4dc1c1781f2f716de763d1e9a7b34c6a894e167e291c7c5d16c72f7a9538545", size = 163901, upload-time = "2026-10-07T12:22:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/bc/00/ee01b7ed4579180fff07142d290257f25ba786f23f3ec6005f620933c2f5/tomli-2.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eff8babca5a7999bc137acbc7482a8b7e17ffca5075ab41f5d770ab408c7bfef", size = 163756, upload-time = "2026-10-07T12:22:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/72/c2/4efebf65372f6583185f79799312109dddb61102d47e5c33dcfd1a297aca/tomli-2.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86665cee9c4835b7a7f1e8ec2c719b5258d4dc782887aded5a8ae7352a96843b", size = 268038, upload-time = "2026-10-07T12:22:18.135Z" }, + { url = "https://files.pythonhosted.org/packages/53/07/5850468e925d898abb36038666f9c333a94d2a223e802a8ba5b6d319d23f/tomli-2.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e369fd63331746182360977b1892bfc215476a30d61612d732425311639f56", size = 276422, upload-time = "2026-10-07T12:22:19.567Z" }, + { url = "https://files.pythonhosted.org/packages/b4/87/f293984cdcf83c054196d4fd3dad44fc68ae55b4b8c44bc76cef360c3150/tomli-2.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ad1ea345759240d6463efa0ed1c704402752e49aa21476620738d74d72d8aa1", size = 272616, upload-time = "2026-10-07T12:22:20.794Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ce/db582886b3c1219d3fec93ebd669332482e5aee7a91e0f7838d84f2d1759/tomli-2.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:96243987194634bd411066ce40c952e108f86af04db533ecd8ac3ff2a85b1885", size = 276593, upload-time = "2026-10-07T12:22:22.12Z" }, + { url = "https://files.pythonhosted.org/packages/bf/72/7619b87dea4261fc27dd7b54c4461c129c1f7d9bb7ba3aec89c797a431b8/tomli-2.5.0-cp311-cp311-win32.whl", hash = "sha256:610b27d99f28ec5f191c7064a48f3ddb179a1fe6ca73d571483ae859f57b605e", size = 101830, upload-time = "2026-10-07T12:22:23.651Z" }, + { url = "https://files.pythonhosted.org/packages/1e/74/220106da34502304b6751a2a9b8a9fbca6c3fd47e737a2e2e3da7c61c9db/tomli-2.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:c804ae44fe7b4bab5da295e4f980a1ff04670bca9d23fe0a4e887e08ebd741a8", size = 112742, upload-time = "2026-10-07T12:22:24.972Z" }, + { url = "https://files.pythonhosted.org/packages/27/99/7d9c8b41837a7773613e169504147375c157a290167aa59ad74a085f521f/tomli-2.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:cfac177ebd6236003846ea339981f71457cb6eb748f23381eb257e45092e3980", size = 109332, upload-time = "2026-10-07T12:22:26.117Z" }, + { url = "https://files.pythonhosted.org/packages/52/ed/7baa86f87493646a594de388c7c1c40a39dd0461f7e9c0359cbeefc91fe8/tomli-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f4a40d03fb9f63424f0979855bdeaf44dd7696b8d59501822c10ed30ba532df", size = 164854, upload-time = "2026-10-07T12:22:27.444Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b1/44c0341f2224397855723c7a8a39f718ea6fcbcc3dacc66e5aeca0f334e3/tomli-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ebf8d19b17bd0daeb7b7dec81a946a439b753942fd0210d6e96c532249eea6b", size = 164074, upload-time = "2026-10-07T12:22:28.679Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e2d5b7d3fba47adedb23de616c16d428ea076c79a3d8e1d95d649ffe197e/tomli-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf0b5e8e0f68ebb494356e577c06c139161efd8d3b9050f93b39b7c26cc54ff0", size = 274274, upload-time = "2026-10-07T12:22:29.804Z" }, + { url = "https://files.pythonhosted.org/packages/43/90/6090e706ff27a6f89f4a40578e3324b95c3cd8c4150868aabf33a8f414c3/tomli-2.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cf74416bdc94ae458b14e37286c1073081850ac8459a00d0c5efef5d44294c6", size = 286435, upload-time = "2026-10-07T12:22:31.297Z" }, + { url = "https://files.pythonhosted.org/packages/0a/9e/a2c40768df16c408f22430afb0a73e9d7e5f79c950884954649d1146b74d/tomli-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:61ea1ebe1e55a34ea8199cc8dbff398d35027b82271c8ac4802fd3a1fd5b1bcc", size = 278119, upload-time = "2026-10-07T12:22:32.601Z" }, + { url = "https://files.pythonhosted.org/packages/12/25/3c0cb485b98e9cfac495629b1c93c87ccf0b72fbe9d2689fd8fe62c6d5a3/tomli-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed53f7e89bb04f6d9e8e7799112360b0c4d5cbff067de0814c98c37c39b920f7", size = 286177, upload-time = "2026-10-07T12:22:33.745Z" }, + { url = "https://files.pythonhosted.org/packages/77/8b/0144c65f0e37e51c18d04ae15c21b19431c165002d0131fe9aa8b0b8b1e8/tomli-2.5.0-cp312-cp312-win32.whl", hash = "sha256:e7ad033e27a516a233bea839cdb77b80146facb3b4f40bf02cd0cac165cdd5c2", size = 102760, upload-time = "2026-10-07T12:22:34.887Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/5d6d8f42fc9a05fce69354e00ff256484192f5f2fc9a2165718fa0de61ec/tomli-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd05de8c1698f8413dd7d869492693a0bf2211543b787ac78cd5e7536af1a6d7", size = 112722, upload-time = "2026-10-07T12:22:36.162Z" }, + { url = "https://files.pythonhosted.org/packages/30/65/df18032218db0fb9b769fb23c8039a051f15c811993995ea04c350273a32/tomli-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:069435bd5480429b98c5e5afb02ab21c219b6f0064680671c6dc0d46817346ea", size = 109534, upload-time = "2026-10-07T12:22:37.296Z" }, + { url = "https://files.pythonhosted.org/packages/42/e5/51736d70da209350969e15aca5c5ab6e2ce1ea87a0a892a6c13aec172a86/tomli-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:943276cf269e0071948d9ff697159c1735e623c1151d88abb09b74659ef0cbea", size = 163328, upload-time = "2026-10-07T12:22:38.373Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/086f80dab4ab497602644274e6dea7ec5dd0b4e262e443a8ad3bb7edee2d/tomli-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:463b16086865b97facd8d0b3fb4cb7c544e3f58d2a69dc3113d6db9653fdb043", size = 162246, upload-time = "2026-10-07T12:22:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/aa/eb/3ecc94459f3635c92321f4e7bde571323fdb2267c50e19e3188a281eae3b/tomli-2.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1245a6638fc4bb0a60af38a7d45413db34a13842027c77597c712c998c62fdf0", size = 272655, upload-time = "2026-10-07T12:22:41.08Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d7/494fd1f0c37a621f1ad9975c2efadb523e8101f144ed6edb2e7fe64738f2/tomli-2.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d8bac3d603c97e6854424e5b2b5b741bdbde387e09f162fb0446812b4a8362b", size = 283595, upload-time = "2026-10-07T12:22:42.222Z" }, + { url = "https://files.pythonhosted.org/packages/70/51/bb8d62b1317e6640866f6949b2d5855e5300f2c99d46de1cd245570bba65/tomli-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:21e4cae4114aba25aa0d4f85cdf486d290fb35c0954d7bba536248da64d43066", size = 276253, upload-time = "2026-10-07T12:22:43.625Z" }, + { url = "https://files.pythonhosted.org/packages/66/f4/f46bd7f0763cd47de2db697dca9257c6a4adfd1a93b018cc75c8190ed5a8/tomli-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bbaefc84548d754be821bba7c4141c4787dda182f9e77f2f87b71213529efa7b", size = 283582, upload-time = "2026-10-07T12:22:44.983Z" }, + { url = "https://files.pythonhosted.org/packages/ac/03/70f2bcb2923a6db37818d917e124270a7f4cfd38ea576f5aa753a91c0ef5/tomli-2.5.0-cp313-cp313-win32.whl", hash = "sha256:abdbf6313b8d9efe157edeb7ab6eae4de064b1300ad31abf73755154b30abe68", size = 102628, upload-time = "2026-10-07T12:22:46.508Z" }, + { url = "https://files.pythonhosted.org/packages/dc/98/d52024bb5b0ff68b4f0d276d867f634c84a67319a7e9f6b7708a37742333/tomli-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd4dc129784e0c5335bd4e61dfcc4487499a013419e655cf2da1d091b7e0efdc", size = 113301, upload-time = "2026-10-07T12:22:47.647Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f2/540db3a70572a8c23a28aba3e9c358ce0ffffbafc990905c1343aa265b31/tomli-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:69491c143d2fe063046e0301e62a810bed338fa4d1ce0fd870c27dc1e09b0d84", size = 109744, upload-time = "2026-10-07T12:22:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/e4/49/caf6b307766eb9567664a8707e9d6be5fcc0e8903f18781c6677a60d80c7/tomli-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d3182ee2d887e507bd67319a0a61105d1dd33facc111329559a233b772c1a105", size = 162899, upload-time = "2026-10-07T12:22:50.088Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c8/68cfce773a2733a49c74f99d627fb461bd990756860099eac25617889585/tomli-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:521345fd1f19d45b8df87657aaa38b6f2ca3800059fadf428e7ebf479a383646", size = 162080, upload-time = "2026-10-07T12:22:51.558Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b2/e5bb8651fdad593f670501a7d718b1a7f73f064d44dea15e04c04dfef45d/tomli-2.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95c7614e705bfe2b04b27aa124adec59752d15813df37e2156747cab3a006b", size = 273380, upload-time = "2026-10-07T12:22:52.918Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d2/9e2d7f8b1dfe0e2b34c245986ebd55c4c553ea4ce6c47c443b332673253f/tomli-2.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac2027d37c3afbdf4bdd377f2676f6f1d2122a5be1f1137b49dced590b37e75", size = 283228, upload-time = "2026-10-07T12:22:54.173Z" }, + { url = "https://files.pythonhosted.org/packages/ba/df/ec7b876b7b1a2718bd74a3743c076fff565b04029ba33e8f61fac262739f/tomli-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c414be4ed9d3cac80c42e348fa5a956117d1a48227f48026e31f59cb4a7671eb", size = 277189, upload-time = "2026-10-07T12:22:55.342Z" }, + { url = "https://files.pythonhosted.org/packages/7d/7b/e192d9eed0b9cb80da799f4d77052297fb9a2c3cc9b19f571f56ea88add6/tomli-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9b03d7dc168353b4132965bde20feceabaa470e570c6f59660dfae59b1f9eeb3", size = 283632, upload-time = "2026-10-07T12:22:56.735Z" }, + { url = "https://files.pythonhosted.org/packages/84/50/ff94454e75461d75623e47401ed323d65c10aab8fe9033242c20cd2fdf32/tomli-2.5.0-cp314-cp314-win32.whl", hash = "sha256:6f041843c4d3a37245c0c056fd955b186bf8b1fb85690cbe40b81230891dc34b", size = 103535, upload-time = "2026-10-07T12:22:58.084Z" }, + { url = "https://files.pythonhosted.org/packages/54/0b/bdacf05f963bd6026ebf6eeb0beda847d1d60e03e440725c64a4e08a0afd/tomli-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:f4b653094e18f9031102d3a1da5c729c8f222d85225b18037dac621695e46e1a", size = 114621, upload-time = "2026-10-07T12:22:59.2Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/53f438fa6ae4f9d4ed0ddde3e7242b3bdc34b48c8f9948b72b9e9b127676/tomli-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3f89d10c1ff6a38d992c27fc8a4816af71a909e08a40ec66934240b1e74347c3", size = 111572, upload-time = "2026-10-07T12:23:00.479Z" }, + { url = "https://files.pythonhosted.org/packages/b9/20/1f88f19427d380a40e90a770e087489eaafe4aeee070ae88ed2bbec00acd/tomli-2.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e9e15b4a6c7dd6b85b5fbab29488a73f1f70de516942308daa266bf0e0aeb0d4", size = 171814, upload-time = "2026-10-07T12:23:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d0/56/cbe5079c9f9a54b9b3e27fc82f08f3cb36edee75561679f53d2380c801d6/tomli-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e12bbcd32897272fb05929110362ae9ff4c1b9bb26bd9e971e71dcd3275b4c3d", size = 171324, upload-time = "2026-10-07T12:23:03.18Z" }, + { url = "https://files.pythonhosted.org/packages/2b/30/1d53fd3b0f1cb3ba542e345ec32c26aefdddc4e829e4f3429af8a4f27782/tomli-2.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20aa36de8f2cf87237143bc1fa1aae8d6612c09118f4da21c6a684db5dd1f6f9", size = 297441, upload-time = "2026-10-07T12:23:04.345Z" }, + { url = "https://files.pythonhosted.org/packages/66/d9/0800acb6a111686f764c1b91ef15cc42a20a66a46013bb42220f1d2c61c1/tomli-2.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22185fad8a1e622f064e78008018a0dd3323550dcb479cb7a1d296888d74024f", size = 307476, upload-time = "2026-10-07T12:23:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/e8/63/30a8f3cd51b5bec37f04744bad0b0dc6160df84aad4f27b0e9283d66f221/tomli-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:984012f71908165449a951de2050d52f276bfe3aa5d5f570f63ddad814370374", size = 296113, upload-time = "2026-10-07T12:23:07.202Z" }, + { url = "https://files.pythonhosted.org/packages/ab/18/0b9ffc597e69c5a1e20a7823cb60d54b39a9f54e91edcb8574f022186758/tomli-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f79203b3965b4000e91808aaa7c040206093f2b8bf86f455982f2274c9ccf442", size = 307725, upload-time = "2026-10-07T12:23:08.508Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c7/18f8baae0b5607a60e8e19b4a7fedee43a8ff6458e3896dcbbadeeac9c22/tomli-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:91294a9fb94a75542f6e46e4a2ae709bd8d9b51134098cae5cf3bea5478b6d03", size = 108546, upload-time = "2026-10-07T12:23:09.956Z" }, + { url = "https://files.pythonhosted.org/packages/72/34/4cca9739254130627bde87500b3f2b512154fe2f278efa7e2a5e10ad4bcb/tomli-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f15e3e0b835a6d68b10c86bf80a3149780498d6911c93c3ffd1861d19f9200f1", size = 117814, upload-time = "2026-10-07T12:23:11.486Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fb/afa530d47dd80a78fce43beac6bc6e00f84558eafcffbc6f37b21e80d056/tomli-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6664b7ae7af7294256c53960a6103077f4914cec8ff98479c352f622c6f6b2f0", size = 115188, upload-time = "2026-10-07T12:23:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/66/98/316fdc00f8c0939e6fe50461dd343c162d3ad51d1286eb25b7db54361d50/tomli-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a525685c2f97da40762b8695eb7aa0af4c8344ca1905c73e4e29cb04d34607dc", size = 162775, upload-time = "2026-10-07T12:23:13.941Z" }, + { url = "https://files.pythonhosted.org/packages/c5/22/7b10fa5bb01c9539f53f69b619361b19350acc73657772ea7ac70ba309a8/tomli-2.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276", size = 161406, upload-time = "2026-10-07T12:23:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e7/1a069d86dfd20f1f84f71c63faed9f83c1d890bc06c27d82dc7d888fb573/tomli-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52", size = 273855, upload-time = "2026-10-07T12:23:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/ae/83/d1ef43d1687d092ab9c235455c76e6e709483b346b056f086095c7c263a5/tomli-2.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7", size = 284910, upload-time = "2026-10-07T12:23:18.166Z" }, + { url = "https://files.pythonhosted.org/packages/cc/05/f4d9cf7de61822ece0c3873f30d291e324911c71a378b8bfe5ced13fd9f5/tomli-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:19b0dd8749f4ea2f112c5fcfb3c5248390c899d7e2e173f1d91abee1fa0ff391", size = 277723, upload-time = "2026-10-07T12:23:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/42/28/78262493141fa543151cf005760c3cb01d09fc28a11f993c05109902cb8c/tomli-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:57b1c3b01fab802e2899bc3d168dca320e14165e2fd9fd584760fb4ca5826859", size = 285115, upload-time = "2026-10-07T12:23:20.698Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b9/e1dab9a30bcb677b5cc5cee810609cfd64f24306a3055767dd3fda00b1e0/tomli-2.5.0-cp315-cp315-win32.whl", hash = "sha256:667e521b37a6c5ccaa044202c235b530f90177ffe2cd4a64ecc213c7dd535feb", size = 103475, upload-time = "2026-10-07T12:23:21.941Z" }, + { url = "https://files.pythonhosted.org/packages/4c/bd/31a3790c11d6ea95fcf5e6022ac0f8d0543c9b61120b730fc481bd43d3b4/tomli-2.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5", size = 114589, upload-time = "2026-10-07T12:23:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/4f6310fa699364f0e3af7ee3af88dddd9af066d33e716a0265bbe2b3ea84/tomli-2.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:75dbcde8751b0a960aa3de173aa5e894d590755c6d7758b7e774c06f1dc3cbdd", size = 111493, upload-time = "2026-10-07T12:23:24.233Z" }, + { url = "https://files.pythonhosted.org/packages/68/14/00853f0b396d8971107ae1921bb5b322fdee1650d2f16bf06c20adb532e5/tomli-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2419c2a189551987b59d80e63ec355671283336f41c6b9b89462df679c7d0c57", size = 171380, upload-time = "2026-10-07T12:23:25.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/ad/fa6949321dadee46b27363974fb197b94c911c3b0f7a5fd26d7dc18fc2a0/tomli-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0dc598040da8d42cf20f0be588ed7004f46db12a0ac6c32e03a59dccedaaadcd", size = 170553, upload-time = "2026-10-07T12:23:26.855Z" }, + { url = "https://files.pythonhosted.org/packages/53/aa/3056c919eb3e084df3752b2cf5f865dcc04af0b27dba2f66d7b28af4633a/tomli-2.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49096930c8d886c9bbdab62d2d0d17ce823ddeea522309a190b36245d5b49e01", size = 294428, upload-time = "2026-10-07T12:23:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/faeeb5d8769ea3832021d73e892c8391eae7b4b4f8b55a789127bd8b18a9/tomli-2.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8ade5023067f99fe72b88accd30d0ea05a158e9e32a11f124e731ea9695313f", size = 304909, upload-time = "2026-10-07T12:23:29.381Z" }, + { url = "https://files.pythonhosted.org/packages/f6/52/f094c09e73fb654b621716d019acb5d29bdfd1be01df80c281d552bda48d/tomli-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b69564772b5c8f22ea5f498dff08cfa825045b4d4c4400529000bdf818aa3b2a", size = 293220, upload-time = "2026-10-07T12:23:30.608Z" }, + { url = "https://files.pythonhosted.org/packages/86/f5/0c30541078ca4b505ce3bd76ed931facbfec524dd018535d691d1af0a6d2/tomli-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8ff3a2ca028c7eee0c777f9a092038d0a594a9fa04e215f929a22c329e2cb142", size = 305705, upload-time = "2026-10-07T12:23:32.181Z" }, + { url = "https://files.pythonhosted.org/packages/05/74/590e7d19d6a118fc5cc5704ff358e21d95b8573f6b9443b1519f29ca8825/tomli-2.5.0-cp315-cp315t-win32.whl", hash = "sha256:62fc1bc8eb03e3a9cadfca713d65614ed8e09d974a283295ffe3a831976b4dc5", size = 108432, upload-time = "2026-10-07T12:23:33.496Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b8/63a75cfb27a17c38550e44025d3a6e7be64516fd8608a3b75703bf37d81b/tomli-2.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f3fcbc57b1791fa6cbe5d8434179d51de12be1a4811469529f47f6e7487a2571", size = 117281, upload-time = "2026-10-07T12:23:34.648Z" }, + { url = "https://files.pythonhosted.org/packages/72/01/e8c1debb2173973372934c68fc8e46170ab60ef23ed4592dff4dec6e8993/tomli-2.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d2ba24db8a9376921b5e87b4762b9adb0f3f1deaea68f2b8b0bb2c11efb9c3e7", size = 115069, upload-time = "2026-10-07T12:23:35.77Z" }, + { url = "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl", hash = "sha256:32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b", size = 14765, upload-time = "2026-10-07T12:23:36.875Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] diff --git a/python-client/wmill/wmill/client.py b/python-client/wmill/wmill/client.py index 824db9741f..f0d0134fa7 100644 --- a/python-client/wmill/wmill/client.py +++ b/python-client/wmill/wmill/client.py @@ -872,15 +872,75 @@ class Windmill: """ return self.get("version").text + def _s3_proxy_connection(self) -> dict | None: + """Where an S3 client reaches the workspace storage through Windmill's S3 proxy, so the + storage's permission rules apply and its credentials stay on the server. + + None when the workspace storage is not S3, when the token is not one the server signs + itself (the proxy re-derives the SigV4 secret from a ``jwt_`` token's header and payload, + which an external ``jwt_ext_`` token's signature does not match), or when the server + predates the proxy's bucket-addressed routes: the caller then connects with the + storage's own credentials. + """ + if ( + not self.token + or not self.token.startswith("jwt_") + or self.token.startswith(("jwt_ext_", "jwt_agent_")) + ): + return None + resp = self.get( + f"/w/{self.workspace}/job_helpers/v2/s3_proxy_bucket", + raise_for_status=False, + ) + if resp.status_code != 200: + return None + bucket = resp.json().get("bucket") + if bucket is None: + return None + access_key, secret_key = self.token.rsplit(".", 1) + return { + "endpoint_url": f"{self.base_url}/w/{self.workspace}/s3_proxy/_wm_bucket_/_default_", + "use_ssl": self.base_url.startswith("https://"), + "access_key": access_key, + "secret_key": secret_key, + # The proxy checks the signature with whatever region the client signed for. + "region": "us-east-1", + "bucket": bucket, + } + def get_duckdb_connection_settings( self, s3_resource_path: str = "", ) -> DuckDbConnectionSettings | None: """ Convenient helpers that takes an S3 resource as input and returns the settings necessary to - initiate an S3 connection from DuckDB + initiate an S3 connection from DuckDB. + + Without a resource path, the connection goes to the workspace storage through Windmill's + S3 proxy when that storage is S3: address it as ``s3://<bucket>/<key>`` with the + storage's own bucket, returned as ``s3_bucket``. Pass the resource path to connect with + the storage's own credentials instead. """ s3_resource_path = parse_resource_syntax(s3_resource_path) or s3_resource_path + proxy = self._s3_proxy_connection() if s3_resource_path == "" else None + if proxy is not None: + endpoint = proxy["endpoint_url"].split("://", 1)[1] + settings = ( + "SET home_directory='./';\n" + "INSTALL 'httpfs';\n" + "SET s3_url_style='path';\n" + f"SET s3_region='{proxy['region']}';\n" + f"SET s3_endpoint='{endpoint}';\n" + f"SET s3_access_key_id='{proxy['access_key']}';\n" + f"SET s3_secret_access_key='{proxy['secret_key']}';\n" + ) + if not proxy["use_ssl"]: + settings += "SET s3_use_ssl=0;\n" + if self.verify is False: + settings += "SET enable_server_cert_verification=false;\n" + return DuckDbConnectionSettings( + {"connection_settings_str": settings, "s3_bucket": proxy["bucket"]} + ) try: raw_obj = self.post( f"/w/{self.workspace}/job_helpers/v2/duckdb_connection_settings", @@ -888,6 +948,7 @@ class Windmill: if s3_resource_path == "" else {"s3_resource_path": s3_resource_path}, ).json() + raw_obj.setdefault("s3_bucket", None) return DuckDbConnectionSettings(raw_obj) except JSONDecodeError as e: raise Exception( @@ -900,9 +961,44 @@ class Windmill: ) -> PolarsConnectionSettings: """ Convenient helpers that takes an S3 resource as input and returns the settings necessary to - initiate an S3 connection from Polars + initiate an S3 connection from Polars. + + Without a resource path, the connection goes to the workspace storage through Windmill's + S3 proxy when that storage is S3: address it as ``s3://<bucket>/<key>`` with the + storage's own bucket, returned as ``s3_bucket``. Pass the resource path to connect with + the storage's own credentials instead. """ s3_resource_path = parse_resource_syntax(s3_resource_path) or s3_resource_path + proxy = self._s3_proxy_connection() if s3_resource_path == "" else None + if proxy is not None: + client_kwargs = {"region_name": proxy["region"]} + storage_options = { + "aws_endpoint_url": proxy["endpoint_url"], + "aws_access_key_id": proxy["access_key"], + "aws_secret_access_key": proxy["secret_key"], + "aws_region": proxy["region"], + "aws_allow_http": str(not proxy["use_ssl"]).lower(), + "aws_virtual_hosted_style_request": "false", + } + if self.verify is not True: + client_kwargs["verify"] = self.verify + if self.verify is False: + storage_options["allow_invalid_certificates"] = "true" + return PolarsConnectionSettings( + { + "s3fs_args": { + "endpoint_url": proxy["endpoint_url"], + "key": proxy["access_key"], + "secret": proxy["secret_key"], + "use_ssl": proxy["use_ssl"], + "cache_regions": False, + "client_kwargs": client_kwargs, + "config_kwargs": {"s3": {"addressing_style": "path"}}, + }, + "storage_options": storage_options, + "s3_bucket": proxy["bucket"], + } + ) try: raw_obj = self.post( f"/w/{self.workspace}/job_helpers/v2/polars_connection_settings", @@ -910,6 +1006,7 @@ class Windmill: if s3_resource_path == "" else {"s3_resource_path": s3_resource_path}, ).json() + raw_obj.setdefault("s3_bucket", None) return PolarsConnectionSettings(raw_obj) except JSONDecodeError as e: raise Exception( @@ -922,9 +1019,30 @@ class Windmill: ) -> Boto3ConnectionSettings: """ Convenient helpers that takes an S3 resource as input and returns the settings necessary to - initiate an S3 connection using boto3 + initiate an S3 connection using boto3. + + Without a resource path, the connection goes to the workspace storage through Windmill's + S3 proxy when that storage is S3: pass the storage's own bucket as ``Bucket``. The proxy + serves object reads, writes, deletes, listing and multipart uploads of that bucket only; + presigned URLs, other buckets and bucket-level APIs need the storage's own credentials, + which passing the resource path returns. """ s3_resource_path = parse_resource_syntax(s3_resource_path) or s3_resource_path + proxy = self._s3_proxy_connection() if s3_resource_path == "" else None + if proxy is not None: + # botocore addresses a custom endpoint path-style, which the proxy's routes need. + settings = Boto3ConnectionSettings( + { + "endpoint_url": proxy["endpoint_url"], + "region_name": proxy["region"], + "use_ssl": proxy["use_ssl"], + "aws_access_key_id": proxy["access_key"], + "aws_secret_access_key": proxy["secret_key"], + } + ) + if self.verify is not True: + settings["verify"] = self.verify + return settings try: s3_resource = self.post( f"/w/{self.workspace}/job_helpers/v2/s3_resource_info", diff --git a/python-client/wmill/wmill/s3_types.py b/python-client/wmill/wmill/s3_types.py index 1b43b65972..058eab68fe 100644 --- a/python-client/wmill/wmill/s3_types.py +++ b/python-client/wmill/wmill/s3_types.py @@ -57,6 +57,7 @@ class PolarsConnectionSettings(dict): """Polars S3 connection settings containing S3FS args and storage options.""" s3fs_args: S3FsArgs storage_options: StorageOptions + s3_bucket: Optional[str] def __getattr__(self, attr): return _item_as_attr(self, attr) @@ -78,6 +79,7 @@ class Boto3ConnectionSettings(dict): class DuckDbConnectionSettings(dict): """DuckDB S3 connection settings as a configuration string.""" connection_settings_str: str + s3_bucket: Optional[str] def __getattr__(self, attr): return _item_as_attr(self, attr) diff --git a/rust-client/src/client.rs b/rust-client/src/client.rs index bf47e7e95a..ea3d92bf28 100644 --- a/rust-client/src/client.rs +++ b/rust-client/src/client.rs @@ -412,6 +412,7 @@ impl Windmill { resource_type: resource_type.to_owned(), labels: None, ws_specific: None, + preserve_on_behalf_of: None, }, // Some(true), None, @@ -429,6 +430,7 @@ impl Windmill { resource_type: Some(resource_type.to_owned()), labels: None, ws_specific: None, + preserve_on_behalf_of: None, }, ) .await?; diff --git a/system_prompts/README.md b/system_prompts/README.md index a651c48692..32c0c521c4 100644 --- a/system_prompts/README.md +++ b/system_prompts/README.md @@ -10,6 +10,10 @@ are built from it: `cli/src/guidance/skills.gen.ts`, next to the `AGENTS.wmill.md` template in `cli/src/guidance/core.ts`. +The workspace MCP server (`backend/windmill-api/src/mcp/session/`) is a third reader, one step +removed: it serves the global chat's own `get_instructions` texts and system prompt, rendered for +an external client by `npm run gen:mcp-session` in `frontend/`. Rerun that after `generate.py`. + Guidance written in one consumer only is guidance the other never gets. Write it here. ## Structure @@ -47,6 +51,7 @@ a CLI-only workflow file (`flow-cli.md`, `script-cli.md`, `raw-app-cli.md`). | workflow-as-code | `getWorkflowAsCodePrompt(lang)` | `write-workflow-as-code` | `workflow-as-code.md`, the WAC SDK | | pipeline | `getPipelinePrompt()` | `write-pipeline` | `pipeline-base.md` | | data tables | `getDatatablePrompt()` | `datatables` | `datatable.md` | +| workspace settings | `getWorkspaceSettingsPrompt()` | `workspace-settings` | `workspace-settings.md` | | triggers, schedules, preview, CLI commands | — | same names | CLI only | The app chat embeds `RAW_APP_BASE` directly (`chat/app/core.ts`). diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index a1daf47e32..c2d1c39ab9 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -84,8 +84,10 @@ datatable related commands - `datatable migrate` - manage datatable migrations - `datatable migrate new <name:string>` - scaffold a new migration (.up.sql / .down.sql files) - `-d --datatable <datatable:string>` - Target datatable (default: main) + - `-s --schemas <schemas:string>` - Comma-separated schemas the migration changes, written as its `-- schemas` line. Without it the migration applies to the whole database and every later migration waits for it - `datatable migrate up` - apply all pending migrations to the main datatable (or one via --datatable) - `-d --datatable <datatable:string>` - Target datatable (default: main) + - `--schema <schema:string>` - Apply only the pending migrations that declare this schema; refused while an earlier overlapping migration is pending - `datatable migrate down` - roll back the most recent migration on the main datatable (or one via --datatable) - `-d --datatable <datatable:string>` - Target datatable (default: main) - `datatable migrate status` - show applied and pending migrations on the main datatable (or one via --datatable) @@ -181,6 +183,7 @@ flow related commands - `--remote` - Use deployed workspace scripts for PathScript steps instead of local files. - `--step <step_id:string>` - Run only the named step instead of the whole flow. Honors --data as the step's args and --remote / local-PathScript resolution the same way the full-flow preview does. - `--tag <tag:string>` - Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the flow's default tag). + - `--on-behalf-of <identity:string>` - Run the preview as this user or group (an email, u/<username> or g/<group>) instead of yourself, e.g. to reproduce a run_on_behalf_of identity. Requires being an admin or a member of wm_deployers. - `flow new <flow_path:string>` - create a new empty flow - `--summary <summary:string>` - flow summary - `--description <description:string>` - flow description @@ -579,6 +582,7 @@ script related commands - `-d --data <data:file>` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-. A resource argument is the bare string $res:<path> as its whole value, and a variable argument is the bare string $var:<path> — not an object wrapper keyed on $res/$var, and not a plain path. - `-s --silent` - Do not output anything other than the final output. Useful for scripting. - `--tag <tag:string>` - Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the script's default tag). + - `--on-behalf-of <identity:string>` - Run the preview as this user or group (an email, u/<username> or g/<group>) instead of yourself, e.g. to reproduce a run_on_behalf_of identity. Requires being an admin or a member of wm_deployers. - `script new <path:file> <language:string>` - create a new script - `--summary <summary:string>` - script summary - `--description <description:string>` - script description @@ -616,6 +620,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--include-users` - Include syncing users - `--include-groups` - Include syncing groups - `--include-settings` - Include syncing workspace settings + - `--include-datatable-permissions` - With --include-settings, also sync who may connect to each data table as which role - `--include-key` - Include workspace encryption key - `--keep-deleted` - Do not delete local files for items that no longer exist on the remote workspace. Only adds and updates. - `--skip-branch-validation` - Skip git branch validation and prompts @@ -647,6 +652,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--include-users` - Include syncing users - `--include-groups` - Include syncing groups - `--include-settings` - Include syncing workspace settings + - `--include-datatable-permissions` - With --include-settings, also sync who may connect to each data table as which role - `--include-key` - Include workspace encryption key - `--skip-reencrypt-on-key-change` - When the pushed encryption key differs from the remote, do NOT re-encrypt existing remote secrets. Only safe if they are already encrypted with the new key (e.g. workspace/instance migration). Default is to re-encrypt. - `--keep-deleted` - Do not delete remote items that no longer exist locally. Only adds and updates. @@ -840,6 +846,13 @@ Arguments (omit both to be prompted interactively): - `--team-id <team_id:string>` - Slack team id - `--team-name <team_name:string>` - Slack team name - `workspace disconnect-slack` - Clear slack_team_id / slack_name on the active workspace (marks the workspace as disconnected). Does NOT remove the bot token variable/resource/folder/group — delete those from the local sync folder and run 'wmill sync push' to tear them down. Does NOT remove the workspace-level OAuth override — set slack_oauth_client_id/_secret to '' in settings.yaml and push. +- `workspace get-settings` - Print the active workspace's settings in the layout of the settings.yaml that 'wmill sync' writes. Secrets show as __SENSITIVE_AND_UNCHANGED__ unless --encrypted. Admin only. + - `--encrypted` - Write secrets encrypted with the workspace key, as 'wmill sync pull' does + - `--json` - Output as JSON +- `workspace apply-settings <file:string>` - Apply a settings document (YAML or JSON, in the layout of settings.yaml) to the active workspace. Only the settings the file states are changed, unless --replace; a setting set to null is cleared. A secret left as __SENSITIVE_AND_UNCHANGED__ keeps its value. Shows the changes and asks before applying. Admin only. + - `--dry-run` - Only show the changes + - `--replace` - Also clear the settings the file omits, as 'wmill sync push' does + - `-y --yes` - Apply without asking diff --git a/system_prompts/auto-generated/flow.md b/system_prompts/auto-generated/flow.md index e5dd862a3b..b6ba8de664 100644 --- a/system_prompts/auto-generated/flow.md +++ b/system_prompts/auto-generated/flow.md @@ -149,6 +149,10 @@ needs becomes unreachable. references. A spread, a call or a computed key leaves the composer unable to tell which input feeds which field, so it offers no control at all — a bare `flow_input.x` for the whole object is read instead as that one input carrying every field +- A step linked to a saved agent (`agent` set) has no `provider` of its own. It exposes the same + two controls through its `model` and `reasoning_effort` inputs, which replace what the agent + saved: `"model": { "type": "javascript", "expr": "flow_input.model" }`. An empty model keeps the + saved one, an empty effort sends none, and the provider resource always stays the agent's - `memory` is what lets the agent see earlier turns; without it every message starts from nothing - `streaming` on makes the answer and its thinking appear token by token instead of all at once - `user_attachments` points at a flow input typed as an array of s3 objects @@ -582,6 +586,14 @@ In the flow schema, set the property type to `"object"` with format `"resource-{ } ``` +### When No Resource Type Fits + +A 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_<name>` 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. + +- 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. +- A plain variable is fine when the user asks for it. + ### As Step Input (Static Reference) Reference a specific resource using `$res:` prefix: @@ -598,4 +610,4 @@ Reference a specific resource using `$res:` prefix: ## OpenFlow Schema -{"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-<type>' (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":"JavaScript expression to return early from the flow"},"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.<step_id>'"}},"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/<flow_path>-<step_id>"},"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.<its_own_id>' 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"}}},"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).\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: { <question name>: answer },\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\nthe answers, follow it with a branchone whose branch exprs read\nresults.<id>.output.<question>.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"]}} \ No newline at end of file +{"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-<type>' (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.<step_id>'"}},"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/<flow_path>-<step_id>"},"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.<its_own_id>' 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: { <question name>: answer },\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\nthe answers, follow it with a branchone whose branch exprs read\nresults.<id>.output.<question>.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"]}} \ No newline at end of file diff --git a/system_prompts/auto-generated/index.d.ts b/system_prompts/auto-generated/index.d.ts index 8ba83f7f74..fee83d38b5 100644 --- a/system_prompts/auto-generated/index.d.ts +++ b/system_prompts/auto-generated/index.d.ts @@ -5,5 +5,6 @@ export declare function getResourcePrompt(): string; export declare function getRawAppPrompt(language?: string): string; export declare function getPipelinePrompt(): string; export declare function getDatatablePrompt(): string; +export declare function getWorkspaceSettingsPrompt(): string; export declare function getDatatableSdkReference(language?: string): string; export declare function getWorkflowAsCodePrompt(language?: string): string; diff --git a/system_prompts/auto-generated/index.ts b/system_prompts/auto-generated/index.ts index 60bb79b031..da3b622046 100644 --- a/system_prompts/auto-generated/index.ts +++ b/system_prompts/auto-generated/index.ts @@ -80,6 +80,13 @@ export function getDatatablePrompt(): string { ].filter(Boolean).join('\n\n'); } +// Helper for the workspace settings document (chat consumers) +export function getWorkspaceSettingsPrompt(): string { + return [ + prompts.WORKSPACE_SETTINGS_BASE + ].filter(Boolean).join('\n\n'); +} + // Helper to get the datatable SQL SDK reference (wmill.datatable()). // Pass a language to get only that SDK; omit it to get both. export function getDatatableSdkReference(language?: string): string { diff --git a/system_prompts/auto-generated/prompts.d.ts b/system_prompts/auto-generated/prompts.d.ts index cfdde52495..5cb7e336b3 100644 --- a/system_prompts/auto-generated/prompts.d.ts +++ b/system_prompts/auto-generated/prompts.d.ts @@ -6,6 +6,7 @@ export declare const RESOURCES_BASE: string; export declare const RAW_APP_BASE: string; export declare const PIPELINE_BASE: string; export declare const DATATABLE_BASE: string; +export declare const WORKSPACE_SETTINGS_BASE: string; export declare const WORKFLOW_AS_CODE_BASE: string; export declare const FLOW_CHAT_SPECIAL_MODULES: string; export declare const SDK_TYPESCRIPT: string; diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 226e1d89c0..b97fc5c3d9 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -5,6 +5,8 @@ export const SCRIPT_BASE = `# Windmill Script Writing Guide ## General Principles - 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 +- 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_<name>\`, which keeps it apart from a hub type synced later: the parameter takes \`RT.C<Name>\` in TypeScript, a \`c_<name>\` 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 +- 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 - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (\`wmill\`), use it to interact with the platform @@ -176,6 +178,10 @@ needs becomes unreachable. references. A spread, a call or a computed key leaves the composer unable to tell which input feeds which field, so it offers no control at all — a bare \`flow_input.x\` for the whole object is read instead as that one input carrying every field +- A step linked to a saved agent (\`agent\` set) has no \`provider\` of its own. It exposes the same + two controls through its \`model\` and \`reasoning_effort\` inputs, which replace what the agent + saved: \`"model": { "type": "javascript", "expr": "flow_input.model" }\`. An empty model keeps the + saved one, an empty effort sends none, and the provider resource always stays the agent's - \`memory\` is what lets the agent see earlier turns; without it every message starts from nothing - \`streaming\` on makes the answer and its thinking appear token by token instead of all at once - \`user_attachments\` points at a flow input typed as an array of s3 objects @@ -609,6 +615,14 @@ In the flow schema, set the property type to \`"object"\` with format \`"resourc } \`\`\` +### When No Resource Type Fits + +A 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_<name>\` 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. + +- 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. +- A plain variable is fine when the user asks for it. + ### As Step Input (Static Reference) Reference a specific resource using \`$res:\` prefix: @@ -648,6 +662,12 @@ Resources store credentials and configuration for external services. - \`value\` - Object containing the resource configuration - \`resource_type\` - Name of the resource type (e.g., "postgresql", "slack") +## Agents + +An agent, as the Agents page lists it, is a resource of type \`ai_agent\`. Its value holds the +agent's provider, system prompt, tools and memory settings, and a flow step uses a saved agent by +linking its path. + ## Variable References Reference variables in resource values: @@ -827,13 +847,23 @@ The prefix must be on the string itself. } \`\`\` +## When No Resource Type Fits + +The Windmill Hub publishes resource types for most services, and an instance gets them by syncing with the hub. When none fits the service: + +- 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. +- 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. +- 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. + ## Custom Resource Types +A 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. + Create custom resource types with JSON Schema: \`\`\`json { - "name": "custom_api", + "name": "c_custom_api", "schema": { "type": "object", "properties": { @@ -847,7 +877,7 @@ Create custom resource types with JSON Schema: } \`\`\` -Save as: \`custom_api.resource-type.json\` +Save as: \`c_custom_api.resource-type.json\` ## OAuth Resources @@ -1024,7 +1054,7 @@ An app can be demoed by recording a session: every interaction becomes a step ca Apply 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. -### Chat UIs over a flow in chat mode +### Chat UIs over a flow in chat mode or a saved agent A 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. @@ -1037,9 +1067,26 @@ const chat = useWindmillChat({ flowPath: 'f/support/assistant' }) \`@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. +#### A saved agent: an \`agent\` runnable + +Chat 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: + +\`\`\`json +{ "chat": { "type": "agent", "path": "f/support/agent" } } +\`\`\` + +\`\`\`tsx +import { useWindmillChat } from '@windmill-labs/chat-sdk/react' +import * as wmill from './wmill' + +const chat = useWindmillChat({ wmill, runnable: 'chat' }) +\`\`\` + +\`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. + ## Backend runnables -Each runnable has a unique key (used to call it from the frontend) and one of four types: +Each runnable has a unique key (used to call it from the frontend) and one of five types: | Type | What it is | |---|---| @@ -1047,6 +1094,7 @@ Each runnable has a unique key (used to call it from the frontend) and one of fo | \`script\` | Reference to an existing workspace script by path. | | \`flow\` | Reference to an existing workspace flow by path. | | \`hubscript\` | Reference to a hub script by path. | +| \`agent\` | Reference to a saved AI agent by path. Each call is one turn of its chat (see the chat section above). | ### Inline runnables @@ -1075,6 +1123,10 @@ def main(user_id: str): return user \`\`\` +#### Sharing code between runnables + +An 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. + #### The \`wmill\` client is already authenticated An 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. @@ -1083,9 +1135,9 @@ An inline runnable runs as an ordinary Windmill job. \`import * as wmill from 'w Use 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. -### Path runnables (script / flow / hubscript) +### Path runnables (script / flow / hubscript / agent) -When \`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. +When \`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. Before 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. @@ -1094,7 +1146,7 @@ Before writing an inline runnable, look for a workspace script or flow that alre This decides whether an app works before anything is deployed: - **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. -- **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. +- **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. So 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. @@ -1120,7 +1172,10 @@ When a deploy widens who may open the app, tell the user in plain words. ## Sandbox isolation and frontend API access -By default a deployed app's frontend runs with the viewer's own Windmill session. A sandboxed app is isolated from it: the frontend reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. The scopes are \`jobs:run\`, \`jobs:read\`, \`users:read\`, \`resources:read\`, \`variables:read\`, \`flow_conversations:read\` and \`flow_conversations:write\`; grant only the ones the frontend calls. +New 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. + +Inside 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. +You cannot turn isolation off; the user does it in the app's deploy settings. ## Reading data as the viewer @@ -1190,7 +1245,7 @@ def main(user_id: str): 7. **Mark sensitive UI with \`data-wm-no-record\`** — it is what keeps that data out of a recorded demo; passwords are handled for you. 8. **Reach for \`backendAsync\` + \`waitJob\`** for long work — never a hand-written job-polling runnable. 9. **Deploy what a path runnable points at** — a path runnable aimed at a draft fails at runtime; tell the user what needs deploying. -10. **Use \`@windmill-labs/chat-sdk\` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream. +10. **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 }\`. `; export const PIPELINE_BASE = `# Data pipeline authoring @@ -1360,6 +1415,7 @@ Writing a migration: - 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. - Give it a \`down\` that undoes the \`up\` whenever the change is reversible. Without one it cannot be rolled back. +- 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. - Migrations apply oldest first. One that is applied is not edited: correct it with a new migration. - Row data is not schema. Insert, update and delete rows with plain SQL, migrations or not. @@ -1386,6 +1442,66 @@ A data table under roles is reached as a Postgres role, and Postgres enforces wh Read 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. `; +export const WORKSPACE_SETTINGS_BASE = `# Workspace settings + +Everything an admin sets on the Workspace settings page is one document: a mapping of top-level +keys, the same layout as the \`settings.yaml\` file \`wmill sync\` keeps. Only a workspace admin can +read or change it. + +## Applying a document + +- A partial document changes only the keys it states; every key it omits is left as it is. +- Each key is replaced whole, so read its current value and send it back edited: a nested object + given with one field replaces the stored object with that one field. +- A key set to \`null\` clears that setting; an on/off switch cleared this way reads as \`false\`. +- Each setting is written through the same path the settings page uses, with the same validation + and audit log. The writes are not one transaction: when one fails the apply stops, and the + error says what was already written. +- A dry run reports the changes, as \`before\` / \`after\` per key, without writing anything. +- \`name\` is never applied: rename a workspace on the settings page. \`color\` is applied only when + set to a value, so \`null\` does not clear it. +- A key the server does not know is ignored and named in the warnings: check them, since a typo + otherwise reads as "nothing to change". +- A setting this edition does not offer (Enterprise only) is refused, by the dry run too. + +## Secrets + +\`slack_oauth_client_secret\` is the one secret. A read shows \`__SENSITIVE_AND_UNCHANGED__\` in place +of a set secret; sending that placeholder back keeps the stored value. A value starting with +\`$encrypted:\` is decrypted with the workspace key, and one encrypted with another workspace's key +is left unchanged with a warning; any other value is taken as plaintext. Never +write a secret you were not given: keep the placeholder. + +## Keys + +| Key | Value | +|---|---| +| \`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. | +| \`error_handler\` / \`success_handler\` | \`{ path, extra_args?, muted_on_cancel?, muted_on_user_path? }\`, \`path\` being \`script/<path>\` or \`flow/<path>\`. | +| \`error_handler_fallback_to_instance_alerts\` | Report failures to the instance critical alert channels when no error handler is set. | +| \`webhook\` | URL POSTed a JSON event on every change to the workspace's items. | +| \`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_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, ... } } }\`. | +| \`dbt_warehouses\` | dbt warehouse connections. | +| \`default_app\` | Path of the app shown as the workspace home. | +| \`default_scripts\` | \`{ order?, hidden?, default_script_content? }\` for the script language picker. | +| \`deploy_ui\` | \`{ include_path?, include_type? }\`: what the deploy-to-workspace UI offers. | +| \`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. | +| \`mute_critical_alerts\` | Mute the workspace's critical alerts. | +| \`slack_team_id\`, \`slack_name\` | Read only: set by connecting Slack on the settings page. | +| \`slack_command_script\` / \`teams_command_script\` | Script or flow handling the Slack / Teams command. | +| \`slack_oauth_client_id\` / \`slack_oauth_client_secret\` | The workspace's own Slack OAuth app. Clearing either removes both. | +| \`public_app_execution_limit_per_minute\` | Rate limit for public apps, \`null\` for none. | +| \`guest_access_enabled\`, \`guest_jwt_public_key\`, \`guest_jwt_jwks_url\` | Guest access to public apps and the key that signs guest JWTs. | +| \`add_admins_and_developers_to_forks\` | Add the workspace's admins and developers to each fork. | +| \`color\` | Workspace color, \`#rrggbb\`. | +`; + export const WORKFLOW_AS_CODE_BASE = `# Windmill Workflow-as-Code Writing Guide ## Scope @@ -2525,15 +2641,31 @@ def get_flow_user_state(key: str) -> Any def version() # Convenient helpers that takes an S3 resource as input and returns the settings necessary to -# initiate an S3 connection from DuckDB +# initiate an S3 connection from DuckDB. +# +# Without a resource path, the connection goes to the workspace storage through Windmill's +# S3 proxy when that storage is S3: address it as \`\`s3://<bucket>/<key>\`\` with the +# storage's own bucket, returned as \`\`s3_bucket\`\`. Pass the resource path to connect with +# the storage's own credentials instead. def get_duckdb_connection_settings(s3_resource_path: str = '') -> DuckDbConnectionSettings | None # Convenient helpers that takes an S3 resource as input and returns the settings necessary to -# initiate an S3 connection from Polars +# initiate an S3 connection from Polars. +# +# Without a resource path, the connection goes to the workspace storage through Windmill's +# S3 proxy when that storage is S3: address it as \`\`s3://<bucket>/<key>\`\` with the +# storage's own bucket, returned as \`\`s3_bucket\`\`. Pass the resource path to connect with +# the storage's own credentials instead. def get_polars_connection_settings(s3_resource_path: str = '') -> PolarsConnectionSettings # Convenient helpers that takes an S3 resource as input and returns the settings necessary to -# initiate an S3 connection using boto3 +# initiate an S3 connection using boto3. +# +# Without a resource path, the connection goes to the workspace storage through Windmill's +# S3 proxy when that storage is S3: pass the storage's own bucket as \`\`Bucket\`\`. The proxy +# serves object reads, writes, deletes, listing and multipart uploads of that bucket only; +# presigned URLs, other buckets and bucket-level APIs need the storage's own credentials, +# which passing the resource path returns. def get_boto3_connection_settings(s3_resource_path: str = '') -> Boto3ConnectionSettings # Load a file from the workspace s3 bucket and returns its content as bytes. @@ -3615,7 +3747,7 @@ class SqlQuery: export const OPENFLOW_SCHEMA = `## OpenFlow Schema -{"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-<type>' (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":"JavaScript expression to return early from the flow"},"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.<step_id>'"}},"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/<flow_path>-<step_id>"},"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.<its_own_id>' 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"}}},"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).\\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: { <question name>: answer },\\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\\nthe answers, follow it with a branchone whose branch exprs read\\nresults.<id>.output.<question>.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"]}}`; +{"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-<type>' (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.<step_id>'"}},"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/<flow_path>-<step_id>"},"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.<its_own_id>' 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: { <question name>: answer },\\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\\nthe answers, follow it with a branchone whose branch exprs read\\nresults.<id>.output.<question>.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"]}}`; export const CLI_COMMANDS = `# Windmill CLI Commands @@ -3703,8 +3835,10 @@ datatable related commands - \`datatable migrate\` - manage datatable migrations - \`datatable migrate new <name:string>\` - scaffold a new migration (.up.sql / .down.sql files) - \`-d --datatable <datatable:string>\` - Target datatable (default: main) + - \`-s --schemas <schemas:string>\` - Comma-separated schemas the migration changes, written as its \`-- schemas\` line. Without it the migration applies to the whole database and every later migration waits for it - \`datatable migrate up\` - apply all pending migrations to the main datatable (or one via --datatable) - \`-d --datatable <datatable:string>\` - Target datatable (default: main) + - \`--schema <schema:string>\` - Apply only the pending migrations that declare this schema; refused while an earlier overlapping migration is pending - \`datatable migrate down\` - roll back the most recent migration on the main datatable (or one via --datatable) - \`-d --datatable <datatable:string>\` - Target datatable (default: main) - \`datatable migrate status\` - show applied and pending migrations on the main datatable (or one via --datatable) @@ -3800,6 +3934,7 @@ flow related commands - \`--remote\` - Use deployed workspace scripts for PathScript steps instead of local files. - \`--step <step_id:string>\` - Run only the named step instead of the whole flow. Honors --data as the step's args and --remote / local-PathScript resolution the same way the full-flow preview does. - \`--tag <tag:string>\` - Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the flow's default tag). + - \`--on-behalf-of <identity:string>\` - Run the preview as this user or group (an email, u/<username> or g/<group>) instead of yourself, e.g. to reproduce a run_on_behalf_of identity. Requires being an admin or a member of wm_deployers. - \`flow new <flow_path:string>\` - create a new empty flow - \`--summary <summary:string>\` - flow summary - \`--description <description:string>\` - flow description @@ -4198,6 +4333,7 @@ script related commands - \`-d --data <data:file>\` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-. A resource argument is the bare string $res:<path> as its whole value, and a variable argument is the bare string $var:<path> — not an object wrapper keyed on $res/$var, and not a plain path. - \`-s --silent\` - Do not output anything other than the final output. Useful for scripting. - \`--tag <tag:string>\` - Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the script's default tag). + - \`--on-behalf-of <identity:string>\` - Run the preview as this user or group (an email, u/<username> or g/<group>) instead of yourself, e.g. to reproduce a run_on_behalf_of identity. Requires being an admin or a member of wm_deployers. - \`script new <path:file> <language:string>\` - create a new script - \`--summary <summary:string>\` - script summary - \`--description <description:string>\` - script description @@ -4235,6 +4371,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--include-users\` - Include syncing users - \`--include-groups\` - Include syncing groups - \`--include-settings\` - Include syncing workspace settings + - \`--include-datatable-permissions\` - With --include-settings, also sync who may connect to each data table as which role - \`--include-key\` - Include workspace encryption key - \`--keep-deleted\` - Do not delete local files for items that no longer exist on the remote workspace. Only adds and updates. - \`--skip-branch-validation\` - Skip git branch validation and prompts @@ -4266,6 +4403,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--include-users\` - Include syncing users - \`--include-groups\` - Include syncing groups - \`--include-settings\` - Include syncing workspace settings + - \`--include-datatable-permissions\` - With --include-settings, also sync who may connect to each data table as which role - \`--include-key\` - Include workspace encryption key - \`--skip-reencrypt-on-key-change\` - When the pushed encryption key differs from the remote, do NOT re-encrypt existing remote secrets. Only safe if they are already encrypted with the new key (e.g. workspace/instance migration). Default is to re-encrypt. - \`--keep-deleted\` - Do not delete remote items that no longer exist locally. Only adds and updates. @@ -4459,6 +4597,13 @@ Arguments (omit both to be prompted interactively): - \`--team-id <team_id:string>\` - Slack team id - \`--team-name <team_name:string>\` - Slack team name - \`workspace disconnect-slack\` - Clear slack_team_id / slack_name on the active workspace (marks the workspace as disconnected). Does NOT remove the bot token variable/resource/folder/group — delete those from the local sync folder and run 'wmill sync push' to tear them down. Does NOT remove the workspace-level OAuth override — set slack_oauth_client_id/_secret to '' in settings.yaml and push. +- \`workspace get-settings\` - Print the active workspace's settings in the layout of the settings.yaml that 'wmill sync' writes. Secrets show as __SENSITIVE_AND_UNCHANGED__ unless --encrypted. Admin only. + - \`--encrypted\` - Write secrets encrypted with the workspace key, as 'wmill sync pull' does + - \`--json\` - Output as JSON +- \`workspace apply-settings <file:string>\` - Apply a settings document (YAML or JSON, in the layout of settings.yaml) to the active workspace. Only the settings the file states are changed, unless --replace; a setting set to null is cleared. A secret left as __SENSITIVE_AND_UNCHANGED__ keeps its value. Shows the changes and asks before applying. Admin only. + - \`--dry-run\` - Only show the changes + - \`--replace\` - Also clear the settings the file omits, as 'wmill sync push' does + - \`-y --yes\` - Apply without asking @@ -4836,7 +4981,7 @@ export async function main(url: string) { ## Pure computation: \`//no_network\` -A 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. It only takes effect on native scripts: without \`//native\` it is ignored. Use it only when the script needs no external data beyond its arguments: +A 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: \`\`\`typescript //native diff --git a/system_prompts/auto-generated/script.md b/system_prompts/auto-generated/script.md index d178ddb6c0..509e34d502 100644 --- a/system_prompts/auto-generated/script.md +++ b/system_prompts/auto-generated/script.md @@ -3,6 +3,8 @@ ## General Principles - 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 +- 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_<name>`, which keeps it apart from a hub type synced later: the parameter takes `RT.C<Name>` in TypeScript, a `c_<name>` 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 +- 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 - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (`wmill`), use it to interact with the platform @@ -377,7 +379,7 @@ export async function main(url: string) { ## Pure computation: `//no_network` -A 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. It only takes effect on native scripts: without `//native` it is ignored. Use it only when the script needs no external data beyond its arguments: +A 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: ```typescript //native @@ -2368,15 +2370,31 @@ def get_flow_user_state(key: str) -> Any def version() # Convenient helpers that takes an S3 resource as input and returns the settings necessary to -# initiate an S3 connection from DuckDB +# initiate an S3 connection from DuckDB. +# +# Without a resource path, the connection goes to the workspace storage through Windmill's +# S3 proxy when that storage is S3: address it as ``s3://<bucket>/<key>`` with the +# storage's own bucket, returned as ``s3_bucket``. Pass the resource path to connect with +# the storage's own credentials instead. def get_duckdb_connection_settings(s3_resource_path: str = '') -> DuckDbConnectionSettings | None # Convenient helpers that takes an S3 resource as input and returns the settings necessary to -# initiate an S3 connection from Polars +# initiate an S3 connection from Polars. +# +# Without a resource path, the connection goes to the workspace storage through Windmill's +# S3 proxy when that storage is S3: address it as ``s3://<bucket>/<key>`` with the +# storage's own bucket, returned as ``s3_bucket``. Pass the resource path to connect with +# the storage's own credentials instead. def get_polars_connection_settings(s3_resource_path: str = '') -> PolarsConnectionSettings # Convenient helpers that takes an S3 resource as input and returns the settings necessary to -# initiate an S3 connection using boto3 +# initiate an S3 connection using boto3. +# +# Without a resource path, the connection goes to the workspace storage through Windmill's +# S3 proxy when that storage is S3: pass the storage's own bucket as ``Bucket``. The proxy +# serves object reads, writes, deletes, listing and multipart uploads of that bucket only; +# presigned URLs, other buckets and bucket-level APIs need the storage's own credentials, +# which passing the resource path returns. def get_boto3_connection_settings(s3_resource_path: str = '') -> Boto3ConnectionSettings # Load a file from the workspace s3 bucket and returns its content as bytes. diff --git a/system_prompts/auto-generated/sdks/python.md b/system_prompts/auto-generated/sdks/python.md index 1964c88685..1a772ae6ee 100644 --- a/system_prompts/auto-generated/sdks/python.md +++ b/system_prompts/auto-generated/sdks/python.md @@ -265,15 +265,31 @@ def get_flow_user_state(key: str) -> Any def version() # Convenient helpers that takes an S3 resource as input and returns the settings necessary to -# initiate an S3 connection from DuckDB +# initiate an S3 connection from DuckDB. +# +# Without a resource path, the connection goes to the workspace storage through Windmill's +# S3 proxy when that storage is S3: address it as ``s3://<bucket>/<key>`` with the +# storage's own bucket, returned as ``s3_bucket``. Pass the resource path to connect with +# the storage's own credentials instead. def get_duckdb_connection_settings(s3_resource_path: str = '') -> DuckDbConnectionSettings | None # Convenient helpers that takes an S3 resource as input and returns the settings necessary to -# initiate an S3 connection from Polars +# initiate an S3 connection from Polars. +# +# Without a resource path, the connection goes to the workspace storage through Windmill's +# S3 proxy when that storage is S3: address it as ``s3://<bucket>/<key>`` with the +# storage's own bucket, returned as ``s3_bucket``. Pass the resource path to connect with +# the storage's own credentials instead. def get_polars_connection_settings(s3_resource_path: str = '') -> PolarsConnectionSettings # Convenient helpers that takes an S3 resource as input and returns the settings necessary to -# initiate an S3 connection using boto3 +# initiate an S3 connection using boto3. +# +# Without a resource path, the connection goes to the workspace storage through Windmill's +# S3 proxy when that storage is S3: pass the storage's own bucket as ``Bucket``. The proxy +# serves object reads, writes, deletes, listing and multipart uploads of that bucket only; +# presigned URLs, other buckets and bucket-level APIs need the storage's own credentials, +# which passing the resource path returns. def get_boto3_connection_settings(s3_resource_path: str = '') -> Boto3ConnectionSettings # Load a file from the workspace s3 bucket and returns its content as bytes. diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index 95bbbc657b..30fb9b93e3 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -89,8 +89,10 @@ datatable related commands - `datatable migrate` - manage datatable migrations - `datatable migrate new <name:string>` - scaffold a new migration (.up.sql / .down.sql files) - `-d --datatable <datatable:string>` - Target datatable (default: main) + - `-s --schemas <schemas:string>` - Comma-separated schemas the migration changes, written as its `-- schemas` line. Without it the migration applies to the whole database and every later migration waits for it - `datatable migrate up` - apply all pending migrations to the main datatable (or one via --datatable) - `-d --datatable <datatable:string>` - Target datatable (default: main) + - `--schema <schema:string>` - Apply only the pending migrations that declare this schema; refused while an earlier overlapping migration is pending - `datatable migrate down` - roll back the most recent migration on the main datatable (or one via --datatable) - `-d --datatable <datatable:string>` - Target datatable (default: main) - `datatable migrate status` - show applied and pending migrations on the main datatable (or one via --datatable) @@ -186,6 +188,7 @@ flow related commands - `--remote` - Use deployed workspace scripts for PathScript steps instead of local files. - `--step <step_id:string>` - Run only the named step instead of the whole flow. Honors --data as the step's args and --remote / local-PathScript resolution the same way the full-flow preview does. - `--tag <tag:string>` - Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the flow's default tag). + - `--on-behalf-of <identity:string>` - Run the preview as this user or group (an email, u/<username> or g/<group>) instead of yourself, e.g. to reproduce a run_on_behalf_of identity. Requires being an admin or a member of wm_deployers. - `flow new <flow_path:string>` - create a new empty flow - `--summary <summary:string>` - flow summary - `--description <description:string>` - flow description @@ -584,6 +587,7 @@ script related commands - `-d --data <data:file>` - Inputs specified as a JSON string or a file using @<filename> or stdin using @-. A resource argument is the bare string $res:<path> as its whole value, and a variable argument is the bare string $var:<path> — not an object wrapper keyed on $res/$var, and not a plain path. - `-s --silent` - Do not output anything other than the final output. Useful for scripting. - `--tag <tag:string>` - Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the script's default tag). + - `--on-behalf-of <identity:string>` - Run the preview as this user or group (an email, u/<username> or g/<group>) instead of yourself, e.g. to reproduce a run_on_behalf_of identity. Requires being an admin or a member of wm_deployers. - `script new <path:file> <language:string>` - create a new script - `--summary <summary:string>` - script summary - `--description <description:string>` - script description @@ -621,6 +625,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--include-users` - Include syncing users - `--include-groups` - Include syncing groups - `--include-settings` - Include syncing workspace settings + - `--include-datatable-permissions` - With --include-settings, also sync who may connect to each data table as which role - `--include-key` - Include workspace encryption key - `--keep-deleted` - Do not delete local files for items that no longer exist on the remote workspace. Only adds and updates. - `--skip-branch-validation` - Skip git branch validation and prompts @@ -652,6 +657,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--include-users` - Include syncing users - `--include-groups` - Include syncing groups - `--include-settings` - Include syncing workspace settings + - `--include-datatable-permissions` - With --include-settings, also sync who may connect to each data table as which role - `--include-key` - Include workspace encryption key - `--skip-reencrypt-on-key-change` - When the pushed encryption key differs from the remote, do NOT re-encrypt existing remote secrets. Only safe if they are already encrypted with the new key (e.g. workspace/instance migration). Default is to re-encrypt. - `--keep-deleted` - Do not delete remote items that no longer exist locally. Only adds and updates. @@ -845,6 +851,13 @@ Arguments (omit both to be prompted interactively): - `--team-id <team_id:string>` - Slack team id - `--team-name <team_name:string>` - Slack team name - `workspace disconnect-slack` - Clear slack_team_id / slack_name on the active workspace (marks the workspace as disconnected). Does NOT remove the bot token variable/resource/folder/group — delete those from the local sync folder and run 'wmill sync push' to tear them down. Does NOT remove the workspace-level OAuth override — set slack_oauth_client_id/_secret to '' in settings.yaml and push. +- `workspace get-settings` - Print the active workspace's settings in the layout of the settings.yaml that 'wmill sync' writes. Secrets show as __SENSITIVE_AND_UNCHANGED__ unless --encrypted. Admin only. + - `--encrypted` - Write secrets encrypted with the workspace key, as 'wmill sync pull' does + - `--json` - Output as JSON +- `workspace apply-settings <file:string>` - Apply a settings document (YAML or JSON, in the layout of settings.yaml) to the active workspace. Only the settings the file states are changed, unless --replace; a setting set to null is cleared. A secret left as __SENSITIVE_AND_UNCHANGED__ keeps its value. Shows the changes and asks before applying. Admin only. + - `--dry-run` - Only show the changes + - `--replace` - Also clear the settings the file omits, as 'wmill sync push' does + - `-y --yes` - Apply without asking diff --git a/system_prompts/auto-generated/skills/datatables/SKILL.md b/system_prompts/auto-generated/skills/datatables/SKILL.md index 1c1cb9eede..ac0bdf257c 100644 --- a/system_prompts/auto-generated/skills/datatables/SKILL.md +++ b/system_prompts/auto-generated/skills/datatables/SKILL.md @@ -18,10 +18,11 @@ Writing a migration: - 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. - Give it a `down` that undoes the `up` whenever the change is reversible. Without one it cannot be rolled back. +- 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. - Migrations apply oldest first. One that is applied is not edited: correct it with a new migration. - Row data is not schema. Insert, update and delete rows with plain SQL, migrations or not. -`wmill datatable migrate new <name>` scaffolds `migrations/datatable/<datatable>/<timestamp>_<name>.up.sql` and `.down.sql`, `wmill datatable migrate up` pushes the local files and applies what is pending, `wmill datatable migrate down` rolls back the most recent one and `wmill datatable migrate status` lists them. All take `-d <datatable>` and default to `main`. +`wmill datatable migrate new <name>` scaffolds `migrations/datatable/<datatable>/<timestamp>_<name>.up.sql` and `.down.sql`, `wmill datatable migrate up` pushes the local files and applies what is pending, `wmill datatable migrate down` rolls back the most recent one and `wmill datatable migrate status` lists them. All take `-d <datatable>` and default to `main`. `migrate new --schemas sales,billing` writes the `-- schemas` line, and `migrate up --schema sales` applies only the pending migrations declaring that schema. ## A migration is not a draft diff --git a/system_prompts/auto-generated/skills/raw-app/SKILL.md b/system_prompts/auto-generated/skills/raw-app/SKILL.md index f4dce90269..f45b85402d 100644 --- a/system_prompts/auto-generated/skills/raw-app/SKILL.md +++ b/system_prompts/auto-generated/skills/raw-app/SKILL.md @@ -380,7 +380,7 @@ An app can be demoed by recording a session: every interaction becomes a step ca Apply 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. -### Chat UIs over a flow in chat mode +### Chat UIs over a flow in chat mode or a saved agent A 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. @@ -393,9 +393,31 @@ const chat = useWindmillChat({ flowPath: 'f/support/assistant' }) `@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. +#### A saved agent: an `agent` runnable + +Chat 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: + +In `raw_app.yaml`: + +```yaml +runnables: + chat: + type: agent + path: f/support/agent +``` + +```tsx +import { useWindmillChat } from '@windmill-labs/chat-sdk/react' +import * as wmill from './wmill' + +const chat = useWindmillChat({ wmill, runnable: 'chat' }) +``` + +`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. + ## Backend runnables -Each runnable has a unique key (used to call it from the frontend) and one of four types: +Each runnable has a unique key (used to call it from the frontend) and one of five types: | Type | What it is | |---|---| @@ -403,6 +425,7 @@ Each runnable has a unique key (used to call it from the frontend) and one of fo | `script` | Reference to an existing workspace script by path. | | `flow` | Reference to an existing workspace flow by path. | | `hubscript` | Reference to a hub script by path. | +| `agent` | Reference to a saved AI agent by path. Each call is one turn of its chat (see the chat section above). | ### Inline runnables @@ -432,6 +455,10 @@ def main(user_id: str): return user ``` +#### Sharing code between runnables + +An 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. + #### The `wmill` client is already authenticated An 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. @@ -440,9 +467,9 @@ An inline runnable runs as an ordinary Windmill job. `import * as wmill from 'wi Use 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. -### Path runnables (script / flow / hubscript) +### Path runnables (script / flow / hubscript / agent) -When `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. +When `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. Before 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. @@ -451,7 +478,7 @@ Before writing an inline runnable, look for a workspace script or flow that alre This decides whether an app works before anything is deployed: - **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. -- **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. +- **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. So 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. @@ -475,14 +502,18 @@ A draft app is reachable by nobody; deploying is what exposes it, and its backen Either one lets those people run the app's backend runnables, so never open an app up unless the user asks. `public: true` in `raw_app.yaml` deploys the app for anonymous users, and `guests: true` for guests. Remove the line and the next push closes the app again. +An app's custom URL is not in `raw_app.yaml`: each workspace has its own file next to the app folder, `<app path>.<workspace>.custom_path.yaml` (for example `f/sales/dashboard.prod.custom_path.yaml` holding `custom_path: dashboard`), where `<workspace>` is the workspace's name in `wmill.yaml`, or its id. A push applies only the target workspace's file and leaves the deployed custom URL alone when there is none, so deleting the file does not remove the URL: `custom_path: ''` does. Setting or clearing one takes a workspace admin. + ## Sandbox isolation and frontend API access -By default a deployed app's frontend runs with the viewer's own Windmill session. A sandboxed app is isolated from it: the frontend reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. The scopes are `jobs:run`, `jobs:read`, `users:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. +New 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. + +Inside 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. In `raw_app.yaml`: ```yaml sandbox: true -frontend_api: [jobs:run, users:read] +frontend_api: [jobs:run] ``` A file with a `sandbox` key states both settings: `sandbox: false` turns isolation off, and a missing `frontend_api` means no scopes. A file without the key leaves the deployed app's isolation and scopes as they are, so removing `sandbox: true` does not unsandbox it. `frontend_api` needs `sandbox: true`; the push fails otherwise. @@ -557,4 +588,4 @@ def main(user_id: str): 7. **Mark sensitive UI with `data-wm-no-record`** — it is what keeps that data out of a recorded demo; passwords are handled for you. 8. **Reach for `backendAsync` + `waitJob`** for long work — never a hand-written job-polling runnable. 9. **Deploy what a path runnable points at** — a path runnable aimed at a draft fails at runtime; tell the user what needs deploying. -10. **Use `@windmill-labs/chat-sdk` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream. +10. **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 }`. diff --git a/system_prompts/auto-generated/skills/resources/SKILL.md b/system_prompts/auto-generated/skills/resources/SKILL.md index bd690b1dec..45d3c64619 100644 --- a/system_prompts/auto-generated/skills/resources/SKILL.md +++ b/system_prompts/auto-generated/skills/resources/SKILL.md @@ -34,6 +34,12 @@ Example: `f/databases/postgres_prod.resource.json` - `value` - Object containing the resource configuration - `resource_type` - Name of the resource type (e.g., "postgresql", "slack") +## Agents + +An agent, as the Agents page lists it, is a resource of type `ai_agent`. Its value holds the +agent's provider, system prompt, tools and memory settings, and a flow step uses a saved agent by +linking its path. + ## Variable References Reference variables in resource values: @@ -214,13 +220,23 @@ The prefix must be on the string itself. } ``` +## When No Resource Type Fits + +The Windmill Hub publishes resource types for most services, and an instance gets them by syncing with the hub. When none fits the service: + +- 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. +- 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. +- 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. + ## Custom Resource Types +A 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. + Create custom resource types with JSON Schema: ```json { - "name": "custom_api", + "name": "c_custom_api", "schema": { "type": "object", "properties": { @@ -234,7 +250,7 @@ Create custom resource types with JSON Schema: } ``` -Save as: `custom_api.resource-type.json` +Save as: `c_custom_api.resource-type.json` ## OAuth Resources diff --git a/system_prompts/auto-generated/skills/workspace-settings/SKILL.md b/system_prompts/auto-generated/skills/workspace-settings/SKILL.md new file mode 100644 index 0000000000..6216b92db6 --- /dev/null +++ b/system_prompts/auto-generated/skills/workspace-settings/SKILL.md @@ -0,0 +1,84 @@ +--- +name: workspace-settings +description: MUST use when reading or changing workspace settings, or the settings.yaml file `wmill sync` keeps. +--- + +# Workspace settings + +Everything an admin sets on the Workspace settings page is one document: a mapping of top-level +keys, the same layout as the `settings.yaml` file `wmill sync` keeps. Only a workspace admin can +read or change it. + +## Applying a document + +- A partial document changes only the keys it states; every key it omits is left as it is. +- Each key is replaced whole, so read its current value and send it back edited: a nested object + given with one field replaces the stored object with that one field. +- A key set to `null` clears that setting; an on/off switch cleared this way reads as `false`. +- Each setting is written through the same path the settings page uses, with the same validation + and audit log. The writes are not one transaction: when one fails the apply stops, and the + error says what was already written. +- A dry run reports the changes, as `before` / `after` per key, without writing anything. +- `name` is never applied: rename a workspace on the settings page. `color` is applied only when + set to a value, so `null` does not clear it. +- A key the server does not know is ignored and named in the warnings: check them, since a typo + otherwise reads as "nothing to change". +- A setting this edition does not offer (Enterprise only) is refused, by the dry run too. + +## Secrets + +`slack_oauth_client_secret` is the one secret. A read shows `__SENSITIVE_AND_UNCHANGED__` in place +of a set secret; sending that placeholder back keeps the stored value. A value starting with +`$encrypted:` is decrypted with the workspace key, and one encrypted with another workspace's key +is left unchanged with a warning; any other value is taken as plaintext. Never +write a secret you were not given: keep the placeholder. + +## Keys + +| Key | Value | +|---|---| +| `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. | +| `error_handler` / `success_handler` | `{ path, extra_args?, muted_on_cancel?, muted_on_user_path? }`, `path` being `script/<path>` or `flow/<path>`. | +| `error_handler_fallback_to_instance_alerts` | Report failures to the instance critical alert channels when no error handler is set. | +| `webhook` | URL POSTed a JSON event on every change to the workspace's items. | +| `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_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, ... } } }`. | +| `dbt_warehouses` | dbt warehouse connections. | +| `default_app` | Path of the app shown as the workspace home. | +| `default_scripts` | `{ order?, hidden?, default_script_content? }` for the script language picker. | +| `deploy_ui` | `{ include_path?, include_type? }`: what the deploy-to-workspace UI offers. | +| `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. | +| `mute_critical_alerts` | Mute the workspace's critical alerts. | +| `slack_team_id`, `slack_name` | Read only: set by connecting Slack on the settings page. | +| `slack_command_script` / `teams_command_script` | Script or flow handling the Slack / Teams command. | +| `slack_oauth_client_id` / `slack_oauth_client_secret` | The workspace's own Slack OAuth app. Clearing either removes both. | +| `public_app_execution_limit_per_minute` | Rate limit for public apps, `null` for none. | +| `guest_access_enabled`, `guest_jwt_public_key`, `guest_jwt_jwks_url` | Guest access to public apps and the key that signs guest JWTs. | +| `add_admins_and_developers_to_forks` | Add the workspace's admins and developers to each fork. | +| `color` | Workspace color, `#rrggbb`. | + +## From the command line + +`wmill workspace get-settings` prints the document (`--encrypted` for secrets encrypted with the +workspace key, `--json` for JSON). `wmill workspace apply-settings <file>` applies a partial +document from a YAML or JSON file: it shows the changes and asks before writing (`--dry-run` to +only show them, `-y` to skip the question, `--replace` to clear what the file omits). + +## In a synced repository + +With `includeSettings: true` in `wmill.yaml`, `wmill sync pull` writes the document to +`settings.yaml`, secrets encrypted with the workspace key (plaintext only with `plainSecrets`), and +`wmill sync push` applies it. Unlike a partial apply, a push treats the file as the whole +document: a setting the file omits is cleared, except `operator_settings`, `color`, the auto-invite +instance groups, and the keys `ducklake`, `dbt_warehouses`, `deploy_ui`, `teams_command_script`, +`public_app_execution_limit_per_minute`, `guest_access_enabled`, `guest_jwt_public_key`, +`guest_jwt_jwks_url`, `error_handler_fallback_to_instance_alerts` and +`add_admins_and_developers_to_forks`, which are managed only when the file states them. +`datatable_permissions` is pushed only with `includeDatatablePermissions: true` (or +`--include-datatable-permissions`). A push ignores `protection_rules`: `protection-rules.yaml` and +`wmill protection-rules push` sync them. diff --git a/system_prompts/auto-generated/skills/write-flow/SKILL.md b/system_prompts/auto-generated/skills/write-flow/SKILL.md index 4b0889c3d8..906f416184 100644 --- a/system_prompts/auto-generated/skills/write-flow/SKILL.md +++ b/system_prompts/auto-generated/skills/write-flow/SKILL.md @@ -236,6 +236,10 @@ needs becomes unreachable. references. A spread, a call or a computed key leaves the composer unable to tell which input feeds which field, so it offers no control at all — a bare `flow_input.x` for the whole object is read instead as that one input carrying every field +- A step linked to a saved agent (`agent` set) has no `provider` of its own. It exposes the same + two controls through its `model` and `reasoning_effort` inputs, which replace what the agent + saved: `"model": { "type": "javascript", "expr": "flow_input.model" }`. An empty model keeps the + saved one, an empty effort sends none, and the provider resource always stays the agent's - `memory` is what lets the agent see earlier turns; without it every message starts from nothing - `streaming` on makes the answer and its thinking appear token by token instead of all at once - `user_attachments` points at a flow input typed as an array of s3 objects @@ -671,6 +675,14 @@ In the flow schema, set the property type to `"object"` with format `"resource-{ } ``` +### When No Resource Type Fits + +A 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_<name>` 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. + +- 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. +- A plain variable is fine when the user asks for it. + ### As Step Input (Static Reference) Reference a specific resource using `$res:` prefix: @@ -686,4 +698,4 @@ Reference a specific resource using `$res:` prefix: ## OpenFlow Schema -{"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-<type>' (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":"JavaScript expression to return early from the flow"},"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.<step_id>'"}},"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/<flow_path>-<step_id>"},"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.<its_own_id>' 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"}}},"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).\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: { <question name>: answer },\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\nthe answers, follow it with a branchone whose branch exprs read\nresults.<id>.output.<question>.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"]}} +{"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-<type>' (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.<step_id>'"}},"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/<flow_path>-<step_id>"},"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.<its_own_id>' 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: { <question name>: answer },\nmodel, usage }, so a later step reads e.g. results.<id>.output.intent.choice. To route on\nthe answers, follow it with a branchone whose branch exprs read\nresults.<id>.output.<question>.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"]}} diff --git a/system_prompts/auto-generated/skills/write-script-ansible/SKILL.md b/system_prompts/auto-generated/skills/write-script-ansible/SKILL.md index 9bb87cb4a1..e67e8fc30c 100644 --- a/system_prompts/auto-generated/skills/write-script-ansible/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-ansible/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - 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 +- 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_<name>`, which keeps it apart from a hub type synced later: the parameter takes `RT.C<Name>` in TypeScript, a `c_<name>` 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 +- 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 +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (`wmill`), use it to interact with the platform diff --git a/system_prompts/auto-generated/skills/write-script-bash/SKILL.md b/system_prompts/auto-generated/skills/write-script-bash/SKILL.md index 0c4634b775..c1e9274a7a 100644 --- a/system_prompts/auto-generated/skills/write-script-bash/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bash/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - 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 +- 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_<name>`, which keeps it apart from a hub type synced later: the parameter takes `RT.C<Name>` in TypeScript, a `c_<name>` 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 +- 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 +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (`wmill`), use it to interact with the platform diff --git a/system_prompts/auto-generated/skills/write-script-bigquery/SKILL.md b/system_prompts/auto-generated/skills/write-script-bigquery/SKILL.md index 497336b001..97cf31976e 100644 --- a/system_prompts/auto-generated/skills/write-script-bigquery/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bigquery/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - 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 +- 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_<name>`, which keeps it apart from a hub type synced later: the parameter takes `RT.C<Name>` in TypeScript, a `c_<name>` 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 +- 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 +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (`wmill`), use it to interact with the platform diff --git a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md index 055b1613b2..9b31261bb8 100644 --- a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - 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 +- 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_<name>`, which keeps it apart from a hub type synced later: the parameter takes `RT.C<Name>` in TypeScript, a `c_<name>` 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 +- 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 +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (`wmill`), use it to interact with the platform diff --git a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md index beaf7817bb..1c7c1294a6 100644 --- a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - 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 +- 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_<name>`, which keeps it apart from a hub type synced later: the parameter takes `RT.C<Name>` in TypeScript, a `c_<name>` 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 +- 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 +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (`wmill`), use it to interact with the platform @@ -121,7 +124,7 @@ export async function main(url: string) { ## Pure computation: `//no_network` -A 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. It only takes effect on native scripts: without `//native` it is ignored. Use it only when the script needs no external data beyond its arguments: +A 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: ```typescript //native diff --git a/system_prompts/auto-generated/skills/write-script-csharp/SKILL.md b/system_prompts/auto-generated/skills/write-script-csharp/SKILL.md index 389c683f7c..2759e58b26 100644 --- a/system_prompts/auto-generated/skills/write-script-csharp/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-csharp/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - 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 +- 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_<name>`, which keeps it apart from a hub type synced later: the parameter takes `RT.C<Name>` in TypeScript, a `c_<name>` 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 +- 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 +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (`wmill`), use it to interact with the platform diff --git a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md index afefb7e987..8325315dfe 100644 --- a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - 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 +- 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_<name>`, which keeps it apart from a hub type synced later: the parameter takes `RT.C<Name>` in TypeScript, a `c_<name>` 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 +- 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 +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (`wmill`), use it to interact with the platform diff --git a/system_prompts/auto-generated/skills/write-script-duckdb/SKILL.md b/system_prompts/auto-generated/skills/write-script-duckdb/SKILL.md index 2ffcf4607c..471a22217a 100644 --- a/system_prompts/auto-generated/skills/write-script-duckdb/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-duckdb/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - 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 +- 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_<name>`, which keeps it apart from a hub type synced later: the parameter takes `RT.C<Name>` in TypeScript, a `c_<name>` 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 +- 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 +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (`wmill`), use it to interact with the platform diff --git a/system_prompts/auto-generated/skills/write-script-go/SKILL.md b/system_prompts/auto-generated/skills/write-script-go/SKILL.md index 1707fcda4c..4573fa816a 100644 --- a/system_prompts/auto-generated/skills/write-script-go/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-go/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - 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 +- 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_<name>`, which keeps it apart from a hub type synced later: the parameter takes `RT.C<Name>` in TypeScript, a `c_<name>` 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 +- 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 +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (`wmill`), use it to interact with the platform diff --git a/system_prompts/auto-generated/skills/write-script-graphql/SKILL.md b/system_prompts/auto-generated/skills/write-script-graphql/SKILL.md index c928dea3bc..225c0e928f 100644 --- a/system_prompts/auto-generated/skills/write-script-graphql/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-graphql/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - 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 +- 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_<name>`, which keeps it apart from a hub type synced later: the parameter takes `RT.C<Name>` in TypeScript, a `c_<name>` 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 +- 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 +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (`wmill`), use it to interact with the platform diff --git a/system_prompts/auto-generated/skills/write-script-java/SKILL.md b/system_prompts/auto-generated/skills/write-script-java/SKILL.md index a768a5faa5..8712ee27c3 100644 --- a/system_prompts/auto-generated/skills/write-script-java/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-java/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - 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 +- 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_<name>`, which keeps it apart from a hub type synced later: the parameter takes `RT.C<Name>` in TypeScript, a `c_<name>` 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 +- 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 +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (`wmill`), use it to interact with the platform diff --git a/system_prompts/auto-generated/skills/write-script-mssql/SKILL.md b/system_prompts/auto-generated/skills/write-script-mssql/SKILL.md index 9215f06906..195aff374b 100644 --- a/system_prompts/auto-generated/skills/write-script-mssql/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-mssql/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - 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 +- 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_<name>`, which keeps it apart from a hub type synced later: the parameter takes `RT.C<Name>` in TypeScript, a `c_<name>` 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 +- 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 +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (`wmill`), use it to interact with the platform diff --git a/system_prompts/auto-generated/skills/write-script-mysql/SKILL.md b/system_prompts/auto-generated/skills/write-script-mysql/SKILL.md index d682cce351..8acf52e23a 100644 --- a/system_prompts/auto-generated/skills/write-script-mysql/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-mysql/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - 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 +- 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_<name>`, which keeps it apart from a hub type synced later: the parameter takes `RT.C<Name>` in TypeScript, a `c_<name>` 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 +- 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 +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (`wmill`), use it to interact with the platform diff --git a/system_prompts/auto-generated/skills/write-script-php/SKILL.md b/system_prompts/auto-generated/skills/write-script-php/SKILL.md index 16c68834dc..2ed0b477ea 100644 --- a/system_prompts/auto-generated/skills/write-script-php/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-php/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - 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 +- 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_<name>`, which keeps it apart from a hub type synced later: the parameter takes `RT.C<Name>` in TypeScript, a `c_<name>` 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 +- 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 +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (`wmill`), use it to interact with the platform diff --git a/system_prompts/auto-generated/skills/write-script-postgresql/SKILL.md b/system_prompts/auto-generated/skills/write-script-postgresql/SKILL.md index b0e14a6b12..f98cffd2b6 100644 --- a/system_prompts/auto-generated/skills/write-script-postgresql/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-postgresql/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - 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 +- 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_<name>`, which keeps it apart from a hub type synced later: the parameter takes `RT.C<Name>` in TypeScript, a `c_<name>` 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 +- 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 +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (`wmill`), use it to interact with the platform diff --git a/system_prompts/auto-generated/skills/write-script-powershell/SKILL.md b/system_prompts/auto-generated/skills/write-script-powershell/SKILL.md index 540776c68a..f60d0b531d 100644 --- a/system_prompts/auto-generated/skills/write-script-powershell/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-powershell/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - 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 +- 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_<name>`, which keeps it apart from a hub type synced later: the parameter takes `RT.C<Name>` in TypeScript, a `c_<name>` 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 +- 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 +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (`wmill`), use it to interact with the platform diff --git a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md index ee50b6d62d..3afa72e5a9 100644 --- a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - 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 +- 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_<name>`, which keeps it apart from a hub type synced later: the parameter takes `RT.C<Name>` in TypeScript, a `c_<name>` 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 +- 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 +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (`wmill`), use it to interact with the platform @@ -473,15 +476,31 @@ def get_flow_user_state(key: str) -> Any def version() # Convenient helpers that takes an S3 resource as input and returns the settings necessary to -# initiate an S3 connection from DuckDB +# initiate an S3 connection from DuckDB. +# +# Without a resource path, the connection goes to the workspace storage through Windmill's +# S3 proxy when that storage is S3: address it as ``s3://<bucket>/<key>`` with the +# storage's own bucket, returned as ``s3_bucket``. Pass the resource path to connect with +# the storage's own credentials instead. def get_duckdb_connection_settings(s3_resource_path: str = '') -> DuckDbConnectionSettings | None # Convenient helpers that takes an S3 resource as input and returns the settings necessary to -# initiate an S3 connection from Polars +# initiate an S3 connection from Polars. +# +# Without a resource path, the connection goes to the workspace storage through Windmill's +# S3 proxy when that storage is S3: address it as ``s3://<bucket>/<key>`` with the +# storage's own bucket, returned as ``s3_bucket``. Pass the resource path to connect with +# the storage's own credentials instead. def get_polars_connection_settings(s3_resource_path: str = '') -> PolarsConnectionSettings # Convenient helpers that takes an S3 resource as input and returns the settings necessary to -# initiate an S3 connection using boto3 +# initiate an S3 connection using boto3. +# +# Without a resource path, the connection goes to the workspace storage through Windmill's +# S3 proxy when that storage is S3: pass the storage's own bucket as ``Bucket``. The proxy +# serves object reads, writes, deletes, listing and multipart uploads of that bucket only; +# presigned URLs, other buckets and bucket-level APIs need the storage's own credentials, +# which passing the resource path returns. def get_boto3_connection_settings(s3_resource_path: str = '') -> Boto3ConnectionSettings # Load a file from the workspace s3 bucket and returns its content as bytes. diff --git a/system_prompts/auto-generated/skills/write-script-rlang/SKILL.md b/system_prompts/auto-generated/skills/write-script-rlang/SKILL.md index 061e1dcc45..636bc100e3 100644 --- a/system_prompts/auto-generated/skills/write-script-rlang/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-rlang/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - 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 +- 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_<name>`, which keeps it apart from a hub type synced later: the parameter takes `RT.C<Name>` in TypeScript, a `c_<name>` 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 +- 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 +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (`wmill`), use it to interact with the platform diff --git a/system_prompts/auto-generated/skills/write-script-rust/SKILL.md b/system_prompts/auto-generated/skills/write-script-rust/SKILL.md index d9b599dffa..2b9280aa46 100644 --- a/system_prompts/auto-generated/skills/write-script-rust/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-rust/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - 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 +- 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_<name>`, which keeps it apart from a hub type synced later: the parameter takes `RT.C<Name>` in TypeScript, a `c_<name>` 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 +- 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 +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (`wmill`), use it to interact with the platform diff --git a/system_prompts/auto-generated/skills/write-script-snowflake/SKILL.md b/system_prompts/auto-generated/skills/write-script-snowflake/SKILL.md index cd79a1f5d4..3e38ebc728 100644 --- a/system_prompts/auto-generated/skills/write-script-snowflake/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-snowflake/SKILL.md @@ -53,6 +53,9 @@ Use `wmill resource-type list --schema` to discover available resource types. ## General Principles - 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 +- 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_<name>`, which keeps it apart from a hub type synced later: the parameter takes `RT.C<Name>` in TypeScript, a `c_<name>` 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 +- 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 +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (`wmill`), use it to interact with the platform diff --git a/system_prompts/base/datatable.md b/system_prompts/base/datatable.md index c90fb5e349..87a583e881 100644 --- a/system_prompts/base/datatable.md +++ b/system_prompts/base/datatable.md @@ -13,11 +13,12 @@ Writing a migration: - 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. - Give it a `down` that undoes the `up` whenever the change is reversible. Without one it cannot be rolled back. +- 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. - Migrations apply oldest first. One that is applied is not edited: correct it with a new migration. - Row data is not schema. Insert, update and delete rows with plain SQL, migrations or not. <!-- cli-only --> -`wmill datatable migrate new <name>` scaffolds `migrations/datatable/<datatable>/<timestamp>_<name>.up.sql` and `.down.sql`, `wmill datatable migrate up` pushes the local files and applies what is pending, `wmill datatable migrate down` rolls back the most recent one and `wmill datatable migrate status` lists them. All take `-d <datatable>` and default to `main`. +`wmill datatable migrate new <name>` scaffolds `migrations/datatable/<datatable>/<timestamp>_<name>.up.sql` and `.down.sql`, `wmill datatable migrate up` pushes the local files and applies what is pending, `wmill datatable migrate down` rolls back the most recent one and `wmill datatable migrate status` lists them. All take `-d <datatable>` and default to `main`. `migrate new --schemas sales,billing` writes the `-- schemas` line, and `migrate up --schema sales` applies only the pending migrations declaring that schema. <!-- /cli-only --> ## A migration is not a draft diff --git a/system_prompts/base/flow-base.md b/system_prompts/base/flow-base.md index ff10f38062..80a83e948a 100644 --- a/system_prompts/base/flow-base.md +++ b/system_prompts/base/flow-base.md @@ -149,6 +149,10 @@ needs becomes unreachable. references. A spread, a call or a computed key leaves the composer unable to tell which input feeds which field, so it offers no control at all — a bare `flow_input.x` for the whole object is read instead as that one input carrying every field +- A step linked to a saved agent (`agent` set) has no `provider` of its own. It exposes the same + two controls through its `model` and `reasoning_effort` inputs, which replace what the agent + saved: `"model": { "type": "javascript", "expr": "flow_input.model" }`. An empty model keeps the + saved one, an empty effort sends none, and the provider resource always stays the agent's - `memory` is what lets the agent see earlier turns; without it every message starts from nothing - `streaming` on makes the answer and its thinking appear token by token instead of all at once - `user_attachments` points at a flow input typed as an array of s3 objects @@ -598,6 +602,14 @@ In the flow schema, set the property type to `"object"` with format `"resource-{ } ``` +### When No Resource Type Fits + +A 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_<name>` 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. + +- 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. +- A plain variable is fine when the user asks for it. + ### As Step Input (Static Reference) Reference a specific resource using `$res:` prefix: diff --git a/system_prompts/base/raw-app.md b/system_prompts/base/raw-app.md index efe05f2c93..69c03594db 100644 --- a/system_prompts/base/raw-app.md +++ b/system_prompts/base/raw-app.md @@ -139,7 +139,7 @@ An app can be demoed by recording a session: every interaction becomes a step ca Apply 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. -### Chat UIs over a flow in chat mode +### Chat UIs over a flow in chat mode or a saved agent A 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. @@ -152,9 +152,38 @@ const chat = useWindmillChat({ flowPath: 'f/support/assistant' }) `@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. +#### A saved agent: an `agent` runnable + +Chat 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: + +<!-- cli-only --> +In `raw_app.yaml`: + +```yaml +runnables: + chat: + type: agent + path: f/support/agent +``` +<!-- /cli-only --> +<!-- chat-only --> +```json +{ "chat": { "type": "agent", "path": "f/support/agent" } } +``` +<!-- /chat-only --> + +```tsx +import { useWindmillChat } from '@windmill-labs/chat-sdk/react' +import * as wmill from './wmill' + +const chat = useWindmillChat({ wmill, runnable: 'chat' }) +``` + +`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. + ## Backend runnables -Each runnable has a unique key (used to call it from the frontend) and one of four types: +Each runnable has a unique key (used to call it from the frontend) and one of five types: | Type | What it is | |---|---| @@ -162,6 +191,7 @@ Each runnable has a unique key (used to call it from the frontend) and one of fo | `script` | Reference to an existing workspace script by path. | | `flow` | Reference to an existing workspace flow by path. | | `hubscript` | Reference to a hub script by path. | +| `agent` | Reference to a saved AI agent by path. Each call is one turn of its chat (see the chat section above). | ### Inline runnables @@ -193,6 +223,10 @@ def main(user_id: str): return user ``` +#### Sharing code between runnables + +An 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. + #### The `wmill` client is already authenticated An 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. @@ -201,9 +235,9 @@ An inline runnable runs as an ordinary Windmill job. `import * as wmill from 'wi Use 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. -### Path runnables (script / flow / hubscript) +### Path runnables (script / flow / hubscript / agent) -When `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. +When `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. Before 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. @@ -212,7 +246,7 @@ Before writing an inline runnable, look for a workspace script or flow that alre This decides whether an app works before anything is deployed: - **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. -- **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. +- **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. So 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. @@ -240,16 +274,25 @@ Either one lets those people run the app's backend runnables, so never open an a <!-- chat-only --> When a deploy widens who may open the app, tell the user in plain words. <!-- /chat-only --> +<!-- cli-only --> + +An app's custom URL is not in `raw_app.yaml`: each workspace has its own file next to the app folder, `<app path>.<workspace>.custom_path.yaml` (for example `f/sales/dashboard.prod.custom_path.yaml` holding `custom_path: dashboard`), where `<workspace>` is the workspace's name in `wmill.yaml`, or its id. A push applies only the target workspace's file and leaves the deployed custom URL alone when there is none, so deleting the file does not remove the URL: `custom_path: ''` does. Setting or clearing one takes a workspace admin. +<!-- /cli-only --> ## Sandbox isolation and frontend API access -By default a deployed app's frontend runs with the viewer's own Windmill session. A sandboxed app is isolated from it: the frontend reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. The scopes are `jobs:run`, `jobs:read`, `users:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. +New 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. + +Inside 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. +<!-- chat-only --> +You cannot turn isolation off; the user does it in the app's deploy settings. +<!-- /chat-only --> <!-- cli-only --> In `raw_app.yaml`: ```yaml sandbox: true -frontend_api: [jobs:run, users:read] +frontend_api: [jobs:run] ``` A file with a `sandbox` key states both settings: `sandbox: false` turns isolation off, and a missing `frontend_api` means no scopes. A file without the key leaves the deployed app's isolation and scopes as they are, so removing `sandbox: true` does not unsandbox it. `frontend_api` needs `sandbox: true`; the push fails otherwise. @@ -332,4 +375,4 @@ def main(user_id: str): 7. **Mark sensitive UI with `data-wm-no-record`** — it is what keeps that data out of a recorded demo; passwords are handled for you. 8. **Reach for `backendAsync` + `waitJob`** for long work — never a hand-written job-polling runnable. 9. **Deploy what a path runnable points at** — a path runnable aimed at a draft fails at runtime; tell the user what needs deploying. -10. **Use `@windmill-labs/chat-sdk` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream. +10. **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 }`. diff --git a/system_prompts/base/resources.md b/system_prompts/base/resources.md index 2929051a03..652bec1964 100644 --- a/system_prompts/base/resources.md +++ b/system_prompts/base/resources.md @@ -31,6 +31,12 @@ Example: `f/databases/postgres_prod.resource.json` - `value` - Object containing the resource configuration - `resource_type` - Name of the resource type (e.g., "postgresql", "slack") +## Agents + +An agent, as the Agents page lists it, is a resource of type `ai_agent`. Its value holds the +agent's provider, system prompt, tools and memory settings, and a flow step uses a saved agent by +linking its path. + ## Variable References Reference variables in resource values: @@ -213,13 +219,23 @@ The prefix must be on the string itself. } ``` +## When No Resource Type Fits + +The Windmill Hub publishes resource types for most services, and an instance gets them by syncing with the hub. When none fits the service: + +- 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. +- 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. +- 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. + ## Custom Resource Types +A 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. + Create custom resource types with JSON Schema: ```json { - "name": "custom_api", + "name": "c_custom_api", "schema": { "type": "object", "properties": { @@ -233,7 +249,7 @@ Create custom resource types with JSON Schema: } ``` -Save as: `custom_api.resource-type.json` +Save as: `c_custom_api.resource-type.json` ## OAuth Resources diff --git a/system_prompts/base/script-base.md b/system_prompts/base/script-base.md index 9bd878ea9b..0a9f313228 100644 --- a/system_prompts/base/script-base.md +++ b/system_prompts/base/script-base.md @@ -3,6 +3,11 @@ ## General Principles - 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 +- 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_<name>`, which keeps it apart from a hub type synced later: the parameter takes `RT.C<Name>` in TypeScript, a `c_<name>` 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 +- 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 +<!-- cli-only --> +- Before relying on a resource type, a hub script's included, run `wmill resource-type list`: it shows whether the workspace has the type, and warns when the instance has not synced the hub's types +<!-- /cli-only --> - Libraries are installed automatically - do not show installation instructions - 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 - Where the language has a Windmill client (`wmill`), use it to interact with the platform diff --git a/system_prompts/base/workspace-settings.md b/system_prompts/base/workspace-settings.md new file mode 100644 index 0000000000..ba4e863b85 --- /dev/null +++ b/system_prompts/base/workspace-settings.md @@ -0,0 +1,81 @@ +# Workspace settings + +Everything an admin sets on the Workspace settings page is one document: a mapping of top-level +keys, the same layout as the `settings.yaml` file `wmill sync` keeps. Only a workspace admin can +read or change it. + +## Applying a document + +- A partial document changes only the keys it states; every key it omits is left as it is. +- Each key is replaced whole, so read its current value and send it back edited: a nested object + given with one field replaces the stored object with that one field. +- A key set to `null` clears that setting; an on/off switch cleared this way reads as `false`. +- Each setting is written through the same path the settings page uses, with the same validation + and audit log. The writes are not one transaction: when one fails the apply stops, and the + error says what was already written. +- A dry run reports the changes, as `before` / `after` per key, without writing anything. +- `name` is never applied: rename a workspace on the settings page. `color` is applied only when + set to a value, so `null` does not clear it. +- A key the server does not know is ignored and named in the warnings: check them, since a typo + otherwise reads as "nothing to change". +- A setting this edition does not offer (Enterprise only) is refused, by the dry run too. + +## Secrets + +`slack_oauth_client_secret` is the one secret. A read shows `__SENSITIVE_AND_UNCHANGED__` in place +of a set secret; sending that placeholder back keeps the stored value. A value starting with +`$encrypted:` is decrypted with the workspace key, and one encrypted with another workspace's key +is left unchanged with a warning; any other value is taken as plaintext. Never +write a secret you were not given: keep the placeholder. + +## Keys + +| Key | Value | +|---|---| +| `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. | +| `error_handler` / `success_handler` | `{ path, extra_args?, muted_on_cancel?, muted_on_user_path? }`, `path` being `script/<path>` or `flow/<path>`. | +| `error_handler_fallback_to_instance_alerts` | Report failures to the instance critical alert channels when no error handler is set. | +| `webhook` | URL POSTed a JSON event on every change to the workspace's items. | +| `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_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, ... } } }`. | +| `dbt_warehouses` | dbt warehouse connections. | +| `default_app` | Path of the app shown as the workspace home. | +| `default_scripts` | `{ order?, hidden?, default_script_content? }` for the script language picker. | +| `deploy_ui` | `{ include_path?, include_type? }`: what the deploy-to-workspace UI offers. | +| `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. | +| `mute_critical_alerts` | Mute the workspace's critical alerts. | +| `slack_team_id`, `slack_name` | Read only: set by connecting Slack on the settings page. | +| `slack_command_script` / `teams_command_script` | Script or flow handling the Slack / Teams command. | +| `slack_oauth_client_id` / `slack_oauth_client_secret` | The workspace's own Slack OAuth app. Clearing either removes both. | +| `public_app_execution_limit_per_minute` | Rate limit for public apps, `null` for none. | +| `guest_access_enabled`, `guest_jwt_public_key`, `guest_jwt_jwks_url` | Guest access to public apps and the key that signs guest JWTs. | +| `add_admins_and_developers_to_forks` | Add the workspace's admins and developers to each fork. | +| `color` | Workspace color, `#rrggbb`. | +<!-- cli-only --> + +## From the command line + +`wmill workspace get-settings` prints the document (`--encrypted` for secrets encrypted with the +workspace key, `--json` for JSON). `wmill workspace apply-settings <file>` applies a partial +document from a YAML or JSON file: it shows the changes and asks before writing (`--dry-run` to +only show them, `-y` to skip the question, `--replace` to clear what the file omits). + +## In a synced repository + +With `includeSettings: true` in `wmill.yaml`, `wmill sync pull` writes the document to +`settings.yaml`, secrets encrypted with the workspace key (plaintext only with `plainSecrets`), and +`wmill sync push` applies it. Unlike a partial apply, a push treats the file as the whole +document: a setting the file omits is cleared, except `operator_settings`, `color`, the auto-invite +instance groups, and the keys `ducklake`, `dbt_warehouses`, `deploy_ui`, `teams_command_script`, +`public_app_execution_limit_per_minute`, `guest_access_enabled`, `guest_jwt_public_key`, +`guest_jwt_jwks_url`, `error_handler_fallback_to_instance_alerts` and +`add_admins_and_developers_to_forks`, which are managed only when the file states them. +`datatable_permissions` is pushed only with `includeDatatablePermissions: true` (or +`--include-datatable-permissions`). A push ignores `protection_rules`: `protection-rules.yaml` and +`wmill protection-rules push` sync them. +<!-- /cli-only --> diff --git a/system_prompts/generate.py b/system_prompts/generate.py index 055f03d543..7249954d01 100644 --- a/system_prompts/generate.py +++ b/system_prompts/generate.py @@ -1702,6 +1702,12 @@ TOPICS: dict[str, dict] = { 'chat_helper': 'getDatatablePrompt', 'parts': ['datatable.md'], }, + 'workspace_settings': { + 'skill': 'workspace-settings', + 'description': 'MUST use when reading or changing workspace settings, or the settings.yaml file `wmill sync` keeps.', + 'chat_helper': 'getWorkspaceSettingsPrompt', + 'parts': ['workspace-settings.md'], + }, 'cli_commands': { 'skill': 'cli-commands', 'description': 'MUST use when using the CLI, including debugging job failures and inspecting run history via `wmill job`.', @@ -1722,6 +1728,7 @@ CHAT_CONSTANTS = { 'raw-app.md': 'RAW_APP_BASE', 'pipeline-base.md': 'PIPELINE_BASE', 'datatable.md': 'DATATABLE_BASE', + 'workspace-settings.md': 'WORKSPACE_SETTINGS_BASE', 'workflow-as-code.md': 'WORKFLOW_AS_CODE_BASE', 'flow-chat-special-modules.md': 'FLOW_CHAT_SPECIAL_MODULES', } @@ -2662,6 +2669,11 @@ export function getDatatablePrompt(): string { __RETURN_datatable__ } +// Helper for the workspace settings document (chat consumers) +export function getWorkspaceSettingsPrompt(): string { + __RETURN_workspace_settings__ +} + // Helper to get the datatable SQL SDK reference (wmill.datatable()). // Pass a language to get only that SDK; omit it to get both. export function getDatatableSdkReference(language?: string): string { @@ -2724,6 +2736,7 @@ export declare function getResourcePrompt(): string; export declare function getRawAppPrompt(language?: string): string; export declare function getPipelinePrompt(): string; export declare function getDatatablePrompt(): string; +export declare function getWorkspaceSettingsPrompt(): string; export declare function getDatatableSdkReference(language?: string): string; export declare function getWorkflowAsCodePrompt(language?: string): string; """ diff --git a/system_prompts/languages/bunnative.md b/system_prompts/languages/bunnative.md index af9055f01d..3d5e7bccbc 100644 --- a/system_prompts/languages/bunnative.md +++ b/system_prompts/languages/bunnative.md @@ -49,7 +49,7 @@ export async function main(url: string) { ## Pure computation: `//no_network` -A 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. It only takes effect on native scripts: without `//native` it is ignored. Use it only when the script needs no external data beyond its arguments: +A 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: ```typescript //native diff --git a/typescript-client/client.ts b/typescript-client/client.ts index da957239cf..dbb667a3aa 100644 --- a/typescript-client/client.ts +++ b/typescript-client/client.ts @@ -137,7 +137,9 @@ export async function getResource( return undefined; } else { throw Error( - `Resource not found at ${path} or not visible to you: ${e.body}` + e.status === undefined + ? `Could not reach the Windmill API to get resource ${path}: ${e.message ?? e}` + : `Resource not found at ${path} or not visible to you: ${e.body}` ); } } @@ -831,7 +833,9 @@ export async function getVariable(path: string): Promise<string> { return await VariableService.getVariableValue({ workspace, path }); } catch (e: any) { throw Error( - `Variable not found at ${path} or not visible to you: ${e.body}` + e.status === undefined + ? `Could not reach the Windmill API to get variable ${path}: ${e.message ?? e}` + : `Variable not found at ${path} or not visible to you: ${e.body}` ); } } diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index d7a04fff19..2a1a7b3c15 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.833.0", + "version": "1.847.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 42ea8acded..5f7b4f3fef 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.833.0", + "version": "1.847.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 d032a2f6fc..44bc59b37f 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.833.0 +1.847.0 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index 61b9f70fd1..fe642da3da 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.833.0", + "version": "1.847.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.833.0", + "version": "1.847.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 d14defa2ad..43d8faa83f 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.833.0", + "version": "1.847.0", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts",