diff --git a/.github/workflows/codex-pr-review.yml b/.github/workflows/codex-pr-review.yml index b4916714f2..66cc4b5f10 100644 --- a/.github/workflows/codex-pr-review.yml +++ b/.github/workflows/codex-pr-review.yml @@ -20,6 +20,8 @@ on: type: string default: '' secrets: + OPENAI_API_KEY: + required: false CODEX_AUTH_JSON: required: false WINDMILL_EE_PRIVATE_ACCESS: @@ -60,13 +62,18 @@ jobs: - name: Check Codex configuration id: codex_config env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }} run: | - if [ -n "$CODEX_AUTH_JSON" ]; then + if [ -n "$OPENAI_API_KEY" ]; then echo "enabled=true" >> "$GITHUB_OUTPUT" + echo "auth_mode=api_key" >> "$GITHUB_OUTPUT" + elif [ -n "$CODEX_AUTH_JSON" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + echo "auth_mode=oauth_json" >> "$GITHUB_OUTPUT" else echo "enabled=false" >> "$GITHUB_OUTPUT" - echo "CODEX_AUTH_JSON is not configured; skipping Codex review." + echo "Codex auth is not configured; set OPENAI_API_KEY or CODEX_AUTH_JSON to enable Codex review." fi - name: Resolve PR metadata @@ -169,9 +176,10 @@ jobs: if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' run: npm install --global @openai/codex@0.128.0 - - name: Configure file-backed Codex auth + - name: Configure Codex auth if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }} run: | CODEX_HOME="$HOME/.codex" @@ -181,9 +189,13 @@ jobs: cat > "$CODEX_HOME/config.toml" <<'EOF' cli_auth_credentials_store = "file" EOF - printf '%s' "$CODEX_AUTH_JSON" > "$CODEX_HOME/auth.json" - chmod 600 "$CODEX_HOME/auth.json" - node -e 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))' "$CODEX_HOME/auth.json" + if [ -n "$OPENAI_API_KEY" ]; then + printf '%s' "$OPENAI_API_KEY" | codex login --with-api-key + else + printf '%s' "$CODEX_AUTH_JSON" > "$CODEX_HOME/auth.json" + chmod 600 "$CODEX_HOME/auth.json" + node -e 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))' "$CODEX_HOME/auth.json" + fi - name: Pre-fetch base and head refs for the PR if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' diff --git a/.github/workflows/pr-review-commands.yml b/.github/workflows/pr-review-commands.yml index 21d5e1decd..ba55bfea2f 100644 --- a/.github/workflows/pr-review-commands.yml +++ b/.github/workflows/pr-review-commands.yml @@ -100,6 +100,7 @@ jobs: extra_prompt: ${{ needs.parse.outputs.extra_prompt }} triggered_by: ${{ github.event.comment.user.login }} secrets: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }} WINDMILL_EE_PRIVATE_ACCESS: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }} diff --git a/.github/workflows/publish-cli-docs.yml b/.github/workflows/publish-cli-docs.yml new file mode 100644 index 0000000000..9e76117eb5 --- /dev/null +++ b/.github/workflows/publish-cli-docs.yml @@ -0,0 +1,84 @@ +name: Publish CLI docs repo + +# Regenerates the windmill-cli-docs repo (consumed by context7) from the +# canonical sources in this repo on every Windmill release. +# +# Required secret: +# CLI_DOCS_DEPLOY_KEY — ed25519 private key whose public half is registered +# as a write-access deploy key on +# windmill-labs/windmill-cli-docs. + +on: + push: + tags: + - "v*" + workflow_dispatch: + +# Serialize pushes to windmill-cli-docs so two release tags landing close +# together (e.g. a release-please bump + a hotfix) can't race to force-push +# the docs repo. +concurrency: + group: publish-cli-docs + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout windmill (source of truth) + uses: actions/checkout@v4 + with: + path: windmill + + - name: Checkout windmill-cli-docs (publish target) + uses: actions/checkout@v4 + with: + repository: windmill-labs/windmill-cli-docs + path: windmill-cli-docs + ssh-key: ${{ secrets.CLI_DOCS_DEPLOY_KEY }} + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install pyyaml + + - name: Regenerate docs + run: | + python3 windmill/system_prompts/generate.py \ + --context7-dir "$GITHUB_WORKSPACE/windmill-cli-docs" + + - name: Commit and push if changed + working-directory: windmill-cli-docs + env: + REF_NAME: ${{ github.ref_name }} + REF_TYPE: ${{ github.ref_type }} + run: | + git config user.name "windmill-bot" + git config user.email "bot@windmill.dev" + git add -A + if git diff --cached --quiet; then + echo "No doc changes for ${REF_NAME}." + committed=false + else + committed=true + if [ "${REF_TYPE}" = "tag" ]; then + git commit -m "chore: sync from windmill ${REF_NAME}" + else + git commit -m "chore: sync from windmill (manual dispatch from ${REF_NAME})" + fi + git push origin HEAD + fi + # Always mirror the version tag on tag pushes, even when content + # didn't change — downstream consumers tie snapshots to releases by + # tag, and skipping it would leave the docs repo without a tag for + # the new Windmill release. + # workflow_dispatch from a non-tag ref skips this so we don't + # create a junk tag named after a branch. + if [ "${REF_TYPE}" = "tag" ]; then + git tag -f "${REF_NAME}" + git push origin "${REF_NAME}" --force + echo "Mirrored tag ${REF_NAME} to windmill-cli-docs (content changed: ${committed})." + fi diff --git a/.gitignore b/.gitignore index 10889080ad..5f733611de 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ rust-client/Cargo.toml # Worktree-specific Claude Code settings (generated by scripts/worktree-env) .claude/settings.local.json +.claude/worktrees/ # Symlinked cache directories (for git worktrees) backend/target diff --git a/CHANGELOG.md b/CHANGELOG.md index 98cdc3b50a..c3524f552c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,88 @@ # Changelog +## [1.702.1](https://github.com/windmill-labs/windmill/compare/v1.702.0...v1.702.1) (2026-05-14) + + +### Bug Fixes + +* **nativets:** pass tracing-enabled OtelConfig to deno_telemetry::init ([#9163](https://github.com/windmill-labs/windmill/issues/9163)) ([bf99283](https://github.com/windmill-labs/windmill/commit/bf99283c3333bcdbc7679f4aea04ba29e41a48a5)) + +## [1.702.0](https://github.com/windmill-labs/windmill/compare/v1.701.0...v1.702.0) (2026-05-14) + + +### Features + +* **git-sync:** sync extra_perms for flows/scripts/apps ([#9162](https://github.com/windmill-labs/windmill/issues/9162)) ([5e909b2](https://github.com/windmill-labs/windmill/commit/5e909b2b4f2819f19deaf06d9e78e6458b324683)) +* include service accounts in instance settings users list ([#9157](https://github.com/windmill-labs/windmill/issues/9157)) ([e5286f4](https://github.com/windmill-labs/windmill/commit/e5286f46074cf2893e6ccd26175f929f16011c8f)) + + +### Bug Fixes + +* **mcp:** sanitize and enrich nested resource schemas ([#9158](https://github.com/windmill-labs/windmill/issues/9158)) ([d870edc](https://github.com/windmill-labs/windmill/commit/d870edc959481a06c894b4eda5e2be1a0269d7d0)) + +## [1.701.0](https://github.com/windmill-labs/windmill/compare/v1.700.2...v1.701.0) (2026-05-13) + + +### Features + +* **frontend:** unified EditorHeader with file picker for flow/script/app editors ([#9047](https://github.com/windmill-labs/windmill/issues/9047)) ([d0f23cc](https://github.com/windmill-labs/windmill/commit/d0f23cc5238b025208c61e983701894de28536d5)) +* read-only flag on API tokens ([#9144](https://github.com/windmill-labs/windmill/issues/9144)) ([d666e84](https://github.com/windmill-labs/windmill/commit/d666e8431cdbf14d9373d9ef625b5aafc50ac50a)) + + +### Bug Fixes + +* align script path existence check with deploy logic; hide Delete for non-admin ([#9152](https://github.com/windmill-labs/windmill/issues/9152)) ([c509206](https://github.com/windmill-labs/windmill/commit/c5092069cbeda2c4c18bea80dd629c7c087b30bf)) +* Allow devops role to use all_workspaces runs filter in admins workspace ([#9153](https://github.com/windmill-labs/windmill/issues/9153)) ([110bef0](https://github.com/windmill-labs/windmill/commit/110bef0a6e76615c7b371c5c0f5bc1f4e7a73a64)) +* **bun:** pass --preserve-symlinks on unbundled execution ([#9147](https://github.com/windmill-labs/windmill/issues/9147)) ([4d0f2c2](https://github.com/windmill-labs/windmill/commit/4d0f2c26a116a0f8a89a64231dc824eabda0a8c3)) +* **cli:** prevent !inline-corruption in flow push/pull ([#9142](https://github.com/windmill-labs/windmill/issues/9142)) ([79c5b7b](https://github.com/windmill-labs/windmill/commit/79c5b7b8b7676b0a06fa6480dd04b7105d39d250)) +* **operator:** refresh IAM RDS / Entra ID tokens in operator process ([#9141](https://github.com/windmill-labs/windmill/issues/9141)) ([7ebb081](https://github.com/windmill-labs/windmill/commit/7ebb08133cd4027bc00bacc4a0fc5865cd5709ec)) +* **python:** preserve strings containing Infinity/NaN in result JSON ([#9149](https://github.com/windmill-labs/windmill/issues/9149)) ([33bf01b](https://github.com/windmill-labs/windmill/commit/33bf01b627c8ea430c03dfc27a97a8f2d770582f)) +* scope promotion-mode debounce key per repo ([#9145](https://github.com/windmill-labs/windmill/issues/9145)) ([2ec1863](https://github.com/windmill-labs/windmill/commit/2ec1863340e759bba3408dbc4f41b16912b959ea)) +* send flow push-loop ping outside transaction so zombie monitor sees it ([#9136](https://github.com/windmill-labs/windmill/issues/9136)) ([818cb31](https://github.com/windmill-labs/windmill/commit/818cb31fbc731fa5c70ddf5942bb37bc4bc56e4d)) + + +### Performance Improvements + +* **dynselect:** only retrigger when helper args actually change ([#9148](https://github.com/windmill-labs/windmill/issues/9148)) ([dd19e52](https://github.com/windmill-labs/windmill/commit/dd19e52a84fb9a9f48e3ad061b084841c2ee7464)) + +## [1.700.2](https://github.com/windmill-labs/windmill/compare/v1.700.1...v1.700.2) (2026-05-12) + + +### Bug Fixes + +* preserve explicit nulls for typed fields in bulk instance config ([#9123](https://github.com/windmill-labs/windmill/issues/9123)) ([cab0000](https://github.com/windmill-labs/windmill/commit/cab0000f3a5e9a0b201a85da1a01b1f82df8a316)) +* preserve negative integers in Bedrock tool schema conversion ([#9116](https://github.com/windmill-labs/windmill/issues/9116)) ([01e21c7](https://github.com/windmill-labs/windmill/commit/01e21c7f913eaf7dffc3d6a31501418ff2104c8b)) + +## [1.700.1](https://github.com/windmill-labs/windmill/compare/v1.700.0...v1.700.1) (2026-05-11) + + +### Bug Fixes + +* CE build broken by enterprise-gated compute_instance_hash ([#9113](https://github.com/windmill-labs/windmill/issues/9113)) ([cd65de4](https://github.com/windmill-labs/windmill/commit/cd65de49285ff60abdd94c883180ded65609f382)) + +## [1.700.0](https://github.com/windmill-labs/windmill/compare/v1.699.0...v1.700.0) (2026-05-11) + + +### Features + +* **cli:** auto-infer args for `wmill app push` ([#9091](https://github.com/windmill-labs/windmill/issues/9091)) ([43b1800](https://github.com/windmill-labs/windmill/commit/43b18006f32fd5db54bbf8ae7ff0e0b314a517e5)) +* **forks:** prompt to delete forked children when deleting a fork ([#9097](https://github.com/windmill-labs/windmill/issues/9097)) ([e43a958](https://github.com/windmill-labs/windmill/commit/e43a958c5c6ae01a1fbecf3db63c6541a245be62)) +* **operators:** allow operators to access assets page ([#9095](https://github.com/windmill-labs/windmill/issues/9095)) ([20ecd90](https://github.com/windmill-labs/windmill/commit/20ecd904e7060c3cf90f2605740bb349b2a3e6ed)) +* **vault:** configurable JWT auth mount path and setup-doc fixes ([#9100](https://github.com/windmill-labs/windmill/issues/9100)) ([f8ba084](https://github.com/windmill-labs/windmill/commit/f8ba0840d74572c880cf458938365b3ec808c6fb)) + + +### Bug Fixes + +* add Input, Result, Trigger to reserved flow step IDs ([#9109](https://github.com/windmill-labs/windmill/issues/9109)) ([9f79a86](https://github.com/windmill-labs/windmill/commit/9f79a86a686708f66ccc512d4f132cb9a00397a7)), closes [#7139](https://github.com/windmill-labs/windmill/issues/7139) +* **frontend:** mark Path dirty when folder picker changes selection ([#9096](https://github.com/windmill-labs/windmill/issues/9096)) ([23bb1b5](https://github.com/windmill-labs/windmill/commit/23bb1b541e78846d5978153fd8d9bb4f01cec72b)) +* mask oauth client secret in instance settings ([#9112](https://github.com/windmill-labs/windmill/issues/9112)) ([ac3c155](https://github.com/windmill-labs/windmill/commit/ac3c155541eb5ca20d65c38ad13dca6c10a572c9)) +* populate raw_code for flowscript and appscript runs ([#9104](https://github.com/windmill-labs/windmill/issues/9104)) ([05172ac](https://github.com/windmill-labs/windmill/commit/05172ac3bdfc3472da5e9d8a825cdd479ba9e375)) + + +### Performance Improvements + +* lazy-load script editor history and hit partial index ([#9107](https://github.com/windmill-labs/windmill/issues/9107)) ([03e8bc8](https://github.com/windmill-labs/windmill/commit/03e8bc8c14258355d7d695333c1588807fbf8cd6)) + ## [1.699.0](https://github.com/windmill-labs/windmill/compare/v1.698.0...v1.699.0) (2026-05-08) diff --git a/ai_evals/AGENTS.md b/ai_evals/AGENTS.md index 096baf5b58..d26e6d60ea 100644 --- a/ai_evals/AGENTS.md +++ b/ai_evals/AGENTS.md @@ -6,6 +6,7 @@ This folder contains black-box benchmark cases for: - `app` - `script` - `cli` +- `global` The goal is to test the current production prompts and guidance with realistic user requests, not to test one exact implementation shape. @@ -75,6 +76,16 @@ Still, avoid benchmark phrasing. The prompt should read like a repo task, not a When relevant, ask the assistant to tell the user which `wmill` commands to run next. That is part of the benchmarked behavior. +## Global-specific rules + +Global prompts should exercise workspace-level drafting behavior: + +- inspecting existing scripts, flows, apps, schedules, triggers, resources, and variables when relevant +- writing AI drafts rather than saving or deploying by default +- producing coherent multi-artifact changes when the request crosses artifact boundaries + +Keep deterministic validation focused on the draft contract: required draft type/path, required content snippets, forbidden draft paths, and forbidden mutating tools such as deploy/delete unless the case explicitly asks for them. + ## Deterministic validation Use deterministic validation only for hard failures such as: diff --git a/ai_evals/README.md b/ai_evals/README.md index 267451aabf..6982d70da9 100644 --- a/ai_evals/README.md +++ b/ai_evals/README.md @@ -1,11 +1,12 @@ # AI Evals -Small benchmark runner for the four Windmill AI generation modes: +Small benchmark runner for the Windmill AI generation modes: - `cli` - `flow` - `script` - `app` +- `global` The benchmark always tests the current production prompts, tools, and guidance in this checkout. @@ -55,8 +56,9 @@ bun run cli -- run flow flow-test4-order-processing-loop --model opus bun run cli -- run flow flow-test0-sum-two-numbers --models haiku,opus,4o bun run cli -- run flow flow-test0-sum-two-numbers --runs 3 --verbose bun run cli -- run flow --record -GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-pro --transport proxy +GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-pro WMILL_AI_EVAL_BACKEND_URL=http://127.0.0.1:8000 bun run cli -- run flow --backend-validation preview +bun run cli -- run global global-test1-script-create bun run cli -- run cli bun-hello-script ``` @@ -72,7 +74,6 @@ Public CLI surface: - `--output `: custom result JSON path - `--model `: choose the model under test - `--models `: run the same cases sequentially against several model aliases -- `--transport `: frontend request transport (`direct` by default, `proxy` to exercise `/api/w/{workspace}/ai/proxy`) - `--verbose`: stream assistant output for frontend runs - `--record`: append a compact tracked summary line to `ai_evals/history/.jsonl` for full-suite runs only - `--backend-validation `: optional backend smoke validation (`off` or `preview`) for `script` and `flow` evals @@ -95,7 +96,7 @@ Today: Notes: - the command also prints accepted alias spellings such as `gpt-4o`, `claude-opus-4.6`, and `claude-haiku-4.5` -- frontend modes (`flow`, `script`, `app`) can use Anthropic, OpenAI, and Gemini-backed aliases +- frontend modes (`flow`, `script`, `app`, `global`) can use Anthropic, OpenAI, and Gemini-backed aliases - `cli` mode always uses the Anthropic agent SDK, so only Anthropic aliases are valid there - the judge model is separate and currently defaults to `claude-sonnet-4-6` @@ -134,6 +135,13 @@ For `app` mode, `validate` can express narrow hard requirements such as: - minimum datatable / datatable-table counts - specific required datatable tables +For `global` mode, `validate` can express draft-level requirements such as: + +- required draft type/path/language +- required or forbidden snippets in draft values +- required or forbidden draft counts +- forbidden draft paths + App fixtures can also include an optional `datatables.json` file at the fixture root. For `flow` mode, an `initial` fixture can also include a benchmark workspace catalog of @@ -145,26 +153,23 @@ If `--backend-validation preview` is enabled: - `script` evals run a real backend script preview in an isolated temp workspace - `flow` evals run a real backend flow preview only for cases that define `runtime.backendPreview` - `flow` cases with `initial.workspace` fixtures seed those scripts and flows into the preview workspace before preview -- when `WMILL_AI_EVAL_BACKEND_WORKSPACE` is set, `ai_evals` treats that workspace as a dedicated test workspace, clears managed eval assets under `f/evals/*` before each preview run, and then reseeds the current case fixtures +- when `WMILL_AI_EVAL_BACKEND_WORKSPACE` is set, `ai_evals` creates or reuses that workspace as a dedicated test workspace, clears managed eval assets under `f/evals/*` before each preview run, and then reseeds the current case fixtures -Supported backend validation env vars: +Supported backend env vars: - `WMILL_AI_EVAL_BACKEND_VALIDATION=preview` - `WMILL_AI_EVAL_BACKEND_URL=http://127.0.0.1:8000` - `WMILL_AI_EVAL_BACKEND_EMAIL=admin@windmill.dev` - `WMILL_AI_EVAL_BACKEND_PASSWORD=changeme` - `WMILL_AI_EVAL_BACKEND_WORKSPACE=integration-tests` to reuse an existing workspace on CE installs with low workspace limits -- `WMILL_AI_EVAL_KEEP_WORKSPACES=1` -- `WMILL_AI_EVAL_WORKSPACE_PREFIX=ai-evals` -Frontend proxy transport uses the same backend auth/workspace env vars. +Frontend modes require a reachable Windmill backend and send model requests through the workspace AI proxy at `/api/w/{workspace}/ai/proxy`. At startup, `ai_evals` checks the resolved backend URL and fails early with setup guidance if the backend cannot be reached or login fails. -When `--transport proxy` is set: +For frontend modes: -- `ai_evals` creates or reuses a backend workspace +- `ai_evals` creates a temporary backend workspace, or creates/reuses `WMILL_AI_EVAL_BACKEND_WORKSPACE` when it is set - it upserts a provider resource under `f/evals/ai/` - frontend requests go through `/api/w/{workspace}/ai/proxy` -- result JSON and history records include `transport` so direct vs proxy runs stay distinguishable ## Results And Artifacts @@ -178,11 +183,12 @@ If `--record` is used, the CLI also appends one compact JSON line to: - `ai_evals/history/flow.jsonl` - `ai_evals/history/script.jsonl` - `ai_evals/history/app.jsonl` +- `ai_evals/history/global.jsonl` - `ai_evals/history/cli.jsonl` Each recorded line contains: -- run metadata (`createdAt`, `gitSha`, `mode`, `runModel`, `transport`, `judgeModel`) +- run metadata (`createdAt`, `gitSha`, `mode`, `runModel`, `judgeModel`) - suite totals (`caseCount`, `attemptCount`, `passedAttempts`, `passRate`, `averageDurationMs`, `averageJudgeScore`) - average token usage (`averageTokenUsagePerAttempt`) - per-case metrics under `cases[]` (`averageDurationMs`, `averageJudgeScore`, `averageTokenUsagePerAttempt`, pass rate) @@ -198,6 +204,7 @@ Typical artifacts by mode: - `flow`: `flow.json` - `script`: `script.json` plus the generated script file - `app`: `app.json` plus frontend/backend files +- `global`: `global-drafts.json` - `cli`: `assistant-output.txt`, `trace.json`, `wmill-invocations.jsonl`, plus generated workspace files - backend-validated attempts also include `backend-preview.json` @@ -213,6 +220,7 @@ Typical artifacts by mode: ## Notes - Frontend modes reuse the production frontend chat code through the Vitest bridge. +- Global mode evaluates the production global AI tools and validates the resulting AI draft store. - CLI mode creates an isolated workspace, writes the current checkout guidance into it, and benchmarks the real skills / `AGENTS.md` flow. - CLI mode now also records a structured trace of invoked skills, tool calls, proposed `wmill` commands, and any attempted `wmill` executions. - Frontend progress streams live while the benchmark is running. diff --git a/ai_evals/adapters/frontend/backendPreview.test.ts b/ai_evals/adapters/frontend/backendPreview.test.ts index 2f12c9a896..d4de361333 100644 --- a/ai_evals/adapters/frontend/backendPreview.test.ts +++ b/ai_evals/adapters/frontend/backendPreview.test.ts @@ -210,8 +210,6 @@ function buildSettings( baseUrl: 'http://backend.test/default', email: 'admin@windmill.dev', password: 'changeme', - keepWorkspaces: true, - workspacePrefix: 'ai-evals', pollIntervalMs: 1, maxWaitMs: 50, ...overrides diff --git a/ai_evals/adapters/frontend/backendPreview.ts b/ai_evals/adapters/frontend/backendPreview.ts index e1be934564..57e1cfdf2a 100644 --- a/ai_evals/adapters/frontend/backendPreview.ts +++ b/ai_evals/adapters/frontend/backendPreview.ts @@ -24,6 +24,7 @@ export interface CompletedPreviewJob { const tokenCache = new Map>() const sharedWorkspaceQueue = new Map>() const managedSharedWorkspacePrefixes = ['f/evals/'] +const DEFAULT_WORKSPACE_PREFIX = 'ai-evals' export class BackendPreviewClient { constructor(private readonly settings: BackendValidationSettings) {} @@ -35,7 +36,7 @@ export class BackendPreviewClient { ): Promise { const workspaceId = this.settings.workspaceOverride ?? - buildWorkspaceId(this.settings.workspacePrefix, caseId, attempt) + buildWorkspaceId(caseId, attempt) const run = async () => { await this.ensureWorkspace(workspaceId) @@ -46,7 +47,7 @@ export class BackendPreviewClient { try { return await body(workspaceId) } finally { - if (!this.settings.keepWorkspaces && !this.settings.workspaceOverride) { + if (!this.settings.workspaceOverride) { await this.deleteWorkspace(workspaceId).catch(() => undefined) } } @@ -440,14 +441,14 @@ async function withSharedWorkspaceLock(workspaceId: string, body: () => Promi } } -function buildWorkspaceId(prefix: string, caseId: string, attempt: number): string { +function buildWorkspaceId(caseId: string, attempt: number): string { const caseSlug = caseId .toLowerCase() .replace(/[^a-z0-9-]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 30) const suffix = randomUUID().slice(0, 8) - return `${prefix}-${caseSlug || 'case'}-a${attempt}-${suffix}` + return `${DEFAULT_WORKSPACE_PREFIX}-${caseSlug || 'case'}-a${attempt}-${suffix}` } function extractFolderName(path: string): string | null { diff --git a/ai_evals/adapters/frontend/benchmarkRunner.ts b/ai_evals/adapters/frontend/benchmarkRunner.ts index 474d434803..14be108a10 100644 --- a/ai_evals/adapters/frontend/benchmarkRunner.ts +++ b/ai_evals/adapters/frontend/benchmarkRunner.ts @@ -1,6 +1,5 @@ import { loadSelectedCases } from "../../core/cases"; import { resolveBackendValidationSettings } from "../../core/backendValidation"; -import { resolveFrontendEvalTransportSettings } from "../../core/frontendTransport"; import { formatRunModelLabel, getFrontendEvalModel, @@ -9,13 +8,15 @@ import { import { buildRunResult } from "../../core/results"; import { runSuite } from "../../core/runSuite"; import type { BenchmarkRunResult, ModeRunner } from "../../core/types"; +import { resolveWindmillBackendSettings } from "../../core/windmillBackendSettings"; import { emitFrontendBenchmarkProgress } from "./progress"; import { createAppModeRunner } from "../../modes/app"; import { createFlowModeRunner } from "../../modes/flow"; +import { createGlobalModeRunner } from "../../modes/global"; import { createScriptModeRunner } from "../../modes/script"; import { DEFAULT_JUDGE_MODEL } from "../../core/judge"; -export type FrontendBenchmarkMode = "flow" | "app" | "script"; +export type FrontendBenchmarkMode = "flow" | "app" | "script" | "global"; export async function runFrontendBenchmarkFromEnv(): Promise { const mode = parseMode(process.env.WMILL_FRONTEND_AI_EVAL_MODE); @@ -36,17 +37,14 @@ export async function runFrontendBenchmarkFromEnv(): Promise evalMode: mode, requestedMode: process.env.WMILL_FRONTEND_AI_EVAL_BACKEND_VALIDATION, }); - const transportSettings = resolveFrontendEvalTransportSettings({ - evalMode: mode, - requestedTransport: process.env.WMILL_FRONTEND_AI_EVAL_TRANSPORT, - }); + const backendSettings = resolveWindmillBackendSettings(); const selectedCases = await loadSelectedCases(mode, caseIds); const modeRunner = getModeRunner( mode, getFrontendEvalModel(model), backendValidation, - transportSettings, + backendSettings, ); const runModel = formatRunModelLabel(mode, model); const caseResults = await runSuite({ @@ -66,7 +64,6 @@ export async function runFrontendBenchmarkFromEnv(): Promise mode, runs, runModel, - transport: transportSettings.transport, judgeModel: DEFAULT_JUDGE_MODEL, caseResults, }); @@ -76,24 +73,26 @@ function getModeRunner( mode: FrontendBenchmarkMode, model: ReturnType, backendValidation: ReturnType, - transportSettings: ReturnType, + backendSettings: ReturnType, ): ModeRunner { switch (mode) { case "flow": - return createFlowModeRunner(model, backendValidation, transportSettings); + return createFlowModeRunner(model, backendValidation, backendSettings); case "app": - return createAppModeRunner(model, transportSettings); + return createAppModeRunner(model, backendSettings); case "script": return createScriptModeRunner( model, backendValidation, - transportSettings, + backendSettings, ); + case "global": + return createGlobalModeRunner(model, backendSettings); } } function parseMode(value: string | undefined): FrontendBenchmarkMode { - if (value === "flow" || value === "app" || value === "script") { + if (value === "flow" || value === "app" || value === "script" || value === "global") { return value; } throw new Error(`Unsupported frontend benchmark mode: ${String(value)}`); diff --git a/ai_evals/adapters/frontend/core/app/appEvalRunner.ts b/ai_evals/adapters/frontend/core/app/appEvalRunner.ts index 55d1e6ab9a..16543b28de 100644 --- a/ai_evals/adapters/frontend/core/app/appEvalRunner.ts +++ b/ai_evals/adapters/frontend/core/app/appEvalRunner.ts @@ -12,7 +12,7 @@ import { prepareAppUserMessage, } from "../../../../../frontend/src/lib/components/copilot/chat/app/core"; import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared"; -import { createAppFileHelpers } from "./fileHelpers"; +import { createAppFileHelpers, type AppEvalChatHelpers } from "./fileHelpers"; import { runEval } from "../shared"; import type { AIProvider } from "$lib/gen/types.gen"; import type { @@ -22,7 +22,6 @@ import type { } from "../../../../core/types"; import type { TokenUsage } from "../shared/types"; import type { AppFilesState } from "../../../../core/validators"; -import type { FrontendEvalTransport } from "../../../../core/frontendTransport"; import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings"; import { createAppBackendRunnableContextElement, @@ -49,8 +48,7 @@ export interface AppEvalOptions { model?: string; maxIterations?: number; provider?: AIProvider; - transport?: FrontendEvalTransport; - backend?: WindmillBackendSettings; + backend: WindmillBackendSettings; workspaceRoot?: string; runContext?: ModeRunContext; } @@ -58,7 +56,7 @@ export interface AppEvalOptions { export async function runAppEval( userPrompt: string, apiKey: string, - options?: AppEvalOptions, + options: AppEvalOptions, ): Promise { const workspaceRoot = options?.workspaceRoot ?? @@ -101,10 +99,9 @@ export async function runAppEval( model, workspace: workspaceRoot, provider: options?.provider, - transport: options?.transport, - backend: options?.backend, - proxyCaseId: options?.runContext?.caseId, - proxyAttempt: options?.runContext?.attempt, + backend: options.backend, + caseId: options?.runContext?.caseId, + attempt: options?.runContext?.attempt, }, }); @@ -124,7 +121,7 @@ export async function runAppEval( async function buildAdditionalContext( appContext: EvalCaseRuntimeAppContextSpec | undefined, - helpers: AppAIChatHelpers, + helpers: AppEvalChatHelpers, ): Promise { const entries = appContext?.additional ?? []; if (entries.length === 0) { diff --git a/ai_evals/adapters/frontend/core/app/fileHelpers.ts b/ai_evals/adapters/frontend/core/app/fileHelpers.ts index e82ddf4672..15721a49ed 100644 --- a/ai_evals/adapters/frontend/core/app/fileHelpers.ts +++ b/ai_evals/adapters/frontend/core/app/fileHelpers.ts @@ -2,6 +2,7 @@ import { mkdir, rm, writeFile } from 'fs/promises' import { dirname, join } from 'path' import type { AppAIChatHelpers, + AppDatatableMetadata, AppFiles, BackendRunnable, DataTableSchema, @@ -10,6 +11,10 @@ import type { } from '../../../../../frontend/src/lib/components/copilot/chat/app/core' import { buildAppWmillTypes, collectAppDiagnostics } from '../../../../core/appDiagnostics' +export interface AppEvalChatHelpers extends AppAIChatHelpers { + getDatatables: () => Promise +} + async function writeFrontendFile( workspaceRoot: string | undefined, path: string, @@ -92,7 +97,7 @@ export async function createAppFileHelpers( initialDatatables: DataTableSchema[] = [], workspaceRoot?: string ): Promise<{ - helpers: AppAIChatHelpers + helpers: AppEvalChatHelpers getFiles: () => AppFiles getEvalState: () => { frontend: Record @@ -137,7 +142,7 @@ export async function createAppFileHelpers( } await persistDatatables(workspaceRoot, datatables) - const helpers: AppAIChatHelpers = { + const helpers: AppEvalChatHelpers = { listFrontendFiles: () => [ ...Object.keys(frontend).filter((path) => path !== '/wmill.d.ts'), '/wmill.d.ts' @@ -211,6 +216,34 @@ export async function createAppFileHelpers( }, lint, getDatatables: async () => structuredClone(datatables), + listDatatableTables: async () => + datatables.map( + (datatable): AppDatatableMetadata => { + const schemas = Object.fromEntries( + Object.entries(datatable.schemas).map(([schemaName, tables]) => [ + schemaName, + Object.keys(tables) + ]) + ) + return { + datatable_name: datatable.datatable_name, + schemas, + tableCount: Object.values(schemas).reduce( + (sum, tableNames) => sum + tableNames.length, + 0 + ), + error: datatable.error + } + } + ), + getDatatableTableSchema: async ( + datatableName: string, + schemaName: string, + tableName: string + ) => { + const datatable = datatables.find((entry) => entry.datatable_name === datatableName) + return structuredClone(datatable?.schemas?.[schemaName]?.[tableName] ?? {}) + }, getAvailableDatatableNames: () => datatables.map((datatable) => datatable.datatable_name), execDatatableSql: async ( datatableName: string, diff --git a/ai_evals/adapters/frontend/core/flow/flowEvalRunner.ts b/ai_evals/adapters/frontend/core/flow/flowEvalRunner.ts index 1b448bdea4..0cd25f5787 100644 --- a/ai_evals/adapters/frontend/core/flow/flowEvalRunner.ts +++ b/ai_evals/adapters/frontend/core/flow/flowEvalRunner.ts @@ -18,7 +18,6 @@ import { import { runEval } from "../shared"; import type { ModeRunContext } from "../../../../core/types"; import type { TokenUsage, ToolCallDetail } from "../shared/types"; -import type { FrontendEvalTransport } from "../../../../core/frontendTransport"; import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings"; export interface FlowFixture { @@ -48,8 +47,7 @@ export interface FlowEvalOptions { model?: string; maxIterations?: number; provider?: AIProvider; - transport?: FrontendEvalTransport; - backend?: WindmillBackendSettings; + backend: WindmillBackendSettings; workspaceRoot?: string; runContext?: ModeRunContext; } @@ -57,7 +55,7 @@ export interface FlowEvalOptions { export async function runFlowEval( userPrompt: string, apiKey: string, - options?: FlowEvalOptions, + options: FlowEvalOptions, ): Promise { const workspaceRoot = options?.workspaceRoot ?? @@ -100,10 +98,9 @@ export async function runFlowEval( model, workspace: workspaceRoot, provider: options?.provider, - transport: options?.transport, - backend: options?.backend, - proxyCaseId: options?.runContext?.caseId, - proxyAttempt: options?.runContext?.attempt, + backend: options.backend, + caseId: options?.runContext?.caseId, + attempt: options?.runContext?.attempt, }, }); diff --git a/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts b/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts new file mode 100644 index 0000000000..5e00dd6f34 --- /dev/null +++ b/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts @@ -0,0 +1,127 @@ +import { mkdtemp, rm } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import type { AIProvider } from "$lib/gen/types.gen"; +import { + globalTools, + prepareGlobalSystemMessage, + prepareGlobalUserMessage, +} from "../../../../../frontend/src/lib/components/copilot/chat/global/core"; +import { globalDraftStore } from "../../../../../frontend/src/lib/components/copilot/chat/global/draftStore.svelte"; +import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared"; +import type { ModeRunContext } from "../../../../core/types"; +import type { GlobalDraftState } from "../../../../core/validators"; +import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings"; +import { + registerBenchmarkWorkspaceRunnables, + unregisterBenchmarkWorkspaceRunnables, + type BenchmarkWorkspaceRunnables, +} from "../../mockBackend"; +import { runEval } from "../shared"; +import type { TokenUsage, ToolCallDetail } from "../shared/types"; + +const MUTATING_GLOBAL_TOOLS = new Set([ + "deploy_workspace_item", + "delete_workspace_item", +]); + +export interface GlobalEvalResult { + success: boolean; + state: GlobalDraftState; + error?: string; + assistantMessageCount: number; + toolCallCount: number; + toolsUsed: string[]; + toolCallDetails: ToolCallDetail[]; + tokenUsage: TokenUsage; +} + +export interface GlobalEvalOptions { + workspaceFixtures?: BenchmarkWorkspaceRunnables; + model?: string; + maxIterations?: number; + provider?: AIProvider; + backend: WindmillBackendSettings; + workspaceRoot?: string; + runContext?: ModeRunContext; +} + +export async function runGlobalEval( + userPrompt: string, + apiKey: string, + options: GlobalEvalOptions, +): Promise { + const workspaceRoot = + options.workspaceRoot ?? + (await mkdtemp(join(tmpdir(), "wmill-frontend-global-benchmark-"))); + + globalDraftStore.clearDrafts(workspaceRoot); + registerBenchmarkWorkspaceRunnables(workspaceRoot, options.workspaceFixtures ?? {}); + + try { + const model = options.model ?? "claude-haiku-4-5-20251001"; + const rawResult = await runEval({ + userPrompt, + systemMessage: prepareGlobalSystemMessage(), + userMessage: prepareGlobalUserMessage(userPrompt), + tools: getGlobalEvalTools(), + helpers: {}, + apiKey, + getOutput: () => ({ drafts: globalDraftStore.listDrafts(workspaceRoot) }), + onAssistantMessageStart: options.runContext?.onAssistantMessageStart, + onAssistantToken: options.runContext?.onAssistantChunk, + onAssistantMessageEnd: options.runContext?.onAssistantMessageEnd, + onToolCall: options.runContext?.onToolCall, + options: { + maxIterations: options.maxIterations, + model, + workspace: workspaceRoot, + provider: options.provider, + backend: options.backend, + caseId: options.runContext?.caseId, + attempt: options.runContext?.attempt, + }, + }); + + return { + state: rawResult.output, + success: rawResult.success, + error: rawResult.error, + assistantMessageCount: rawResult.iterations, + toolCallCount: rawResult.toolCallsCount, + toolsUsed: rawResult.toolsCalled, + toolCallDetails: rawResult.toolCallDetails, + tokenUsage: rawResult.tokenUsage, + }; + } finally { + globalDraftStore.clearDrafts(workspaceRoot); + unregisterBenchmarkWorkspaceRunnables(workspaceRoot); + if (!options.workspaceRoot) { + await rm(workspaceRoot, { recursive: true, force: true }); + } + } +} + +function getGlobalEvalTools(): ProductionTool<{}>[] { + return (globalTools as ProductionTool<{}>[]).map((tool) => { + if (!MUTATING_GLOBAL_TOOLS.has(tool.def.function.name)) { + return tool; + } + + return { + ...tool, + requiresConfirmation: false, + validateBeforeConfirmation: undefined, + fn: async () => + JSON.stringify( + { + success: false, + error: + "This mutating workspace tool is disabled during ai_evals global mode.", + }, + null, + 2, + ), + }; + }); +} diff --git a/ai_evals/adapters/frontend/core/script/scriptEvalRunner.ts b/ai_evals/adapters/frontend/core/script/scriptEvalRunner.ts index 30aa2c81e7..95ce6555e6 100644 --- a/ai_evals/adapters/frontend/core/script/scriptEvalRunner.ts +++ b/ai_evals/adapters/frontend/core/script/scriptEvalRunner.ts @@ -14,7 +14,6 @@ import { createScriptFileHelpers, type ScriptEvalState } from "./fileHelpers"; import { runEval } from "../shared"; import type { ModeRunContext } from "../../../../core/types"; import type { TokenUsage, ToolCallDetail } from "../shared/types"; -import type { FrontendEvalTransport } from "../../../../core/frontendTransport"; import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings"; export interface ScriptEvalResult { @@ -33,8 +32,7 @@ export interface ScriptEvalOptions { model?: string; maxIterations?: number; provider?: AIProvider; - transport?: FrontendEvalTransport; - backend?: WindmillBackendSettings; + backend: WindmillBackendSettings; workspaceRoot?: string; runContext?: ModeRunContext; } @@ -98,10 +96,9 @@ export async function runScriptEval( model, workspace: workspaceRoot, provider: modelProvider.provider, - transport: options.transport, backend: options.backend, - proxyCaseId: options.runContext?.caseId, - proxyAttempt: options.runContext?.attempt, + caseId: options.runContext?.caseId, + attempt: options.runContext?.attempt, }, }); diff --git a/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts b/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts index 5b1f2e948e..7fd43ccb87 100644 --- a/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts +++ b/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts @@ -40,8 +40,8 @@ export interface RunEvalParams { apiKey: string; /** Function to get the current output state */ getOutput: () => TOutput; - /** Optional configuration */ - options?: EvalRunnerOptions; + /** Model and Windmill backend configuration */ + options: EvalRunnerOptions; onAssistantMessageStart?: () => void; onAssistantToken?: (token: string) => void; onAssistantMessageEnd?: () => void; @@ -70,10 +70,10 @@ export async function runEval( } = params; let shouldEmitMessageStart = true; - const model = options?.model ?? "gpt-4o"; - const maxIterations = options?.maxIterations ?? 20; - const workspace = options?.workspace ?? "test-workspace"; - const provider = toFrontendEvalProvider(options?.provider); + const model = options.model ?? "gpt-4o"; + const maxIterations = options.maxIterations ?? 20; + const workspace = options.workspace ?? "test-workspace"; + const provider = toFrontendEvalProvider(options.provider); const modelProvider = resolveEvalModelProvider(model, provider); @@ -203,45 +203,31 @@ export async function runEval( } }; - if (options?.transport === "proxy") { - const backendSettings = options.backend; - if (!backendSettings) { - throw new Error("Missing backend settings for proxy transport"); - } - - const backendClient = new WindmillBackendClient(backendSettings); - return await backendClient.withWorkspace( - options.proxyCaseId ?? "eval", - options.proxyAttempt ?? 1, - async (proxyWorkspaceId) => { - const resourcePath = buildProxyResourcePath(modelProvider.provider); - await backendClient.upsertResource({ - workspaceId: proxyWorkspaceId, - path: resourcePath, - resourceType: modelProvider.provider, - value: { api_key: apiKey }, - }); - const token = await backendClient.getToken(); - const clients = createEvalClients({ - provider: modelProvider.provider, - apiKey, - transport: "proxy", - proxy: { - baseURL: `${backendSettings.baseUrl}/api/w/${encodeURIComponent(proxyWorkspaceId)}/ai/proxy`, - bearerToken: token, - resourcePath, - }, - }) as unknown as ChatClients; - return await executeChatLoop(clients); - }, - ); - } - - const clients = createEvalClients({ - provider: modelProvider.provider, - apiKey, - }) as unknown as ChatClients; - return await executeChatLoop(clients); + const backendSettings = options.backend; + const backendClient = new WindmillBackendClient(backendSettings); + return await backendClient.withWorkspace( + options.caseId ?? "eval", + options.attempt ?? 1, + async (proxyWorkspaceId) => { + const resourcePath = buildProxyResourcePath(modelProvider.provider); + await backendClient.upsertResource({ + workspaceId: proxyWorkspaceId, + path: resourcePath, + resourceType: modelProvider.provider, + value: { api_key: apiKey }, + }); + const token = await backendClient.getToken(); + const clients = createEvalClients({ + provider: modelProvider.provider, + proxy: { + baseURL: `${backendSettings.baseUrl}/api/w/${encodeURIComponent(proxyWorkspaceId)}/ai/proxy`, + bearerToken: token, + resourcePath, + }, + }) as unknown as ChatClients; + return await executeChatLoop(clients); + }, + ); } function toFrontendEvalProvider( diff --git a/ai_evals/adapters/frontend/core/shared/providerConfig.test.ts b/ai_evals/adapters/frontend/core/shared/providerConfig.test.ts index e504ced376..77d9154da3 100644 --- a/ai_evals/adapters/frontend/core/shared/providerConfig.test.ts +++ b/ai_evals/adapters/frontend/core/shared/providerConfig.test.ts @@ -2,35 +2,9 @@ import { describe, expect, it } from "bun:test"; import { buildProxyHeaders, buildProxyResourcePath, - buildOpenAICompatibleClientOptions, resolveEvalModelProvider, } from "./providerConfig"; -describe("buildOpenAICompatibleClientOptions", () => { - it("adds Gemini's OpenAI-compatible base URL and client header", () => { - const options = buildOpenAICompatibleClientOptions( - "googleai", - "gemini-test-key", - ); - - expect(options).toMatchObject({ - apiKey: "gemini-test-key", - baseURL: "https://generativelanguage.googleapis.com/v1beta/openai/", - defaultHeaders: { - "x-goog-api-client": "windmill-ai-evals/1.0", - }, - }); - }); - - it("keeps the default OpenAI-compatible config for OpenAI", () => { - expect( - buildOpenAICompatibleClientOptions("openai", "openai-test-key"), - ).toEqual({ - apiKey: "openai-test-key", - }); - }); -}); - describe("proxy helpers", () => { it("builds provider-scoped proxy resource paths", () => { expect(buildProxyResourcePath("googleai")).toBe("f/evals/ai/googleai"); diff --git a/ai_evals/adapters/frontend/core/shared/providerConfig.ts b/ai_evals/adapters/frontend/core/shared/providerConfig.ts index 62ba221a40..15372049fe 100644 --- a/ai_evals/adapters/frontend/core/shared/providerConfig.ts +++ b/ai_evals/adapters/frontend/core/shared/providerConfig.ts @@ -1,7 +1,6 @@ import Anthropic from "@anthropic-ai/sdk"; import OpenAI from "openai"; import type { FrontendEvalModelConfig } from "../../../../core/models"; -import type { FrontendEvalTransport } from "../../../../core/frontendTransport"; export type FrontendEvalProvider = FrontendEvalModelConfig["provider"]; @@ -15,15 +14,12 @@ export interface ResolvedEvalModelProvider { model: string; } -export interface EvalProxyClientConfig { +export interface WindmillAiProxyClientConfig { baseURL: string; bearerToken: string; resourcePath: string; } -const GEMINI_OPENAI_BASE_URL = - "https://generativelanguage.googleapis.com/v1beta/openai/"; -const GEMINI_GOOG_API_CLIENT = "windmill-ai-evals/1.0"; const EVAL_PROXY_RESOURCE_PREFIX = "f/evals/ai"; export function buildProxyHeaders( @@ -40,25 +36,8 @@ export function buildProxyResourcePath(provider: FrontendEvalProvider): string { return `${EVAL_PROXY_RESOURCE_PREFIX}/${provider}`; } -export function buildOpenAICompatibleClientOptions( - provider: Exclude, - apiKey: string, -): ConstructorParameters[0] { - if (provider === "googleai") { - return { - apiKey, - baseURL: GEMINI_OPENAI_BASE_URL, - defaultHeaders: { - "x-goog-api-client": GEMINI_GOOG_API_CLIENT, - }, - }; - } - - return { apiKey }; -} - function buildProxyOpenAIClientOptions( - proxy: EvalProxyClientConfig, + proxy: WindmillAiProxyClientConfig, ): ConstructorParameters[0] { return { apiKey: "unused", @@ -69,52 +48,24 @@ function buildProxyOpenAIClientOptions( export function createEvalClients(input: { provider: FrontendEvalProvider; - apiKey: string; - transport?: FrontendEvalTransport; - proxy?: EvalProxyClientConfig; + proxy: WindmillAiProxyClientConfig; }): EvalClients { - const transport = input.transport ?? "direct"; - if (input.provider === "anthropic") { - if (transport === "proxy") { - if (!input.proxy) { - throw new Error( - "Missing proxy client configuration for proxy transport", - ); - } - return { - openai: new OpenAI({ apiKey: "unused" }), - anthropic: new Anthropic({ - apiKey: "unused", - baseURL: input.proxy.baseURL, - defaultHeaders: buildProxyHeaders( - input.proxy.bearerToken, - input.proxy.resourcePath, - ), - }), - }; - } - return { openai: new OpenAI({ apiKey: "unused" }), - anthropic: new Anthropic({ apiKey: input.apiKey }), - }; - } - - if (transport === "proxy") { - if (!input.proxy) { - throw new Error("Missing proxy client configuration for proxy transport"); - } - return { - openai: new OpenAI(buildProxyOpenAIClientOptions(input.proxy)), - anthropic: new Anthropic({ apiKey: "unused" }), + anthropic: new Anthropic({ + apiKey: "unused", + baseURL: input.proxy.baseURL, + defaultHeaders: buildProxyHeaders( + input.proxy.bearerToken, + input.proxy.resourcePath, + ), + }), }; } return { - openai: new OpenAI( - buildOpenAICompatibleClientOptions(input.provider, input.apiKey), - ), + openai: new OpenAI(buildProxyOpenAIClientOptions(input.proxy)), anthropic: new Anthropic({ apiKey: "unused" }), }; } diff --git a/ai_evals/adapters/frontend/core/shared/types.ts b/ai_evals/adapters/frontend/core/shared/types.ts index f2a3f04794..f081fe398a 100644 --- a/ai_evals/adapters/frontend/core/shared/types.ts +++ b/ai_evals/adapters/frontend/core/shared/types.ts @@ -1,6 +1,5 @@ import type { ChatCompletionMessageParam } from "openai/resources/chat/completions.mjs"; import type { AIProvider } from "$lib/gen/types.gen"; -import type { FrontendEvalTransport } from "../../../../core/frontendTransport"; import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings"; export interface TokenUsage { @@ -15,14 +14,13 @@ export interface ToolCallDetail { } export interface EvalRunnerOptions { + backend: WindmillBackendSettings; maxIterations?: number; model?: string; workspace?: string; provider?: AIProvider; - transport?: FrontendEvalTransport; - backend?: WindmillBackendSettings; - proxyCaseId?: string; - proxyAttempt?: number; + caseId?: string; + attempt?: number; } export interface RawEvalResult { diff --git a/ai_evals/adapters/frontend/progress.ts b/ai_evals/adapters/frontend/progress.ts index b5b8f12c83..3a4810c4a3 100644 --- a/ai_evals/adapters/frontend/progress.ts +++ b/ai_evals/adapters/frontend/progress.ts @@ -1,4 +1,4 @@ -export type FrontendBenchmarkProgressSurface = 'flow' | 'app' | 'script' +export type FrontendBenchmarkProgressSurface = 'flow' | 'app' | 'script' | 'global' export type FrontendBenchmarkProgressEvent = | { diff --git a/ai_evals/adapters/frontend/runtime.ts b/ai_evals/adapters/frontend/runtime.ts index 6a76fceeb0..347e15191c 100644 --- a/ai_evals/adapters/frontend/runtime.ts +++ b/ai_evals/adapters/frontend/runtime.ts @@ -16,14 +16,13 @@ const FRONTEND_BENCHMARK_TEST = const FRONTEND_BENCHMARK_CONFIG = "../ai_evals/adapters/frontend/vitest.config.ts"; -export type FrontendMode = "flow" | "app" | "script"; +export type FrontendMode = "flow" | "app" | "script" | "global"; export async function runFrontendBenchmarkAdapter(input: { mode: FrontendMode; caseIds: string[]; runs: number; model?: string; - transport?: string; verbose?: boolean; backendValidation?: string; }): Promise { @@ -44,10 +43,6 @@ export async function runFrontendBenchmarkAdapter(input: { WMILL_FRONTEND_AI_EVAL_BACKEND_VALIDATION: input.backendValidation ?? "", }; - if (input.transport) { - env.WMILL_FRONTEND_AI_EVAL_TRANSPORT = input.transport; - } - try { await runVitestBenchmark( path.join(FRONTEND_DIR, "node_modules", ".bin", "vitest"), diff --git a/ai_evals/adapters/frontend/vitestAdapter.test.ts b/ai_evals/adapters/frontend/vitestAdapter.test.ts index 542feaf89b..1275acf0b4 100644 --- a/ai_evals/adapters/frontend/vitestAdapter.test.ts +++ b/ai_evals/adapters/frontend/vitestAdapter.test.ts @@ -65,6 +65,10 @@ vi.mock('$lib/gen', async () => { hasBenchmarkWorkspace(data.workspace) ? (listBenchmarkScripts(data.workspace) ?? []) : actual.ScriptService.listScripts(data), + existsScriptByPath: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) + ? Boolean(getBenchmarkScriptByPath(data.workspace, data.path)) + : actual.ScriptService.existsScriptByPath(data), getScriptByPath: async (data: { workspace: string; path: string }) => { if (hasBenchmarkWorkspace(data.workspace)) { const script = getBenchmarkScriptByPath(data.workspace, data.path) @@ -91,6 +95,10 @@ vi.mock('$lib/gen', async () => { hasBenchmarkWorkspace(data.workspace) ? (listBenchmarkFlows(data.workspace) ?? []) : actual.FlowService.listFlows(data), + existsFlowByPath: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) + ? Boolean(getBenchmarkFlowByPath(data.workspace, data.path)) + : actual.FlowService.existsFlowByPath(data), getFlowByPath: async (data: { workspace: string; path: string }) => { if (hasBenchmarkWorkspace(data.workspace)) { const flow = getBenchmarkFlowByPath(data.workspace, data.path) @@ -142,6 +150,16 @@ vi.mock('$lib/gen', async () => { } }), ScheduleService: wrapService(actual.ScheduleService, { + existsSchedule: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) ? false : actual.ScheduleService.existsSchedule(data), + listSchedules: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.ScheduleService.listSchedules(data), + getSchedule: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`Schedule "${data.path}" not found in benchmark workspace`) + } + return actual.ScheduleService.getSchedule(data) + }, previewSchedule: async (data: { requestBody?: Record }) => previewBenchmarkSchedule(data), createSchedule: async (data: { workspace: string; requestBody: Record }) => @@ -149,11 +167,167 @@ vi.mock('$lib/gen', async () => { ? createBenchmarkSchedule(data) : actual.ScheduleService.createSchedule(data) }), + ResourceService: wrapService(actual.ResourceService, { + existsResource: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) ? false : actual.ResourceService.existsResource(data), + listResource: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.ResourceService.listResource(data), + getResource: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`Resource "${data.path}" not found in benchmark workspace`) + } + return actual.ResourceService.getResource(data) + }, + queryResourceTypes: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.ResourceService.queryResourceTypes(data) + }), + VariableService: wrapService(actual.VariableService, { + existsVariable: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) ? false : actual.VariableService.existsVariable(data), + listVariable: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.VariableService.listVariable(data), + getVariable: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`Variable "${data.path}" not found in benchmark workspace`) + } + return actual.VariableService.getVariable(data) + } + }), + AppService: wrapService(actual.AppService, { + existsApp: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) ? false : actual.AppService.existsApp(data), + listApps: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.AppService.listApps(data), + getAppByPath: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`App "${data.path}" not found in benchmark workspace`) + } + return actual.AppService.getAppByPath(data) + } + }), HttpTriggerService: wrapService(actual.HttpTriggerService, { + existsHttpTrigger: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) ? false : actual.HttpTriggerService.existsHttpTrigger(data), + listHttpTriggers: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.HttpTriggerService.listHttpTriggers(data), + getHttpTrigger: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`HTTP trigger "${data.path}" not found in benchmark workspace`) + } + return actual.HttpTriggerService.getHttpTrigger(data) + }, createHttpTrigger: async (data: { workspace: string; requestBody: Record }) => hasBenchmarkWorkspace(data.workspace) ? createBenchmarkHttpTrigger(data) : actual.HttpTriggerService.createHttpTrigger(data) + }), + WebsocketTriggerService: wrapService(actual.WebsocketTriggerService, { + existsWebsocketTrigger: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) + ? false + : actual.WebsocketTriggerService.existsWebsocketTrigger(data), + listWebsocketTriggers: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) + ? [] + : actual.WebsocketTriggerService.listWebsocketTriggers(data), + getWebsocketTrigger: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`Websocket trigger "${data.path}" not found in benchmark workspace`) + } + return actual.WebsocketTriggerService.getWebsocketTrigger(data) + } + }), + KafkaTriggerService: wrapService(actual.KafkaTriggerService, { + existsKafkaTrigger: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) + ? false + : actual.KafkaTriggerService.existsKafkaTrigger(data), + listKafkaTriggers: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.KafkaTriggerService.listKafkaTriggers(data), + getKafkaTrigger: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`Kafka trigger "${data.path}" not found in benchmark workspace`) + } + return actual.KafkaTriggerService.getKafkaTrigger(data) + } + }), + NatsTriggerService: wrapService(actual.NatsTriggerService, { + existsNatsTrigger: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) ? false : actual.NatsTriggerService.existsNatsTrigger(data), + listNatsTriggers: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.NatsTriggerService.listNatsTriggers(data), + getNatsTrigger: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`NATS trigger "${data.path}" not found in benchmark workspace`) + } + return actual.NatsTriggerService.getNatsTrigger(data) + } + }), + PostgresTriggerService: wrapService(actual.PostgresTriggerService, { + existsPostgresTrigger: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) + ? false + : actual.PostgresTriggerService.existsPostgresTrigger(data), + listPostgresTriggers: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) + ? [] + : actual.PostgresTriggerService.listPostgresTriggers(data), + getPostgresTrigger: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`Postgres trigger "${data.path}" not found in benchmark workspace`) + } + return actual.PostgresTriggerService.getPostgresTrigger(data) + } + }), + MqttTriggerService: wrapService(actual.MqttTriggerService, { + existsMqttTrigger: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) ? false : actual.MqttTriggerService.existsMqttTrigger(data), + listMqttTriggers: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.MqttTriggerService.listMqttTriggers(data), + getMqttTrigger: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`MQTT trigger "${data.path}" not found in benchmark workspace`) + } + return actual.MqttTriggerService.getMqttTrigger(data) + } + }), + SqsTriggerService: wrapService(actual.SqsTriggerService, { + existsSqsTrigger: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) ? false : actual.SqsTriggerService.existsSqsTrigger(data), + listSqsTriggers: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.SqsTriggerService.listSqsTriggers(data), + getSqsTrigger: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`SQS trigger "${data.path}" not found in benchmark workspace`) + } + return actual.SqsTriggerService.getSqsTrigger(data) + } + }), + GcpTriggerService: wrapService(actual.GcpTriggerService, { + existsGcpTrigger: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) ? false : actual.GcpTriggerService.existsGcpTrigger(data), + listGcpTriggers: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.GcpTriggerService.listGcpTriggers(data), + getGcpTrigger: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`GCP trigger "${data.path}" not found in benchmark workspace`) + } + return actual.GcpTriggerService.getGcpTrigger(data) + } + }), + AzureTriggerService: wrapService(actual.AzureTriggerService, { + existsAzureTrigger: async (data: { workspace: string; path: string }) => + hasBenchmarkWorkspace(data.workspace) + ? false + : actual.AzureTriggerService.existsAzureTrigger(data), + listAzureTriggers: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) ? [] : actual.AzureTriggerService.listAzureTriggers(data), + getAzureTrigger: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + throw new Error(`Azure trigger "${data.path}" not found in benchmark workspace`) + } + return actual.AzureTriggerService.getAzureTrigger(data) + } }) } }) diff --git a/ai_evals/adapters/frontend/windmillBackend.test.ts b/ai_evals/adapters/frontend/windmillBackend.test.ts new file mode 100644 index 0000000000..302502c2d9 --- /dev/null +++ b/ai_evals/adapters/frontend/windmillBackend.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import type { WindmillBackendSettings } from "../../core/windmillBackendSettings"; +import { + WindmillBackendClient, + assertWindmillBackendReachable, +} from "./windmillBackend"; + +const ORIGINAL_FETCH = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = ORIGINAL_FETCH; +}); + +describe("assertWindmillBackendReachable", () => { + it("logs in to verify backend reachability", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + globalThis.fetch = mockFetch(requests, textResponse(200, "token")); + + await expect( + assertWindmillBackendReachable( + buildSettings({ baseUrl: "http://backend.test/reachable" }), + ), + ).resolves.toBeUndefined(); + + expect(requests.map((entry) => entry.url)).toEqual([ + "http://backend.test/reachable/api/auth/login", + ]); + }); + + it("adds setup guidance when the backend cannot be initialized", async () => { + globalThis.fetch = mockFetch( + [], + textResponse(401, "invalid password"), + ); + + await expect( + assertWindmillBackendReachable( + buildSettings({ baseUrl: "http://backend.test/auth-failure" }), + ), + ).rejects.toThrow( + "Start a Windmill backend at that URL, or set WMILL_AI_EVAL_BACKEND_URL=.", + ); + }); +}); + +describe("WindmillBackendClient", () => { + it("creates or reuses the specified backend workspace without deleting it", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + globalThis.fetch = mockFetch( + requests, + textResponse(200, "token"), + textResponse(200, "false"), + textResponse(200, ""), + ); + + const client = new WindmillBackendClient( + buildSettings({ + baseUrl: "http://backend.test/shared-workspace", + workspaceOverride: "shared-evals", + }), + ); + + await expect( + client.withWorkspace("case-a", 1, async (workspaceId) => workspaceId), + ).resolves.toBe("shared-evals"); + + expect(requests.map((entry) => entry.url)).toEqual([ + "http://backend.test/shared-workspace/api/auth/login", + "http://backend.test/shared-workspace/api/workspaces/exists", + "http://backend.test/shared-workspace/api/workspaces/create", + ]); + }); +}); + +function buildSettings( + overrides: Partial = {}, +): WindmillBackendSettings { + return { + baseUrl: "http://backend.test/default", + email: "admin@windmill.dev", + password: "changeme", + ...overrides, + }; +} + +function mockFetch( + requests: Array<{ url: string; init?: RequestInit }>, + ...responses: Response[] +): typeof fetch { + const queue = [...responses]; + return async (input, init) => { + const url = String(input); + requests.push({ url, init }); + const next = queue.shift(); + if (!next) { + throw new Error(`Unexpected fetch: ${url}`); + } + return next; + }; +} + +function textResponse(status: number, body: string): Response { + return new Response(body, { status }); +} diff --git a/ai_evals/adapters/frontend/windmillBackend.ts b/ai_evals/adapters/frontend/windmillBackend.ts index c8d9537779..2247d8e5d5 100644 --- a/ai_evals/adapters/frontend/windmillBackend.ts +++ b/ai_evals/adapters/frontend/windmillBackend.ts @@ -3,6 +3,7 @@ import type { WindmillBackendSettings } from "../../core/windmillBackendSettings const tokenCache = new Map>(); const sharedWorkspaceQueue = new Map>(); +const DEFAULT_WORKSPACE_PREFIX = "ai-evals"; export class WindmillBackendClient { constructor(private readonly settings: WindmillBackendSettings) {} @@ -14,7 +15,7 @@ export class WindmillBackendClient { ): Promise { const workspaceId = this.settings.workspaceOverride ?? - buildWorkspaceId(this.settings.workspacePrefix, caseId, attempt); + buildWorkspaceId(caseId, attempt); const run = async () => { await this.ensureWorkspace(workspaceId); @@ -22,7 +23,7 @@ export class WindmillBackendClient { try { return await body(workspaceId); } finally { - if (!this.settings.keepWorkspaces && !this.settings.workspaceOverride) { + if (!this.settings.workspaceOverride) { await this.deleteWorkspace(workspaceId).catch(() => undefined); } } @@ -136,6 +137,24 @@ export class WindmillBackendClient { } } +export async function assertWindmillBackendReachable( + settings: WindmillBackendSettings, +): Promise { + try { + await new WindmillBackendClient(settings).getToken(); + } catch (error) { + const details = error instanceof Error ? error.message : String(error); + throw new Error( + [ + `Could not initialize the Windmill backend for AI eval proxy at ${settings.baseUrl}.`, + "Start a Windmill backend at that URL, or set WMILL_AI_EVAL_BACKEND_URL=.", + `Using login ${settings.email}; if authentication failed, set WMILL_AI_EVAL_BACKEND_EMAIL and WMILL_AI_EVAL_BACKEND_PASSWORD.`, + `Details: ${details}`, + ].join("\n"), + ); + } +} + async function withSharedWorkspaceLock( workspaceId: string, body: () => Promise, @@ -160,18 +179,14 @@ async function withSharedWorkspaceLock( } } -function buildWorkspaceId( - prefix: string, - caseId: string, - attempt: number, -): string { +function buildWorkspaceId(caseId: string, attempt: number): string { const caseSlug = caseId .toLowerCase() .replace(/[^a-z0-9-]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 30); const suffix = randomUUID().slice(0, 8); - return `${prefix}-${caseSlug || "case"}-a${attempt}-${suffix}`; + return `${DEFAULT_WORKSPACE_PREFIX}-${caseSlug || "case"}-a${attempt}-${suffix}`; } async function expectOk(response: Response, context: string): Promise { diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml new file mode 100644 index 0000000000..b4526f8a85 --- /dev/null +++ b/ai_evals/cases/global.yaml @@ -0,0 +1,89 @@ +- id: global-test1-script-create + prompt: |- + Create a draft Bun script at `f/evals/global/greet_user`. + It should take a string `name` input and return `Hello, ${name}!`. + Leave it as an AI draft only; do not deploy or save it. + runtime: + maxTurns: 8 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: script + path: f/evals/global/greet_user + language: bun + valueIncludes: + - name + - Hello + toolExpect: + requiredToolsUsed: + - write_script + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - creates a Bun script draft at f/evals/global/greet_user + - the script accepts a name input + - the script returns a greeting containing Hello, the provided name, and an exclamation mark + - the result stays as an AI draft and is not deployed or saved to the workspace + +- id: global-test2-script-edit-existing + prompt: |- + Update the existing workspace script at `f/evals/global/format_greeting`. + Keep it as a Bun script, but change the greeting so the provided name is uppercased and the returned message ends with an exclamation mark. + Leave the result as an AI draft only; do not deploy or save it. + initial: ai_evals/fixtures/frontend/global/initial/format_greeting_script.json + runtime: + maxTurns: 8 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: script + path: f/evals/global/format_greeting + language: bun + valueIncludes: + - toUpperCase + - "!" + toolExpect: + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - creates an AI draft for the existing f/evals/global/format_greeting script + - preserves the script as Bun + - uppercases the provided name in the greeting + - returns a message ending with an exclamation mark + - does not deploy or save the draft to the workspace + +- id: global-test3-flow-create + prompt: |- + Create a draft flow at `f/evals/global/sum_numbers`. + It should take two numeric inputs, `a` and `b`, and return their sum. + Leave it as an AI draft only; do not deploy or save it. + runtime: + maxTurns: 8 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: flow + path: f/evals/global/sum_numbers + valueIncludes: + - modules + - rawscript + - flow_input.a + - flow_input.b + toolExpect: + requiredToolsUsed: + - write_flow + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: write_flow + field: modules + stringStartsWithAnyOf: + - "[" + judgeChecklist: + - creates a flow draft at f/evals/global/sum_numbers + - the flow accepts numeric inputs a and b + - the flow returns the sum of a and b + - the result stays as an AI draft and is not deployed or saved to the workspace diff --git a/ai_evals/cli/index.ts b/ai_evals/cli/index.ts index 202d826078..8ed61740c8 100644 --- a/ai_evals/cli/index.ts +++ b/ai_evals/cli/index.ts @@ -27,11 +27,8 @@ import { EVAL_MODES, type EvalMode } from "../core/types"; import { DEFAULT_JUDGE_MODEL } from "../core/judge"; import { createCliModeRunner } from "../modes/cli"; import { runFrontendBenchmarkAdapter } from "../adapters/frontend/runtime"; -import { - FRONTEND_EVAL_TRANSPORTS, - type FrontendEvalTransport, - parseFrontendEvalTransport, -} from "../core/frontendTransport"; +import { resolveWindmillBackendSettings } from "../core/windmillBackendSettings"; +import { assertWindmillBackendReachable } from "../adapters/frontend/windmillBackend"; async function main() { const program = new Command() @@ -56,6 +53,7 @@ async function main() { " bun run cli -- run flow --record", " bun run cli -- run flow --backend-validation preview", " bun run cli -- run flow flow-test5-simple-modification --runs 3", + " bun run cli -- run global global-test1-script-create", " bun run cli -- run cli bun-hello-script", "", "Models:", @@ -73,7 +71,7 @@ async function main() { program .command("cases") .description("List available cases") - .argument("[mode]", "cli, flow, script, or app", parseOptionalMode) + .argument("[mode]", "cli, flow, script, app, or global", parseOptionalMode) .action(async (mode?: EvalMode) => { await handleCases(mode); }); @@ -81,7 +79,7 @@ async function main() { program .command("run") .description("Run one benchmark mode") - .argument("", "cli, flow, script, or app", parseMode) + .argument("", "cli, flow, script, app, or global", parseMode) .argument("[caseIds...]", "specific case ids to run") .option( "--runs ", @@ -98,10 +96,6 @@ async function main() { "--models ", "comma-separated model aliases to run sequentially", ) - .option( - "--transport ", - `frontend transport (${FRONTEND_EVAL_TRANSPORTS.join(", ")})`, - ) .option("--verbose", "stream assistant output during frontend runs") .option( "--record", @@ -120,7 +114,6 @@ async function main() { output?: string; model?: string; models?: string; - transport?: string; verbose?: boolean; record?: boolean; backendValidation?: string; @@ -133,9 +126,6 @@ async function main() { outputPath: options.output, model: options.model, models: options.models, - transport: options.transport - ? parseFrontendEvalTransport(options.transport) - : undefined, verbose: options.verbose ?? false, record: options.record ?? false, backendValidation: options.backendValidation, @@ -163,7 +153,7 @@ function handleModels() { process.stdout.write("Available models\n"); for (const model of EVAL_MODELS) { const supports = [ - ...(model.frontend ? ["flow", "script", "app"] : []), + ...(model.frontend ? ["flow", "script", "app", "global"] : []), ...(model.cli ? ["cli"] : []), ]; const aliases = [ @@ -184,7 +174,6 @@ async function handleRun(input: { outputPath?: string; model?: string; models?: string; - transport?: FrontendEvalTransport; verbose: boolean; record: boolean; backendValidation?: string; @@ -197,11 +186,6 @@ async function handleRun(input: { if (input.model && input.models) { throw new Error("Use either --model or --models, not both"); } - if (input.mode === "cli" && input.transport === "proxy") { - throw new Error( - "--transport proxy is only supported for flow, script, and app modes", - ); - } const selectedCases = await loadSelectedCases(input.mode, input.caseIds); const models = resolveRequestedModels(input.mode, input.model, input.models); @@ -220,6 +204,9 @@ async function handleRun(input: { "--backend-validation currently supports only flow and script modes", ); } + if (input.mode !== "cli") { + await assertWindmillBackendReachable(resolveWindmillBackendSettings()); + } const summaries: Array<{ label: string; @@ -249,7 +236,6 @@ async function handleRun(input: { caseIds: input.caseIds, runs: input.runs, model: model.id, - transport: input.transport, verbose: input.verbose, backendValidation, }); diff --git a/ai_evals/core/backendValidation.ts b/ai_evals/core/backendValidation.ts index 87094fd23f..1484aee2d4 100644 --- a/ai_evals/core/backendValidation.ts +++ b/ai_evals/core/backendValidation.ts @@ -13,9 +13,7 @@ export interface BackendValidationSettings { baseUrl: string; email: string; password: string; - keepWorkspaces: boolean; workspaceOverride?: string; - workspacePrefix: string; pollIntervalMs: number; maxWaitMs: number; } diff --git a/ai_evals/core/cases.test.ts b/ai_evals/core/cases.test.ts index 977bb71390..733d34ddd2 100644 --- a/ai_evals/core/cases.test.ts +++ b/ai_evals/core/cases.test.ts @@ -183,6 +183,26 @@ describe("loadCases", () => { }); }); + it("loads global draft validation and forbidden tool expectations", async () => { + const globalCases = await loadCases("global"); + const caseEntry = globalCases.find((entry) => entry.id === "global-test1-script-create"); + + expect(caseEntry?.validate).toMatchObject({ + draftCountExactly: 1, + requiredDrafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + language: "bun", + }, + ], + }); + expect(caseEntry?.toolExpect).toMatchObject({ + requiredToolsUsed: ["write_script"], + forbiddenToolsUsed: ["deploy_workspace_item", "delete_workspace_item"], + }); + }); + it("loads tool expectations for workspace mutation cases", async () => { const scriptCases = await loadCases("script"); const caseEntry = scriptCases.find( diff --git a/ai_evals/core/frontendTransport.test.ts b/ai_evals/core/frontendTransport.test.ts deleted file mode 100644 index 09ebd1b3a7..0000000000 --- a/ai_evals/core/frontendTransport.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { afterEach, describe, expect, it } from "bun:test"; -import { - parseFrontendEvalTransport, - resolveFrontendEvalTransportSettings, -} from "./frontendTransport"; - -const ORIGINAL_ENV = { - WMILL_AI_EVAL_BACKEND_URL: process.env.WMILL_AI_EVAL_BACKEND_URL, -}; - -afterEach(() => { - if (ORIGINAL_ENV.WMILL_AI_EVAL_BACKEND_URL === undefined) { - delete process.env.WMILL_AI_EVAL_BACKEND_URL; - } else { - process.env.WMILL_AI_EVAL_BACKEND_URL = - ORIGINAL_ENV.WMILL_AI_EVAL_BACKEND_URL; - } -}); - -describe("parseFrontendEvalTransport", () => { - it("defaults to direct when unset", () => { - expect(parseFrontendEvalTransport(undefined)).toBe("direct"); - }); - - it("accepts proxy explicitly", () => { - expect(parseFrontendEvalTransport("proxy")).toBe("proxy"); - }); - - it("rejects unsupported values", () => { - expect(() => parseFrontendEvalTransport("worker")).toThrow( - "Unsupported frontend eval transport: worker", - ); - }); -}); - -describe("resolveFrontendEvalTransportSettings", () => { - it("includes backend settings for proxy transport", () => { - process.env.WMILL_AI_EVAL_BACKEND_URL = "http://127.0.0.1:8000/"; - - expect( - resolveFrontendEvalTransportSettings({ - evalMode: "app", - requestedTransport: "proxy", - }), - ).toMatchObject({ - transport: "proxy", - backend: { - baseUrl: "http://127.0.0.1:8000", - }, - }); - }); - - it("keeps direct transport for cli runs", () => { - expect( - resolveFrontendEvalTransportSettings({ - evalMode: "cli", - requestedTransport: "direct", - }), - ).toEqual({ - transport: "direct", - backend: undefined, - }); - }); -}); diff --git a/ai_evals/core/frontendTransport.ts b/ai_evals/core/frontendTransport.ts deleted file mode 100644 index fa78505113..0000000000 --- a/ai_evals/core/frontendTransport.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { EvalMode } from "./types"; -import type { WindmillBackendSettings } from "./windmillBackendSettings"; -import { resolveWindmillBackendSettings } from "./windmillBackendSettings"; - -export const FRONTEND_EVAL_TRANSPORTS = ["direct", "proxy"] as const; - -export type FrontendEvalTransport = (typeof FRONTEND_EVAL_TRANSPORTS)[number]; - -export interface FrontendEvalTransportSettings { - transport: FrontendEvalTransport; - backend?: WindmillBackendSettings; -} - -export function parseFrontendEvalTransport( - value?: string | null, -): FrontendEvalTransport { - const normalized = value?.trim().toLowerCase(); - - if (!normalized || normalized === "direct") { - return "direct"; - } - - if (normalized === "proxy") { - return "proxy"; - } - - throw new Error( - `Unsupported frontend eval transport: ${value}. Use one of: ${FRONTEND_EVAL_TRANSPORTS.join(", ")}`, - ); -} - -export function resolveFrontendEvalTransportSettings(input: { - evalMode: EvalMode; - requestedTransport?: string | null; -}): FrontendEvalTransportSettings { - const transport = parseFrontendEvalTransport(input.requestedTransport); - - if (transport === "proxy" && input.evalMode === "cli") { - throw new Error( - 'Frontend eval transport "proxy" is only supported for flow, script, and app evals', - ); - } - - return { - transport, - backend: - transport === "proxy" ? resolveWindmillBackendSettings() : undefined, - }; -} diff --git a/ai_evals/core/models.ts b/ai_evals/core/models.ts index 9cc0ab0597..82f3b3f69b 100644 --- a/ai_evals/core/models.ts +++ b/ai_evals/core/models.ts @@ -145,7 +145,7 @@ export function resolveEvalModel(mode: EvalMode, alias?: string): EvalModelSpec export function getEvalModelHelpText(): string { return EVAL_MODELS.map((model) => { const modes = [ - ...(model.frontend ? ["flow", "script", "app"] : []), + ...(model.frontend ? ["flow", "script", "app", "global"] : []), ...(model.cli ? ["cli"] : []), ]; return ` ${model.id.padEnd(8)} ${model.label} (${modes.join(", ")})`; diff --git a/ai_evals/core/results.ts b/ai_evals/core/results.ts index c5f6b8749b..e58840f911 100644 --- a/ai_evals/core/results.ts +++ b/ai_evals/core/results.ts @@ -74,7 +74,6 @@ export function buildRunResult(input: { mode: EvalMode; runs: number; runModel: string | null; - transport?: BenchmarkRunResult["transport"]; judgeModel: string | null; caseResults: BenchmarkCaseResult[]; }): BenchmarkRunResult { @@ -116,7 +115,6 @@ export function buildRunResult(input: { gitSha: getGitSha(), runs: input.runs, runModel: input.runModel, - transport: input.transport ?? null, judgeModel: input.judgeModel, caseCount: input.caseResults.length, attemptCount, @@ -142,9 +140,6 @@ export function formatRunSummary(result: BenchmarkRunResult): string { `Pass rate: ${formatPercent(result.passRate)} (${result.passedAttempts}/${result.attemptCount})`, `Average duration: ${Math.round(result.averageDurationMs)}ms`, ]; - if (result.transport) { - lines.splice(1, 0, `Transport: ${result.transport}`); - } const failures = collectFailures(result); if (failures.length > 0) { @@ -251,7 +246,6 @@ function toHistoryRecord(result: BenchmarkRunResult) { mode: result.mode, runs: result.runs, runModel: result.runModel, - transport: result.transport, judgeModel: result.judgeModel, caseCount: result.caseCount, attemptCount: result.attemptCount, diff --git a/ai_evals/core/types.ts b/ai_evals/core/types.ts index 2d612be21a..2b42a0dfc5 100644 --- a/ai_evals/core/types.ts +++ b/ai_evals/core/types.ts @@ -1,7 +1,6 @@ -export const EVAL_MODES = ["cli", "flow", "script", "app"] as const; +export const EVAL_MODES = ["cli", "flow", "script", "app", "global"] as const; export type EvalMode = (typeof EVAL_MODES)[number]; -export type FrontendEvalTransport = "direct" | "proxy"; export interface EvalCaseRuntimeBackendPreview { args?: Record; @@ -109,6 +108,27 @@ export interface AppValidationSpec { forbiddenAppContent?: string[]; } +export interface GlobalDraftRequirement { + type: string; + path: string; + triggerKind?: string; + language?: string; + summaryIncludes?: string[]; + valueIncludes?: string[]; + valueExcludes?: string[]; +} + +export interface GlobalValidationSpec { + draftCountAtLeast?: number; + draftCountExactly?: number; + requiredDrafts?: GlobalDraftRequirement[]; + forbiddenDrafts?: Array<{ + type: string; + path: string; + triggerKind?: string; + }>; +} + export interface CliValidationSpec { requiredSkills?: string[]; forbiddenSkills?: string[]; @@ -137,10 +157,11 @@ export interface ToolCallArgumentRule { export interface ToolValidationSpec { requiredToolsUsed?: string[]; + forbiddenToolsUsed?: string[]; toolCallArgs?: ToolCallArgumentRule[]; } -export type EvalValidationSpec = FlowValidationSpec | AppValidationSpec; +export type EvalValidationSpec = FlowValidationSpec | AppValidationSpec | GlobalValidationSpec; export interface EvalCase { id: string; @@ -297,7 +318,6 @@ export interface BenchmarkRunResult { gitSha: string | null; runs: number; runModel: string | null; - transport: FrontendEvalTransport | null; judgeModel: string | null; caseCount: number; attemptCount: number; diff --git a/ai_evals/core/validators.test.ts b/ai_evals/core/validators.test.ts index 406172b955..d2a6e954bb 100644 --- a/ai_evals/core/validators.test.ts +++ b/ai_evals/core/validators.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"; import { validateAppState, validateCliWorkspace, + validateGlobalState, validateScriptState, validateToolExpectations, } from "./validators"; @@ -117,6 +118,230 @@ describe("validateToolExpectations", () => { details: 'rejected prefixes: schedules/; values: "schedules/greet_user_daily"', }); }); + + it("rejects forbidden tool usage", () => { + const checks = validateToolExpectations({ + run: { + success: true, + actual: {}, + assistantMessageCount: 1, + toolCallCount: 1, + toolsUsed: ["write_script", "deploy_workspace_item"], + skillsInvoked: [], + }, + toolExpect: { + forbiddenToolsUsed: ["deploy_workspace_item"], + }, + }); + + expect(checks).toContainEqual({ + name: "does not use deploy_workspace_item", + passed: false, + details: "tools used: write_script, deploy_workspace_item", + }); + }); +}); + +describe("validateGlobalState", () => { + it("accepts a required script draft", () => { + const checks = validateGlobalState({ + actual: { + drafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + language: "bun", + value: + "export async function main(name: string) {\n return `Hello, ${name}!`\n}\n", + isDraft: true, + }, + ], + }, + validate: { + draftCountExactly: 1, + requiredDrafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + language: "bun", + valueIncludes: ["Hello"], + }, + ], + }, + }); + + expect(checks.every((check) => check.passed)).toBe(true); + }); + + it("fails when a required draft is missing", () => { + const checks = validateGlobalState({ + actual: { + drafts: [], + }, + validate: { + requiredDrafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + }, + ], + }, + }); + + expect(checks).toContainEqual({ + name: "global includes script draft f/evals/global/greet_user", + passed: false, + details: "drafts: none", + }); + }); + + it("does not require a TypeScript entrypoint for non-TypeScript script drafts", () => { + const checks = validateGlobalState({ + actual: { + drafts: [ + { + type: "script", + path: "f/evals/global/greet_python", + language: "python3", + value: "def main(name: str):\n return f'Hello, {name}!'\n", + isDraft: true, + }, + ], + }, + }); + + expect(checks.some((check) => check.name.includes("exports entrypoint"))).toBe( + false + ); + expect(checks.every((check) => check.passed)).toBe(true); + }); + + it("allows read-only global cases without draft expectations", () => { + const checks = validateGlobalState({ + actual: { + drafts: [], + }, + }); + + expect( + checks.some( + (check) => check.name === "global produced at least one draft" + ) + ).toBe(false); + expect(checks.every((check) => check.passed)).toBe(true); + }); + + it("matches expected global draft fixtures", () => { + const checks = validateGlobalState({ + actual: { + drafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + language: "bun", + value: + "export async function main(name: string) {\r\n return `Hello, ${name}!`\r\n}\r\n", + isDraft: true, + }, + ], + }, + expected: { + drafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + language: "bun", + value: + "export async function main(name: string) {\n return `Hello, ${name}!`\n}\n", + isDraft: true, + }, + ], + }, + }); + + expect(checks).toContainEqual({ + name: "global drafts match expected", + passed: true, + }); + }); + + it("fails when expected global draft fixtures differ", () => { + const checks = validateGlobalState({ + actual: { + drafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + language: "bun", + value: + "export async function main(name: string) {\n return `Hello, ${name}!`\n}\n", + isDraft: true, + }, + ], + }, + expected: { + drafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + language: "bun", + value: + "export async function main(name: string) {\n return `Bonjour, ${name}!`\n}\n", + isDraft: true, + }, + ], + }, + }); + + const expectedMatchCheck = checks.find( + (check) => check.name === "global drafts match expected" + ); + expect(expectedMatchCheck?.passed).toBe(false); + expect(expectedMatchCheck?.details).toContain( + "script:f/evals/global/greet_user value differs" + ); + expect(expectedMatchCheck?.details).toContain("Hello"); + expect(expectedMatchCheck?.details).toContain("Bonjour"); + }); + + it("explains expected global draft metadata mismatches", () => { + const checks = validateGlobalState({ + actual: { + drafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + language: "bun", + value: + "export async function main(name: string) {\n return `Hello, ${name}!`\n}\n", + isDraft: true, + }, + ], + }, + expected: { + drafts: [ + { + type: "script", + path: "f/evals/global/greet_user", + language: "python3", + value: + "export async function main(name: string) {\n return `Hello, ${name}!`\n}\n", + isDraft: true, + }, + ], + }, + }); + + const expectedMatchCheck = checks.find( + (check) => check.name === "global drafts match expected" + ); + expect(expectedMatchCheck?.passed).toBe(false); + expect(expectedMatchCheck?.details).toContain( + "script:f/evals/global/greet_user language differs" + ); + expect(expectedMatchCheck?.details).toContain('actual="bun"'); + expect(expectedMatchCheck?.details).toContain('expected="python3"'); + }); }); describe("validateAppState", () => { diff --git a/ai_evals/core/validators.ts b/ai_evals/core/validators.ts index e690b3d7eb..4f59368113 100644 --- a/ai_evals/core/validators.ts +++ b/ai_evals/core/validators.ts @@ -6,6 +6,7 @@ import type { CliTrace, CliValidationSpec, FlowValidationSpec, + GlobalValidationSpec, ModeRunOutput, ToolValidationSpec, } from "./types"; @@ -51,6 +52,20 @@ export interface AppDatatableState { error?: string; } +export interface GlobalDraftState { + drafts: GlobalDraft[]; +} + +export interface GlobalDraft { + type: string; + path: string; + triggerKind?: string; + summary?: string; + language?: string; + value?: unknown; + isDraft?: boolean; +} + const TS_LIKE_LANGUAGES = new Set(["bun", "deno", "nativets", "bunnative", "ts", "typescript"]); const CONTROL_FLOW_MODULE_TYPES = new Set(["branchone", "branchall", "forloopflow", "whileloopflow"]); @@ -154,6 +169,16 @@ export function validateToolExpectations(input: { ); } + for (const toolName of expect.forbiddenToolsUsed ?? []) { + checks.push( + check( + `does not use ${toolName}`, + !input.run.toolsUsed.includes(toolName), + `tools used: ${input.run.toolsUsed.join(", ") || "none"}` + ) + ); + } + for (const rule of expect.toolCallArgs ?? []) { const calls = toolCallDetails.filter((call) => call.name === rule.tool); checks.push( @@ -202,6 +227,161 @@ export function validateToolExpectations(input: { return checks; } +export function validateGlobalState(input: { + actual: GlobalDraftState; + expected?: GlobalDraftState; + validate?: GlobalValidationSpec; +}): BenchmarkCheck[] { + const drafts = input.actual.drafts ?? []; + const checks: BenchmarkCheck[] = []; + + // Read-only global cases are valid; only enforce draft production when the + // case explicitly asks for draft output. + if (globalValidationExpectsDrafts(input)) { + checks.push( + check( + "global produced at least one draft", + drafts.length > 0, + `drafts=${drafts.length}` + ) + ); + } + + checks.push( + check( + "all global outputs are drafts", + drafts.every((draft) => draft.isDraft === true), + summarizeGlobalDrafts(drafts) + ) + ); + + for (const draft of drafts) { + if (draft.type !== "script" || typeof draft.value !== "string") { + continue; + } + + const language = (draft.language ?? "bun").toLowerCase(); + const syntaxErrors = getScriptSyntaxErrors(draft.value, language); + if (TS_LIKE_LANGUAGES.has(language)) { + checks.push( + check( + `script draft ${draft.path} exports entrypoint`, + hasSupportedEntrypoint(draft.value) + ) + ); + } + checks.push( + check( + `script draft ${draft.path} has no syntax errors`, + syntaxErrors.length === 0, + summarizeProblems(syntaxErrors) + ) + ); + } + + if (input.expected) { + checks.push( + check( + "global drafts match expected", + globalDraftStatesEqual(input.actual, input.expected), + describeGlobalDraftStateMismatch(input.actual, input.expected) + ) + ); + } + + const validate = input.validate; + if (!validate) { + return checks; + } + + if (validate.draftCountAtLeast !== undefined) { + checks.push( + check( + `global includes at least ${validate.draftCountAtLeast} draft(s)`, + drafts.length >= validate.draftCountAtLeast, + `drafts=${drafts.length}` + ) + ); + } + + if (validate.draftCountExactly !== undefined) { + checks.push( + check( + `global includes exactly ${validate.draftCountExactly} draft(s)`, + drafts.length === validate.draftCountExactly, + `drafts=${drafts.length}` + ) + ); + } + + for (const required of validate.requiredDrafts ?? []) { + const draft = findGlobalDraft(drafts, required.type, required.path, required.triggerKind); + checks.push( + check( + `global includes ${required.type} draft ${required.path}`, + Boolean(draft), + summarizeGlobalDrafts(drafts) + ) + ); + if (!draft) { + continue; + } + + if (required.language !== undefined) { + checks.push( + check( + `${required.type} draft ${required.path} uses ${required.language}`, + draft.language === required.language, + `language=${draft.language ?? "(none)"}` + ) + ); + } + + for (const snippet of required.summaryIncludes ?? []) { + checks.push( + check( + `${required.type} draft ${required.path} summary includes '${snippet}'`, + normalizeText(draft.summary ?? "").includes(normalizeText(snippet)), + `summary=${draft.summary ?? ""}` + ) + ); + } + + const valueText = stringifyGlobalDraftValue(draft.value); + for (const snippet of required.valueIncludes ?? []) { + checks.push( + check( + `${required.type} draft ${required.path} value includes '${snippet}'`, + normalizeText(valueText).includes(normalizeText(snippet)), + truncateForDetails(valueText) + ) + ); + } + + for (const snippet of required.valueExcludes ?? []) { + checks.push( + check( + `${required.type} draft ${required.path} value excludes '${snippet}'`, + !normalizeText(valueText).includes(normalizeText(snippet)), + truncateForDetails(valueText) + ) + ); + } + } + + for (const forbidden of validate.forbiddenDrafts ?? []) { + checks.push( + check( + `global does not include ${forbidden.type} draft ${forbidden.path}`, + !findGlobalDraft(drafts, forbidden.type, forbidden.path, forbidden.triggerKind), + summarizeGlobalDrafts(drafts) + ) + ); + } + + return checks; +} + export function validateAppState(input: { actual: AppFilesState; initial?: AppFilesState; @@ -433,6 +613,202 @@ function summarizeProblems(problems: string[], limit = 5): string | undefined { return `${problems.slice(0, limit).join("; ")}; ...and ${problems.length - limit} more`; } +function findGlobalDraft( + drafts: GlobalDraft[], + type: string, + path: string, + triggerKind?: string +): GlobalDraft | undefined { + return drafts.find( + (draft) => + draft.type === type && + draft.path === path && + (triggerKind === undefined || draft.triggerKind === triggerKind) + ); +} + +function summarizeGlobalDrafts(drafts: GlobalDraft[]): string { + const summary = drafts + .map((draft) => formatGlobalDraftKey(draft)) + .join(", "); + return `drafts: ${summary || "none"}`; +} + +function formatGlobalDraftKey(draft: GlobalDraft): string { + return `${draft.type}${draft.triggerKind ? `:${draft.triggerKind}` : ""}:${draft.path}`; +} + +function globalValidationExpectsDrafts(input: { + expected?: GlobalDraftState; + validate?: GlobalValidationSpec; +}): boolean { + const validate = input.validate; + return ( + (input.expected?.drafts?.length ?? 0) > 0 || + (validate?.requiredDrafts?.length ?? 0) > 0 || + (validate?.draftCountAtLeast ?? 0) > 0 || + (validate?.draftCountExactly ?? 0) > 0 + ); +} + +function globalDraftStatesEqual(left: GlobalDraftState, right: GlobalDraftState): boolean { + return ( + JSON.stringify(canonicalizeGlobalDrafts(left.drafts ?? [])) === + JSON.stringify(canonicalizeGlobalDrafts(right.drafts ?? [])) + ); +} + +function describeGlobalDraftStateMismatch( + actual: GlobalDraftState, + expected: GlobalDraftState +): string { + const actualDrafts = actual.drafts ?? []; + const expectedDrafts = expected.drafts ?? []; + const actualByKey = new Map( + actualDrafts.map((draft) => [globalDraftSortKey(draft), draft] as const) + ); + const expectedByKey = new Map( + expectedDrafts.map((draft) => [globalDraftSortKey(draft), draft] as const) + ); + + for (const key of Array.from(expectedByKey.keys()).sort()) { + const expectedDraft = expectedByKey.get(key); + if (expectedDraft && !actualByKey.has(key)) { + return `missing expected draft ${formatGlobalDraftKey(expectedDraft)}; actual=${summarizeGlobalDrafts(actualDrafts)}`; + } + } + + for (const key of Array.from(actualByKey.keys()).sort()) { + const actualDraft = actualByKey.get(key); + if (actualDraft && !expectedByKey.has(key)) { + return `unexpected draft ${formatGlobalDraftKey(actualDraft)}; expected=${summarizeGlobalDrafts(expectedDrafts)}`; + } + } + + for (const key of Array.from(expectedByKey.keys()).sort()) { + const actualDraft = actualByKey.get(key); + const expectedDraft = expectedByKey.get(key); + if (!actualDraft || !expectedDraft) { + continue; + } + + const fieldMismatch = describeGlobalDraftFieldMismatch( + formatGlobalDraftKey(expectedDraft), + actualDraft, + expectedDraft + ); + if (fieldMismatch) { + return fieldMismatch; + } + } + + return `actual=${summarizeGlobalDrafts(actualDrafts)}; expected=${summarizeGlobalDrafts(expectedDrafts)}`; +} + +function describeGlobalDraftFieldMismatch( + key: string, + actual: GlobalDraft, + expected: GlobalDraft +): string | undefined { + const fields: Array<"language" | "summary" | "value" | "isDraft"> = [ + "language", + "summary", + "value", + "isDraft", + ]; + + for (const field of fields) { + const actualValue = comparableGlobalDraftFieldValue(actual, field); + const expectedValue = comparableGlobalDraftFieldValue(expected, field); + if (JSON.stringify(actualValue) === JSON.stringify(expectedValue)) { + continue; + } + + return `${key} ${field} differs: actual=${formatGlobalDraftFieldValue( + actualValue + )}; expected=${formatGlobalDraftFieldValue(expectedValue)}`; + } + + return undefined; +} + +function comparableGlobalDraftFieldValue( + draft: GlobalDraft, + field: "language" | "summary" | "value" | "isDraft" +): unknown { + if (field === "summary" && typeof draft.summary === "string") { + return normalizeText(draft.summary); + } + if (field === "value" && typeof draft.value === "string") { + return normalizeText(draft.value); + } + if (field === "value") { + return canonicalizeJsonValue(draft.value); + } + return draft[field]; +} + +function formatGlobalDraftFieldValue(value: unknown): string { + if (value === undefined) { + return "(missing)"; + } + return truncateForDetails(JSON.stringify(value), 300); +} + +function canonicalizeGlobalDrafts(drafts: GlobalDraft[]): unknown[] { + return drafts + .slice() + .sort((left, right) => globalDraftSortKey(left).localeCompare(globalDraftSortKey(right))) + .map((draft) => + canonicalizeJsonValue({ + type: draft.type, + path: draft.path, + triggerKind: draft.triggerKind, + language: draft.language, + summary: + typeof draft.summary === "string" ? normalizeText(draft.summary) : draft.summary, + value: + typeof draft.value === "string" + ? normalizeText(draft.value) + : canonicalizeJsonValue(draft.value), + isDraft: draft.isDraft, + }) + ); +} + +function globalDraftSortKey(draft: GlobalDraft): string { + return `${draft.type}:${draft.triggerKind ?? ""}:${draft.path}`; +} + +function canonicalizeJsonValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalizeJsonValue); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nested]) => [key, canonicalizeJsonValue(nested)]) + ); + } + return value; +} + +function stringifyGlobalDraftValue(value: unknown): string { + if (typeof value === "string") { + return value; + } + return JSON.stringify(value ?? null, null, 2); +} + +function truncateForDetails(value: string, maxLength = 500): string { + const normalized = value.replace(/\s+/g, " ").trim(); + if (normalized.length <= maxLength) { + return normalized; + } + return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`; +} + function validateCliExpectations( assistantOutput: string, trace: CliTrace | undefined, diff --git a/ai_evals/core/windmillBackendSettings.test.ts b/ai_evals/core/windmillBackendSettings.test.ts new file mode 100644 index 0000000000..200bc4aa7a --- /dev/null +++ b/ai_evals/core/windmillBackendSettings.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { resolveWindmillBackendSettings } from "./windmillBackendSettings"; + +const ENV_KEYS = [ + "WMILL_AI_EVAL_BACKEND_URL", + "WINDMILL_URL", + "WINDMILL_BASE_URL", + "REMOTE", + "WMILL_AI_EVAL_BACKEND_EMAIL", + "WMILL_AI_EVAL_BACKEND_PASSWORD", + "WMILL_AI_EVAL_BACKEND_WORKSPACE", +] as const; + +const ORIGINAL_ENV = Object.fromEntries( + ENV_KEYS.map((key) => [key, process.env[key]]), +) as Record<(typeof ENV_KEYS)[number], string | undefined>; + +afterEach(() => { + for (const key of ENV_KEYS) { + const value = ORIGINAL_ENV[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +}); + +describe("resolveWindmillBackendSettings", () => { + it("uses backend URL/auth defaults and the optional explicit workspace", () => { + delete process.env.WMILL_AI_EVAL_BACKEND_URL; + delete process.env.WINDMILL_URL; + delete process.env.WINDMILL_BASE_URL; + delete process.env.REMOTE; + process.env.WMILL_AI_EVAL_BACKEND_WORKSPACE = "shared-evals"; + + expect(resolveWindmillBackendSettings()).toEqual({ + baseUrl: "http://127.0.0.1:8000", + email: "admin@windmill.dev", + password: "changeme", + workspaceOverride: "shared-evals", + }); + }); + + it("does not expose workspace retention knobs", () => { + process.env.WMILL_AI_EVAL_BACKEND_URL = "http://backend.test/"; + + const settings = resolveWindmillBackendSettings(); + + expect(settings).toEqual({ + baseUrl: "http://backend.test", + email: "admin@windmill.dev", + password: "changeme", + workspaceOverride: undefined, + }); + expect(Object.keys(settings).sort()).toEqual([ + "baseUrl", + "email", + "password", + "workspaceOverride", + ]); + }); +}); diff --git a/ai_evals/core/windmillBackendSettings.ts b/ai_evals/core/windmillBackendSettings.ts index c3a0a52d47..2388c32b4d 100644 --- a/ai_evals/core/windmillBackendSettings.ts +++ b/ai_evals/core/windmillBackendSettings.ts @@ -2,9 +2,7 @@ export interface WindmillBackendSettings { baseUrl: string; email: string; password: string; - keepWorkspaces: boolean; workspaceOverride?: string; - workspacePrefix: string; } export function resolveWindmillBackendSettings(): WindmillBackendSettings { @@ -18,13 +16,9 @@ export function resolveWindmillBackendSettings(): WindmillBackendSettings { ), email: process.env.WMILL_AI_EVAL_BACKEND_EMAIL ?? "admin@windmill.dev", password: process.env.WMILL_AI_EVAL_BACKEND_PASSWORD ?? "changeme", - keepWorkspaces: isTruthy(process.env.WMILL_AI_EVAL_KEEP_WORKSPACES), workspaceOverride: sanitizeOptionalWorkspaceId( process.env.WMILL_AI_EVAL_BACKEND_WORKSPACE, ), - workspacePrefix: sanitizeWorkspacePrefix( - process.env.WMILL_AI_EVAL_WORKSPACE_PREFIX ?? "ai-evals", - ), }; } @@ -43,25 +37,9 @@ function normalizeBaseUrl(value: string): string { return value.replace(/\/+$/, ""); } -function sanitizeWorkspacePrefix(value: string): string { - const sanitized = value - .trim() - .toLowerCase() - .replace(/[^a-z0-9-]+/g, "-") - .replace(/^-+|-+$/g, ""); - return sanitized.length > 0 ? sanitized : "ai-evals"; -} - function sanitizeOptionalWorkspaceId( value: string | undefined, ): string | undefined { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; } - -function isTruthy(value: string | undefined): boolean { - if (!value) { - return false; - } - return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase()); -} diff --git a/ai_evals/fixtures/frontend/global/initial/format_greeting_script.json b/ai_evals/fixtures/frontend/global/initial/format_greeting_script.json new file mode 100644 index 0000000000..e66eee2ed2 --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/format_greeting_script.json @@ -0,0 +1,23 @@ +{ + "workspace": { + "scripts": [ + { + "path": "f/evals/global/format_greeting", + "summary": "Format a greeting for a provided name", + "description": "Returns a plain greeting for the provided name.", + "language": "bun", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + }, + "content": "export async function main(name: string) {\n return `Hello, ${name}`\n}\n" + } + ] + } +} diff --git a/ai_evals/modes/app.ts b/ai_evals/modes/app.ts index 5bca0ad878..af9d7c667b 100644 --- a/ai_evals/modes/app.ts +++ b/ai_evals/modes/app.ts @@ -5,15 +5,12 @@ import type { FrontendEvalModelConfig } from "../core/models"; import { validateAppState, type AppFilesState } from "../core/validators"; import type { BenchmarkArtifactFile, ModeRunner } from "../core/types"; import { runAppEval } from "../adapters/frontend/core/app/appEvalRunner"; -import { - DEFAULT_FRONTEND_EVAL_MODEL, - getFrontendApiKey, -} from "./frontendCommon"; -import type { FrontendEvalTransportSettings } from "../core/frontendTransport"; +import { getFrontendApiKey } from "./frontendCommon"; +import type { WindmillBackendSettings } from "../core/windmillBackendSettings"; export function createAppModeRunner( - modelConfig: FrontendEvalModelConfig = DEFAULT_FRONTEND_EVAL_MODEL, - transportSettings?: FrontendEvalTransportSettings, + modelConfig: FrontendEvalModelConfig, + backendSettings: WindmillBackendSettings, ): ModeRunner { return { mode: "app", @@ -37,8 +34,7 @@ export function createAppModeRunner( appContext: context.evalCase?.runtime?.appContext, provider: modelConfig.provider, model: modelConfig.model, - transport: transportSettings?.transport, - backend: transportSettings?.backend, + backend: backendSettings, runContext: context, }, ); diff --git a/ai_evals/modes/flow.ts b/ai_evals/modes/flow.ts index 4e4f6451b9..e40a495573 100644 --- a/ai_evals/modes/flow.ts +++ b/ai_evals/modes/flow.ts @@ -7,11 +7,8 @@ import type { BenchmarkArtifactFile, ModeRunner } from "../core/types"; import { runFlowEval } from "../adapters/frontend/core/flow/flowEvalRunner"; import type { FlowWorkspaceFixtures } from "../adapters/frontend/core/flow/fileHelpers"; import { BackendPreviewClient } from "../adapters/frontend/backendPreview"; -import { - DEFAULT_FRONTEND_EVAL_MODEL, - getFrontendApiKey, -} from "./frontendCommon"; -import type { FrontendEvalTransportSettings } from "../core/frontendTransport"; +import { getFrontendApiKey } from "./frontendCommon"; +import type { WindmillBackendSettings } from "../core/windmillBackendSettings"; import { normalizeFlowInitialFixture, normalizeFlowStateFixture, @@ -19,9 +16,9 @@ import { } from "./flowFixtures"; export function createFlowModeRunner( - modelConfig: FrontendEvalModelConfig = DEFAULT_FRONTEND_EVAL_MODEL, - backendValidation?: BackendValidationSettings, - transportSettings?: FrontendEvalTransportSettings, + modelConfig: FrontendEvalModelConfig, + backendValidation: BackendValidationSettings | undefined, + backendSettings: WindmillBackendSettings, ): ModeRunner { return { mode: "flow", @@ -49,8 +46,7 @@ export function createFlowModeRunner( maxIterations: context.evalCase?.runtime?.maxTurns, provider: modelConfig.provider, model: modelConfig.model, - transport: transportSettings?.transport, - backend: transportSettings?.backend, + backend: backendSettings, runContext: context, }, ); diff --git a/ai_evals/modes/frontendCommon.ts b/ai_evals/modes/frontendCommon.ts index 2619d21821..f121551d86 100644 --- a/ai_evals/modes/frontendCommon.ts +++ b/ai_evals/modes/frontendCommon.ts @@ -1,12 +1,4 @@ -import { - getFrontendEvalModel, - resolveEvalModel, - type FrontendEvalModelConfig, -} from "../core/models"; - -export const DEFAULT_FRONTEND_EVAL_MODEL: FrontendEvalModelConfig = getFrontendEvalModel( - resolveEvalModel("flow") -); +import type { FrontendEvalModelConfig } from "../core/models"; export function getFrontendApiKey(provider: FrontendEvalModelConfig["provider"]): string { const envName = diff --git a/ai_evals/modes/global.ts b/ai_evals/modes/global.ts new file mode 100644 index 0000000000..d68df9f5f8 --- /dev/null +++ b/ai_evals/modes/global.ts @@ -0,0 +1,81 @@ +import { readFile } from "node:fs/promises"; +import { runGlobalEval } from "../adapters/frontend/core/global/globalEvalRunner"; +import type { BenchmarkWorkspaceRunnables } from "../adapters/frontend/mockBackend"; +import type { FrontendEvalModelConfig } from "../core/models"; +import type { BenchmarkArtifactFile, GlobalValidationSpec, ModeRunner } from "../core/types"; +import { validateGlobalState, type GlobalDraftState } from "../core/validators"; +import type { WindmillBackendSettings } from "../core/windmillBackendSettings"; +import { getFrontendApiKey } from "./frontendCommon"; + +export interface GlobalInitialFixture { + workspace?: BenchmarkWorkspaceRunnables; +} + +export function createGlobalModeRunner( + modelConfig: FrontendEvalModelConfig, + backendSettings: WindmillBackendSettings, +): ModeRunner { + return { + mode: "global", + concurrency: 3, + judgeThreshold: 80, + async loadInitial(path) { + return path ? await loadGlobalInitialFixture(path) : undefined; + }, + async loadExpected(path) { + return path ? await loadGlobalExpectedFixture(path) : undefined; + }, + async run(prompt, initial, context) { + const result = await runGlobalEval( + prompt, + getFrontendApiKey(modelConfig.provider), + { + workspaceFixtures: initial?.workspace, + maxIterations: context.evalCase?.runtime?.maxTurns, + provider: modelConfig.provider, + model: modelConfig.model, + backend: backendSettings, + runContext: context, + }, + ); + + return { + success: result.success, + actual: result.state, + error: result.error, + assistantMessageCount: result.assistantMessageCount, + toolCallCount: result.toolCallCount, + toolsUsed: result.toolsUsed, + toolCallDetails: result.toolCallDetails, + skillsInvoked: [], + tokenUsage: result.tokenUsage, + }; + }, + validate({ evalCase, actual, expected }) { + return validateGlobalState({ + actual, + expected, + validate: evalCase.validate as GlobalValidationSpec | undefined, + }); + }, + buildArtifacts(actual): BenchmarkArtifactFile[] { + return [ + { + path: "global-drafts.json", + content: JSON.stringify(actual, null, 2) + "\n", + }, + ]; + }, + }; +} + +async function loadGlobalInitialFixture(path: string): Promise { + const parsed = JSON.parse(await readFile(path, "utf8")) as GlobalInitialFixture; + return { + workspace: parsed.workspace ?? {}, + }; +} + +async function loadGlobalExpectedFixture(path: string): Promise { + return JSON.parse(await readFile(path, "utf8")) as GlobalDraftState; +} diff --git a/ai_evals/modes/script.ts b/ai_evals/modes/script.ts index 7671e8220a..0c49b05d7d 100644 --- a/ai_evals/modes/script.ts +++ b/ai_evals/modes/script.ts @@ -6,16 +6,13 @@ import type { BenchmarkArtifactFile, ModeRunner } from "../core/types"; import { BackendPreviewClient } from "../adapters/frontend/backendPreview"; import { runScriptEval } from "../adapters/frontend/core/script/scriptEvalRunner"; import type { ScriptEvalState } from "../adapters/frontend/core/script/fileHelpers"; -import { - DEFAULT_FRONTEND_EVAL_MODEL, - getFrontendApiKey, -} from "./frontendCommon"; -import type { FrontendEvalTransportSettings } from "../core/frontendTransport"; +import { getFrontendApiKey } from "./frontendCommon"; +import type { WindmillBackendSettings } from "../core/windmillBackendSettings"; export function createScriptModeRunner( - modelConfig: FrontendEvalModelConfig = DEFAULT_FRONTEND_EVAL_MODEL, - backendValidation?: BackendValidationSettings, - transportSettings?: FrontendEvalTransportSettings, + modelConfig: FrontendEvalModelConfig, + backendValidation: BackendValidationSettings | undefined, + backendSettings: WindmillBackendSettings, ): ModeRunner { return { mode: "script", @@ -40,8 +37,7 @@ export function createScriptModeRunner( maxIterations: context.evalCase?.runtime?.maxTurns, provider: modelConfig.provider, model: modelConfig.model, - transport: transportSettings?.transport, - backend: transportSettings?.backend, + backend: backendSettings, runContext: context, }, ); diff --git a/backend/.sqlx/query-0142d9dc9c1b57487dd5709a0376794f18d33e5bd6340c0189be7818cda64328.json b/backend/.sqlx/query-0142d9dc9c1b57487dd5709a0376794f18d33e5bd6340c0189be7818cda64328.json new file mode 100644 index 0000000000..461d1afb14 --- /dev/null +++ b/backend/.sqlx/query-0142d9dc9c1b57487dd5709a0376794f18d33e5bd6340c0189be7818cda64328.json @@ -0,0 +1,94 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled, NULL::text as workspace_id FROM password WHERE email = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "login_type", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "super_admin", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "devops", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "verified", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "company", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "operator_only", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "first_time_user", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "role_source", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "disabled", + "type_info": "Bool" + }, + { + "ordinal": 12, + "name": "workspace_id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + null, + false, + false, + false, + true, + true, + true, + null, + false, + false, + false, + null + ] + }, + "hash": "0142d9dc9c1b57487dd5709a0376794f18d33e5bd6340c0189be7818cda64328" +} diff --git a/backend/.sqlx/query-16b4496c21d0619dab4521dca22e5fe144c59156a8f06d8592291684c49b2f37.json b/backend/.sqlx/query-16b4496c21d0619dab4521dca22e5fe144c59156a8f06d8592291684c49b2f37.json new file mode 100644 index 0000000000..023eaaa010 --- /dev/null +++ b/backend/.sqlx/query-16b4496c21d0619dab4521dca22e5fe144c59156a8f06d8592291684c49b2f37.json @@ -0,0 +1,95 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email as \"email!\", login_type::text, verified as \"verified!\", super_admin as \"super_admin!\", devops as \"devops!\", name, company, username, NULL::bool as operator_only, first_time_user as \"first_time_user!\", role_source as \"role_source!\", disabled as \"disabled!\", NULL::text as workspace_id FROM password\n UNION ALL\n SELECT email as \"email!\", 'service_account'::text as login_type, true as \"verified!\", false as \"super_admin!\", false as \"devops!\", NULL::text as name, NULL::text as company, username, true as operator_only, false as \"first_time_user!\", 'service_account'::text as \"role_source!\", disabled as \"disabled!\", workspace_id\n FROM usr\n WHERE is_service_account IS true\n ORDER BY \"super_admin!\" DESC, \"devops!\" DESC, \"email!\"\n LIMIT $1 OFFSET $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email!", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "login_type", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "verified!", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "super_admin!", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "devops!", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "company", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "operator_only", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "first_time_user!", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "role_source!", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "disabled!", + "type_info": "Bool" + }, + { + "ordinal": 12, + "name": "workspace_id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "hash": "16b4496c21d0619dab4521dca22e5fe144c59156a8f06d8592291684c49b2f37" +} diff --git a/backend/.sqlx/query-2a49e5b5486b650d96f3e9038cba8a5f2e75d3b12ee4718452e82c7318b1bcf4.json b/backend/.sqlx/query-2a49e5b5486b650d96f3e9038cba8a5f2e75d3b12ee4718452e82c7318b1bcf4.json deleted file mode 100644 index 77f61ccc47..0000000000 --- a/backend/.sqlx/query-2a49e5b5486b650d96f3e9038cba8a5f2e75d3b12ee4718452e82c7318b1bcf4.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1)", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "exists", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "2a49e5b5486b650d96f3e9038cba8a5f2e75d3b12ee4718452e82c7318b1bcf4" -} diff --git a/backend/.sqlx/query-52777947d60d5ddd6d28852a1cf104a5af2fd565706204b7d5d33594ff91e61e.json b/backend/.sqlx/query-52777947d60d5ddd6d28852a1cf104a5af2fd565706204b7d5d33594ff91e61e.json new file mode 100644 index 0000000000..3aba64d16c --- /dev/null +++ b/backend/.sqlx/query-52777947d60d5ddd6d28852a1cf104a5af2fd565706204b7d5d33594ff91e61e.json @@ -0,0 +1,66 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT label, token_prefix, expiration, created_at, last_used_at, scopes, workspace_id, read_only FROM token WHERE email = $1\n ORDER BY created_at DESC LIMIT $2 OFFSET $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "label", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "token_prefix", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "expiration", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "last_used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 6, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "read_only", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [ + true, + false, + true, + false, + false, + true, + true, + false + ] + }, + "hash": "52777947d60d5ddd6d28852a1cf104a5af2fd565706204b7d5d33594ff91e61e" +} diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 36ddb8ab9f..713ccb9dd3 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - true + null ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-9a1bf7c424154172b56bbd29b51ef468969b5452150ee6fffe31d59b52a4f1c2.json b/backend/.sqlx/query-9a1bf7c424154172b56bbd29b51ef468969b5452150ee6fffe31d59b52a4f1c2.json new file mode 100644 index 0000000000..b4715fa36b --- /dev/null +++ b/backend/.sqlx/query-9a1bf7c424154172b56bbd29b51ef468969b5452150ee6fffe31d59b52a4f1c2.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id, read_only)\n SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10\n WHERE $9::varchar IS NULL OR NOT EXISTS(\n SELECT 1 FROM workspace WHERE id = $9 AND deleted = true\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Timestamptz", + "Bool", + "TextArray", + "Varchar", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "9a1bf7c424154172b56bbd29b51ef468969b5452150ee6fffe31d59b52a4f1c2" +} diff --git a/backend/.sqlx/query-c17c39add3f70218dbae38595a909d02cfabe7e0864af577df6968428ac448ef.json b/backend/.sqlx/query-c17c39add3f70218dbae38595a909d02cfabe7e0864af577df6968428ac448ef.json new file mode 100644 index 0000000000..36be033396 --- /dev/null +++ b/backend/.sqlx/query-c17c39add3f70218dbae38595a909d02cfabe7e0864af577df6968428ac448ef.json @@ -0,0 +1,95 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)\n SELECT email as \"email!\", (email NOT IN (SELECT email FROM authors)) as operator_only, login_type::text, verified as \"verified!\", super_admin as \"super_admin!\", devops as \"devops!\", name, company, username, first_time_user as \"first_time_user!\", role_source as \"role_source!\", disabled as \"disabled!\", NULL::text as workspace_id\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n UNION ALL\n SELECT email as \"email!\", true as operator_only, 'service_account'::text as login_type, true as \"verified!\", false as \"super_admin!\", false as \"devops!\", NULL::text as name, NULL::text as company, username, false as \"first_time_user!\", 'service_account'::text as \"role_source!\", disabled as \"disabled!\", workspace_id\n FROM usr\n WHERE is_service_account IS true\n ORDER BY \"super_admin!\" DESC, \"devops!\" DESC\n LIMIT $1 OFFSET $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email!", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "operator_only", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "login_type", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "verified!", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "super_admin!", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "devops!", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "company", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "first_time_user!", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "role_source!", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "disabled!", + "type_info": "Bool" + }, + { + "ordinal": 12, + "name": "workspace_id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "hash": "c17c39add3f70218dbae38595a909d02cfabe7e0864af577df6968428ac448ef" +} diff --git a/backend/.sqlx/query-e001fb68c60fa736cecce52be49c9fb0714c9c91a7ea3924811d345bee94c013.json b/backend/.sqlx/query-e001fb68c60fa736cecce52be49c9fb0714c9c91a7ea3924811d345bee94c013.json new file mode 100644 index 0000000000..d0d9bd8d95 --- /dev/null +++ b/backend/.sqlx/query-e001fb68c60fa736cecce52be49c9fb0714c9c91a7ea3924811d345bee94c013.json @@ -0,0 +1,66 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT label, token_prefix, expiration, created_at, last_used_at, scopes, workspace_id, read_only FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL)\n ORDER BY created_at DESC LIMIT $2 OFFSET $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "label", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "token_prefix", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "expiration", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "last_used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 6, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "read_only", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [ + true, + false, + true, + false, + false, + true, + true, + false + ] + }, + "hash": "e001fb68c60fa736cecce52be49c9fb0714c9c91a7ea3924811d345bee94c013" +} diff --git a/backend/.sqlx/query-ed82ffc4d806a19519701e39abc43e8e250cc9772cca4a7dae3442be86f4060c.json b/backend/.sqlx/query-ed82ffc4d806a19519701e39abc43e8e250cc9772cca4a7dae3442be86f4060c.json new file mode 100644 index 0000000000..b1f0fc0cff --- /dev/null +++ b/backend/.sqlx/query-ed82ffc4d806a19519701e39abc43e8e250cc9772cca4a7dae3442be86f4060c.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE token SET last_used_at = now() WHERE\n token_hash = $1\n AND (expiration > NOW() OR expiration IS NULL)\n AND (workspace_id IS NULL OR workspace_id = $2)\n RETURNING owner, email, super_admin, scopes, label, read_only", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "owner", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "super_admin", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 4, + "name": "label", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "read_only", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + true, + true, + false, + true, + true, + false + ] + }, + "hash": "ed82ffc4d806a19519701e39abc43e8e250cc9772cca4a7dae3442be86f4060c" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 82235725ee..185cb538c9 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -41,9 +41,9 @@ dependencies = [ [[package]] name = "aes" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac1f845298e95f983ff1944b728ae08b8cebab80d684f0a832ed0fc74dfa27e2" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher 0.4.4", @@ -57,7 +57,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ "aead", - "aes 0.8.3", + "aes 0.8.4", "cipher 0.4.4", "ctr", "ghash", @@ -164,7 +164,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -175,7 +175,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -775,9 +775,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", "zeroize", @@ -785,9 +785,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "cc", "cmake", @@ -1323,9 +1323,9 @@ dependencies = [ [[package]] name = "axum" -version = "0.8.4" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core 0.5.6", "axum-macros", @@ -1344,8 +1344,7 @@ dependencies = [ "multer", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", @@ -1522,7 +1521,7 @@ version = "0.71.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "cexpr", "clang-sys", "itertools 0.13.0", @@ -1542,7 +1541,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "cexpr", "clang-sys", "itertools 0.13.0", @@ -1585,11 +1584,11 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.4" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -1816,6 +1815,15 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.12.1" @@ -2091,6 +2099,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.44" @@ -2552,14 +2571,14 @@ dependencies = [ [[package]] name = "csv" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdc4883a9c96732e4733212c01447ebd805833b7275a73ca3ee080fd77afdaf" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" dependencies = [ "csv-core", "itoa", "ryu", - "serde", + "serde_core", ] [[package]] @@ -2592,7 +2611,7 @@ dependencies = [ "openssl-sys", "pkg-config", "vcpkg", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2652,16 +2671,6 @@ dependencies = [ "darling_macro 0.20.11", ] -[[package]] -name = "darling" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" -dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", -] - [[package]] name = "darling" version = "0.23.0" @@ -2714,20 +2723,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "darling_core" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim 0.11.1", - "syn 2.0.117", -] - [[package]] name = "darling_core" version = "0.23.0" @@ -2774,17 +2769,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "darling_macro" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" -dependencies = [ - "darling_core 0.21.3", - "quote", - "syn 2.0.117", -] - [[package]] name = "darling_macro" version = "0.23.0" @@ -4155,7 +4139,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4450,7 +4434,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4575,13 +4559,12 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "filetime" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ "cfg-if", "libc", - "libredox", ] [[package]] @@ -4602,7 +4585,7 @@ version = "25.12.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "rustc_version 0.4.1", ] @@ -5010,8 +4993,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", - "windows-result 0.3.4", + "windows-link 0.2.1", + "windows-result 0.4.1", ] [[package]] @@ -5091,6 +5074,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -5367,21 +5351,22 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", "foldhash 0.2.0", "serde", + "serde_core", ] [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashify" @@ -5916,7 +5901,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.56.0", + "windows-core 0.62.2", ] [[package]] @@ -6067,7 +6052,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -6115,7 +6100,7 @@ version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d09b98f7eace8982db770e4408e7470b028ce513ac28fecdc6bf4c30fe92b62" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "cfg-if", "libc", ] @@ -6603,7 +6588,7 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e668df13f2e97f3eed52d9301f6b1c4c1ccfccc30eab9e6628e4a8c1fc3546" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "bytes", "lazy_static", "libgssapi-sys", @@ -6652,7 +6637,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "libc", "plain", "redox_syscall 0.7.5", @@ -6771,7 +6756,7 @@ version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" dependencies = [ - "hashbrown 0.16.0", + "hashbrown 0.16.1", ] [[package]] @@ -6800,9 +6785,9 @@ dependencies = [ [[package]] name = "lz4_flex" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db9a0d582c2874f68138a16ce1867e0ffde6c0bb0a0df85e1f36d04146db488a" +checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" [[package]] name = "lzma-sys" @@ -7247,7 +7232,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbb9f371618ce723f095c61fbcdc36e8936956d2b62832f9c7648689b338e052" dependencies = [ "base64 0.22.1", - "bitflags 2.9.4", + "bitflags 2.11.1", "btoi", "byteorder", "bytes", @@ -7289,7 +7274,7 @@ dependencies = [ "openssl-probe 0.2.1", "openssl-sys", "schannel", - "security-framework 3.6.0", + "security-framework 3.7.0", "security-framework-sys", "tempfile", ] @@ -7306,7 +7291,7 @@ version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "cfg-if", "libc", ] @@ -7317,7 +7302,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "cfg-if", "cfg_aliases", "libc", @@ -7329,7 +7314,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "cfg-if", "cfg_aliases", "libc", @@ -7341,7 +7326,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "cfg-if", "cfg_aliases", "libc", @@ -7388,7 +7373,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7683,7 +7668,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.21.7", + "base64 0.22.1", "chrono", "getrandom 0.2.17", "http 1.4.0", @@ -7791,7 +7776,7 @@ version = "6.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "libc", "once_cell", "onig_sys", @@ -7850,7 +7835,7 @@ version = "0.10.79" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "cfg-if", "foreign-types", "libc", @@ -8507,18 +8492,18 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.12" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.12" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", @@ -8695,11 +8680,11 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.23.4", + "toml_edit 0.25.11+spec-1.1.0", ] [[package]] @@ -8799,7 +8784,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc5b72d8145275d844d4b5f6d4e1eef00c8cd889edb6035c21675d1bb1f45c9f" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "chrono", "flate2", "hex", @@ -8813,7 +8798,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "239df02d8349b06fc07398a3a1697b06418223b1c7725085e801e7c0fc6a12ec" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "chrono", "hex", ] @@ -8900,7 +8885,7 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "getopts", "memchr", "unicase", @@ -8963,7 +8948,7 @@ checksum = "5a70b1b8b47e31d0498ecbc3c5470bb931399a8bfed1fd79d1717a61ce7f96e3" dependencies = [ "ahash 0.8.12", "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.16.1", "parking_lot", ] @@ -9085,6 +9070,17 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -9142,6 +9138,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_distr" version = "0.5.1" @@ -9167,7 +9169,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", ] [[package]] @@ -9281,7 +9283,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", ] [[package]] @@ -9290,7 +9292,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", ] [[package]] @@ -9532,11 +9534,11 @@ checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" [[package]] name = "retry-policies" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503c78f59814e2664c9980b739b19e40f549233ecf39928040ee54fdd431f614" +checksum = "dc05fbf560421a0357a750cbe78c7ca19d4923918490daabba313d5dbc871e47" dependencies = [ - "rand 0.8.5", + "rand 0.10.1", ] [[package]] @@ -9614,7 +9616,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bef41ebc9ebed2c1b1d90203e9d1756091e8a00bbc3107676151f39868ca0ee" dependencies = [ "async-trait", - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "bytes", "chrono", @@ -9672,7 +9674,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bf7840285c321c3ab20e752a9afb95548c75cd7f4632a0627cea3507e310c1" dependencies = [ "async-lock", - "hashbrown 0.16.0", + "hashbrown 0.16.1", "relative-path", "rquickjs-sys", ] @@ -9857,7 +9859,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -9870,11 +9872,11 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -9953,7 +9955,7 @@ dependencies = [ "openssl-probe 0.2.1", "rustls-pki-types", "schannel", - "security-framework 3.6.0", + "security-framework 3.7.0", ] [[package]] @@ -9999,10 +10001,10 @@ dependencies = [ "rustls-native-certs 0.8.3", "rustls-platform-verifier-android", "rustls-webpki 0.103.13", - "security-framework 3.6.0", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -10137,7 +10139,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "675656c1eabb620b921efea4f9199f97fc86e36dd6ffd1fbbe48d0f59a4987f5" dependencies = [ - "hashbrown 0.16.0", + "hashbrown 0.16.1", "serde", "serde_json", ] @@ -10338,7 +10340,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -10347,11 +10349,11 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.6.0" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -10560,19 +10562,19 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.14.1" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c522100790450cf78eeac1507263d0a350d4d5b30df0c8e1fe051a10c22b376e" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" dependencies = [ "base64 0.22.1", + "bs58", "chrono", "hex", "indexmap 1.9.3", "indexmap 2.14.0", "schemars 0.9.0", "schemars 1.2.1", - "serde", - "serde_derive", + "serde_core", "serde_json", "serde_with_macros", "time", @@ -10580,11 +10582,11 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.14.1" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "327ada00f7d64abaac1e55a6911e90cf665aa051b9a561c7006c157f4633135e" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" dependencies = [ - "darling 0.21.3", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.117", @@ -10851,7 +10853,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -11062,7 +11064,7 @@ dependencies = [ "atoi", "base64 0.22.1", "bigdecimal", - "bitflags 2.9.4", + "bitflags 2.11.1", "byteorder", "bytes", "chrono", @@ -11107,7 +11109,7 @@ dependencies = [ "atoi", "base64 0.22.1", "bigdecimal", - "bitflags 2.9.4", + "bitflags 2.11.1", "byteorder", "chrono", "crc", @@ -11194,7 +11196,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -11404,7 +11406,7 @@ version = "15.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c25af97d53cf8aab66a6c68f3418663313fc969ad267fc2a4d19402c329be1" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "is-macro", "num-bigint", "once_cell", @@ -11460,7 +11462,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "017d06ea85008234aa9fb34d805c7dc563f2ea6e03869ed5ac5a2dc27d561e4d" dependencies = [ "arrayvec", - "bitflags 2.9.4", + "bitflags 2.11.1", "either", "num-bigint", "phf 0.11.3", @@ -11782,7 +11784,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "byteorder", "enum-as-inner", "libc", @@ -11810,7 +11812,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -11869,7 +11871,7 @@ dependencies = [ "levenshtein_automata", "log", "lru 0.16.4", - "lz4_flex 0.13.0", + "lz4_flex 0.13.1", "measure_time", "memmap2", "once_cell", @@ -12021,7 +12023,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -12040,7 +12042,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -12556,11 +12558,11 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -12578,14 +12580,14 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.23.4" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7211ff1b8f0d3adae1663b7da9ffe396eabe1ca25f0b0bee42b0da29a9ddce93" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap 2.14.0", - "toml_datetime 0.7.0", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 0.7.15", + "winnow 1.0.2", ] [[package]] @@ -12638,7 +12640,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9" dependencies = [ "async-trait", - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "bytes", "h2 0.4.14", @@ -12725,7 +12727,7 @@ checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ "async-compression", "base64 0.22.1", - "bitflags 2.9.4", + "bitflags 2.11.1", "bytes", "futures-core", "futures-util", @@ -13348,13 +13350,13 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.18.1" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.2", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] @@ -13365,7 +13367,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33995a1fee055ff743281cde33a41f0d618ee0bdbe8bdf6859e11864499c2595" dependencies = [ "bindgen 0.71.1", - "bitflags 2.9.4", + "bitflags 2.11.1", "fslock", "gzip-header", "home", @@ -13620,7 +13622,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap 2.14.0", "semver 1.0.28", @@ -13762,7 +13764,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -13786,14 +13788,14 @@ dependencies = [ [[package]] name = "windmill" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "async-nats", "aws-config", "aws-credential-types", "aws-sdk-sqs", - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "chrono", "constant_time_eq 0.3.1", @@ -13867,7 +13869,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.699.0" +version = "1.702.1" dependencies = [ "async-trait", "aws-config", @@ -13897,9 +13899,9 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.699.0" +version = "1.702.1" dependencies = [ - "axum 0.8.4", + "axum 0.8.9", "chrono", "serde", "serde_json", @@ -13910,7 +13912,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "argon2", @@ -13926,7 +13928,7 @@ dependencies = [ "aws-sdk-config", "aws-sigv4", "aws-smithy-types", - "axum 0.8.4", + "axum 0.8.9", "base32", "base64 0.22.1", "bytes", @@ -14053,9 +14055,9 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.699.0" +version = "1.702.1" dependencies = [ - "axum 0.8.4", + "axum 0.8.9", "chrono", "http 1.4.0", "hyper 1.9.0", @@ -14076,9 +14078,9 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.699.0" +version = "1.702.1" dependencies = [ - "axum 0.8.4", + "axum 0.8.9", "chrono", "serde", "serde_json", @@ -14089,10 +14091,10 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", - "axum 0.8.4", + "axum 0.8.9", "chrono", "http 1.4.0", "itertools 0.14.0", @@ -14115,7 +14117,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.699.0" +version = "1.702.1" dependencies = [ "reqwest 0.12.28", "serde", @@ -14125,9 +14127,9 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.699.0" +version = "1.702.1" dependencies = [ - "axum 0.8.4", + "axum 0.8.9", "chrono", "itertools 0.14.0", "serde", @@ -14142,9 +14144,9 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.699.0" +version = "1.702.1" dependencies = [ - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "chrono", "ed25519-dalek", @@ -14164,10 +14166,10 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", - "axum 0.8.4", + "axum 0.8.9", "candle-core", "candle-nn", "candle-transformers", @@ -14187,9 +14189,9 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.699.0" +version = "1.702.1" dependencies = [ - "axum 0.8.4", + "axum 0.8.9", "chrono", "serde", "sql-builder", @@ -14203,9 +14205,9 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.699.0" +version = "1.702.1" dependencies = [ - "axum 0.8.4", + "axum 0.8.9", "chrono", "hyper 1.9.0", "serde", @@ -14224,9 +14226,9 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.699.0" +version = "1.702.1" dependencies = [ - "axum 0.8.4", + "axum 0.8.9", "chrono", "globset", "lazy_static", @@ -14245,9 +14247,9 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.699.0" +version = "1.702.1" dependencies = [ - "axum 0.8.4", + "axum 0.8.9", "chrono", "serde", "serde_json", @@ -14259,14 +14261,14 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "async-nats", "aws-config", "aws-credential-types", "aws-sdk-sqs", - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "futures", "rand 0.9.0", @@ -14291,10 +14293,10 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "chrono", "http 1.4.0", @@ -14316,9 +14318,9 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.699.0" +version = "1.702.1" dependencies = [ - "axum 0.8.4", + "axum 0.8.9", "flate2", "reqwest 0.13.1", "serde", @@ -14334,10 +14336,10 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", - "axum 0.8.4", + "axum 0.8.9", "http 1.4.0", "indexmap 2.14.0", "itertools 0.14.0", @@ -14356,9 +14358,9 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.699.0" +version = "1.702.1" dependencies = [ - "axum 0.8.4", + "axum 0.8.9", "chrono", "chrono-tz", "serde", @@ -14376,9 +14378,9 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.699.0" +version = "1.702.1" dependencies = [ - "axum 0.8.4", + "axum 0.8.9", "chrono", "futures", "http 1.4.0", @@ -14406,10 +14408,10 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "bytes", "chrono", @@ -14434,7 +14436,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.699.0" +version = "1.702.1" dependencies = [ "lazy_static", "serde", @@ -14446,10 +14448,10 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.699.0" +version = "1.702.1" dependencies = [ "argon2", - "axum 0.8.4", + "axum 0.8.9", "chrono", "dashmap", "http 1.4.0", @@ -14471,9 +14473,9 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.699.0" +version = "1.702.1" dependencies = [ - "axum 0.8.4", + "axum 0.8.9", "chrono", "serde", "serde_json", @@ -14485,9 +14487,9 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.699.0" +version = "1.702.1" dependencies = [ - "axum 0.8.4", + "axum 0.8.9", "chrono", "hex", "http 1.4.0", @@ -14518,7 +14520,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.699.0" +version = "1.702.1" dependencies = [ "chrono", "lazy_static", @@ -14532,10 +14534,10 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", - "axum 0.8.4", + "axum 0.8.9", "k8s-openapi", "kube", "serde", @@ -14551,7 +14553,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.699.0" +version = "1.702.1" dependencies = [ "aes-gcm", "aho-corasick", @@ -14565,10 +14567,10 @@ dependencies = [ "aws-sdk-secretsmanager", "aws-sdk-sts", "aws-smithy-types-convert", - "axum 0.8.4", + "axum 0.8.9", "backon", "base64 0.22.1", - "bitflags 2.9.4", + "bitflags 2.11.1", "bytes", "chrono", "chrono-tz", @@ -14652,7 +14654,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.699.0" +version = "1.702.1" dependencies = [ "chrono", "itertools 0.14.0", @@ -14671,7 +14673,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.699.0" +version = "1.702.1" dependencies = [ "regex", "serde", @@ -14686,7 +14688,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14710,7 +14712,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "futures", @@ -14727,7 +14729,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.699.0" +version = "1.702.1" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14743,7 +14745,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "async-trait", @@ -14764,11 +14766,11 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "async-trait", - "axum 0.8.4", + "axum 0.8.9", "backon", "base64 0.22.1", "chrono", @@ -14795,12 +14797,12 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "arc-swap", "async-oauth2", - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "chrono", "hex", @@ -14820,7 +14822,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "async-stream", @@ -14829,7 +14831,7 @@ dependencies = [ "aws-credential-types", "aws-sdk-sts", "aws-smithy-types-convert", - "axum 0.8.4", + "axum 0.8.9", "bytes", "chrono", "datafusion", @@ -14854,7 +14856,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "futures", @@ -14872,7 +14874,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.699.0" +version = "1.702.1" dependencies = [ "convert_case 0.6.0", "serde", @@ -14881,7 +14883,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "lazy_static", @@ -14893,7 +14895,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "serde_json", @@ -14905,7 +14907,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "gosyn", @@ -14917,7 +14919,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "lazy_static", @@ -14929,7 +14931,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "serde_json", @@ -14941,7 +14943,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "nu-parser", @@ -14952,7 +14954,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14963,7 +14965,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14975,7 +14977,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "rustpython-ast", @@ -14986,7 +14988,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "async-recursion", @@ -15008,7 +15010,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "serde_json", @@ -15020,7 +15022,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "lazy_static", @@ -15034,7 +15036,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15051,7 +15053,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "lazy_static", @@ -15064,7 +15066,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "serde", @@ -15076,7 +15078,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "lazy_static", @@ -15094,7 +15096,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15110,7 +15112,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "rustpython-ast", @@ -15126,7 +15128,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "serde", @@ -15137,11 +15139,11 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "async-recursion", - "axum 0.8.4", + "axum 0.8.9", "backon", "chrono", "chrono-tz", @@ -15174,7 +15176,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "const_format", @@ -15212,7 +15214,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.699.0" +version = "1.702.1" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15223,11 +15225,11 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "async-recursion", - "axum 0.8.4", + "axum 0.8.9", "chrono", "futures", "http 1.4.0", @@ -15253,11 +15255,11 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "async-trait", - "axum 0.8.4", + "axum 0.8.9", "chrono", "futures", "serde", @@ -15277,11 +15279,11 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "async-trait", - "axum 0.8.4", + "axum 0.8.9", "chrono", "http 1.4.0", "hyper 1.9.0", @@ -15310,11 +15312,11 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "async-trait", - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "bytes", "chrono", @@ -15343,11 +15345,11 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "async-trait", - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "lazy_static", "regex", @@ -15363,11 +15365,11 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "async-trait", - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "bytes", "chrono", @@ -15397,11 +15399,11 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "async-trait", - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "chrono", "constant_time_eq 0.3.1", @@ -15433,11 +15435,11 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "async-trait", - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "itertools 0.14.0", "rdkafka", @@ -15456,11 +15458,11 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "async-trait", - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "bytes", "itertools 0.14.0", @@ -15480,12 +15482,12 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "async-nats", "async-trait", - "axum 0.8.4", + "axum 0.8.9", "base64 0.22.1", "itertools 0.14.0", "nkeys", @@ -15504,11 +15506,11 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "async-trait", - "axum 0.8.4", + "axum 0.8.9", "byteorder", "bytes", "chrono", @@ -15539,7 +15541,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "async-trait", @@ -15548,7 +15550,7 @@ dependencies = [ "aws-sdk-sqs", "aws-sdk-sts", "aws-smithy-types", - "axum 0.8.4", + "axum 0.8.9", "backon", "chrono", "itertools 0.14.0", @@ -15567,11 +15569,11 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "async-trait", - "axum 0.8.4", + "axum 0.8.9", "futures", "http 1.4.0", "itertools 0.14.0", @@ -15590,10 +15592,10 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", - "bitflags 2.9.4", + "bitflags 2.11.1", "chrono", "hex", "itertools 0.14.0", @@ -15609,18 +15611,14 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "async-once-cell", "async-recursion", "async-stream", "async-trait", - "aws-config", - "aws-credential-types", - "aws-sdk-bedrockruntime", - "aws-smithy-types", - "axum 0.8.4", + "axum 0.8.9", "backon", "base64 0.22.1", "bit-vec 0.6.3", @@ -15722,7 +15720,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.699.0" +version = "1.702.1" dependencies = [ "bytes", "futures", @@ -15817,6 +15815,19 @@ dependencies = [ "windows-strings 0.4.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-future" version = "0.2.1" @@ -15960,7 +15971,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d24d6bcc7f734a4091ecf8d7a64c5f7d7066f45585c1861eba06449909609c8a" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "widestring", "windows-sys 0.52.0", ] @@ -16312,6 +16323,9 @@ name = "winnow" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +dependencies = [ + "memchr", +] [[package]] name = "winsafe" @@ -16383,7 +16397,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.9.4", + "bitflags 2.11.1", "indexmap 2.14.0", "log", "serde", @@ -16540,9 +16554,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 326b777385..ccc6467e67 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.699.0" +version = "1.702.1" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.699.0" +version = "1.702.1" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 91d8581950..919919c4c6 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -c6cd1afe2d9e04809b30751cd1687b28a65e62b1 +19a76a09ffb43649ee19e62d07e8b8a42d78757b diff --git a/backend/migrations/20260513095235_token_read_only.down.sql b/backend/migrations/20260513095235_token_read_only.down.sql new file mode 100644 index 0000000000..e5380a3b02 --- /dev/null +++ b/backend/migrations/20260513095235_token_read_only.down.sql @@ -0,0 +1 @@ +ALTER TABLE token DROP COLUMN IF EXISTS read_only; diff --git a/backend/migrations/20260513095235_token_read_only.up.sql b/backend/migrations/20260513095235_token_read_only.up.sql new file mode 100644 index 0000000000..4fdeb9db3a --- /dev/null +++ b/backend/migrations/20260513095235_token_read_only.up.sql @@ -0,0 +1,4 @@ +-- Add a flag to restrict a token to read-only HTTP endpoints. +-- Orthogonal to `scopes`: even if scopes grant write/run, this flag denies +-- mutating methods (POST/PUT/PATCH/DELETE) and Run actions. +ALTER TABLE token ADD COLUMN read_only BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/migrations/20260514130526_fix_auto_kind_lib_for_runnable_script_kinds.down.sql b/backend/migrations/20260514130526_fix_auto_kind_lib_for_runnable_script_kinds.down.sql new file mode 100644 index 0000000000..bb9b57ac0b --- /dev/null +++ b/backend/migrations/20260514130526_fix_auto_kind_lib_for_runnable_script_kinds.down.sql @@ -0,0 +1,3 @@ +-- No-op: clearing a stray `auto_kind = 'lib'` value on failure/trigger/approval +-- scripts is not reversible (the original NULL/'lib' distinction is lost), and +-- restoring `'lib'` here would re-hide these scripts from their pickers. diff --git a/backend/migrations/20260514130526_fix_auto_kind_lib_for_runnable_script_kinds.up.sql b/backend/migrations/20260514130526_fix_auto_kind_lib_for_runnable_script_kinds.up.sql new file mode 100644 index 0000000000..e2038bb78c --- /dev/null +++ b/backend/migrations/20260514130526_fix_auto_kind_lib_for_runnable_script_kinds.up.sql @@ -0,0 +1,9 @@ +-- Failure, Trigger, and Approval scripts are runnable entrypoints by +-- definition. A prior parser regression occasionally classified them as +-- `auto_kind = 'lib'`, which hid them from the flow error-handler / +-- trigger / approval pickers. Clear those stray values so existing +-- affected scripts re-appear without requiring a redeploy. +UPDATE script +SET auto_kind = NULL +WHERE auto_kind = 'lib' + AND kind IN ('failure', 'trigger', 'approval'); diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index d337f4838d..b595cfeca1 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.699.0" +version = "1.702.1" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.699.0" +version = "1.702.1" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.699.0" +version = "1.702.1" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.699.0" +version = "1.702.1" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 80d8c63983..3be5965444 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.699.0" +version = "1.702.1" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/src/db_connect.rs b/backend/src/db_connect.rs index 3860c18909..deda032058 100644 --- a/backend/src/db_connect.rs +++ b/backend/src/db_connect.rs @@ -6,6 +6,8 @@ use windmill_common::{ pub const DEFAULT_MAX_CONNECTIONS_SERVER: u32 = 50; pub const DEFAULT_MAX_CONNECTIONS_WORKER: u32 = 5; pub const DEFAULT_MAX_CONNECTIONS_INDEXER: u32 = 5; +#[cfg(feature = "operator")] +pub const DEFAULT_MAX_CONNECTIONS_OPERATOR: u32 = 2; pub async fn initial_connection() -> Result, error::Error> { let connect_options = get_database_url().await?.connect_options().await?; @@ -16,12 +18,35 @@ pub async fn initial_connection() -> Result, error::E .map_err(|err| Error::ConnectingToDatabase(err.to_string())) } +/// Connect to the database for the Kubernetes operator process. +/// +/// Long-running operator pods need IAM RDS / Entra ID token refresh just like the server, +/// otherwise new pool connections start failing once the initial token expires (~15 min). +#[cfg(feature = "operator")] +pub async fn operator_connection( + #[cfg(all(feature = "enterprise", feature = "private"))] + killpill_rx: tokio::sync::broadcast::Receiver<()>, +) -> anyhow::Result> { + let database_url = get_database_url().await?; + let pool = connect( + database_url.clone(), + DEFAULT_MAX_CONNECTIONS_OPERATOR, + false, + ) + .await?; + + #[cfg(all(feature = "enterprise", feature = "private"))] + spawn_token_refresh_task(pool.clone(), database_url, killpill_rx); + + Ok(pool) +} + pub async fn connect_db( server_mode: bool, indexer_mode: bool, worker_mode: bool, num_workers: i32, - #[cfg(feature = "private")] mut killpill_rx: tokio::sync::broadcast::Receiver<()>, + #[cfg(feature = "private")] killpill_rx: tokio::sync::broadcast::Receiver<()>, ) -> anyhow::Result> { use anyhow::Context; @@ -43,70 +68,72 @@ pub async fn connect_db( let pool = connect(database_url.clone(), max_connections, worker_mode).await?; #[cfg(all(feature = "enterprise", feature = "private"))] - { - let needs_token_refresh = matches!( - database_url, - DatabaseUrl::IamRds(_) | DatabaseUrl::EntraId(_) - ); - let label = match &database_url { - DatabaseUrl::IamRds(_) => "IAM RDS", - DatabaseUrl::EntraId(_) => "Entra ID", - DatabaseUrl::Static(_) => "", - }; - if needs_token_refresh { - let pool2 = pool.clone(); - let database_url2 = database_url.clone(); - tokio::spawn(async move { - loop { - tokio::select! { - _ = killpill_rx.recv() => { - break; - } - _ = tokio::time::sleep(std::time::Duration::from_secs(10)) => { - if !database_url2.needs_refresh().await { - continue; - } - let new_url = tokio::time::timeout( - std::time::Duration::from_secs(10), - get_database_url(), - ) - .await; - match new_url { - Ok(Ok(new_url)) => { - match new_url.connect_options().await { - Ok(connect_options) => { - pool2.set_connect_options(connect_options); - tracing::info!("Refreshed {label} URL successfully"); - } - Err(e) => { - tracing::error!( - "Error getting {label} connect options, retrying in 10s: {e}" - ); - continue; - } - } - } - Ok(Err(e)) => { - tracing::error!( - "Error refreshing {label} URL, trying again in 10s: {e}" - ); - continue; + spawn_token_refresh_task(pool.clone(), database_url, killpill_rx); + + Ok(pool) +} + +/// Spawn a background task that refreshes IAM RDS / Entra ID tokens before they expire +/// and updates the pool's connect options so new connections use the fresh token. +/// No-op for static (password-based) database URLs. +#[cfg(all(feature = "enterprise", feature = "private"))] +pub fn spawn_token_refresh_task( + pool: sqlx::Pool, + database_url: DatabaseUrl, + mut killpill_rx: tokio::sync::broadcast::Receiver<()>, +) { + let label = match &database_url { + DatabaseUrl::IamRds(_) => "IAM RDS", + DatabaseUrl::EntraId(_) => "Entra ID", + DatabaseUrl::Static(_) => return, + }; + tokio::spawn(async move { + loop { + tokio::select! { + _ = killpill_rx.recv() => { + break; + } + _ = tokio::time::sleep(std::time::Duration::from_secs(10)) => { + if !database_url.needs_refresh().await { + continue; + } + let new_url = tokio::time::timeout( + std::time::Duration::from_secs(10), + get_database_url(), + ) + .await; + match new_url { + Ok(Ok(new_url)) => { + match new_url.connect_options().await { + Ok(connect_options) => { + pool.set_connect_options(connect_options); + tracing::info!("Refreshed {label} URL successfully"); } Err(e) => { tracing::error!( - "Timeout after 10s refreshing {label} URL, trying again in 10s: {e}" + "Error getting {label} connect options, retrying in 10s: {e}" ); continue; } } } + Ok(Err(e)) => { + tracing::error!( + "Error refreshing {label} URL, trying again in 10s: {e}" + ); + continue; + } + Err(e) => { + tracing::error!( + "Timeout after 10s refreshing {label} URL, trying again in 10s: {e}" + ); + continue; + } } } - }); + } } - } - - Ok(pool) + }); } pub async fn connect( diff --git a/backend/src/main.rs b/backend/src/main.rs index 9a2709e9a9..74e2c240ba 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -670,7 +670,24 @@ async fn windmill_main() -> anyhow::Result<()> { tracing_subscriber::fmt::init(); tracing::info!("Starting Windmill Kubernetes operator..."); tracing::info!("Connecting to database..."); - let db = crate::db_connect::initial_connection().await?; + + #[cfg(all(feature = "enterprise", feature = "private"))] + let (operator_killpill_tx, operator_killpill_rx) = + tokio::sync::broadcast::channel::<()>(2); + + let db = crate::db_connect::operator_connection( + #[cfg(all(feature = "enterprise", feature = "private"))] + operator_killpill_rx, + ) + .await?; + + #[cfg(all(feature = "enterprise", feature = "private"))] + tokio::spawn(async move { + if let Ok(()) = tokio::signal::ctrl_c().await { + let _ = operator_killpill_tx.send(()); + } + }); + tracing::info!("Database connected. Starting ConfigMap watcher..."); windmill_operator::run(db).await?; return Ok(()); diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 0ddc02b256..d5ea145cfc 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -886,11 +886,13 @@ pub async fn reload_otel_tracing_proxy_setting(conn: &Connection) { let mut current = OTEL_TRACING_PROXY_SETTINGS.write().await; if current.enabled != new_settings.enabled || current.enabled_languages != new_settings.enabled_languages + || current.no_proxy_hosts != new_settings.no_proxy_hosts { tracing::info!( - "OTEL tracing proxy settings changed: enabled={}, languages={:?}", + "OTEL tracing proxy settings changed: enabled={}, languages={:?}, no_proxy_hosts={:?}", new_settings.enabled, - new_settings.enabled_languages + new_settings.enabled_languages, + new_settings.no_proxy_hosts, ); *current = new_settings; } diff --git a/backend/tests/bun_jobs.rs b/backend/tests/bun_jobs.rs index fa15866bca..d51e51f58b 100644 --- a/backend/tests/bun_jobs.rs +++ b/backend/tests/bun_jobs.rs @@ -577,6 +577,63 @@ export function main() { Ok(()) } +/// Regression test: a `//nobundling` script that pulls a package whose CJS +/// internals do bare-specifier `require()` of a sibling dependency. +/// +/// Before the `--preserve-symlinks` fix, Bun 1.2/1.3+ would follow the +/// directory symlink in `node_modules/@langchain/core` to its global cache +/// entry, walk parent dirs from the cache realpath, and fail to find +/// `node_modules/zod` — producing: +/// ENOENT while resolving package 'zod/v3' from +/// '.../cache_nomount/bun/@langchain/core@@@@1/dist/runnables/base.js' +/// +/// The fix passes `--preserve-symlinks` so Bun resolves from the +/// symlink path under `/node_modules/`, where `zod` is a sibling. +#[sqlx::test(fixtures("base"))] +async fn test_bun_nobundling_transitive_require(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let content = r#"//nobundling +import { ChatPromptTemplate } from "@langchain/core/prompts"; + +export async function main() { + const tpl = ChatPromptTemplate.fromMessages([ + ["system", "you are a {role}"], + ["human", "{input}"], + ]); + const out = await tpl.formatMessages({ role: "tester", input: "ping" }); + return out.length; +} +"# + .to_owned(); + + let job = JobPayload::Code(RawCode { + hash: None, + content, + path: None, + language: ScriptLang::Bun, + lock: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + modules: None, + tag: None, + }); + + let result = run_job_in_new_worker_until_complete(&db, false, job, port) + .await + .json_result() + .unwrap(); + + assert_eq!(result, serde_json::json!(2)); + Ok(()) +} + // ============================================================================ // Native Mode Tests (requires deno_core feature) // ============================================================================ diff --git a/backend/tests/python_jobs.rs b/backend/tests/python_jobs.rs index dda3ec7082..791ccd4557 100644 --- a/backend/tests/python_jobs.rs +++ b/backend/tests/python_jobs.rs @@ -760,6 +760,112 @@ def main(): Ok(()) } +#[cfg(feature = "python")] +#[sqlx::test(fixtures("base"))] +async fn test_python_result_preserves_infinity_in_string(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let content = r#" +def main(): + return { + "plain": "Infinity", + "embedded": "value=-Infinity end", + "nan_word": "this is NaN inside text", + "nested": [{"k": "Infinity"}], + } + "# + .to_owned(); + + let job = JobPayload::Code(RawCode { + hash: None, + content, + path: None, + language: ScriptLang::Python3, + lock: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + modules: None, + tag: None, + }); + + let result = run_job_in_new_worker_until_complete(&db, false, job, port) + .await + .json_result() + .unwrap(); + + assert_eq!( + result, + serde_json::json!({ + "plain": "Infinity", + "embedded": "value=-Infinity end", + "nan_word": "this is NaN inside text", + "nested": [{"k": "Infinity"}], + }) + ); + Ok(()) +} + +#[cfg(feature = "python")] +#[sqlx::test(fixtures("base"))] +async fn test_python_result_non_finite_floats_become_null( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let content = r#" +def main(): + return { + "inf": float("inf"), + "neg_inf": float("-inf"), + "nan": float("nan"), + "finite": 1.5, + "nested": [float("inf"), {"x": float("nan")}], + } + "# + .to_owned(); + + let job = JobPayload::Code(RawCode { + hash: None, + content, + path: None, + language: ScriptLang::Python3, + lock: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + modules: None, + tag: None, + }); + + let result = run_job_in_new_worker_until_complete(&db, false, job, port) + .await + .json_result() + .unwrap(); + + assert_eq!( + result, + serde_json::json!({ + "inf": null, + "neg_inf": null, + "nan": null, + "finite": 1.5, + "nested": [null, {"x": null}], + }) + ); + Ok(()) +} + #[cfg(feature = "python")] #[sqlx::test(fixtures("base"))] async fn test_python_global_site_packages(db: Pool) -> anyhow::Result<()> { diff --git a/backend/tests/script_auto_kind_failure.rs b/backend/tests/script_auto_kind_failure.rs new file mode 100644 index 0000000000..4110bd4ba5 --- /dev/null +++ b/backend/tests/script_auto_kind_failure.rs @@ -0,0 +1,122 @@ +use std::collections::HashMap; + +use sqlx::{Pool, Postgres}; +use windmill_api_client::types::{NewScript, ScriptLang}; +use windmill_test_utils::init_client; + +fn quick_ns(content: &str, path: &str, kind: Option<&str>) -> NewScript { + NewScript { + content: content.into(), + language: ScriptLang::Bun, + lock: None, + parent_hash: None, + path: path.into(), + concurrent_limit: None, + concurrency_time_window_s: None, + cache_ttl: None, + dedicated_worker: None, + description: "".to_string(), + draft_only: None, + envs: vec![], + is_template: None, + kind: kind.map(|s| s.to_string()), + summary: "".to_string(), + tag: None, + schema: HashMap::new(), + ws_error_handler_muted: Some(false), + priority: None, + delete_after_secs: None, + timeout: None, + restart_unless_cancelled: None, + deployment_message: None, + concurrency_key: None, + visible_to_runner_only: None, + auto_kind: None, + codebase: None, + has_preprocessor: None, + on_behalf_of_email: None, + assets: vec![], + modules: None, + } +} + +/// Regression: a `failure`-kind script must never be marked `auto_kind = 'lib'` +/// even if the parser fails to detect a `main` function, because the flow +/// error-handler picker filters out lib scripts and would otherwise hide it. +#[sqlx::test(fixtures("base"))] +async fn failure_kind_script_without_main_is_not_marked_lib( + db: Pool, +) -> anyhow::Result<()> { + let (client, _port, _s) = init_client(db.clone()).await; + + // Content with no `main` — TS parser would normally set auto_kind = 'lib'. + client + .create_script( + "test-workspace", + &quick_ns( + "export function notMain() { return 42 }", + "u/test-user/failure_no_main", + Some("failure"), + ), + ) + .await + .unwrap(); + + let auto_kind: Option = sqlx::query_scalar( + "SELECT auto_kind FROM script \ + WHERE workspace_id = $1 AND path = $2", + ) + .bind("test-workspace") + .bind("u/test-user/failure_no_main") + .fetch_one(&db) + .await?; + + assert_ne!( + auto_kind.as_deref(), + Some("lib"), + "failure-kind script must not be marked as 'lib' auto_kind, got {:?}", + auto_kind + ); + + Ok(()) +} + +/// Sibling: a normal `script` kind WITHOUT main should still be marked `lib` +/// (so it stays hidden from the regular script picker). Guards against an +/// over-broad sanitizer accidentally clearing the value for plain scripts. +#[sqlx::test(fixtures("base"))] +async fn regular_script_without_main_is_still_marked_lib( + db: Pool, +) -> anyhow::Result<()> { + let (client, _port, _s) = init_client(db.clone()).await; + + client + .create_script( + "test-workspace", + &quick_ns( + "export function notMain() { return 42 }", + "u/test-user/script_no_main", + Some("script"), + ), + ) + .await + .unwrap(); + + let auto_kind: Option = sqlx::query_scalar( + "SELECT auto_kind FROM script \ + WHERE workspace_id = $1 AND path = $2", + ) + .bind("test-workspace") + .bind("u/test-user/script_no_main") + .fetch_one(&db) + .await?; + + assert_eq!( + auto_kind.as_deref(), + Some("lib"), + "regular script without main should be marked 'lib', got {:?}", + auto_kind + ); + + Ok(()) +} diff --git a/backend/tests/trigger_listener_queries.rs b/backend/tests/trigger_listener_queries.rs index c169d5289a..d1c20da407 100644 --- a/backend/tests/trigger_listener_queries.rs +++ b/backend/tests/trigger_listener_queries.rs @@ -174,6 +174,7 @@ fn make_authed() -> windmill_api_auth::ApiAuthed { scopes: None, username_override: None, token_prefix: None, + read_only: false, } } diff --git a/backend/windmill-ai/src/ai_bedrock.rs b/backend/windmill-ai/src/ai_bedrock.rs index 49166978e8..c05094b8f4 100644 --- a/backend/windmill-ai/src/ai_bedrock.rs +++ b/backend/windmill-ai/src/ai_bedrock.rs @@ -326,8 +326,10 @@ pub fn json_to_document(value: serde_json::Value) -> aws_smithy_types::Document } Value::Array(arr) => Document::Array(arr.into_iter().map(json_to_document).collect()), Value::Number(num) => { - if let Some(i) = num.as_i64() { - Document::Number(aws_smithy_types::Number::PosInt(i as u64)) + if let Some(u) = num.as_u64() { + Document::Number(aws_smithy_types::Number::PosInt(u)) + } else if let Some(i) = num.as_i64() { + Document::Number(aws_smithy_types::Number::NegInt(i)) } else if let Some(f) = num.as_f64() { Document::Number(aws_smithy_types::Number::Float(f)) } else { @@ -844,6 +846,36 @@ mod tests { } } + #[test] + fn json_to_document_preserves_negative_integers() { + let value = serde_json::json!(-1); + let doc = json_to_document(value); + assert!(matches!( + doc, + aws_smithy_types::Document::Number(aws_smithy_types::Number::NegInt(-1)) + )); + } + + #[test] + fn json_to_document_handles_large_u64_above_i64_max() { + let value = serde_json::json!(u64::MAX); + let doc = json_to_document(value); + assert!(matches!( + doc, + aws_smithy_types::Document::Number(aws_smithy_types::Number::PosInt(u)) if u == u64::MAX + )); + } + + #[test] + fn json_to_document_handles_positive_integers() { + let value = serde_json::json!(42); + let doc = json_to_document(value); + assert!(matches!( + doc, + aws_smithy_types::Document::Number(aws_smithy_types::Number::PosInt(42)) + )); + } + #[test] fn openai_messages_to_bedrock_adds_cache_points_when_enabled() { let messages = vec![ diff --git a/backend/windmill-ai/src/lib.rs b/backend/windmill-ai/src/lib.rs index 945091613b..6b174bf2e6 100644 --- a/backend/windmill-ai/src/lib.rs +++ b/backend/windmill-ai/src/lib.rs @@ -5,6 +5,7 @@ pub mod ai_google; pub mod ai_providers; pub mod ai_types; pub mod image_handler; +pub mod providers; pub mod query_builder; pub mod sse; pub mod types; diff --git a/backend/windmill-worker/src/ai/providers/anthropic.rs b/backend/windmill-ai/src/providers/anthropic.rs similarity index 99% rename from backend/windmill-worker/src/ai/providers/anthropic.rs rename to backend/windmill-ai/src/providers/anthropic.rs index 0a3c5df6e4..43cb503d96 100644 --- a/backend/windmill-worker/src/ai/providers/anthropic.rs +++ b/backend/windmill-ai/src/providers/anthropic.rs @@ -1,7 +1,4 @@ -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use serde_json::value::RawValue; -use windmill_ai::{ +use crate::{ ai_google::parse_data_url, ai_providers::AIProvider, image_handler::prepare_messages_for_api, @@ -10,6 +7,9 @@ use windmill_ai::{ types::*, utils::{extract_text_content, should_use_structured_output_tool}, }; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::value::RawValue; use windmill_common::{client::AuthedClient, error::Error}; /// Anthropic API version for standard API diff --git a/backend/windmill-worker/src/ai/providers/bedrock.rs b/backend/windmill-ai/src/providers/bedrock.rs similarity index 97% rename from backend/windmill-worker/src/ai/providers/bedrock.rs rename to backend/windmill-ai/src/providers/bedrock.rs index 8c37433cab..005327c15b 100644 --- a/backend/windmill-worker/src/ai/providers/bedrock.rs +++ b/backend/windmill-ai/src/providers/bedrock.rs @@ -1,4 +1,4 @@ -//! AWS Bedrock provider for the AI agent. +//! AWS Bedrock provider for AI requests. //! //! Uses shared SDK code from windmill_ai::ai_bedrock for: //! - BedrockClient (SDK wrapper with auth) @@ -6,16 +6,16 @@ //! - Stream event parsing //! - Helper utilities -use std::collections::HashMap; -use windmill_ai::{ +use crate::{ image_handler::prepare_messages_for_api, query_builder::{ParsedResponse, StreamEventSink}, types::{OpenAIMessage, StreamingEvent, TokenUsage, ToolDef}, }; +use std::collections::HashMap; use windmill_common::{client::AuthedClient, error::Error}; -// Import shared Bedrock helpers for worker-specific orchestration. -use windmill_ai::ai_bedrock::{ +// Import shared Bedrock helpers for provider orchestration. +use crate::ai_bedrock::{ bedrock_model_supports_prompt_caching, bedrock_stream_event_is_block_stop, bedrock_stream_event_to_text, bedrock_stream_event_to_tool_delta, bedrock_stream_event_to_tool_start, build_tool_config, create_inference_config, @@ -24,7 +24,7 @@ use windmill_ai::ai_bedrock::{ }; // ============================================================================ -// Query Builder (Worker-specific orchestration) +// Query Builder // ============================================================================ #[derive(Default)] diff --git a/backend/windmill-worker/src/ai/providers/google_ai.rs b/backend/windmill-ai/src/providers/google_ai.rs similarity index 99% rename from backend/windmill-worker/src/ai/providers/google_ai.rs rename to backend/windmill-ai/src/providers/google_ai.rs index 19c50d06de..81f34e7acb 100644 --- a/backend/windmill-worker/src/ai/providers/google_ai.rs +++ b/backend/windmill-ai/src/providers/google_ai.rs @@ -1,5 +1,4 @@ -use async_trait::async_trait; -use windmill_ai::{ +use crate::{ ai_google::{ openai_messages_to_gemini, openai_tools_to_gemini, GeminiGenerationConfig, GeminiImageContent, GeminiImageRequest, GeminiImageResponse, GeminiInlineData, GeminiPart, @@ -10,6 +9,7 @@ use windmill_ai::{ sse::{GeminiSSEParser, SSEParser}, types::*, }; +use async_trait::async_trait; use windmill_common::{client::AuthedClient, error::Error}; // ============================================================================ diff --git a/backend/windmill-ai/src/providers/mod.rs b/backend/windmill-ai/src/providers/mod.rs new file mode 100644 index 0000000000..8f9e2382a8 --- /dev/null +++ b/backend/windmill-ai/src/providers/mod.rs @@ -0,0 +1,31 @@ +pub mod anthropic; +#[cfg(feature = "bedrock")] +pub mod bedrock; +pub mod google_ai; +pub mod openai; +pub mod openrouter; +pub mod other; + +use crate::{ai_providers::AIProvider, query_builder::QueryBuilder, types::ProviderWithResource}; + +use self::{ + anthropic::AnthropicQueryBuilder, google_ai::GoogleAIQueryBuilder, openai::OpenAIQueryBuilder, + openrouter::OpenRouterQueryBuilder, other::OtherQueryBuilder, +}; + +/// Factory function to create the appropriate query builder for a provider. +pub fn create_query_builder(provider: &ProviderWithResource) -> Box { + match provider.kind { + AIProvider::GoogleAI => { + Box::new(GoogleAIQueryBuilder::new(provider.get_platform().clone())) + } + AIProvider::OpenAI => Box::new(OpenAIQueryBuilder::new(provider.kind.clone())), + AIProvider::Anthropic => Box::new(AnthropicQueryBuilder::new( + provider.kind.clone(), + provider.get_platform().clone(), + provider.get_enable_1m_context(), + )), + AIProvider::OpenRouter => Box::new(OpenRouterQueryBuilder::new()), + _ => Box::new(OtherQueryBuilder::new(provider.kind.clone())), + } +} diff --git a/backend/windmill-worker/src/ai/providers/openai.rs b/backend/windmill-ai/src/providers/openai.rs similarity index 99% rename from backend/windmill-worker/src/ai/providers/openai.rs rename to backend/windmill-ai/src/providers/openai.rs index e0767d5dda..f8928ca940 100644 --- a/backend/windmill-worker/src/ai/providers/openai.rs +++ b/backend/windmill-ai/src/providers/openai.rs @@ -1,7 +1,4 @@ -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use serde_json::value::RawValue; -use windmill_ai::{ +use crate::{ ai_providers::AIProvider, ai_types::OpenAIToolCall, image_handler::{prepare_messages_for_api, s3_object_to_content_part}, @@ -10,6 +7,9 @@ use windmill_ai::{ types::*, utils::extract_text_content, }; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::value::RawValue; use windmill_common::{client::AuthedClient, error::Error}; // Responses API structures diff --git a/backend/windmill-worker/src/ai/providers/openrouter.rs b/backend/windmill-ai/src/providers/openrouter.rs similarity index 98% rename from backend/windmill-worker/src/ai/providers/openrouter.rs rename to backend/windmill-ai/src/providers/openrouter.rs index ede541cd13..aaefbb08ca 100644 --- a/backend/windmill-worker/src/ai/providers/openrouter.rs +++ b/backend/windmill-ai/src/providers/openrouter.rs @@ -1,15 +1,15 @@ -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use serde_json; -use windmill_ai::{ +use crate::{ ai_providers::AIProvider, image_handler::prepare_messages_for_api, query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventSink}, types::*, }; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json; use windmill_common::{client::AuthedClient, error::Error}; -use crate::ai::providers::other::OtherQueryBuilder; +use crate::providers::other::OtherQueryBuilder; // OpenRouter-specific types #[derive(Serialize)] diff --git a/backend/windmill-worker/src/ai/providers/other.rs b/backend/windmill-ai/src/providers/other.rs similarity index 99% rename from backend/windmill-worker/src/ai/providers/other.rs rename to backend/windmill-ai/src/providers/other.rs index a840517133..08f922a1de 100644 --- a/backend/windmill-worker/src/ai/providers/other.rs +++ b/backend/windmill-ai/src/providers/other.rs @@ -1,7 +1,4 @@ -use async_trait::async_trait; -use serde::Serialize; -use serde_json; -use windmill_ai::{ +use crate::{ ai_providers::AIProvider, image_handler::prepare_messages_for_api, query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventSink}, @@ -9,6 +6,9 @@ use windmill_ai::{ types::*, utils::should_use_structured_output_tool, }; +use async_trait::async_trait; +use serde::Serialize; +use serde_json; use windmill_common::{client::AuthedClient, error::Error}; #[derive(Serialize, Debug, Clone)] diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index ac4017b81c..bda9f54bdd 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -194,6 +194,7 @@ impl AuthCache { scopes: None, username_override, token_prefix: claims.audit_span, + read_only: false, }; let job_id = claims.job_id.and_then(|j| uuid::Uuid::from_str(&j).ok()); AUTH_CACHE.insert( @@ -221,11 +222,20 @@ impl AuthCache { token_hash = $1 AND (expiration > NOW() OR expiration IS NULL) AND (workspace_id IS NULL OR workspace_id = $2) - RETURNING owner, email, super_admin, scopes, label", + RETURNING owner, email, super_admin, scopes, label, read_only", t_hash, w_id.as_ref(), ) - .map(|x| (x.owner, x.email, x.super_admin, x.scopes, x.label)) + .map(|x| { + ( + x.owner, + x.email, + x.super_admin, + x.scopes, + x.label, + x.read_only, + ) + }) .fetch_optional(&self.db) .await .ok() @@ -234,7 +244,9 @@ impl AuthCache { if let Some(user) = user_o { let authed_o = { match user { - (Some(owner), Some(email), super_admin, _, label) if w_id.is_some() => { + (Some(owner), Some(email), super_admin, _, label, read_only) + if w_id.is_some() => + { let username_override = username_override_from_label(label); if let Some((prefix, name)) = owner.split_once('/') { if prefix == "u" { @@ -280,6 +292,7 @@ impl AuthCache { scopes: None, username_override, token_prefix: Some(safe_token_prefix(token)), + read_only, }) } else { let groups = vec![name.to_string()]; @@ -305,6 +318,7 @@ impl AuthCache { scopes: None, username_override, token_prefix: Some(safe_token_prefix(token)), + read_only, }) } } else { @@ -320,10 +334,11 @@ impl AuthCache { scopes: None, username_override, token_prefix: Some(safe_token_prefix(token)), + read_only, }) } } - (_, Some(email), super_admin, scopes, label) => { + (_, Some(email), super_admin, scopes, label, read_only) => { let username_override = username_override_from_label(label); if w_id.is_some() { let row_o = sqlx::query!( @@ -368,6 +383,7 @@ impl AuthCache { scopes, username_override, token_prefix: Some(safe_token_prefix(token)), + read_only, }) } None if super_admin => Some(ApiAuthed { @@ -380,6 +396,7 @@ impl AuthCache { scopes, username_override, token_prefix: Some(safe_token_prefix(token)), + read_only, }), None => None, } @@ -394,6 +411,7 @@ impl AuthCache { scopes, username_override, token_prefix: Some(safe_token_prefix(token)), + read_only, }) } } @@ -428,6 +446,7 @@ impl AuthCache { scopes: None, username_override: None, token_prefix: Some(safe_token_prefix(token)), + read_only: false, }; Some(OptJobAuthed { authed, job_id: None }) } else { @@ -630,6 +649,7 @@ pub async fn resolve_opt_job_authed( scopes: None, username_override: None, token_prefix: None, + read_only: false, }; return Ok((OptJobAuthed { authed, job_id: None }, parts)); } @@ -667,12 +687,11 @@ pub async fn resolve_opt_job_authed( cache.get_opt_job_authed(workspace_id.clone(), &token).await { let authed = &mut opt_job_authed.authed; + let path = original_uri.path(); + let method = parts.method.as_str(); if authed.scopes.is_some() { transform_old_scope_to_new_scope(authed.scopes.as_mut()); - let path = original_uri.path(); - let method = parts.method.as_str(); - if let Err(err) = crate::scopes::check_scopes_for_route( authed.scopes.as_deref(), path, @@ -681,6 +700,27 @@ pub async fn resolve_opt_job_authed( return Err((err, parts)); } } + if authed.read_only { + // MCP transport runs over POST (streamable HTTP / SSE handshake), + // so the middleware can't safely reject mutating methods on it — + // the MCP runner itself filters out write tools and rejects + // mutating tool calls for read-only tokens. Narrow to the actual + // transport endpoints: anything else under `/api/mcp/*` (OAuth + // approve, token exchange, client registration) must still go + // through the read-only check, otherwise a read-only token + // could approve an OAuth flow that mints a new non-read-only + // token. + let is_mcp_transport = path == "/api/mcp/gateway" + || (path.starts_with("/api/mcp/w/") + && (path.ends_with("/mcp") + || path.ends_with("/sse") + || path.ends_with("/list_tools"))); + if !is_mcp_transport { + if let Err(err) = crate::scopes::check_read_only_for_route(path, method) { + return Err((err, parts)); + } + } + } parts.extensions.insert(authed.clone()); Span::current().record("username", &authed.username.as_str()); diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index c5b9b5adc7..b9bc2e2417 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -55,6 +55,7 @@ pub struct ApiAuthed { pub scopes: Option>, pub username_override: Option, pub token_prefix: Option, + pub read_only: bool, } impl ApiAuthed { @@ -103,6 +104,7 @@ impl From for ApiAuthed { scopes: value.scopes, username_override: None, token_prefix: value.token_prefix, + read_only: false, } } } @@ -183,6 +185,10 @@ impl windmill_mcp::server::McpAuth for ApiAuthed { fn scopes(&self) -> Option<&[String]> { self.scopes.as_deref() } + + fn read_only(&self) -> bool { + self.read_only + } } // ------------ Utility functions ------------ @@ -478,6 +484,7 @@ pub async fn fetch_api_authed_from_permissioned_as( scopes: authed.scopes, username_override: None, token_prefix: authed.token_prefix, + read_only: false, }; API_AUTHED_CACHE.insert( @@ -506,6 +513,8 @@ pub struct NewToken { pub impersonate_email: Option, pub scopes: Option>, pub workspace_id: Option, + #[serde(default)] + pub read_only: Option, } impl NewToken { @@ -515,8 +524,9 @@ impl NewToken { impersonate_email: Option, scopes: Option>, workspace_id: Option, + read_only: Option, ) -> Self { - Self { label, expiration, impersonate_email, scopes, workspace_id } + Self { label, expiration, impersonate_email, scopes, workspace_id, read_only } } } @@ -564,8 +574,8 @@ pub async fn create_token_internal( } let rows = sqlx::query!( "INSERT INTO token - (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id) - SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9 + (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id, read_only) + SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 WHERE $9::varchar IS NULL OR NOT EXISTS( SELECT 1 FROM workspace WHERE id = $9 AND deleted = true )", @@ -578,6 +588,7 @@ pub async fn create_token_internal( is_super_admin, token_config.scopes.as_ref().map(|x| x.as_slice()), token_config.workspace_id, + token_config.read_only.unwrap_or(false), ) .execute(&mut *tx) .await?; diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index 6df2f74ae8..87ca3a8862 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -686,6 +686,19 @@ fn scope_grants_access( Ok(true) } +/// Enforces a token's `read_only` flag: only methods classified as `Read` +/// (GET/HEAD/OPTIONS) are allowed. Run actions and mutating methods are +/// rejected. Independent of `scopes`. +pub fn check_read_only_for_route(route_path: &str, http_method: &str) -> Result<()> { + if map_http_method_to_action(http_method, route_path) == ScopeAction::Read { + Ok(()) + } else { + Err(Error::PermissionDenied( + "Token is read-only. Mutating endpoints are not allowed.".to_string(), + )) + } +} + /// Helper function to check if scopes allow access to a route pub fn check_scopes_for_route( token_scopes: Option<&[String]>, @@ -778,6 +791,34 @@ mod tests { assert_eq!(route_suffix, Some("flow_conversations/list".to_string())); } + #[test] + fn test_check_read_only_for_route() { + // Plain GETs pass. + assert!(check_read_only_for_route("/api/w/x/scripts/list", "GET").is_ok()); + assert!(check_read_only_for_route("/api/w/x/scripts/get/foo", "HEAD").is_ok()); + assert!(check_read_only_for_route("/api/w/x/anything", "OPTIONS").is_ok()); + + // Mutating methods are rejected. + assert!(check_read_only_for_route("/api/w/x/scripts/create", "POST").is_err()); + assert!(check_read_only_for_route("/api/w/x/scripts/update", "PUT").is_err()); + assert!(check_read_only_for_route("/api/w/x/scripts/delete", "DELETE").is_err()); + assert!(check_read_only_for_route("/api/w/x/scripts/patch", "PATCH").is_err()); + + // Run paths are rejected even on GET (map_http_method_to_action elevates + // them to Run via RUN_PATH_ACTIONS). + assert!(check_read_only_for_route("/api/w/x/jobs/run/p/f/foo", "GET").is_err()); + assert!(check_read_only_for_route("/api/w/x/jobs/run/p/f/foo", "POST").is_err()); + + // OAuth/registration endpoints under /api/mcp/* must NOT be exempted by + // the auth middleware — they go through this check on the gateway side + // because they can mint non-read-only tokens. The middleware decides + // which paths to exempt; this helper is method-only, so we just assert + // that mutating methods still fail. + assert!( + check_read_only_for_route("/api/mcp/gateway/oauth/server/approve", "POST").is_err() + ); + } + #[test] fn test_specific_scope_access() { let scopes = vec!["jobs:read".to_string()]; diff --git a/backend/windmill-api-groups/src/granular_acls.rs b/backend/windmill-api-groups/src/granular_acls.rs index 95243b6626..3f0549f4e8 100644 --- a/backend/windmill-api-groups/src/granular_acls.rs +++ b/backend/windmill-api-groups/src/granular_acls.rs @@ -12,6 +12,9 @@ use axum::{ Json, Router, }; use windmill_api_auth::require_owner_of_path; +use windmill_audit::audit_oss::audit_log; +use windmill_audit::ActionKind; +use windmill_common::scripts::ScriptHash; use windmill_common::DB; use windmill_git_sync::{handle_deployment_metadata, DeployedObject}; @@ -24,6 +27,31 @@ use windmill_common::{ utils::{not_found_if_none, StripPath}, }; +/// Map a granular-ACL kind segment to the audit-log action prefix used by the +/// per-kind CRUD endpoints (e.g. `flows.update`, `scripts.update`). The +/// resulting action is suffixed with `.grant_acl` / `.revoke_acl` so the +/// audit log keeps a per-resource record of every `/acls/*` mutation — +/// folder/group already log via their dedicated permission-history tables. +fn audit_action_prefix_for_acl_kind(kind: &str) -> Option<&'static str> { + match kind { + "script" => Some("scripts"), + "flow" => Some("flows"), + "app" => Some("apps"), + // Distinct prefix so dashboards aggregating on `action` can separate + // raw_app ACL mutations from regular app ones without parsing the + // `kind` parameters field. (The granular_acls SQL routes raw_app + // writes to the same `app` table; audit log identity is separate.) + "raw_app" => Some("raw_apps"), + "resource" => Some("resources"), + "variable" => Some("variables"), + "schedule" => Some("schedules"), + "http_trigger" | "websocket_trigger" | "kafka_trigger" | "nats_trigger" + | "postgres_trigger" | "mqtt_trigger" | "gcp_trigger" | "azure_trigger" | "sqs_trigger" + | "email_trigger" => Some("triggers"), + _ => None, + } +} + const KINDS: [&str; 20] = [ "script", "group_", @@ -131,9 +159,15 @@ async fn add_granular_acl( } } + // v2 raw apps are stored in the `app` table (with `app_version.raw_app = true` + // distinguishing them from regular apps); the legacy `raw_app` table no longer + // backs the workspace export, so granting/revoking on `raw_app` must hit `app` + // for the change to be visible. Git-sync dispatch still uses + // DeployedObject::RawApp so the worker writes back `.raw_app.json`. + let table = if kind == "raw_app" { "app" } else { kind }; // SAFETY: `kind` has been validated against the `KINDS` allowlist before reaching this function. let obj_o = sqlx::query_scalar::<_, serde_json::Value>(&format!( - "UPDATE {kind} SET extra_perms = jsonb_set(extra_perms, $1, to_jsonb($2), \ + "UPDATE {table} SET extra_perms = jsonb_set(extra_perms, $1, to_jsonb($2), \ true) WHERE {identifier} = $3 AND workspace_id = $4 RETURNING extra_perms" )) .bind(vec![owner.clone()]) @@ -175,6 +209,34 @@ async fn add_granular_acl( Some(&owner), ) .await?; + } else if let Some(prefix) = audit_action_prefix_for_acl_kind(kind) { + // Mirror the folder/group permission-history coverage for every other + // ACLable kind. Folder/group already wrote a dedicated history row + // above; everything else (script/flow/app/raw_app/resource/...) lands + // in the general audit_log table here. + let access = if write.unwrap_or(false) { + "write" + } else { + "read" + }; + let action = format!("{}.grant_acl", prefix); + audit_log( + &mut *tx, + &authed, + action.as_str(), + ActionKind::Update, + &w_id, + Some(path), + Some( + [ + ("kind", kind), + ("owner", owner.as_str()), + ("access", access), + ] + .into(), + ), + ) + .await?; } tx.commit().await?; @@ -193,46 +255,67 @@ async fn add_granular_acl( ) .await? } - // "app" => { - // handle_deployment_metadata( - // &authed.email, - // &authed.username, - // &db, - // &w_id, - // DeployedObject::App { path: path.to_string(), parent_path: None, version: 0 }, - // Some(format!("App '{}' changed permissions", path)), - // // true, - // ) - // .await? - // } - // "script" => { - // handle_deployment_metadata( - // &authed.email, - // &authed.username, - // &db, - // &w_id, - // DeployedObject::Script { - // path: path.to_string(), - // parent_path: None, - // hash: ScriptHash(0), - // }, - // Some(format!("Script '{}' changed permissions", path)), - // // true, - // ) - // .await? - // } - // "flow" => { - // handle_deployment_metadata( - // &authed.email, - // &authed.username, - // &db, - // &w_id, - // DeployedObject::Flow { path: path.to_string(), parent_path: None }, - // Some(format!("Flow '{}' changed permissions", path)), - // // true, - // ) - // .await? - // } + "app" => { + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::App { path: path.to_string(), parent_path: None, version: 0 }, + Some(format!("App '{}' changed permissions", path)), + true, + None, + ) + .await? + } + "raw_app" => { + // RawApp deliberately uses its own DeployedObject variant: the + // git-sync worker reads `path_type` ("app" vs "raw_app") to decide + // whether to write `.app.json` or `.raw_app.json`. + // Collapsing this into `App` would dispatch raw_app perm changes + // against the wrong file in the repo. + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::RawApp { path: path.to_string(), parent_path: None, version: 0 }, + Some(format!("Raw App '{}' changed permissions", path)), + true, + None, + ) + .await? + } + "script" => { + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Script { + path: path.to_string(), + parent_path: None, + hash: ScriptHash(0), + }, + Some(format!("Script '{}' changed permissions", path)), + true, + None, + ) + .await? + } + "flow" => { + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Flow { path: path.to_string(), parent_path: None, version: 0 }, + Some(format!("Flow '{}' changed permissions", path)), + true, + None, + ) + .await? + } _ => (), } @@ -295,13 +378,17 @@ async fn remove_granular_acl( require_owner_of_path(&authed, path)?; } + // See add_granular_acl: kind="raw_app" must hit the `app` table because v2 + // raw apps live there and the legacy `raw_app` table no longer backs the + // workspace export. + let table = if kind == "raw_app" { "app" } else { kind }; // SAFETY: `kind` has been validated against the `KINDS` allowlist before reaching this function. let obj_o = sqlx::query_scalar::<_, bool>(&format!( "WITH old AS ( - SELECT extra_perms->$1 as old_write FROM {kind} + SELECT extra_perms->$1 as old_write FROM {table} WHERE {identifier} = $2 AND workspace_id = $3 AND extra_perms ? $1 ) - UPDATE {kind} SET extra_perms = extra_perms - $1 + UPDATE {table} SET extra_perms = extra_perms - $1 WHERE {identifier} = $2 AND workspace_id = $3 AND extra_perms ? $1 RETURNING (SELECT old_write FROM old)::bool" )) @@ -335,6 +422,28 @@ async fn remove_granular_acl( Some(&owner), ) .await?; + } else if let Some(prefix) = audit_action_prefix_for_acl_kind(kind) { + // Mirror the add path: standard audit_log row for every kind that + // doesn't have a dedicated permission-history table. + let access = if write { "write" } else { "read" }; + let action = format!("{}.revoke_acl", prefix); + audit_log( + &mut *tx, + &authed, + action.as_str(), + ActionKind::Update, + &w_id, + Some(path), + Some( + [ + ("kind", kind), + ("owner", owner.as_str()), + ("access", access), + ] + .into(), + ), + ) + .await?; } tx.commit().await?; @@ -353,46 +462,68 @@ async fn remove_granular_acl( ) .await? } - // "app" => { - // handle_deployment_metadata( - // &authed.email, - // &authed.username, - // &db, - // &w_id, - // DeployedObject::App { path: path.to_string(), parent_path: None, version: 0 }, - // Some(format!("App '{}' changed permissions", path)), - // // true, - // ) - // .await? - // } - // "script" => { - // handle_deployment_metadata( - // &authed.email, - // &authed.username, - // &db, - // &w_id, - // DeployedObject::Script { - // path: path.to_string(), - // parent_path: None, - // hash: ScriptHash(0), - // }, - // Some(format!("Script '{}' changed permissions", path)), - // // true, - // ) - // .await? - // } - // "flow" => { - // handle_deployment_metadata( - // &authed.email, - // &authed.username, - // &db, - // &w_id, - // DeployedObject::Flow { path: path.to_string(), parent_path: None }, - // Some(format!("Flow '{}' changed permissions", path)), - // // true, - // ) - // .await? - // } + "app" => { + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::App { path: path.to_string(), parent_path: None, version: 0 }, + Some(format!("App '{}' changed permissions", path)), + true, + None, + ) + .await? + } + "raw_app" => { + // See add_granular_acl: raw_app must use its own DeployedObject + // variant so git-sync writes `.raw_app.json`. + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::RawApp { + path: path.to_string(), + parent_path: None, + version: 0, + }, + Some(format!("Raw App '{}' changed permissions", path)), + true, + None, + ) + .await? + } + "script" => { + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Script { + path: path.to_string(), + parent_path: None, + hash: ScriptHash(0), + }, + Some(format!("Script '{}' changed permissions", path)), + true, + None, + ) + .await? + } + "flow" => { + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Flow { path: path.to_string(), parent_path: None, version: 0 }, + Some(format!("Flow '{}' changed permissions", path)), + true, + None, + ) + .await? + } _ => (), } } @@ -421,9 +552,13 @@ async fn get_granular_acls( } else { "path" }; + // See add_granular_acl: raw_app rows live in the `app` table now, so the + // read path must also target `app` — otherwise GET would return stale or + // 404 state while POST /acls/add and /acls/remove write to `app`. + let table = if kind == "raw_app" { "app" } else { kind }; // SAFETY: `kind` has been validated against the `KINDS` allowlist before reaching this function. let obj_o = sqlx::query_scalar::<_, serde_json::Value>(&format!( - "SELECT extra_perms from {kind} WHERE {identifier} = $1 AND workspace_id = $2" + "SELECT extra_perms from {table} WHERE {identifier} = $1 AND workspace_id = $2" )) .bind(path) .bind(w_id) diff --git a/backend/windmill-api-integration-tests/tests/native_triggers.rs b/backend/windmill-api-integration-tests/tests/native_triggers.rs index 0d1530a7d9..159236ed4b 100644 --- a/backend/windmill-api-integration-tests/tests/native_triggers.rs +++ b/backend/windmill-api-integration-tests/tests/native_triggers.rs @@ -51,6 +51,7 @@ fn test_authed() -> ApiAuthed { scopes: None, username_override: None, token_prefix: None, + read_only: false, } } diff --git a/backend/windmill-api-integration-tests/tests/workspace_dependencies_git_sync.rs b/backend/windmill-api-integration-tests/tests/workspace_dependencies_git_sync.rs index 0049280aec..a5af874f8c 100644 --- a/backend/windmill-api-integration-tests/tests/workspace_dependencies_git_sync.rs +++ b/backend/windmill-api-integration-tests/tests/workspace_dependencies_git_sync.rs @@ -573,8 +573,10 @@ async fn test_promotion_individual_branch_debounces_per_path( // Wait for both deployment callbacks to resolve a debounce key. The // alpha key is polled first as a warm-up, then we also wait for beta // so the assertions don't race the second spawned callback. - let expected_alpha = "git_sync:script:f/target/alpha"; - let expected_beta = "git_sync:script:f/target/beta"; + // Keys are namespaced by the repo's resource path so multiple promotion + // repos don't collide on the same key. + let expected_alpha = "git_sync:$res:u/test-user/test_git_repo:script:f/target/alpha"; + let expected_beta = "git_sync:$res:u/test-user/test_git_repo:script:f/target/beta"; let _ = wait_for_debounce_key(&db, expected_alpha, Duration::from_secs(5)).await?; let keys = wait_for_debounce_key(&db, expected_beta, Duration::from_secs(5)).await?; assert!( @@ -589,6 +591,162 @@ async fn test_promotion_individual_branch_debounces_per_path( Ok(()) } +/// Create a second git repository resource for multi-repo tests. +#[allow(dead_code)] +async fn create_second_git_repo_resource(db: &Pool) -> anyhow::Result<()> { + sqlx::query( + r#" + INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) + VALUES ('test-workspace', 'u/test-user/test_git_repo_2', $1::jsonb, 'git_repository', '{}'::jsonb, 'test-user') + ON CONFLICT (workspace_id, path) DO NOTHING + "#, + ) + .bind(json!({ + "url": "https://github.com/test/test2.git", + "branch": "main", + "token": "test-token-2" + })) + .execute(db) + .await?; + Ok(()) +} + +/// Configure git sync with TWO promotion-mode repositories pointing at distinct +/// git repo resources. Both repos use the same sync script and the same item +/// filters — they only differ in the repo they target. +#[allow(dead_code)] +async fn setup_two_promotion_repos_config( + db: &Pool, + sync_script_path: &str, + group_by_folder: bool, +) -> anyhow::Result<()> { + let git_sync_config = json!({ + "include_type": ["script"], + "include_path": ["**"], + "repositories": [ + { + "script_path": sync_script_path, + "git_repo_resource_path": "$res:u/test-user/test_git_repo", + "use_individual_branch": true, + "group_by_folder": group_by_folder + }, + { + "script_path": sync_script_path, + "git_repo_resource_path": "$res:u/test-user/test_git_repo_2", + "use_individual_branch": true, + "group_by_folder": group_by_folder + } + ] + }); + + sqlx::query!( + "UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2", + git_sync_config, + "test-workspace" + ) + .execute(db) + .await?; + + Ok(()) +} + +/// Regression test for: when two promotion-mode repos are configured with the +/// same `use_individual_branch=true` settings (i.e. a primary and a secondary +/// promotion repo), deploying a single script must enqueue ONE deployment +/// callback per repo. Both callbacks must remain in the queue — neither may +/// be debounced into oblivion by the other. +/// +/// The bug this guards against: the debounce key for promotion mode was +/// derived only from (path_type, path) and omitted any per-repo identifier, +/// so the second repo's push hit ON CONFLICT in `upsert_debounce_key` and +/// `complete_debounced_job` flagged the first repo's job as `status='skipped'` +/// — silently dropping one of the two pushes. +#[cfg(all(feature = "enterprise", feature = "private"))] +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_two_promotion_repos_both_enqueue_callback(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + create_folder(&db, "28103").await?; + create_folder(&db, "target").await?; + create_git_repo_resource(&db).await?; + create_second_git_repo_resource(&db).await?; + let sync_script_path = "f/28103/test_sync_two_promotion_repos"; + create_sync_script(&db, sync_script_path).await?; + setup_two_promotion_repos_config(&db, sync_script_path, false).await?; + + let (client, _port, _server) = init_client(db.clone()).await; + + // Deploy a single script — handle_deployment_metadata should iterate + // both repos and create one callback job per repo. + create_test_script(&client, "f/target/alpha").await?; + + // Both callbacks should reach the queue. With the bug, only one survives + // (the other is moved to v2_job_completed with status='skipped'). + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + let mut last_jobs: Vec = vec![]; + loop { + last_jobs = + get_deployment_callback_jobs(&db, sync_script_path, Duration::from_millis(200)).await?; + if last_jobs.len() >= 2 { + break; + } + if tokio::time::Instant::now() >= deadline { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + // Inspect what landed in v2_job_completed so failure messages explain why. + let skipped: Vec<(uuid::Uuid, String)> = sqlx::query_as( + r#" + SELECT c.id, c.status::text + FROM v2_job_completed c + JOIN v2_job j ON j.id = c.id + WHERE j.runnable_path = $1 AND j.kind = 'deploymentcallback' + "#, + ) + .bind(sync_script_path) + .fetch_all(&db) + .await?; + + assert_eq!( + last_jobs.len(), + 2, + "expected 2 deployment callback jobs in v2_job_queue (one per promotion repo), got {} queued + {:?} completed", + last_jobs.len(), + skipped, + ); + + // Per-repo args sanity check: the two jobs must target different repos. + let mut repo_paths: Vec = last_jobs + .iter() + .filter_map(|j| { + j.args + .as_ref() + .and_then(|a| a.get("repo_url_resource_path")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .collect(); + repo_paths.sort(); + repo_paths.dedup(); + assert_eq!( + repo_paths.len(), + 2, + "expected callbacks to target two distinct repos, got: {:?}", + last_jobs.iter().map(|j| &j.args).collect::>() + ); + + // No callback should have been silently skipped via debouncing collision. + assert!( + skipped.iter().all(|(_, s)| s != "skipped"), + "no deployment callback should be marked skipped, got: {:?}", + skipped, + ); + + Ok(()) +} + /// Promotion mode with group_by_folder: items destined for the same per-folder /// branch must share one debounce key so they accumulate into a single sync /// job; scripts in different folders must get distinct keys. @@ -615,8 +773,9 @@ async fn test_promotion_group_by_folder_debounces_per_folder( // One in a different folder — should get its own key. create_test_script(&client, "f/other/gamma").await?; - let expected_grouped = "git_sync:folder:f/grouped"; - let expected_other = "git_sync:folder:f/other"; + // Keys are namespaced by the repo's resource path. + let expected_grouped = "git_sync:$res:u/test-user/test_git_repo:folder:f/grouped"; + let expected_other = "git_sync:$res:u/test-user/test_git_repo:folder:f/other"; // Wait for BOTH folder keys to appear, not just the first one. let keys = wait_for_debounce_key(&db, expected_other, Duration::from_secs(5)).await?; assert!( @@ -629,7 +788,9 @@ async fn test_promotion_group_by_folder_debounces_per_folder( ); // Paths within the same folder must NOT leak as their own keys. assert!( - !keys.iter().any(|k| k.starts_with("git_sync:script:")), + !keys + .iter() + .any(|k| k.contains(":script:f/grouped/") || k.contains(":script:f/other/")), "group_by_folder mode should not emit per-path keys, got: {keys:?}" ); diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 365c3361d9..958df312b2 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -980,12 +980,10 @@ async fn create_script_internal<'c>( .fetch_one(&mut *tx) .await?; } - let clashing_script = sqlx::query_as::<_, Script>( - &format!( - "SELECT {} FROM script WHERE path = $1 AND archived = false AND workspace_id = $2", - windmill_common::scripts::SCRIPT_COLUMNS, - ), - ) + let clashing_script = sqlx::query_as::<_, Script>(&format!( + "SELECT {} FROM script WHERE path = $1 AND archived = false AND workspace_id = $2", + windmill_common::scripts::SCRIPT_COLUMNS, + )) .bind(&ns.path) .bind(&w_id) .fetch_optional(&mut *tx) @@ -1218,6 +1216,22 @@ async fn create_script_internal<'c>( } }; + // Failure, Trigger, and Approval scripts are runnable entrypoints by + // definition. They must never be marked `auto_kind = 'lib'`, or they + // disappear from the flow error-handler / trigger / approval pickers + // (which filter out lib scripts). Strip a stray `lib` here so a parser + // misclassification — e.g. failing to detect `main` after a deno_ast + // bump — cannot orphan these scripts in the UI. + let auto_kind = if matches!( + ns.kind, + Some(ScriptKind::Failure) | Some(ScriptKind::Trigger) | Some(ScriptKind::Approval) + ) && auto_kind.as_deref() == Some("lib") + { + None + } else { + auto_kind + }; + let ci_test_refs = windmill_common::schema::parse_ci_test_annotation(&ns.content, &lang.as_comment_lit()); // `pipeline` wins over `test` and any client-supplied auto_kind. The @@ -2298,7 +2312,7 @@ async fn exists_script_by_path( let path = path.to_path(); let exists = sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1)", + "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false ORDER BY created_at DESC LIMIT 1)", path, w_id ) @@ -2332,12 +2346,10 @@ async fn get_script_by_hash_internal<'c>( .fetch_optional(&mut **db) .await? } else { - sqlx::query_as::<_, ScriptWithStarred>( - &format!( - "SELECT {}, NULL as starred FROM script WHERE hash = $1 AND workspace_id = $2", - windmill_common::scripts::SCRIPT_COLUMNS, - ), - ) + sqlx::query_as::<_, ScriptWithStarred>(&format!( + "SELECT {}, NULL as starred FROM script WHERE hash = $1 AND workspace_id = $2", + windmill_common::scripts::SCRIPT_COLUMNS, + )) .bind(hash) .bind(workspace_id) .fetch_optional(&mut **db) diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index e4540ca903..0eb9d26b5d 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -210,6 +210,8 @@ pub struct GlobalUserInfo { first_time_user: bool, role_source: String, disabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + workspace_id: Option, } #[derive(Serialize, Debug)] @@ -297,6 +299,7 @@ pub struct TruncatedToken { pub last_used_at: chrono::DateTime, pub scopes: Option>, pub workspace_id: Option, + pub read_only: bool, } // NewToken is re-exported from windmill-api-auth above @@ -450,13 +453,17 @@ async fn list_users_as_super_admin( let rows = if active_only.is_some_and(|x| x) { sqlx::query_as!( GlobalUserInfo, - "WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')), + r#"WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')), authors as (SELECT distinct email FROM usr WHERE usr.operator IS false) - SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user, role_source, disabled + SELECT email as "email!", (email NOT IN (SELECT email FROM authors)) as operator_only, login_type::text, verified as "verified!", super_admin as "super_admin!", devops as "devops!", name, company, username, first_time_user as "first_time_user!", role_source as "role_source!", disabled as "disabled!", NULL::text as workspace_id FROM password WHERE email IN (SELECT email FROM active_users) - ORDER BY super_admin DESC, devops DESC - LIMIT $1 OFFSET $2", + UNION ALL + SELECT email as "email!", true as operator_only, 'service_account'::text as login_type, true as "verified!", false as "super_admin!", false as "devops!", NULL::text as name, NULL::text as company, username, false as "first_time_user!", 'service_account'::text as "role_source!", disabled as "disabled!", workspace_id + FROM usr + WHERE is_service_account IS true + ORDER BY "super_admin!" DESC, "devops!" DESC + LIMIT $1 OFFSET $2"#, per_page as i32, offset as i32 ) @@ -465,8 +472,13 @@ async fn list_users_as_super_admin( } else { sqlx::query_as!( GlobalUserInfo, - "SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT \ - $1 OFFSET $2", + r#"SELECT email as "email!", login_type::text, verified as "verified!", super_admin as "super_admin!", devops as "devops!", name, company, username, NULL::bool as operator_only, first_time_user as "first_time_user!", role_source as "role_source!", disabled as "disabled!", NULL::text as workspace_id FROM password + UNION ALL + SELECT email as "email!", 'service_account'::text as login_type, true as "verified!", false as "super_admin!", false as "devops!", NULL::text as name, NULL::text as company, username, true as operator_only, false as "first_time_user!", 'service_account'::text as "role_source!", disabled as "disabled!", workspace_id + FROM usr + WHERE is_service_account IS true + ORDER BY "super_admin!" DESC, "devops!" DESC, "email!" + LIMIT $1 OFFSET $2"#, per_page as i32, offset as i32 ) @@ -715,7 +727,7 @@ async fn global_whoami( ) -> JsonResult { let user = sqlx::query_as!( GlobalUserInfo, - "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled FROM password WHERE \ + "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled, NULL::text as workspace_id FROM password WHERE \ email = $1", email ) @@ -739,6 +751,7 @@ async fn global_whoami( first_time_user: false, role_source: "manual".to_string(), disabled: false, + workspace_id: None, })) } else { // Service accounts don't have a password row @@ -755,6 +768,7 @@ async fn global_whoami( first_time_user: false, role_source: "service_account".to_string(), disabled: false, + workspace_id: None, })) } } @@ -2249,7 +2263,7 @@ async fn list_tokens( sqlx::query_as!( TruncatedToken, "SELECT label, token_prefix, expiration, created_at, \ - last_used_at, scopes, workspace_id FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL) + last_used_at, scopes, workspace_id, read_only FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL) ORDER BY created_at DESC LIMIT $2 OFFSET $3", email, per_page as i64, @@ -2261,7 +2275,7 @@ async fn list_tokens( sqlx::query_as!( TruncatedToken, "SELECT label, token_prefix, expiration, created_at, \ - last_used_at, scopes, workspace_id FROM token WHERE email = $1 + last_used_at, scopes, workspace_id, read_only FROM token WHERE email = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", email, per_page as i64, diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index e7130e1565..5490a39d4f 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.699.0 + version: 1.702.1 title: Windmill API contact: @@ -22631,10 +22631,13 @@ components: type: string workspace_id: type: string + read_only: + type: boolean required: - token_prefix - created_at - last_used_at + - read_only ExternalJwtToken: type: object @@ -22683,6 +22686,12 @@ components: type: string workspace_id: type: string + read_only: + type: boolean + description: | + If true, the token is restricted to read-only HTTP methods + (GET/HEAD/OPTIONS). Mutating endpoints and job-run actions are + rejected with 403, regardless of the scopes attached. NewTokenImpersonate: type: object @@ -26355,7 +26364,7 @@ components: type: string login_type: type: string - enum: ["password", "github"] + enum: ["password", "github", "service_account"] super_admin: type: boolean devops: @@ -26374,9 +26383,11 @@ components: type: boolean role_source: type: string - enum: ["manual", "instance_group"] + enum: ["manual", "instance_group", "service_account"] disabled: type: boolean + workspace_id: + type: string required: - email diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 0fde5cacd1..75b3469c41 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -330,6 +330,7 @@ async fn inject_agent_authed( scopes: None, username_override: None, token_prefix: None, + read_only: false, }, job_id: None, }); diff --git a/backend/windmill-api/src/mcp/core.rs b/backend/windmill-api/src/mcp/core.rs index 07ce233278..52fec66096 100644 --- a/backend/windmill-api/src/mcp/core.rs +++ b/backend/windmill-api/src/mcp/core.rs @@ -7,6 +7,7 @@ use async_trait::async_trait; use serde_json::Value; use std::collections::HashMap; use windmill_common::{db::UserDB, utils::StripPath, DB}; +use windmill_mcp::common::schema::enrich_resource_schemas; use windmill_mcp::common::transform::apply_key_transformation; use windmill_mcp::common::types::{ FlowInfo, HubScriptInfo, ResourceInfo, ResourceType, SchemaType, ScriptInfo, @@ -203,71 +204,13 @@ impl McpBackend for WindmillBackend { schema_obj.properties.insert(new_key, value); } - for (_key, prop_value) in schema_obj.properties.iter_mut() { - if let Value::Object(prop_map) = prop_value { - if let Some(format_value) = prop_map.get("format") { - if let Value::String(format_str) = format_value { - if format_str.starts_with("resource-") { - let resource_type_key = - format_str.split("-").last().unwrap_or_default().to_string(); - let resource_type = resources_types - .iter() - .find(|rt| rt.name == resource_type_key); - let resource_type_obj = resource_type.cloned(); - - if let Some(resource_cache) = resources_cache.get(&resource_type_key) { - let resources_count = resource_cache.len(); - let description = match resource_type_obj { - Some(resource_type_obj) => format!( - "This is a resource named `{}` with the following description: `{}`.\\nThe path of the resource should be used to specify the resource.\\n{}", - resource_type_obj.name, - resource_type_obj.description.as_deref().unwrap_or("No description"), - if resources_count == 0 { - "This resource does not have any available instances, you should create one from your windmill workspace." - } else if resources_count > 1 { - "This resource has multiple available instances, you should precisely select the one you want to use." - } else { - "There is 1 resource available." - } - ), - None => "An object parameter.".to_string(), - }; - prop_map.insert( - "type".to_string(), - Value::String("string".to_string()), - ); - prop_map - .insert("description".to_string(), Value::String(description)); - if resources_count > 0 { - let resources_description = resource_cache - .iter() - .map(|resource| { - format!( - "{}: $res:{}", - resource - .description - .as_deref() - .unwrap_or("No title"), - resource.path - ) - }) - .collect::>() - .join("\\n"); - - prop_map.insert( - "description".to_string(), - Value::String(format!( - "{}\\nHere are the available resources, in the format title:path. Title can be empty. Path should be used to specify the resource:\\n{}", - prop_map.get("description").unwrap_or(&Value::String("No description".to_string())), - resources_description - )), - ); - } - } - } - } - } - } + // Enrich every resource reference in the schema — including those + // inside `items`, nested `properties`, etc. — with a description + // listing the available resources. Both shapes are handled: + // { type: "object", format: "resource-" } (top-level scalar) + // { type: "resource", resourceType: "" } (inside list items) + for prop_value in schema_obj.properties.values_mut() { + enrich_resource_schemas(prop_value, resources_cache, resources_types); } schema_obj diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index e6b3b9ee0f..3f0cd3c646 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -110,6 +110,12 @@ struct ScriptMetadata { pub debouncing_settings: DebouncingSettings, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + #[serde(skip_serializing_if = "is_empty_extra_perms")] + pub extra_perms: serde_json::Value, +} + +fn is_empty_extra_perms(value: &serde_json::Value) -> bool { + value.as_object().is_some_and(|o| o.is_empty()) || value.is_null() } pub fn is_none_or_false(val: &Option) -> bool { @@ -224,12 +230,34 @@ pub(crate) struct ArchiveQueryParams { default_ts: Option, /// Settings format version: "v1" (default) returns legacy flat format, "v2" returns grouped format settings_version: Option, + /// Opt-in: include `extra_perms` on flow / script / app rows. Default `false` + /// so cross-workspace tarball imports do not carry over ACLs referring to + /// identities that may not exist in the target workspace. `wmill sync pull` + /// passes `true` to surface ACLs in the git-tracked yaml. + preserve_extra_perms: Option, +} + +/// How to handle `extra_perms` in the serialized output. +/// +/// * `Drop` — strip the field unconditionally (legacy behavior for +/// types that have never carried ACLs in source). +/// * `KeepEvenEmpty` — always keep the field, even when `{}`. Matches the +/// pre-existing serialization for folders and groups so +/// no customer sees a one-time noisy diff on upgrade. +/// * `KeepIfNonEmpty` — keep when there is at least one entry, drop when `{}` +/// or null. New surface for flow / script / app, which +/// never carried ACLs in source before this change. +#[derive(Clone, Copy)] +pub enum ExtraPermsBehavior { + Drop, + KeepEvenEmpty, + KeepIfNonEmpty, } #[inline] pub fn to_string_without_metadata( value: &T, - preserve_extra_perms: bool, + extra_perms: ExtraPermsBehavior, ignore_keys: Option>, ) -> Result where @@ -274,8 +302,19 @@ where o2.remove("on_behalf_of"); o2.remove("on_behalf_of_email"); } - if !preserve_extra_perms && obj.contains_key("extra_perms") { - obj.remove("extra_perms"); + if obj.contains_key("extra_perms") { + let is_empty_extra_perms = obj + .get("extra_perms") + .map(|v| v.as_object().is_some_and(|o| o.is_empty()) || v.is_null()) + .unwrap_or(true); + let drop = match extra_perms { + ExtraPermsBehavior::Drop => true, + ExtraPermsBehavior::KeepEvenEmpty => false, + ExtraPermsBehavior::KeepIfNonEmpty => is_empty_extra_perms, + }; + if drop { + obj.remove("extra_perms"); + } } if obj .get("default_permissioned_as") @@ -442,6 +481,7 @@ pub(crate) async fn tarball_workspace( include_workspace_dependencies, default_ts, settings_version, + preserve_extra_perms, }): Query, ) -> Result<([(HeaderName, String); 2], impl IntoResponse)> { tracing::info!( @@ -452,6 +492,16 @@ pub(crate) async fn tarball_workspace( skip_resources ); + // Opt-in behavior for surfacing per-resource ACLs on flow/app rows. + // Folder and group rows have always carried `extra_perms` in source and + // continue to do so unconditionally (`KeepEvenEmpty`) so existing + // customer git repos see no one-time noisy diff. + let new_kinds_extra_perms = if preserve_extra_perms.unwrap_or(false) { + ExtraPermsBehavior::KeepIfNonEmpty + } else { + ExtraPermsBehavior::Drop + }; + let mut tx = user_db.begin(&authed).await?; // Source-of-truth check for fork-ness: the workspace's parent_workspace_id @@ -498,7 +548,8 @@ pub(crate) async fn tarball_workspace( for folder in folders { archive .write_to_archive( - &to_string_without_metadata(&folder, true, None).unwrap(), + &to_string_without_metadata(&folder, ExtraPermsBehavior::KeepEvenEmpty, None) + .unwrap(), &format!("f/{}/folder.meta.json", folder.name), ) .await?; @@ -506,15 +557,13 @@ pub(crate) async fn tarball_workspace( } { - let scripts = sqlx::query_as::<_, Script>( - &format!( - "SELECT {} FROM script as o WHERE workspace_id = $1 AND archived = false + let scripts = sqlx::query_as::<_, Script>(&format!( + "SELECT {} FROM script as o WHERE workspace_id = $1 AND archived = false AND (draft_only IS NULL OR draft_only = false) AND created_at = (select max(created_at) from script where path = o.path AND \ workspace_id = $1)", - windmill_common::scripts::SCRIPT_COLUMNS, - ), - ) + windmill_common::scripts::SCRIPT_COLUMNS, + )) .bind(&w_id) .fetch_all(&mut *tx) .await?; @@ -587,6 +636,15 @@ pub(crate) async fn tarball_workspace( on_behalf_of_email: script.on_behalf_of_email, modules: script.modules, labels: script.labels, + // Same opt-in contract as flow/app: the tarball only surfaces + // ACLs when `?preserve_extra_perms=true`. Passing `Null` lets the + // `is_empty_extra_perms` skip-serializer drop the field entirely. + extra_perms: if matches!(new_kinds_extra_perms, ExtraPermsBehavior::KeepIfNonEmpty) + { + script.extra_perms + } else { + serde_json::Value::Null + }, }; let metadata_str = serde_json::to_string_pretty(&metadata).unwrap(); archive @@ -605,7 +663,8 @@ pub(crate) async fn tarball_workspace( .await?; for resource in resources { - let resource_str = &to_string_without_metadata(&resource, false, None).unwrap(); + let resource_str = + &to_string_without_metadata(&resource, ExtraPermsBehavior::Drop, None).unwrap(); archive .write_to_archive(&resource_str, &format!("{}.resource.json", resource.path)) .await?; @@ -622,7 +681,9 @@ pub(crate) async fn tarball_workspace( .await?; for resource_type in resource_types { - let resource_str = &to_string_without_metadata(&resource_type, false, None).unwrap(); + let resource_str = + &to_string_without_metadata(&resource_type, ExtraPermsBehavior::Drop, None) + .unwrap(); archive .write_to_archive( &resource_str, @@ -644,7 +705,7 @@ pub(crate) async fn tarball_workspace( .await?; for flow in flows { - let flow_str = &to_string_without_metadata(&flow, false, None).unwrap(); + let flow_str = &to_string_without_metadata(&flow, new_kinds_extra_perms, None).unwrap(); archive .write_to_archive(&flow_str, &format!("{}.flow.json", flow.path)) .await?; @@ -673,7 +734,8 @@ pub(crate) async fn tarball_workspace( Error::internal_err(format!("Error decrypting variable {}: {}", var.path, e)) })?); } - let var_str = &to_string_without_metadata(&var, false, None).unwrap(); + let var_str = + &to_string_without_metadata(&var, ExtraPermsBehavior::Drop, None).unwrap(); archive .write_to_archive(&var_str, &format!("{}.variable.json", var.path)) .await?; @@ -693,7 +755,7 @@ pub(crate) async fn tarball_workspace( .await?; for app in apps { - let app_str = &to_string_without_metadata(&app, false, None).unwrap(); + let app_str = &to_string_without_metadata(&app, new_kinds_extra_perms, None).unwrap(); let kind = if app.raw_app { "raw_app" } else { "app" }; archive .write_to_archive(&app_str, &format!("{}.{}.json", app.path, kind)) @@ -711,7 +773,7 @@ pub(crate) async fn tarball_workspace( workspace_dependencies.len() ); for dep in workspace_dependencies { - // let dep_str = &to_string_without_metadata(&dep, false, None).unwrap(); + // let dep_str = &to_string_without_metadata(&dep, ExtraPermsBehavior::Drop, None).unwrap(); let filename = WorkspaceDependencies::to_path(&dep.name, dep.language)?; tracing::info!( "Adding workspace dependency: name={:?}, language={:?}, filename={}", @@ -739,9 +801,12 @@ pub(crate) async fn tarball_workspace( let schedule_ignore_keys = fork_schedule_ignore_keys(is_fork); for schedule in schedules { - let app_str = - &to_string_without_metadata(&schedule, false, schedule_ignore_keys.clone()) - .unwrap(); + let app_str = &to_string_without_metadata( + &schedule, + ExtraPermsBehavior::Drop, + schedule_ignore_keys.clone(), + ) + .unwrap(); archive .write_to_archive(&app_str, &format!("{}.schedule.json", schedule.path)) .await?; @@ -777,9 +842,12 @@ pub(crate) async fn tarball_workspace( let http_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in http_triggers { - let trigger_str = - &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) - .unwrap(); + let trigger_str = &to_string_without_metadata( + &trigger, + ExtraPermsBehavior::Drop, + trigger_ignore_keys.clone(), + ) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -796,9 +864,12 @@ pub(crate) async fn tarball_workspace( let websocket_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in websocket_triggers { - let trigger_str = - &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) - .unwrap(); + let trigger_str = &to_string_without_metadata( + &trigger, + ExtraPermsBehavior::Drop, + trigger_ignore_keys.clone(), + ) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -815,9 +886,12 @@ pub(crate) async fn tarball_workspace( let kafka_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in kafka_triggers { - let trigger_str = - &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) - .unwrap(); + let trigger_str = &to_string_without_metadata( + &trigger, + ExtraPermsBehavior::Drop, + trigger_ignore_keys.clone(), + ) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -834,9 +908,12 @@ pub(crate) async fn tarball_workspace( let sqs_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in sqs_triggers { - let trigger_str = - &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) - .unwrap(); + let trigger_str = &to_string_without_metadata( + &trigger, + ExtraPermsBehavior::Drop, + trigger_ignore_keys.clone(), + ) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -853,9 +930,12 @@ pub(crate) async fn tarball_workspace( let gcp_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in gcp_triggers { - let trigger_str = - &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) - .unwrap(); + let trigger_str = &to_string_without_metadata( + &trigger, + ExtraPermsBehavior::Drop, + trigger_ignore_keys.clone(), + ) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -872,9 +952,12 @@ pub(crate) async fn tarball_workspace( let azure_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in azure_triggers { - let trigger_str = - &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) - .unwrap(); + let trigger_str = &to_string_without_metadata( + &trigger, + ExtraPermsBehavior::Drop, + trigger_ignore_keys.clone(), + ) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -891,9 +974,12 @@ pub(crate) async fn tarball_workspace( let nats_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in nats_triggers { - let trigger_str: &String = - &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) - .unwrap(); + let trigger_str: &String = &to_string_without_metadata( + &trigger, + ExtraPermsBehavior::Drop, + trigger_ignore_keys.clone(), + ) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -910,9 +996,12 @@ pub(crate) async fn tarball_workspace( let postgres_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in postgres_triggers { - let trigger_str = - &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) - .unwrap(); + let trigger_str = &to_string_without_metadata( + &trigger, + ExtraPermsBehavior::Drop, + trigger_ignore_keys.clone(), + ) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -929,9 +1018,12 @@ pub(crate) async fn tarball_workspace( let mqtt_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in mqtt_triggers { - let trigger_str = - &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) - .unwrap(); + let trigger_str = &to_string_without_metadata( + &trigger, + ExtraPermsBehavior::Drop, + trigger_ignore_keys.clone(), + ) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -948,9 +1040,12 @@ pub(crate) async fn tarball_workspace( let email_triggers = handler.list_triggers(&mut *tx, &w_id, None).await?; for trigger in email_triggers { - let trigger_str = - &to_string_without_metadata(&trigger, false, trigger_ignore_keys.clone()) - .unwrap(); + let trigger_str = &to_string_without_metadata( + &trigger, + ExtraPermsBehavior::Drop, + trigger_ignore_keys.clone(), + ) + .unwrap(); archive .write_to_archive( &trigger_str, @@ -978,7 +1073,7 @@ pub(crate) async fn tarball_workspace( for trigger in native_triggers { let trigger_str = &to_string_without_metadata( &trigger, - false, + ExtraPermsBehavior::Drop, Some(native_ignore_keys.clone()), ) .unwrap(); @@ -1021,7 +1116,9 @@ pub(crate) async fn tarball_workspace( disabled: user.disabled, email: user.email, }; - let user_str = &to_string_without_metadata(&user, false, Some(vec!["email"])).unwrap(); + let user_str = + &to_string_without_metadata(&user, ExtraPermsBehavior::Drop, Some(vec!["email"])) + .unwrap(); archive .write_to_archive(&user_str, &format!("users/{}.user.json", user.email)) .await?; @@ -1081,7 +1178,9 @@ pub(crate) async fn tarball_workspace( admins, }; - let group_str = &to_string_without_metadata(&group, true, None).unwrap(); + let group_str = + &to_string_without_metadata(&group, ExtraPermsBehavior::KeepEvenEmpty, None) + .unwrap(); archive .write_to_archive(&group_str, &format!("groups/{}.group.json", group.name)) .await?; diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index e9a9731f74..3b168761e1 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -200,7 +200,15 @@ fn opaque_json_schema(_: &mut schemars::gen::SchemaGenerator) -> schemars::schem /// Typed global settings with schema validation. /// Known settings have explicit fields; unknown settings pass through via `extra`. +/// +/// `#[serde(remote = "Self")]` turns the derived (de)serializers into inherent +/// associated functions so we can wrap them with the manual `Deserialize` impl +/// below. The wrapper preserves explicit `null` values (which the typed +/// `Option` fields would otherwise silently drop on round-trip through +/// `to_settings_map`) by stashing them in `extra`, where they survive +/// re-serialization and reach `diff_global_settings` as proper deletes. #[derive(Deserialize, Serialize, Clone, Debug, Default)] +#[serde(remote = "Self")] #[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))] pub struct GlobalSettings { // Numeric settings @@ -373,6 +381,42 @@ pub struct GlobalSettings { pub extra: BTreeMap, } +impl Serialize for GlobalSettings { + fn serialize(&self, serializer: S) -> Result { + Self::serialize(self, serializer) + } +} + +impl<'de> Deserialize<'de> for GlobalSettings { + fn deserialize>(deserializer: D) -> Result { + // Capture top-level keys explicitly set to `null` so they survive the + // `to_settings_map` round-trip — typed `Option` fields all use + // `skip_serializing_if = "Option::is_none"`, which would otherwise + // silently drop the null. We stash the null entries in `extra`, which + // serializes them back out as `null` for `diff_global_settings` to + // route to deletes. + let mut value = serde_json::Value::deserialize(deserializer)?; + let null_keys: Vec = value + .as_object() + .map(|m| { + m.iter() + .filter_map(|(k, v)| v.is_null().then(|| k.clone())) + .collect() + }) + .unwrap_or_default(); + if let Some(obj) = value.as_object_mut() { + for k in &null_keys { + obj.remove(k); + } + } + let mut s = Self::deserialize(value).map_err(serde::de::Error::custom)?; + for k in null_keys { + s.extra.insert(k, serde_json::Value::Null); + } + Ok(s) + } +} + impl GlobalSettings { /// Convert to a flat `BTreeMap` suitable for DB sync. pub fn to_settings_map(&self) -> BTreeMap { @@ -572,6 +616,12 @@ pub struct OtelTracingProxySettings { pub enabled: bool, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub enabled_languages: Vec, + /// Comma-separated list of host patterns injected as NO_PROXY into jobs so their HTTP + /// clients bypass the local MITM tracing proxy. Independent of the worker's own + /// NO_PROXY env (which governs the proxy's upstream relay). Use this for clients that + /// pin their own CA (kubectl, helm, terraform providers, aws cli for EKS, etc.). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub no_proxy_hosts: Option, } /// Script language identifier (for instance config use). @@ -1930,6 +1980,101 @@ mod tests { // to_settings_map edge cases // ----------------------------------------------------------------------- + /// Regression test for bulk-endpoint deletion of typed settings. + /// + /// A naive `#[derive(Deserialize)]` would route + /// `{"object_store_cache_config": null}` to the typed `Option<...>` field + /// as `None`, and `skip_serializing_if = "Option::is_none"` would then + /// strip it from `to_settings_map`, so `diff_global_settings` (Merge mode) + /// would never see the deletion. The manual `Deserialize` impl on + /// `GlobalSettings` captures explicit top-level nulls into `extra` so they + /// survive the round-trip and reach `diff_global_settings` as proper + /// deletes. + #[test] + fn explicit_null_on_typed_field_survives_round_trip() { + let json = serde_json::json!({ + "object_store_cache_config": null, + "secret_backend": null, + "smtp_settings": null, + "base_url": "https://x", + }); + let settings: GlobalSettings = + serde_json::from_value(json).expect("null should deserialize"); + assert!(settings.object_store_cache_config.is_none()); + assert!(settings.secret_backend.is_none()); + assert!(settings.smtp_settings.is_none()); + assert_eq!(settings.base_url.as_deref(), Some("https://x")); + let map = settings.to_settings_map(); + assert_eq!(map["object_store_cache_config"], serde_json::Value::Null); + assert_eq!(map["secret_backend"], serde_json::Value::Null); + assert_eq!(map["smtp_settings"], serde_json::Value::Null); + assert_eq!(map["base_url"], serde_json::json!("https://x")); + } + + /// Absent keys remain absent — critical so a PUT that only sets a single + /// field doesn't accidentally delete every other setting in Merge mode. + #[test] + fn absent_typed_fields_do_not_appear_in_map() { + let settings: GlobalSettings = + serde_json::from_value(serde_json::json!({"base_url": "https://x"})).unwrap(); + let map = settings.to_settings_map(); + assert!(!map.contains_key("object_store_cache_config")); + assert!(!map.contains_key("smtp_settings")); + assert_eq!(map.get("base_url"), Some(&serde_json::json!("https://x"))); + } + + /// End-to-end: deserialize → `to_settings_map` → diff in Merge mode with + /// an explicit null produces a delete (the scenario that silently failed + /// before the manual `Deserialize` impl). + #[test] + fn deserialize_then_diff_deletes_typed_null() { + let mut current = BTreeMap::new(); + current.insert( + "object_store_cache_config".to_string(), + serde_json::json!({"type": "S3", "bucket": "b"}), + ); + let desired: GlobalSettings = + serde_json::from_value(serde_json::json!({"object_store_cache_config": null})).unwrap(); + let desired_map = desired.to_settings_map(); + let diff = diff_global_settings(¤t, &desired_map, ApplyMode::Merge); + assert!(diff.upserts.is_empty()); + assert_eq!(diff.deletes, vec!["object_store_cache_config".to_string()]); + } + + /// Nested nulls inside a typed sub-struct are not promoted to top-level + /// deletes — only the top-level key matters, mirroring the per-key API. + #[test] + fn nested_null_inside_typed_field_is_not_treated_as_top_level_null() { + let settings: GlobalSettings = + serde_json::from_value(serde_json::json!({"smtp_settings": {"smtp_host": null}})) + .unwrap(); + let map = settings.to_settings_map(); + assert!( + map["smtp_settings"].is_object(), + "smtp_settings should be an object, not null" + ); + } + + /// Unknown/extra keys with explicit null still flow through `extra` — this + /// was already correct before the manual impl; guard against regression. + #[test] + fn explicit_null_on_extra_field_survives_round_trip() { + let settings: GlobalSettings = + serde_json::from_value(serde_json::json!({"unknown_legacy_setting": null})).unwrap(); + let map = settings.to_settings_map(); + assert_eq!(map["unknown_legacy_setting"], serde_json::Value::Null); + } + + /// Top-level non-object input must reject with a deserialize error rather + /// than silently producing defaults. Matches the previous derive behavior. + #[test] + fn non_object_top_level_input_errors() { + assert!(serde_json::from_value::(serde_json::json!(null)).is_err()); + assert!(serde_json::from_value::(serde_json::json!("s")).is_err()); + assert!(serde_json::from_value::(serde_json::json!(42)).is_err()); + assert!(serde_json::from_value::(serde_json::json!([])).is_err()); + } + #[test] fn to_settings_map_empty_defaults() { let settings = GlobalSettings::default(); diff --git a/backend/windmill-mcp/src/common/schema.rs b/backend/windmill-mcp/src/common/schema.rs index 054b0e999a..de90248689 100644 --- a/backend/windmill-mcp/src/common/schema.rs +++ b/backend/windmill-mcp/src/common/schema.rs @@ -2,11 +2,11 @@ //! //! Contains functions for converting Windmill schemas into MCP-compatible formats. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; -use serde_json::Value; +use serde_json::{Map, Value}; -use super::types::SchemaType; +use super::types::{ResourceInfo, ResourceType, SchemaType}; use windmill_common::scripts::Schema; /// Convert a Windmill Schema to a SchemaType @@ -22,38 +22,195 @@ pub fn convert_schema_to_schema_type(schema: Option) -> SchemaType { schema_obj } -/// Extract resource type keys from a schema -/// -/// Scans the schema properties for fields with format "resource-{type}" -/// and returns a set of all unique resource type names found. -pub fn extract_resource_types_from_schema(schema: &SchemaType) -> HashSet { - let mut resource_types = HashSet::new(); - for (_key, prop_value) in schema.properties.iter() { - if let Value::Object(prop_map) = prop_value { - if let Some(Value::String(format_str)) = prop_map.get("format") { - if let Some(rt) = format_str.strip_prefix("resource-") { - resource_types.insert(rt.to_string()); - } +/// If `node` is a schema describing a Windmill resource reference, return the +/// resource type name. Recognizes both on-disk shapes: +/// - Form A (top-level scalar): `{ type: "object", format: "resource-" }` +/// - Form B (inside `items`): `{ type: "resource", resourceType: "" }` +fn resource_type_of_schema_node(node: &Value) -> Option { + let obj = node.as_object()?; + if let Some(Value::String(fmt)) = obj.get("format") { + if let Some(name) = fmt.strip_prefix("resource-") { + return Some(name.to_string()); + } + } + if obj.get("type").and_then(Value::as_str) == Some("resource") { + if let Some(Value::String(name)) = obj.get("resourceType") { + return Some(name.clone()); + } + } + None +} + +/// Recursively collect resource type names referenced at any depth in `node`. +fn collect_resource_types(node: &Value, out: &mut HashSet) { + if let Some(rt) = resource_type_of_schema_node(node) { + out.insert(rt); + } + let Some(obj) = node.as_object() else { return }; + if let Some(Value::Object(props)) = obj.get("properties") { + for v in props.values() { + collect_resource_types(v, out); + } + } + if let Some(items) = obj.get("items") { + collect_resource_types(items, out); + } + if let Some(additional) = obj.get("additionalProperties") { + if additional.is_object() { + collect_resource_types(additional, out); + } + } + for kw in ["allOf", "oneOf", "anyOf"] { + if let Some(Value::Array(arr)) = obj.get(kw) { + for s in arr { + collect_resource_types(s, out); } } } +} + +/// Extract resource type keys referenced anywhere in a schema (top-level +/// properties, nested objects, array items, additionalProperties, and +/// allOf/oneOf/anyOf subschemas). Recognizes both the `format: resource-` +/// and `type: resource` + `resourceType` shapes. +pub fn extract_resource_types_from_schema(schema: &SchemaType) -> HashSet { + let mut resource_types = HashSet::new(); + for prop_value in schema.properties.values() { + collect_resource_types(prop_value, &mut resource_types); + } resource_types } +/// Rewrite a single schema node that points to a Windmill resource: set +/// `type: "string"` and inject a description listing the available resources. +/// Mirrors the top-level behavior that used to live in +/// `transform_schema_for_resources`. No-op if the resource type isn't in the +/// pre-fetched cache. +fn apply_resource_enrichment( + prop_map: &mut Map, + resource_type_key: &str, + resources_cache: &HashMap>, + resources_types: &[ResourceType], +) { + let Some(resource_cache) = resources_cache.get(resource_type_key) else { + return; + }; + let resource_type = resources_types + .iter() + .find(|rt| rt.name == resource_type_key); + let resources_count = resource_cache.len(); + let description = match resource_type { + Some(rt) => format!( + "This is a resource named `{}` with the following description: `{}`.\\nThe path of the resource should be used to specify the resource.\\n{}", + rt.name, + rt.description.as_deref().unwrap_or("No description"), + if resources_count == 0 { + "This resource does not have any available instances, you should create one from your windmill workspace." + } else if resources_count > 1 { + "This resource has multiple available instances, you should precisely select the one you want to use." + } else { + "There is 1 resource available." + } + ), + None => "An object parameter.".to_string(), + }; + prop_map.insert("type".to_string(), Value::String("string".to_string())); + prop_map.insert("description".to_string(), Value::String(description)); + // Drop the Windmill-internal keys we just consumed so the node is clean + // regardless of whether `make_schema_compatible` runs after us. (Its strip + // only fires while `type == "resource"`, which is no longer true here.) + prop_map.remove("resourceType"); + if prop_map + .get("format") + .and_then(Value::as_str) + .is_some_and(|s| s.starts_with("resource-")) + { + prop_map.remove("format"); + } + if resources_count > 0 { + let resources_description = resource_cache + .iter() + .map(|resource| { + format!( + "{}: $res:{}", + resource.description.as_deref().unwrap_or("No title"), + resource.path + ) + }) + .collect::>() + .join("\\n"); + let prior_description = prop_map + .get("description") + .and_then(Value::as_str) + .unwrap_or("No description") + .to_string(); + prop_map.insert( + "description".to_string(), + Value::String(format!( + "{}\\nHere are the available resources, in the format title:path. Title can be empty. Path should be used to specify the resource:\\n{}", + prior_description, resources_description + )), + ); + } +} + +/// Walk a schema and enrich every Windmill-resource reference (in either shape, +/// at any nesting depth) with `type: "string"` and a description listing +/// available resources. The non-standard keys (`format: resource-*`, +/// `resourceType`) consumed by the enrichment are stripped in place. +pub fn enrich_resource_schemas( + node: &mut Value, + resources_cache: &HashMap>, + resources_types: &[ResourceType], +) { + if let Some(rt_key) = resource_type_of_schema_node(node) { + if let Value::Object(obj) = node { + apply_resource_enrichment(obj, &rt_key, resources_cache, resources_types); + } + } + let Some(obj) = node.as_object_mut() else { + return; + }; + if let Some(Value::Object(props)) = obj.get_mut("properties") { + for v in props.values_mut() { + enrich_resource_schemas(v, resources_cache, resources_types); + } + } + if let Some(items) = obj.get_mut("items") { + enrich_resource_schemas(items, resources_cache, resources_types); + } + if let Some(additional) = obj.get_mut("additionalProperties") { + if additional.is_object() { + enrich_resource_schemas(additional, resources_cache, resources_types); + } + } + for kw in ["allOf", "oneOf", "anyOf"] { + if let Some(Value::Array(arr)) = obj.get_mut(kw) { + for s in arr.iter_mut() { + enrich_resource_schemas(s, resources_cache, resources_types); + } + } + } +} + /// Transform a JSON schema for maximum MCP client compatibility. /// /// Ensures schemas conform to JSON Schema draft 2020-12 by: /// - Converting `integer` type to `number` (some clients don't support integer) /// - Removing invalid non-array `enum` values /// - Stripping non-standard keywords (`originalType`, `format` with `resource-*` prefix) +/// - Rewriting the Windmill pseudo-type `type: "resource"` to `type: "string"` /// - Fixing contradictory schemas (`type: "string"` with `properties` → `type: "object"`) /// - Removing `default: null` when the type doesn't include `null` /// - Adding `type: "object"` to empty schemas that have no type pub fn make_schema_compatible(schema: &mut Value) { let Value::Object(obj) = schema else { return }; - // 1. Strip non-standard keywords that aren't part of JSON Schema + // 1. Strip non-standard keywords that aren't part of JSON Schema. The + // Windmill-internal `resourceType` is dropped unconditionally so it can't + // leak through even on enrichment cache-miss paths. obj.remove("originalType"); + obj.remove("resourceType"); // 2. Strip non-standard format values (resource-* is Windmill-internal) if obj @@ -64,6 +221,14 @@ pub fn make_schema_compatible(schema: &mut Value) { obj.remove("format"); } + // 2b. Rewrite Windmill pseudo-type `resource` to `string`. The parser emits + // this shape (with a sibling `resourceType` key) for `list[ResourceType]` + // params; "resource" is not in the JSON Schema 2020-12 type enum and is + // rejected by strict validators (e.g. Anthropic's tool registration). + if obj.get("type").and_then(|v| v.as_str()) == Some("resource") { + obj.insert("type".to_string(), Value::String("string".to_string())); + } + // 3. Fix contradictory type: if `properties` is present, type must be "object" if obj.contains_key("properties") { match obj.get("type").and_then(|v| v.as_str()) { @@ -151,8 +316,27 @@ pub fn make_schema_compatible(schema: &mut Value) { #[cfg(test)] mod tests { - use super::make_schema_compatible; + use super::*; + use crate::common::types::{ResourceInfo, ResourceType}; use serde_json::json; + use std::collections::HashMap; + + fn aws_resources() -> (HashMap>, Vec) { + let mut cache = HashMap::new(); + cache.insert( + "c_aws_account".to_string(), + vec![ResourceInfo { + path: "f/platform/aws_dev".to_string(), + description: Some("Dev account".to_string()), + resource_type: "c_aws_account".to_string(), + }], + ); + let types = vec![ResourceType { + name: "c_aws_account".to_string(), + description: Some("AWS account".to_string()), + }]; + (cache, types) + } #[test] fn converts_nested_integer_types() { @@ -356,6 +540,96 @@ mod tests { assert_eq!(schema, json!({})); } + #[test] + fn rewrites_resource_pseudo_type_to_string() { + let mut schema = json!({ + "type": "resource", + "resourceType": "c_aws_account" + }); + + make_schema_compatible(&mut schema); + + assert_eq!(schema["type"], json!("string")); + assert!(schema.get("resourceType").is_none()); + } + + #[test] + fn rewrites_resource_pseudo_type_inside_array_items() { + // Phocas repro: list[ResourceType] parameter. Anthropic rejected this + // with "tools..custom.input_schema: JSON schema is invalid" because + // "resource" is not in the draft 2020-12 type enum. + let mut schema = json!({ + "type": "object", + "properties": { + "accounts": { + "type": "array", + "items": { + "type": "resource", + "resourceType": "c_aws_account" + } + } + } + }); + + make_schema_compatible(&mut schema); + + assert_eq!( + schema["properties"]["accounts"]["items"]["type"], + json!("string") + ); + assert!(schema["properties"]["accounts"]["items"] + .get("resourceType") + .is_none()); + } + + #[test] + fn rewrites_resource_pseudo_type_inside_nested_properties() { + let mut schema = json!({ + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "db": { + "type": "resource", + "resourceType": "postgresql" + } + } + } + } + }); + + make_schema_compatible(&mut schema); + + assert_eq!( + schema["properties"]["config"]["properties"]["db"]["type"], + json!("string") + ); + assert!(schema["properties"]["config"]["properties"]["db"] + .get("resourceType") + .is_none()); + } + + #[test] + fn strips_nested_resource_format_inside_array_items() { + let mut schema = json!({ + "type": "object", + "properties": { + "dbs": { + "type": "array", + "items": { + "type": "object", + "format": "resource-postgresql" + } + } + } + }); + + make_schema_compatible(&mut schema); + + assert!(schema["properties"]["dbs"]["items"].get("format").is_none()); + } + #[test] fn adds_type_to_schema_with_properties_but_no_type() { let mut schema = json!({ @@ -368,4 +642,144 @@ mod tests { assert_eq!(schema["type"], json!("object")); } + + #[test] + fn extract_resource_types_top_level_form_a() { + let schema: SchemaType = serde_json::from_value(json!({ + "type": "object", + "properties": { + "db": { "type": "object", "format": "resource-postgresql" } + }, + "required": [] + })) + .unwrap(); + + let types = extract_resource_types_from_schema(&schema); + assert!(types.contains("postgresql")); + assert_eq!(types.len(), 1); + } + + #[test] + fn extract_resource_types_inside_array_items_form_b() { + let schema: SchemaType = serde_json::from_value(json!({ + "type": "object", + "properties": { + "accounts": { + "type": "array", + "items": { "type": "resource", "resourceType": "c_aws_account" } + } + }, + "required": [] + })) + .unwrap(); + + let types = extract_resource_types_from_schema(&schema); + assert!(types.contains("c_aws_account")); + } + + #[test] + fn extract_resource_types_inside_nested_properties_and_one_of() { + let schema: SchemaType = serde_json::from_value(json!({ + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "db": { "type": "resource", "resourceType": "postgresql" } + } + }, + "either": { + "oneOf": [ + { "type": "object", "format": "resource-mysql" }, + { "type": "string" } + ] + } + }, + "required": [] + })) + .unwrap(); + + let types = extract_resource_types_from_schema(&schema); + assert!(types.contains("postgresql")); + assert!(types.contains("mysql")); + } + + #[test] + fn enrich_top_level_form_a_resource() { + let (cache, types) = aws_resources(); + let mut node = json!({ + "type": "object", + "format": "resource-c_aws_account" + }); + + enrich_resource_schemas(&mut node, &cache, &types); + + assert_eq!(node["type"], json!("string")); + assert!(node.get("format").is_none()); + let desc = node["description"].as_str().unwrap(); + assert!(desc.contains("c_aws_account")); + assert!(desc.contains("$res:f/platform/aws_dev")); + } + + #[test] + fn enrich_inside_array_items_form_b() { + // Phocas repro: items use the parser-style `type: resource` form. + let (cache, types) = aws_resources(); + let mut node = json!({ + "type": "array", + "items": { "type": "resource", "resourceType": "c_aws_account" } + }); + + enrich_resource_schemas(&mut node, &cache, &types); + + // The items schema should be rewritten to string with a description + // listing the available resources, and the Windmill-internal + // resourceType key should be stripped. + assert_eq!(node["items"]["type"], json!("string")); + assert!(node["items"].get("resourceType").is_none()); + let desc = node["items"]["description"].as_str().unwrap(); + assert!(desc.contains("$res:f/platform/aws_dev")); + } + + #[test] + fn enrich_is_noop_when_resource_type_not_in_cache() { + let mut node = json!({ + "type": "object", + "format": "resource-unknown_type" + }); + let before = node.clone(); + + enrich_resource_schemas(&mut node, &HashMap::new(), &[]); + + assert_eq!(node, before); + } + + #[test] + fn enrich_deeply_nested_resource() { + let (cache, types) = aws_resources(); + let mut node = json!({ + "type": "object", + "properties": { + "outer": { + "type": "object", + "properties": { + "inner": { + "type": "array", + "items": { + "type": "resource", + "resourceType": "c_aws_account" + } + } + } + } + } + }); + + enrich_resource_schemas(&mut node, &cache, &types); + + assert_eq!( + node["properties"]["outer"]["properties"]["inner"]["items"]["type"], + json!("string") + ); + } } diff --git a/backend/windmill-mcp/src/server/backend.rs b/backend/windmill-mcp/src/server/backend.rs index 0b942353b3..7a6e007c4d 100644 --- a/backend/windmill-mcp/src/server/backend.rs +++ b/backend/windmill-mcp/src/server/backend.rs @@ -33,6 +33,13 @@ pub trait McpAuth: Send + Sync + Clone + 'static { /// Get token scopes fn scopes(&self) -> Option<&[String]>; + /// True if the token was created with the `read_only` flag. + /// When set, write-capable tools must be hidden from `list_tools` and + /// rejected by `call_tool`. Defaults to false so existing impls compile. + fn read_only(&self) -> bool { + false + } + /// Check if the user has an MCP scope fn has_mcp_scope(&self) -> bool { self.scopes() diff --git a/backend/windmill-mcp/src/server/endpoints.rs b/backend/windmill-mcp/src/server/endpoints.rs index 2401b10757..373db36eb1 100644 --- a/backend/windmill-mcp/src/server/endpoints.rs +++ b/backend/windmill-mcp/src/server/endpoints.rs @@ -26,6 +26,12 @@ pub struct EndpointTool { pub body_field_renames: Option, } +/// True if this endpoint is safe to expose to a read-only token. Mirrors the +/// `read_only_hint` computed by `create_endpoint_annotations`: only `GET`. +pub fn is_endpoint_read_only(tool: &EndpointTool) -> bool { + tool.method.as_ref() == "GET" +} + /// Convert a single endpoint tool to MCP tool pub fn endpoint_tool_to_mcp_tool(tool: &EndpointTool) -> Tool { let mut combined_properties = serde_json::Map::new(); diff --git a/backend/windmill-mcp/src/server/mod.rs b/backend/windmill-mcp/src/server/mod.rs index d489b3f429..688c12b827 100644 --- a/backend/windmill-mcp/src/server/mod.rs +++ b/backend/windmill-mcp/src/server/mod.rs @@ -12,7 +12,7 @@ pub mod tools; // Re-export main types pub use backend::{BackendResult, McpAuth, McpBackend}; -pub use endpoints::{endpoint_tool_to_mcp_tool, EndpointTool}; +pub use endpoints::{endpoint_tool_to_mcp_tool, is_endpoint_read_only, EndpointTool}; pub use runner::Runner; pub use tools::create_tool_from_item; diff --git a/backend/windmill-mcp/src/server/runner.rs b/backend/windmill-mcp/src/server/runner.rs index 0cb2962c55..6d33573d95 100644 --- a/backend/windmill-mcp/src/server/runner.rs +++ b/backend/windmill-mcp/src/server/runner.rs @@ -145,94 +145,100 @@ impl ServerHandler for Runner { parse_mcp_scopes(scopes).map_err(|e| ErrorData::internal_error(e, None))?; let favorites_only = scope_config.favorites; - - // Fetch all items concurrently - let (scripts, flows, resource_types, hub_scripts) = tokio::try_join!( - self.backend - .list_scripts(&auth, &workspace_id, favorites_only, None), - self.backend - .list_flows(&auth, &workspace_id, favorites_only, None), - self.backend.list_resource_types(&auth, &workspace_id), - async { - if let Some(ref apps) = scope_config.hub_apps { - self.backend.list_hub_scripts(Some(apps)).await - } else { - Ok(vec![]) - } - } - )?; - - // Filter items based on scope - let filtered_scripts: Vec<_> = scripts - .into_iter() - .filter(|s| !scope_config.granular || scope_config.is_allowed("script", &s.path)) - .collect(); - - let filtered_flows: Vec<_> = flows - .into_iter() - .filter(|f| !scope_config.granular || scope_config.is_allowed("flow", &f.path)) - .collect(); - - // Collect all needed resource types from all schemas - let mut needed_resource_types: HashSet = HashSet::new(); - for script in &filtered_scripts { - needed_resource_types.extend(extract_resource_types_from_schema(&script.get_schema())); - } - for flow in &filtered_flows { - needed_resource_types.extend(extract_resource_types_from_schema(&flow.get_schema())); - } - for hub_script in &hub_scripts { - needed_resource_types - .extend(extract_resource_types_from_schema(&hub_script.get_schema())); - } - - // Pre-fetch all resources - let resource_futures: Vec<_> = needed_resource_types - .into_iter() - .map(|rt| { - let backend = self.backend.clone(); - let auth = auth.clone(); - let workspace_id = workspace_id.clone(); - async move { - backend - .list_resources(&auth, &workspace_id, &rt) - .await - .map(|resources| (rt, resources)) - } - }) - .collect(); - - let resource_results = futures::future::try_join_all(resource_futures).await?; - let resources_cache: HashMap> = - resource_results.into_iter().collect(); + let read_only = auth.read_only(); let mut tools = Vec::new(); - for script in &filtered_scripts { - tools.push(create_tool_from_item( - script, - self.backend.as_ref(), - &resources_cache, - &resource_types, - )); - } + // Read-only tokens cannot run scripts/flows/hub-scripts (running is a + // mutating action), so skip the script/flow/hub/resource fetches + // entirely — they would only be discarded below. + if !read_only { + let (scripts, flows, resource_types, hub_scripts) = tokio::try_join!( + self.backend + .list_scripts(&auth, &workspace_id, favorites_only, None), + self.backend + .list_flows(&auth, &workspace_id, favorites_only, None), + self.backend.list_resource_types(&auth, &workspace_id), + async { + if let Some(ref apps) = scope_config.hub_apps { + self.backend.list_hub_scripts(Some(apps)).await + } else { + Ok(vec![]) + } + } + )?; - for flow in &filtered_flows { - tools.push(create_tool_from_item( - flow, - self.backend.as_ref(), - &resources_cache, - &resource_types, - )); - } + let filtered_scripts: Vec<_> = scripts + .into_iter() + .filter(|s| !scope_config.granular || scope_config.is_allowed("script", &s.path)) + .collect(); - for hub_script in &hub_scripts { - tools.push(create_tool_from_item( - hub_script, - self.backend.as_ref(), - &resources_cache, - &resource_types, - )); + let filtered_flows: Vec<_> = flows + .into_iter() + .filter(|f| !scope_config.granular || scope_config.is_allowed("flow", &f.path)) + .collect(); + + // Collect all needed resource types from all schemas + let mut needed_resource_types: HashSet = HashSet::new(); + for script in &filtered_scripts { + needed_resource_types + .extend(extract_resource_types_from_schema(&script.get_schema())); + } + for flow in &filtered_flows { + needed_resource_types + .extend(extract_resource_types_from_schema(&flow.get_schema())); + } + for hub_script in &hub_scripts { + needed_resource_types + .extend(extract_resource_types_from_schema(&hub_script.get_schema())); + } + + // Pre-fetch all resources + let resource_futures: Vec<_> = needed_resource_types + .into_iter() + .map(|rt| { + let backend = self.backend.clone(); + let auth = auth.clone(); + let workspace_id = workspace_id.clone(); + async move { + backend + .list_resources(&auth, &workspace_id, &rt) + .await + .map(|resources| (rt, resources)) + } + }) + .collect(); + + let resource_results = futures::future::try_join_all(resource_futures).await?; + let resources_cache: HashMap> = + resource_results.into_iter().collect(); + + for script in &filtered_scripts { + tools.push(create_tool_from_item( + script, + self.backend.as_ref(), + &resources_cache, + &resource_types, + )); + } + + for flow in &filtered_flows { + tools.push(create_tool_from_item( + flow, + self.backend.as_ref(), + &resources_cache, + &resource_types, + )); + } + + for hub_script in &hub_scripts { + tools.push(create_tool_from_item( + hub_script, + self.backend.as_ref(), + &resources_cache, + &resource_types, + )); + } } // Add endpoint tools from the generated MCP tools, filtered by scope @@ -241,6 +247,9 @@ impl ServerHandler for Runner { if scope_config.granular && !scope_config.is_allowed("endpoint", &endpoint_tool.name) { continue; } + if read_only && !crate::server::is_endpoint_read_only(&endpoint_tool) { + continue; + } tools.push(endpoint_tool_to_mcp_tool(&endpoint_tool)); } @@ -259,6 +268,7 @@ impl ServerHandler for Runner { let scopes = auth.scopes().unwrap_or(&[]); let scope_config = parse_mcp_scopes(scopes).map_err(|e| ErrorData::internal_error(e, None))?; + let read_only = auth.read_only(); let args = request.arguments.map(Value::Object).unwrap_or(Value::Null); @@ -278,6 +288,15 @@ impl ServerHandler for Runner { None, )); } + if read_only && !crate::server::is_endpoint_read_only(endpoint_tool) { + return Err(ErrorData::internal_error( + format!( + "Access denied: endpoint '{}' is not read-only and this token is restricted to read-only operations", + endpoint_tool.name + ), + None, + )); + } // This is an endpoint tool, call via backend let result = self @@ -294,6 +313,18 @@ impl ServerHandler for Runner { } } + // Anything below this point runs a script or flow, which is a mutating + // action and must be denied for read-only tokens. + if read_only { + return Err(ErrorData::internal_error( + format!( + "Access denied: tool '{}' runs a script/flow and this token is restricted to read-only operations", + request.name + ), + None, + )); + } + // Resolve the tool name to (type, path, is_hub) let (type_str, is_hub, is_hashed) = parse_tool_prefix(&request.name).map_err(|e| { ErrorData::internal_error(format!("Failed to parse tool name: {}", e), None) diff --git a/backend/windmill-native-triggers/src/handler.rs b/backend/windmill-native-triggers/src/handler.rs index 1b6aa83b92..fda28af407 100644 --- a/backend/windmill-native-triggers/src/handler.rs +++ b/backend/windmill-native-triggers/src/handler.rs @@ -94,6 +94,7 @@ async fn new_webhook_token( None, Some(scopes), Some(workspace_id.to_owned()), + None, ); let token = create_token_internal(&mut *tx, &db, &authed, token_config).await?; diff --git a/backend/windmill-runtime-nativets/tests/otel_e2e.rs b/backend/windmill-runtime-nativets/tests/otel_e2e.rs new file mode 100644 index 0000000000..cadddb2fcf --- /dev/null +++ b/backend/windmill-runtime-nativets/tests/otel_e2e.rs @@ -0,0 +1,206 @@ +//! End-to-end coverage for the EE HTTP-tracing path on nativets. +//! +//! Pairs with `otel_init.rs` (which pins only the `OTEL_GLOBALS` +//! population contract). This test exercises the full chain that +//! actually delivers a span to a collector: +//! +//! `deno_telemetry::init` with EE config (Rust) +//! → `globalThis.__bootstrapOtel()` (JS, flips TRACING_ENABLED) +//! → user `fetch()` → deno_fetch's `builtinTracer().startSpan` +//! → `BatchSpanProcessor` → `HttpExporter` (OTLP/HTTP-binary) +//! → our mock OTLP listener captures the request bytes +//! +//! Without the v1.702.0 fix (#573 EE / #9163 OSS), the third arrow +//! panics in a tokio worker. With the fix in place, the span is +//! emitted and shows up at the listener — which is what the customer +//! is paying for when they enable HTTP tracing on nativets. +//! +//! `#[ignore]`'d: spins a V8 isolate (~seconds) and binds two TCP +//! listeners. Run with `cargo test -p windmill-runtime-nativets +//! --test otel_e2e -- --ignored`. +//! +//! Owns its own test binary so `OTEL_GLOBALS`'s `OnceCell` doesn't +//! race with `otel_init.rs`. + +use std::sync::Arc; +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::sync::Mutex; + +use windmill_runtime_nativets::{deno_telemetry, transpile_ts, NativeAnnotation, PrewarmedIsolate}; + +/// Bind 127.0.0.1:0 and spawn an accept loop. Each connection is +/// read until idle/EOF, the body is appended to `captured`, then we +/// respond with HTTP 200. Returns the bound port. +async fn spawn_capturing_http(captured: Arc>>>) -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + loop { + let Ok((mut sock, _)) = listener.accept().await else { + break; + }; + let captured = captured.clone(); + tokio::spawn(async move { + let mut buf = Vec::new(); + let mut tmp = [0u8; 8192]; + let _ = tokio::time::timeout(Duration::from_millis(300), async { + loop { + match sock.read(&mut tmp).await { + Ok(0) | Err(_) => break, + Ok(n) => buf.extend_from_slice(&tmp[..n]), + } + } + }) + .await; + let _ = sock + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + .await; + captured.lock().await.push(buf); + }); + } + }); + port +} + +/// Initialize `deno_telemetry` with the exact `OtelConfig` shape that +/// the EE `load_internal_otel_exporter` ships in production. Keeps +/// this test in lockstep with the actual call site: if production +/// drifts away from `tracing_enabled + Capture`, this test breaks +/// before the customer-facing panic does. +fn init_with_ee_otel_config() { + deno_telemetry::init( + deno_telemetry::OtelRuntimeConfig { + runtime_name: "windmill-nativets".into(), + runtime_version: "0".into(), + }, + deno_telemetry::OtelConfig { + tracing_enabled: true, + console: deno_telemetry::OtelConsoleConfig::Capture, + ..Default::default() + }, + ) + .expect("deno_telemetry init failed"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "spins V8 + tcp listeners; run with --ignored"] +async fn fetch_after_init_otel_emits_span_to_collector() { + let _ = rustls::crypto::ring::default_provider().install_default(); + + // 1. Stand up two listeners: one that pretends to be the user's + // fetch target, one that pretends to be the OTLP collector. + let fetch_hits: Arc>>> = Arc::new(Mutex::new(Vec::new())); + let otlp_hits: Arc>>> = Arc::new(Mutex::new(Vec::new())); + let fetch_port = spawn_capturing_http(fetch_hits.clone()).await; + let otlp_port = spawn_capturing_http(otlp_hits.clone()).await; + + // 2. Point the deno_telemetry exporter at the mock collector and + // initialize. Mirrors `load_internal_otel_exporter` in EE. + // + // SAFETY: test runs in its own process binary; no other thread + // reads OTEL_EXPORTER_OTLP_ENDPOINT before init returns. + unsafe { + std::env::set_var( + "OTEL_EXPORTER_OTLP_ENDPOINT", + format!("http://127.0.0.1:{otlp_port}"), + ); + } + init_with_ee_otel_config(); + assert!( + deno_telemetry::OTEL_GLOBALS.get().is_some(), + "OTEL_GLOBALS missing — load_internal_otel_exporter's config regressed?" + ); + + // 3. Run user TS that bootstraps OTel and then issues a fetch + // at the mock target. `__bootstrapOtel` is fire-and-forget + // (resolves a dynamic import on the microtask queue); the + // `setTimeout` loop yields a few times so the import resolves + // and the `TRACING_ENABLED` flag is set before fetch runs. + let ts = format!( + r#" +declare const globalThis: any; +export async function main(): Promise {{ + globalThis.__bootstrapOtel(); + // Yield multiple microtask + timer turns so the dynamic import + // in __bootstrapOtel resolves and TRACING_ENABLED flips before + // fetch runs (otherwise deno_fetch skips the span entirely). + for (let i = 0; i < 5; i++) {{ + await new Promise(r => setTimeout(r, 10)); + }} + const resp = await fetch("http://127.0.0.1:{fetch_port}/probe"); + return resp.status; +}} +"# + ); + let js = transpile_ts(ts).expect("transpile failed"); + let ann = NativeAnnotation { useragent: None, proxy: None }; + + let mut iso = PrewarmedIsolate::spawn(String::new(), js, ann, vec![], None); + iso.wait_ready().await.expect("isolate failed to pre-warm"); + let res = iso + .start_execution("{}".to_string()) + .wait() + .await + .expect("isolate panicked"); + + // The exact bug: pre-fix, fetch panics this isolate. Post-fix, + // we get back the mock target's 200. + let raw = res.result.expect("user script returned an error"); + assert_eq!(raw.get(), "200", "fetch should return mock target status"); + + assert_eq!( + fetch_hits.lock().await.len(), + 1, + "fetch target should have been hit exactly once" + ); + + // 4. Force the BatchSpanProcessor to flush so the exporter posts + // to our mock collector synchronously (default flush interval + // is ~5s; tests can't wait that long). + deno_telemetry::flush(); + // Exporter is async over the OTel runtime; give it a beat to + // actually send the HTTP request. + tokio::time::sleep(Duration::from_millis(500)).await; + + let otlp_captured = otlp_hits.lock().await; + assert!( + !otlp_captured.is_empty(), + "OTLP collector should have received at least one export — \ + spans aren't reaching the collector after init" + ); + + // Verify the export carries our fetch span. OTLP is protobuf, so + // grep the raw bytes for OTel HTTP semantic-convention markers + // that deno_fetch's auto-instrumentation attaches: + // - the target URL ("url.full" attribute) + // - "http.request.method" attribute + let combined: Vec = otlp_captured.iter().flatten().copied().collect(); + let bytes_contain = |needle: &[u8]| combined.windows(needle.len()).any(|w| w == needle); + + let url_marker = format!("http://127.0.0.1:{fetch_port}/probe"); + let has_url = bytes_contain(url_marker.as_bytes()); + let has_method_attr = bytes_contain(b"http.request.method"); + + if !(has_url && has_method_attr) { + eprintln!( + "OTLP bytes ({}): {:?}", + combined.len(), + String::from_utf8_lossy(&combined) + ); + } + + assert!( + has_url, + "exported OTLP body should reference the fetched URL ({}); got {} bytes", + url_marker, + combined.len() + ); + assert!( + has_method_attr, + "exported OTLP body should carry HTTP semconv attributes (http.request.method); got {} bytes", + combined.len() + ); +} diff --git a/backend/windmill-runtime-nativets/tests/otel_init.rs b/backend/windmill-runtime-nativets/tests/otel_init.rs new file mode 100644 index 0000000000..fdf1ac944d --- /dev/null +++ b/backend/windmill-runtime-nativets/tests/otel_init.rs @@ -0,0 +1,88 @@ +//! Regression test for the deno_telemetry 0.31 nativets-fetch panic. +//! +//! Setup: when EE's `load_internal_otel_exporter` runs (HTTP tracing +//! enabled), it calls `deno_telemetry::init` and then flips +//! `DENO_OTEL_INITIALIZED=true` so `js_eval` will run +//! `globalThis.__bootstrapOtel()` inside the nativets JsRuntime. That +//! bootstrap unconditionally sets JS-side `TRACING_ENABLED=true`, which +//! makes `deno_fetch`'s `fetch()` call `builtinTracer().startSpan(...)` +//! — and `OtelTracer::builtin()` does `OTEL_GLOBALS.get().unwrap()`. +//! +//! In deno_telemetry 0.31, `init` was given an early-return guard: if +//! `tracing_enabled`, `metrics_enabled`, and `console` are all +//! off/Ignore, it returns `Ok(())` *without populating OTEL_GLOBALS*. +//! v1.700.0 was passing `OtelConfig::default()` (all off) — so the JS +//! bootstrap proceeded but the Rust-side OnceCell was empty, and the +//! first `fetch()` panicked the tokio worker in a context that cannot +//! unwind. Fixed in v1.702.0 by passing `tracing_enabled: true` + +//! `console: Capture` from `load_internal_otel_exporter` (PRs #573 EE +//! / #9163 OSS), matching the JS bootstrap shape `[1, 0, 1, 0]`. +//! +//! This test pins that contract directly against `deno_telemetry:: +//! init` — the function the EE call site invokes — so the next time +//! the dep is bumped and someone is tempted to "simplify" the config +//! back to `OtelConfig::default()`, CI catches it. +//! +//! Lives in `tests/` (not `src/`) so it gets its own test binary and +//! its own process — `OTEL_GLOBALS` is a `OnceCell`, so sharing it +//! with the smoke tests in `src/smoke_tests.rs` would race. + +use windmill_runtime_nativets::deno_telemetry; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ee_otel_init_config_populates_otel_globals() { + // deno_telemetry::init builds an HttpExporter (uses rustls) — it + // needs a process-wide CryptoProvider, which the real binary + // installs in setup_deno_runtime / main. Mirror that here. + let _ = rustls::crypto::ring::default_provider().install_default(); + + // Pre-condition: nothing else in this test binary has touched the + // OnceCell, so it must be empty. + assert!( + deno_telemetry::OTEL_GLOBALS.get().is_none(), + "OTEL_GLOBALS must start empty in a fresh test process" + ); + + // Step 1: reproduce the footgun. `OtelConfig::default()` has + // tracing/metrics off and `console = Ignore`, which trips the + // 0.31 early-return — Ok(()) but OnceCell stays empty. + deno_telemetry::init( + deno_telemetry::OtelRuntimeConfig { + runtime_name: "windmill-nativets-test".into(), + runtime_version: "0".into(), + }, + deno_telemetry::OtelConfig::default(), + ) + .expect("init with default config returns Ok (early-return)"); + + assert!( + deno_telemetry::OTEL_GLOBALS.get().is_none(), + "deno_telemetry 0.31 contract: init with all-disabled config \ + must NOT populate OTEL_GLOBALS — if this changes on a future \ + bump, load_internal_otel_exporter can drop the explicit \ + tracing_enabled/console fields." + ); + + // Step 2: re-call init with the exact config shape that the EE + // `load_internal_otel_exporter` ships — this is what production + // hits, so the test exercises the actual prod call path. + deno_telemetry::init( + deno_telemetry::OtelRuntimeConfig { + runtime_name: "windmill-nativets".into(), + runtime_version: "0".into(), + }, + deno_telemetry::OtelConfig { + tracing_enabled: true, + console: deno_telemetry::OtelConsoleConfig::Capture, + ..Default::default() + }, + ) + .expect("init with tracing_enabled config must succeed on a fresh OnceCell"); + + assert!( + deno_telemetry::OTEL_GLOBALS.get().is_some(), + "load_internal_otel_exporter's OtelConfig must populate \ + OTEL_GLOBALS so OtelTracer::builtin() does not panic on \ + .unwrap() once __bootstrapOtel flips JS-side TRACING_ENABLED" + ); +} diff --git a/backend/windmill-worker/Cargo.toml b/backend/windmill-worker/Cargo.toml index f1c7fa6c99..5ebcb0b370 100644 --- a/backend/windmill-worker/Cargo.toml +++ b/backend/windmill-worker/Cargo.toml @@ -39,7 +39,7 @@ ruby = ["dep:windmill-parser-ruby"] rlang = ["dep:windmill-parser-r"] duckdb = ["dep:libloading"] quickjs = ["windmill-jseval/quickjs"] -bedrock = ["windmill-ai/bedrock", "dep:aws-sdk-bedrockruntime", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-types"] +bedrock = ["windmill-ai/bedrock"] [dependencies] windmill-ai = { workspace = true, default-features = false } @@ -71,10 +71,6 @@ windmill-parser-sql.workspace = true windmill-parser-graphql.workspace = true windmill-parser-php = { workspace = true, optional = true } windmill-git-sync.workspace = true -aws-sdk-bedrockruntime = { workspace = true, optional = true } -aws-config = { workspace = true, optional = true } -aws-credential-types = { workspace = true, optional = true } -aws-smithy-types = { workspace = true, optional = true } flume.workspace = true sqlx.workspace = true uuid.workspace = true diff --git a/backend/windmill-worker/src/ai/mod.rs b/backend/windmill-worker/src/ai/mod.rs index b004b0ec20..24e877ab13 100644 --- a/backend/windmill-worker/src/ai/mod.rs +++ b/backend/windmill-worker/src/ai/mod.rs @@ -1,7 +1,6 @@ // AI executor module structure // This module will contain all AI-related execution logic -pub mod providers; pub mod query_builder; pub mod tools; pub mod utils; diff --git a/backend/windmill-worker/src/ai/providers/mod.rs b/backend/windmill-worker/src/ai/providers/mod.rs deleted file mode 100644 index f558c28289..0000000000 --- a/backend/windmill-worker/src/ai/providers/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod anthropic; -#[cfg(feature = "bedrock")] -pub mod bedrock; -pub mod google_ai; -pub mod openai; -pub mod openrouter; -pub mod other; diff --git a/backend/windmill-worker/src/ai/query_builder.rs b/backend/windmill-worker/src/ai/query_builder.rs index 606aa8f9db..fe8a3b9b00 100644 --- a/backend/windmill-worker/src/ai/query_builder.rs +++ b/backend/windmill-worker/src/ai/query_builder.rs @@ -1,37 +1,9 @@ use async_trait::async_trait; -use windmill_ai::{ - query_builder::{QueryBuilder, StreamEventSink}, - types::*, -}; +use windmill_ai::{query_builder::StreamEventSink, types::*}; use windmill_common::{error::Error, worker::Connection}; use windmill_queue::MiniPulledJob; -use crate::{ - ai::providers::{ - anthropic::AnthropicQueryBuilder, google_ai::GoogleAIQueryBuilder, - openai::OpenAIQueryBuilder, openrouter::OpenRouterQueryBuilder, other::OtherQueryBuilder, - }, - job_logger::append_result_stream, -}; - -/// Factory function to create the appropriate query builder for a provider -pub fn create_query_builder(provider: &ProviderWithResource) -> Box { - use windmill_ai::ai_providers::AIProvider; - - match provider.kind { - AIProvider::GoogleAI => { - Box::new(GoogleAIQueryBuilder::new(provider.get_platform().clone())) - } - AIProvider::OpenAI => Box::new(OpenAIQueryBuilder::new(provider.kind.clone())), - AIProvider::Anthropic => Box::new(AnthropicQueryBuilder::new( - provider.kind.clone(), - provider.get_platform().clone(), - provider.get_enable_1m_context(), - )), - AIProvider::OpenRouter => Box::new(OpenRouterQueryBuilder::new()), - _ => Box::new(OtherQueryBuilder::new(provider.kind.clone())), - } -} +use crate::job_logger::append_result_stream; /// Processes streaming events by persisting them to the database. /// Implements StreamEventSink so it can be passed to QueryBuilder methods. diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index cdf2995f9c..dae4f277f2 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -23,6 +23,7 @@ use crate::ai::tools::McpClientStub as McpClient; use windmill_ai::{ ai_providers::AIProvider, image_handler::upload_image_to_s3, + providers::create_query_builder, query_builder::{BuildRequestArgs, ParsedResponse}, types::*, utils::{should_use_structured_output_tool, AI_HTTP_HEADERS}, @@ -44,7 +45,7 @@ use windmill_common::{ use windmill_queue::{cancel_single_job, CanceledBy, MiniPulledJob}; use crate::{ - ai::query_builder::{create_query_builder, StreamEventProcessor}, + ai::query_builder::StreamEventProcessor, common::{build_args_map, resolve_job_timeout, OccupancyMetrics, StreamNotifier}, handle_child::{run_future_with_polling_update_job_poller_graceful, GracefulPollOutcome}, }; @@ -866,7 +867,7 @@ pub async fn run_agent( .get_region() .unwrap_or(windmill_ai::ai_providers::USE_ENV_REGION); // Use Bedrock SDK via dedicated query builder - crate::ai::providers::bedrock::BedrockQueryBuilder::default() + windmill_ai::providers::bedrock::BedrockQueryBuilder::default() .execute_request( &messages, tool_defs.as_deref(), diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 5e640c1fde..97ff28cae1 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -2172,6 +2172,7 @@ try {{ "--", &BUN_PATH, "run", + "--preserve-symlinks", "-i", "--prefer-offline", "-r", @@ -2238,6 +2239,7 @@ try {{ } else { vec![ "run", + "--preserve-symlinks", "-i", "--prefer-offline", "-r", @@ -3895,6 +3897,7 @@ pub async fn start_worker( common_bun_proc_envs, vec![ "run", + "--preserve-symlinks", "-i", "--prefer-offline", "-r", diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 4db7b20fd5..d3d3de62d6 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -178,6 +178,44 @@ pub fn handle_ephemeral_token(x: String) -> String { x } +/// Removes lockfile/requirements entries matching the worker's `pip_local_dependencies` +/// regexes. Those packages are already provided locally (e.g. via `additional_python_paths`), +/// so installing them again duplicates files and triggers expensive `postinstall` copies on +/// every job. `#`-prefixed comment lines (e.g. the `# py:` lockfile header) are always kept. +/// Returns `(kept_lines, ignored_lines)`. +fn filter_pip_local_dependencies(lines: Vec) -> (Vec, Vec) { + let Some(pip_local_dependencies) = WORKER_CONFIG.load().pip_local_dependencies.clone() else { + return (lines, vec![]); + }; + + let compiled_deps = pip_local_dependencies + .iter() + .filter_map(|dep| match Regex::new(dep) { + Ok(r) => Some(r), + Err(e) => { + tracing::warn!( + "regex compilation failed for Python local dependency: '{}' - it will be ignored", + e + ); + None + } + }) + .collect::>(); + + filter_lines_by_deps(lines, &compiled_deps) +} + +/// Pure core of [`filter_pip_local_dependencies`]: partitions `lines` into +/// `(kept, ignored)`. A line is ignored when it is not a `#` comment and matches any of +/// `compiled_deps`. Kept separate from config/regex loading so it can be unit-tested. +fn filter_lines_by_deps(lines: Vec, compiled_deps: &[Regex]) -> (Vec, Vec) { + let (ignored, kept): (Vec, Vec) = lines + .into_iter() + .partition(|s| !s.starts_with('#') && compiled_deps.iter().any(|dep| dep.is_match(s))); + + (kept, ignored) +} + // This function only invoked during deployment of script or test run. // And never for already deployed scripts, these have their lockfiles in PostgreSQL // thus this function call is skipped. @@ -200,33 +238,13 @@ pub async fn uv_pip_compile( logs.push_str(&format!("\nresolving dependencies...")); logs.push_str(&format!("\ncontent of requirements:\n{}\n", requirements)); - let requirements = if let Some(pip_local_dependencies) = - WORKER_CONFIG.load().pip_local_dependencies.as_ref() - { - let deps = pip_local_dependencies.clone(); - let compiled_deps = deps.iter().map(|dep| { - let compiled_dep = Regex::new(dep); - match compiled_dep { - Ok(compiled_dep) => Some(compiled_dep), - Err(e) => { - tracing::warn!("regex compilation failed for Python local dependency: '{}' - it will be ignored", e); - return None; - } - } - }).filter(|dep_maybe| dep_maybe.is_some()).map(|dep| dep.unwrap()).collect::>(); - requirements - .lines() - .filter(|s| { - if compiled_deps.iter().any(|dep| dep.is_match(s)) { - logs.push_str(&format!("\nignoring local dependency: {}", s)); - return false; - } else { - return true; - } - }) - .join("\n") - } else { - requirements.to_string() + let requirements = { + let (kept, ignored) = + filter_pip_local_dependencies(requirements.lines().map(str::to_owned).collect()); + for line in ignored { + logs.push_str(&format!("\nignoring local dependency: {}", line)); + } + kept.join("\n") }; let uv_index_strategy = UV_INDEX_STRATEGY.read().await.clone(); @@ -841,7 +859,11 @@ def to_b_64(v: bytes): b64 = base64.b64encode(v) return b64.decode('ascii') -replace_invalid_fields = re.compile(r'(?:\bNaN\b|\\*\\u0000|Infinity|\-Infinity)') +_u=re.compile(r'\\\\|\\u0000') +_us=lambda m:' null ' if m.group(0)[1]=='u' else m.group(0) +_r=lambda m,s='':(_u.sub(_us,s) if '\\u0000' in s else s) if (s:=m.group(0))[0]=='"' else ' null ' +replace_invalid_fields=re.compile(r'"(?:\\.|[^"\\])*"|\bNaN\b|-?Infinity') +_fix=lambda s:s if 'Infinity' not in s and 'NaN' not in s and '\\u0000' not in s else re.sub(replace_invalid_fields,_r,s) result_json = os.path.join(os.path.abspath(os.path.dirname(__file__)), "result.json") @@ -1367,7 +1389,11 @@ def to_b_64(v: bytes): b64 = base64.b64encode(v) return b64.decode('ascii') -replace_invalid_fields = re.compile(r'(?:\bNaN\b|\\u0000|Infinity|\-Infinity)') +_u=re.compile(r'\\\\|\\u0000') +_us=lambda m:' null ' if m.group(0)[1]=='u' else m.group(0) +_r=lambda m,s='':(_u.sub(_us,s) if '\\u0000' in s else s) if (s:=m.group(0))[0]=='"' else ' null ' +replace_invalid_fields=re.compile(r'"(?:\\.|[^"\\])*"|\bNaN\b|-?Infinity') +_fix=lambda s:s if 'Infinity' not in s and 'NaN' not in s and '\\u0000' not in s else re.sub(replace_invalid_fields,_r,s) def res_to_json(res, typ): {res_to_json_body} @@ -1866,6 +1892,24 @@ Returned from server: py_version - {:?}, py_version_v2 - {:?} } }; + // Filter out packages matched by pip_local_dependencies. For preview runs this is also + // handled inside uv_pip_compile, but deployed scripts skip uv_pip_compile entirely and + // would otherwise pass every lockfile entry to handle_python_reqs — causing duplicate + // installs alongside additional_python_paths and triggering expensive postinstall copies. + let resolved_lines = { + let (kept, ignored) = filter_pip_local_dependencies(resolved_lines); + if !ignored.is_empty() { + append_logs( + job_id, + w_id, + format!("\nignoring local dependencies:\n{}\n", ignored.join("\n")), + conn, + ) + .await; + } + kept + }; + if !resolved_lines.is_empty() { let mut venv_path = handle_python_reqs( resolved_lines, @@ -2951,7 +2995,7 @@ fn get_result_postprocessor<'a>(skip: bool) -> &'a str { if skip { "unprocessed" } else { - "re.sub(replace_invalid_fields, ' null ', unprocessed)" + "_fix(unprocessed)" } } @@ -3220,4 +3264,39 @@ mod tests { let pre = cg.pre_spread.as_ref().unwrap(); assert!(pre.contains("pre_args[\"input\"]")); } + + fn lines(v: &[&str]) -> Vec { + v.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn test_filter_lines_by_deps_no_deps_keeps_everything() { + let (kept, ignored) = filter_lines_by_deps(lines(&["requests==2.0", "numpy==1.0"]), &[]); + assert_eq!(kept, lines(&["requests==2.0", "numpy==1.0"])); + assert!(ignored.is_empty()); + } + + #[test] + fn test_filter_lines_by_deps_matches_and_partitions() { + let deps = vec![Regex::new("^my-local-pkg").unwrap()]; + let (kept, ignored) = filter_lines_by_deps( + lines(&["requests==2.0", "my-local-pkg==1.2.3", "numpy==1.0"]), + &deps, + ); + assert_eq!(kept, lines(&["requests==2.0", "numpy==1.0"])); + assert_eq!(ignored, lines(&["my-local-pkg==1.2.3"])); + } + + #[test] + fn test_filter_lines_by_deps_preserves_comment_lines() { + // `#` lines (e.g. the `# py: 3.11` lockfile header) must survive even when a + // dependency regex would otherwise match them. + let deps = vec![Regex::new("py").unwrap()]; + let (kept, ignored) = filter_lines_by_deps( + lines(&["# py: 3.11", "pyyaml==6.0", "requests==2.0"]), + &deps, + ); + assert_eq!(kept, lines(&["# py: 3.11", "requests==2.0"])); + assert_eq!(ignored, lines(&["pyyaml==6.0"])); + } } diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 15405800be..f9305961eb 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -132,7 +132,8 @@ use crate::{ bun_executor::handle_bun_job, common::{ build_args_map, cached_result_path, get_cached_resource_value_if_valid, - get_reserved_variables, update_worker_ping_for_failed_init_script, OccupancyMetrics, + get_reserved_variables, get_root_job_id, update_worker_ping_for_failed_init_script, + OccupancyMetrics, }, csharp_executor::handle_csharp_job, deno_executor::handle_deno_job, @@ -277,6 +278,8 @@ pub struct OtelTracingProxySettings { pub enabled: bool, #[serde(default)] pub enabled_languages: HashSet, + #[serde(default)] + pub no_proxy_hosts: Option, } #[cfg(feature = "prometheus")] @@ -969,14 +972,15 @@ async fn get_otel_tracing_proxy_envs( } }; let proxy_url = format!("http://127.0.0.1:{}", port); + let no_proxy = build_tracing_proxy_no_proxy().await; Ok(vec![ ("HTTP_PROXY", proxy_url.clone()), ("HTTPS_PROXY", proxy_url.clone()), // Lowercase variants for Ruby and other runtimes that check lowercase first ("http_proxy", proxy_url.clone()), ("https_proxy", proxy_url), - ("NO_PROXY", "".to_string()), - ("no_proxy", "".to_string()), + ("NO_PROXY", no_proxy.clone()), + ("no_proxy", no_proxy), // CA cert for various runtimes to trust the tracing proxy ("SSL_CERT_FILE", TRACING_PROXY_CA_CERT_PATH.to_string()), ("REQUESTS_CA_BUNDLE", TRACING_PROXY_CA_CERT_PATH.to_string()), @@ -990,6 +994,70 @@ async fn get_otel_tracing_proxy_envs( ]) } +/// NO_PROXY value injected into jobs so their HTTP clients bypass the local MITM proxy for +/// the configured hosts. This is distinct from the worker's own NO_PROXY env, which governs +/// what the MITM proxy bypasses when relaying upstream (e.g. through a corporate proxy) and +/// is honored automatically by the in-process MITM. The configured hosts are tunneled +/// through the proxy without TLS interception, so clients that pin their own CA (kubectl, +/// helm, terraform, etc.) keep working. Empty when unset, matching the prior behavior of +/// intercepting all destinations including loopback. +#[cfg(all(feature = "private", feature = "enterprise"))] +async fn build_tracing_proxy_no_proxy() -> String { + let configured = OTEL_TRACING_PROXY_SETTINGS + .read() + .await + .no_proxy_hosts + .clone(); + normalize_no_proxy_hosts(configured.as_deref()) +} + +/// Split a comma-separated NO_PROXY value, trim whitespace, drop empty entries, and +/// deduplicate while preserving order. `None` returns an empty string. +#[cfg(all(feature = "private", feature = "enterprise"))] +fn normalize_no_proxy_hosts(configured: Option<&str>) -> String { + let Some(configured) = configured else { + return String::new(); + }; + let mut seen = std::collections::HashSet::new(); + let mut out: Vec<&str> = Vec::new(); + for entry in configured.split(',') { + let trimmed = entry.trim(); + if !trimmed.is_empty() && seen.insert(trimmed) { + out.push(trimmed); + } + } + out.join(",") +} + +#[cfg(all(test, feature = "private", feature = "enterprise"))] +mod no_proxy_tests { + use super::normalize_no_proxy_hosts; + + #[test] + fn unset_returns_empty() { + assert_eq!(normalize_no_proxy_hosts(None), ""); + } + + #[test] + fn empty_and_whitespace_only_returns_empty() { + assert_eq!(normalize_no_proxy_hosts(Some("")), ""); + assert_eq!(normalize_no_proxy_hosts(Some(" , ,\t")), ""); + } + + #[test] + fn trims_and_skips_empties() { + assert_eq!( + normalize_no_proxy_hosts(Some(" *.eks.amazonaws.com ,, *.internal ")), + "*.eks.amazonaws.com,*.internal" + ); + } + + #[test] + fn dedupes_preserving_first_occurrence_order() { + assert_eq!(normalize_no_proxy_hosts(Some("a,b,a,c,b,d")), "a,b,c,d"); + } +} + #[cfg(windows)] lazy_static::lazy_static! { pub static ref SYSTEM_ROOT: String = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".to_string()); @@ -1253,8 +1321,6 @@ async fn insert_wait_time( .await?; if let Some(root_id) = root_job_id { - // TODO: queued_job.root_job is not guaranteed to be the true root job (e.g. parallel flow - // subflows). So this is currently incorrect for those cases sqlx::query!( "INSERT INTO outstanding_wait_time(job_id, aggregate_wait_time_ms) VALUES ($1, $2) ON CONFLICT (job_id) DO UPDATE SET aggregate_wait_time_ms = @@ -1286,7 +1352,10 @@ fn add_outstanding_wait_time( } let job_id = queued_job.id; - let root_job_id = queued_job.flow_innermost_root_job; + // Aggregate onto the true top-level root (root_job → flow_innermost_root_job → parent_job). + // `get_root_job_id` falls back to the job's own id when none are set; filter that out so + // standalone scripts (no parent flow) skip the aggregate insertion. + let root_job_id = Some(get_root_job_id(queued_job)).filter(|&id| id != job_id); let conn = conn.clone(); if let Some(db) = conn.as_sql() { diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index 53c94a7458..68ca83c270 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -3919,11 +3919,14 @@ async fn push_next_flow_job( for (i, payload_tag) in job_payloads.into_iter().enumerate() { if i % 100 == 0 && i != 0 { tracing::info!(id = %flow_job.id, root_id = %job_root, "pushed (non-commited yet) first {i} subflows of {len}"); + // Ping on the pool, outside `tx`, so the zombie flow monitor sees it before the + // push transaction commits — otherwise large parallel pushes can be flagged as + // zombie and trigger a cancel/push deadlock. sqlx::query!( - "UPDATE v2_job_runtime SET ping = now() WHERE id = $1 AND ping < now()", + "UPDATE v2_job_runtime SET ping = now() WHERE id = $1", flow_job.id, ) - .execute(&mut *tx) + .execute(db) .warn_after_seconds(3) .await?; } diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 3d7b371b24..4df4d4ca7d 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.699.0"; +export const VERSION = "v1.702.1"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/commands/app/app.ts b/cli/src/commands/app/app.ts index 2749c0269c..a6f85fde1c 100644 --- a/cli/src/commands/app/app.ts +++ b/cli/src/commands/app/app.ts @@ -20,12 +20,18 @@ import newCommand from "./new.ts"; import generateAgentsCommand from "./generate_agents.ts"; import { isVersionsGeq1585 } from "../sync/global.ts"; import type { PermissionedAsContext } from "../../core/permissioned_as.ts"; +import { applyExtraPermsDiff } from "../../core/extra_perms.ts"; export interface AppFile { value: any; public?: boolean; summary: string; policy: Policy; + // Mirrors granular ACLs on the app path. Omitted from app.yaml when no + // perms are set. The CLI applies diffs through /acls/add and /acls/remove + // (see applyExtraPermsDiff) — never through update_app — so a perm-only + // change never bumps the app version. + extra_perms?: Record; } const alreadySynced: string[] = []; @@ -168,21 +174,27 @@ export async function pushApp( // On create: backend applies folder defaults } + // extra_perms goes through /acls/* — strip from the body so a perms-only + // edit never bumps the app version (see applyExtraPermsDiff for details). + const { extra_perms: localPerms, ...localAppBody } = localApp as AppFile & { + extra_perms?: Record; + }; + if (app) { - if (isSuperset(localApp, app)) { + if (isSuperset(localAppBody, app)) { log.info(colors.green(`App ${remotePath} is up to date`)); - return; + } else { + log.info(colors.bold.yellow(`Updating app ${remotePath}...`)); + await wmill.updateApp({ + workspace, + path: remotePath, + requestBody: { + deployment_message: message, + ...localAppBody, + ...preserveFields, + }, + }); } - log.info(colors.bold.yellow(`Updating app ${remotePath}...`)); - await wmill.updateApp({ - workspace, - path: remotePath, - requestBody: { - deployment_message: message, - ...localApp, - ...preserveFields, - }, - }); } else { log.info(colors.yellow.bold("Creating new app...")); @@ -191,11 +203,23 @@ export async function pushApp( requestBody: { path: remotePath, deployment_message: message, - ...localApp, + ...localAppBody, ...preserveFields, }, }); } + + // Independent perms sync via /acls/* — self-contained log + non-fatal errors. + // No refetch: extra_perms is item-specific and folder perms are never merged + // onto item.extra_perms, and the body sent to update_app / create_app omits + // the field — so the value we already have from getAppByPath is authoritative. + await applyExtraPermsDiff( + workspace, + "app", + remotePath, + localPerms, + (app as any)?.extra_perms, + ); } export async function generatingPolicy( diff --git a/cli/src/commands/app/raw_apps.ts b/cli/src/commands/app/raw_apps.ts index 43c39ec9e4..5b9e880adc 100644 --- a/cli/src/commands/app/raw_apps.ts +++ b/cli/src/commands/app/raw_apps.ts @@ -19,6 +19,7 @@ import { createBundle, detectFrameworks } from "./bundle.ts"; import { APP_BACKEND_FOLDER } from "./app_metadata.ts"; import { writeIfChanged } from "../../utils/utils.ts"; import { yamlOptions } from "../sync/sync.ts"; +import { applyExtraPermsDiff } from "../../core/extra_perms.ts"; import { EXTENSION_TO_LANGUAGE, getLanguageFromExtension, @@ -35,6 +36,10 @@ export interface AppFile { datatable?: string; schema?: string; }; + // Mirrors granular ACLs on the raw_app path. Synced via /acls/* by + // applyExtraPermsDiff — never through update_app_raw — so a perm-only + // change never bumps the app version. Stripped from the yaml when empty. + extra_perms?: Record; } // Match siblings of a YAML metadata file case-insensitively. A buggy CLI @@ -431,35 +436,45 @@ export async function pushRawApp( value.data = localApp.data; } + // extra_perms is synced independently via /acls/* — strip from the + // up-to-date comparison so a perm-only edit doesn't trigger a rebuild + + // new app_version. The kind segment is "raw_app" so git-sync writes back + // to `.raw_app.json`, not `.app.json`. The backend granular_acls + // handler routes "raw_app" to the `app` table (where v2 raw apps actually + // live) while still dispatching DeployedObject::RawApp for git-sync. + const { extra_perms: localPerms, ...localAppNoPerms } = localApp as AppFile & { + extra_perms?: Record; + }; + 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({ ...localApp, runnables }, app); + const metadataUpToDate = isSuperset({ ...localAppNoPerms, runnables }, app); const filesUpToDate = deepEqual(files, app.value?.files); if (metadataUpToDate && filesUpToDate) { log.info(colors.green(`App ${remotePath} is up to date`)); - return; - } - const { js, css } = await createBundleRaw(); - log.info(colors.bold.yellow(`Updating app ${remotePath}...`)); - await wmill.updateAppRaw({ - workspace, - path: remotePath, - formData: { - app: { - value, - path: remotePath, - summary: localApp.summary, - policy: appForPolicy.policy, - deployment_message: message, - ...(localApp.custom_path - ? { custom_path: localApp.custom_path } - : {}), + } else { + const { js, css } = await createBundleRaw(); + log.info(colors.bold.yellow(`Updating app ${remotePath}...`)); + await wmill.updateAppRaw({ + workspace, + path: remotePath, + formData: { + app: { + value, + path: remotePath, + summary: localApp.summary, + policy: appForPolicy.policy, + deployment_message: message, + ...(localApp.custom_path + ? { custom_path: localApp.custom_path } + : {}), + }, + js, + css, }, - js, - css, - }, - }); + }); + } } else { const { js, css } = await createBundleRaw(); await wmill.createAppRaw({ @@ -480,6 +495,16 @@ export async function pushRawApp( }, }); } + + // No refetch needed: folder perms are never merged into item.extra_perms, + // and the body sent to update_app_raw / create_app_raw omits the field. + await applyExtraPermsDiff( + workspace, + "raw_app", + remotePath, + localPerms, + (app as any)?.extra_perms, + ); } export async function generatingPolicy( diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index efd05c376a..7f437b8005 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -19,6 +19,7 @@ import { defaultFlowDefinition } from "../../../bootstrap/flow_bootstrap.ts"; import { SyncOptions, mergeConfigWithConfigFile } from "../../core/conf.ts"; import { FSFSElement, elementsToMap, ignoreF } from "../sync/sync.ts"; import { Flow } from "../../../gen/types.gen.ts"; +import { applyExtraPermsDiff } from "../../core/extra_perms.ts"; import type { PermissionedAsContext } from "../../core/permissioned_as.ts"; import { collectPathScriptPaths, @@ -41,6 +42,11 @@ export interface FlowFile { schema?: any; on_behalf_of_email?: string; has_on_behalf_of?: boolean; + // Mirrors granular ACLs on the flow path. Omitted from flow.yaml when no + // perms are set. The CLI applies diffs through /acls/add and /acls/remove + // (see applyExtraPermsDiff) — never through update_flow — so a perm-only + // change never bumps the flow version. + extra_perms?: Record; } function normalizeOptionalString(value: string | null | undefined): string | undefined { @@ -174,10 +180,14 @@ export async function pushFlow( await replaceInlineScripts([localFlow.value.preprocessor_module], fileReader, log, localPath, SEP, undefined, missingFiles); } if (missingFiles.length > 0) { - log.warn(colors.yellow( - `Warning: missing inline script file(s): ${missingFiles.join(", ")}. ` + - `The flow will be pushed with unresolved !inline references.` - )); + // Hard-fail rather than push the literal `!inline path` text as + // rawscript.content. That string would be persisted in flow_version.value + // and round-trip as the script body on the next pull, overwriting the + // user's local handler with the directive — see GIT-871 / #9140. + throw new Error( + `Cannot push flow ${remotePath}: missing inline script file(s): ${missingFiles.join(", ")}. ` + + `Either restore the file(s) or remove the !inline reference(s) from flow.yaml before pushing.` + ); } const hasOnBehalfOf = (localFlow as any).has_on_behalf_of ?? !!localFlow.on_behalf_of_email; @@ -193,22 +203,30 @@ export async function pushFlow( // On create: backend applies folder defaults — no client-side resolution needed } + // extra_perms is synced independently via /acls/* (see applyExtraPermsDiff) + // so a perm-only edit never bumps the flow version. Strip the field from the + // body that goes to update_flow / create_flow and treat it as a separate + // step both for the up-to-date short-circuit and after the deploy. + const { extra_perms: localPerms, ...localFlowBody } = localFlow as FlowFile & { + extra_perms?: Record; + }; + if (flow) { - if (isSuperset(localFlow, flow)) { + if (isSuperset(localFlowBody, flow)) { log.info(colors.green(`Flow ${remotePath} is up to date`)); - return; - } - log.info(colors.bold.yellow(`Updating flow ${remotePath}...`)); - await wmill.updateFlow({ - workspace: workspace, - path: remotePath.replaceAll(SEP, "/"), - requestBody: { + } else { + log.info(colors.bold.yellow(`Updating flow ${remotePath}...`)); + await wmill.updateFlow({ + workspace: workspace, path: remotePath.replaceAll(SEP, "/"), - deployment_message: message, - ...localFlow, - ...preserveFields, - }, - }); + requestBody: { + path: remotePath.replaceAll(SEP, "/"), + deployment_message: message, + ...localFlowBody, + ...preserveFields, + }, + }); + } } else { log.info(colors.bold.yellow("Creating new flow...")); try { @@ -217,7 +235,7 @@ export async function pushFlow( requestBody: { path: remotePath.replaceAll(SEP, "/"), deployment_message: message, - ...localFlow, + ...localFlowBody, ...preserveFields, }, }); @@ -228,6 +246,22 @@ export async function pushFlow( ); } } + + // Independent of whether the flow body changed, sync extra_perms via /acls/*. + // Self-contained log line + non-fatal failures. + // + // No refetch is needed: extra_perms is item-specific and additive on top of + // folder perms — folder perms are never merged onto item.extra_perms. And + // since the request body sent to update_flow / create_flow doesn't carry + // extra_perms, the value we read in the initial getFlowByPath above is + // also the post-write value (a no-op deploy can't drift it). + await applyExtraPermsDiff( + workspace, + "flow", + remotePath.replaceAll(SEP, "/"), + localPerms, + (flow as any)?.extra_perms, + ); } type Options = GlobalOptions; diff --git a/cli/src/commands/flow/flow_metadata.ts b/cli/src/commands/flow/flow_metadata.ts index f33ef07bcc..5c3ca578c7 100644 --- a/cli/src/commands/flow/flow_metadata.ts +++ b/cli/src/commands/flow/flow_metadata.ts @@ -266,19 +266,32 @@ export async function generateFlowLockInternal( return tree.isStale(treePath); }) : changedScripts; + const missingFiles: string[] = []; await replaceInlineScripts( flowValue.value.modules, fileReader, log, folder + SEP!, SEP, - locksToRemove + locksToRemove, + missingFiles ); if (flowValue.value.failure_module) { - await replaceInlineScripts([flowValue.value.failure_module], fileReader, log, folder + SEP!, SEP, locksToRemove); + await replaceInlineScripts([flowValue.value.failure_module], fileReader, log, folder + SEP!, SEP, locksToRemove, missingFiles); } if (flowValue.value.preprocessor_module) { - await replaceInlineScripts([flowValue.value.preprocessor_module], fileReader, log, folder + SEP!, SEP, locksToRemove); + await replaceInlineScripts([flowValue.value.preprocessor_module], fileReader, log, folder + SEP!, SEP, locksToRemove, missingFiles); + } + if (missingFiles.length > 0) { + // Abort before updateFlow rather than push the literal `!inline path` + // string as rawscript.content (GIT-871 / #9140). Note: at this point + // replaceInlineScripts has already mutated `flowValue.value` in place + // for the modules that *did* resolve. All current callers re-throw on + // this error; do not catch and reuse `flowValue` without re-parsing. + throw new Error( + `Cannot regenerate lock for flow ${remote_path}: missing inline script file(s): ${missingFiles.join(", ")}. ` + + `Either restore the file(s) or remove the !inline reference(s) from flow.yaml before retrying.` + ); } //removeChangedLocks @@ -304,18 +317,23 @@ export async function generateFlowLockInternal( const lockAssigner = newPathAssigner(opts.defaultTs ?? "bun", { skipInlineScriptSuffix: getNonDottedPaths(), }); + // flowValue.value here is the backend's response from updateFlow, so a + // rawscript whose content is `!inline ...` is corruption (GIT-871) — fail + // fast rather than writing the literal directive back to a script file. + const extractOpts = { skipInlineScriptSuffix: getNonDottedPaths(), failOnInlineDirective: true }; const inlineScripts = extractInlineScriptsForFlows( flowValue.value.modules, currentMapping, SEP, opts.defaultTs, - lockAssigner + lockAssigner, + extractOpts ); if (flowValue.value.failure_module) { - inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.failure_module], currentMapping, SEP, opts.defaultTs, lockAssigner)); + inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.failure_module], currentMapping, SEP, opts.defaultTs, lockAssigner, extractOpts)); } if (flowValue.value.preprocessor_module) { - inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.preprocessor_module], currentMapping, SEP, opts.defaultTs, lockAssigner)); + inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.preprocessor_module], currentMapping, SEP, opts.defaultTs, lockAssigner, extractOpts)); } inlineScripts.forEach((s) => { writeIfChanged(process.cwd() + SEP + folder + SEP + s.path, s.content); diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index f1e605d8ff..b9d9c6ee60 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -2,6 +2,7 @@ import { GlobalOptions } from "../../types.ts"; import { requireLogin } from "../../core/auth.ts"; import { resolveWorkspace, validatePath } from "../../core/context.ts"; import type { PermissionedAsContext } from "../../core/permissioned_as.ts"; +import { applyExtraPermsDiff } from "../../core/extra_perms.ts"; import { writeFile, stat, mkdir } from "node:fs/promises"; import { Buffer } from "node:buffer"; import { colors } from "@cliffy/ansi/colors"; @@ -83,6 +84,11 @@ export interface ScriptFile { is_template?: boolean; lock?: Array; kind?: "script" | "failure" | "trigger" | "command" | "approval"; + // Mirrors granular ACLs on the script path. Omitted from .script.yaml when + // no perms are set. The CLI applies diffs through /acls/add and /acls/remove + // (see applyExtraPermsDiff) — never through create_script — so a perm-only + // change never bumps the script hash/version. + extra_perms?: Record; } /** @@ -542,6 +548,15 @@ export async function handleFile( deepEqual(modules ?? null, remote.modules ?? null)) ) { log.info(colors.green(`Script ${remotePath} is up to date`)); + // Even when the body is unchanged, perms may still drift — sync them + // independently before returning. + await applyExtraPermsDiff( + workspaceId, + "script", + remotePath.replaceAll(SEP, "/"), + (typed as any)?.extra_perms, + (remote as any)?.extra_perms, + ); return true; } } @@ -581,6 +596,27 @@ export async function handleFile( ) ); } + + // Sync granular ACLs as an independent step — perm-only edits never reach + // create_script (which would bump the script hash) and instead route + // through /acls/* via applyExtraPermsDiff. + // + // No refetch is needed: + // - folder perms are additive at auth time, never merged onto item rows; + // - the body sent to create_script doesn't carry extra_perms, so a fresh + // deploy of an existing path inherits the previous version's perms + // unchanged. The diff against `remote` (captured before the deploy) + // therefore matches what `wmill acl remove` would do — and the granular + // ACL endpoint updates every matching row, so the inheritance on the + // new version doesn't leave ghost entries. + await applyExtraPermsDiff( + workspaceId, + "script", + remotePath.replaceAll(SEP, "/"), + (typed as any)?.extra_perms, + (remote as any)?.extra_perms, + ); + return true; } return false; diff --git a/cli/src/commands/sync/pull.ts b/cli/src/commands/sync/pull.ts index b89d81b91c..adfd486294 100644 --- a/cli/src/commands/sync/pull.ts +++ b/cli/src/commands/sync/pull.ts @@ -98,12 +98,16 @@ export async function downloadZip( } const includeWorkspaceDependenciesValue = !(skipWorkspaceDependencies ?? false); + // `preserve_extra_perms=true` opts the tarball into surfacing granular ACLs + // on flow / script / app rows. Default-off on the server protects cross- + // workspace tarball imports from carrying ACLs that reference identities + // missing in the target workspace; the CLI sync flow explicitly wants them. const baseParams = `&plain_secret=${plainSecrets ?? false }&skip_variables=${skipVariables ?? false}&skip_resources=${skipResources ?? false }&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_key=${includeKey ?? false}&include_workspace_dependencies=${includeWorkspaceDependenciesValue}&default_ts=${defaultTs ?? "bun"}&skip_resource_types=${skipResourceTypes ?? false}&settings_version=v2`; + }&include_key=${includeKey ?? false}&include_workspace_dependencies=${includeWorkspaceDependenciesValue}&default_ts=${defaultTs ?? "bun"}&skip_resource_types=${skipResourceTypes ?? false}&settings_version=v2&preserve_extra_perms=true`; 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 6cd3545c5d..215a0d3120 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -950,7 +950,7 @@ function ZipFSElement( SEP, defaultTs, assigner, - { skipInlineScriptSuffix: getNonDottedPaths() }, + { skipInlineScriptSuffix: getNonDottedPaths(), failOnInlineDirective: true }, ); if (flow.value.failure_module) { inlineScripts.push(...extractInlineScriptsForFlows( @@ -959,7 +959,7 @@ function ZipFSElement( SEP, defaultTs, assigner, - { skipInlineScriptSuffix: getNonDottedPaths() }, + { skipInlineScriptSuffix: getNonDottedPaths(), failOnInlineDirective: true }, )); } if (flow.value.preprocessor_module) { @@ -969,7 +969,7 @@ function ZipFSElement( SEP, defaultTs, assigner, - { skipInlineScriptSuffix: getNonDottedPaths() }, + { skipInlineScriptSuffix: getNonDottedPaths(), failOnInlineDirective: true }, )); } } catch (error) { diff --git a/cli/src/core/extra_perms.ts b/cli/src/core/extra_perms.ts new file mode 100644 index 0000000000..7b50d34c8d --- /dev/null +++ b/cli/src/core/extra_perms.ts @@ -0,0 +1,192 @@ +import * as wmill from "../../gen/services.gen.ts"; +import * as log from "./log.ts"; +import { colors } from "@cliffy/ansi/colors"; +import type { AddGranularAclsData } from "../../gen/types.gen.ts"; + +export type ExtraPermsKind = AddGranularAclsData["kind"]; + +type PermsMap = Record; + +type Normalized = { + perms: PermsMap; + /** Keys with non-boolean values: never revoked, never granted — treat as "no opinion". */ + invalidOwners: Set; + /** Top-level value is not a plain object (array, primitive, etc.). The + * entire map is rejected and the caller must treat it like "no opinion" + * to avoid silently revoking every remote ACL. */ + malformedTop: boolean; +}; + +function normalize(value: unknown, source: string): Normalized { + const perms: PermsMap = {}; + const invalidOwners = new Set(); + + if ( + value === null || + value === undefined || + typeof value !== "object" || + Array.isArray(value) + ) { + if (value !== undefined && value !== null) { + log.error( + colors.red( + `extra_perms: ${source} is not a {owner: boolean} map — skipping ACL sync to avoid clobbering remote ACLs`, + ), + ); + return { perms, invalidOwners, malformedTop: true }; + } + return { perms, invalidOwners, malformedTop: false }; + } + + const invalidList: string[] = []; + for (const [k, v] of Object.entries(value as Record)) { + if (typeof v === "boolean") { + perms[k] = v; + } else { + invalidOwners.add(k); + invalidList.push(k); + } + } + if (invalidList.length > 0) { + log.error( + colors.red( + `extra_perms: ${invalidList.length} invalid entry/entries in ${source} (non-boolean value, treating as "no opinion"): ${invalidList.join(", ")}`, + ), + ); + } + return { perms, invalidOwners, malformedTop: false }; +} + +function formatError(e: unknown): string { + if (!e || typeof e !== "object") return String(e); + const anyE = e as { body?: unknown; message?: unknown }; + if (anyE.body !== undefined) { + if (typeof anyE.body === "string") return anyE.body; + try { + return JSON.stringify(anyE.body); + } catch { + // fall through + } + } + if (typeof anyE.message === "string") return anyE.message; + try { + return JSON.stringify(e); + } catch { + return String(e); + } +} + +/** + * Apply the diff between `local` and `remote` granular ACL maps as a sequence + * of `/acls/add` and `/acls/remove` calls. Used by every CLI push path so a + * yaml change that *only* touches `extra_perms` never bumps the script/flow/app + * version — perm mutations route through the dedicated granular-ACL endpoints + * exactly as if the user had clicked through the UI. + * + * **`local === undefined` means "no opinion".** If the yaml does not carry an + * `extra_perms` field at all, this function is a no-op — the remote ACLs are + * left untouched. This is what prevents a stale local checkout from racing + * a concurrent UI grant: only users who explicitly track perms in source (by + * writing `extra_perms:` in the yaml, even as `{}`) get destructive sync. + * + * The function is intentionally independent of the surrounding push logic: + * it has its own log lines and its own non-fatal failure mode. Each grant / + * revoke is logged as a separate line on success; failures are logged in red + * but never throw — a stale yaml referencing a deleted user/group surfaces as + * a red error, not a hard error that would block the surrounding deploy. + * + * @returns number of /acls/* calls actually issued (0 means perms in sync, or + * the local yaml had no `extra_perms` field). + */ +export async function applyExtraPermsDiff( + workspace: string, + kind: ExtraPermsKind, + path: string, + local: unknown, + remote: unknown, +): Promise { + // Absent local field = "no opinion" — never call /acls/* in this case. + // Crucially, this protects users who don't track ACLs in source from having + // their UI-managed perms silently revoked by a stale CLI push. + if (local === undefined || local === null) { + return 0; + } + + const localN = normalize(local, "local yaml"); + // Top-level malformed (array, primitive, etc.) → treat as "no opinion" so a + // typo in yaml can never silently revoke every remote ACL. + if (localN.malformedTop) { + return 0; + } + const remoteN = normalize(remote, "remote response"); + + const localPerms = localN.perms; + const remotePerms = remoteN.perms; + + const toGrant: Array<[string, boolean]> = []; + for (const [owner, write] of Object.entries(localPerms)) { + if (!(owner in remotePerms) || remotePerms[owner] !== write) { + toGrant.push([owner, write]); + } + } + + // Owners with a malformed value in local yaml are treated as "no opinion": + // they are excluded from the revoke set so a typo (`g/devs: "write"`) never + // silently strips an existing ACL. + const toRevoke: string[] = Object.keys(remotePerms).filter( + (owner) => + !(owner in localPerms) && !localN.invalidOwners.has(owner), + ); + + if (toGrant.length === 0 && toRevoke.length === 0) { + return 0; + } + + let calls = 0; + for (const [owner, write] of toGrant) { + const access = write ? "write" : "read"; + try { + await wmill.addGranularAcls({ + workspace, + kind, + path, + requestBody: { owner, write }, + }); + log.info( + colors.green( + ` extra_perms: granted ${access} to ${owner} on ${kind}/${path}`, + ), + ); + calls += 1; + } catch (e: any) { + log.error( + colors.red( + ` extra_perms: failed to grant ${access} to ${owner} on ${kind}/${path}: ${formatError(e)}`, + ), + ); + } + } + + for (const owner of toRevoke) { + try { + await wmill.removeGranularAcls({ + workspace, + kind, + path, + requestBody: { owner }, + }); + log.info( + colors.green(` extra_perms: revoked ${owner} on ${kind}/${path}`), + ); + calls += 1; + } catch (e: any) { + log.error( + colors.red( + ` extra_perms: failed to revoke ${owner} on ${kind}/${path}: ${formatError(e)}`, + ), + ); + } + } + + return calls; +} diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 656f24e5a9..2b0962e09d 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -5426,9 +5426,9 @@ name: raw-app description: MUST use when creating raw apps. --- -# Windmill Raw Apps +# Windmill Raw Apps — CLI workflow -Raw apps let you build custom frontends with React, Svelte, or Vue that connect to Windmill backend runnables and datatables. +This guide covers raw apps from the terminal: scaffolding via \`wmill app new\`, the on-disk layout, and the file-based conventions the CLI uses to represent backend runnables and data table configuration. The platform shape (how a raw app behaves at runtime — frontend bundling, runnable types, datatable SDK calls) is covered in the companion authoring guide. ## Creating a Raw App @@ -5497,7 +5497,7 @@ wmill app new This is the wizard. It only works when run by a human in a real terminal. Don't call it this way from an agent. -## App Structure +## On-disk app layout \`\`\` my_app{{RAW_APP_SUFFIX}}/ @@ -5517,11 +5517,7 @@ my_app{{RAW_APP_SUFFIX}}/ └── *.sql # SQL files to apply via dev server \`\`\` -## Backend Runnables - -Backend runnables are server-side scripts that your frontend can call. They live in the \`backend/\` folder. - -### Creating a Backend Runnable +## Backend runnables on disk Add a code file to the \`backend/\` folder: @@ -5531,7 +5527,7 @@ backend/. The runnable ID is the filename without extension. For example, \`get_user.ts\` creates a runnable with ID \`get_user\`. -### Supported Languages +### Supported languages (extension-driven) | Language | Extension | Example | |------------------|--------------|------------------| @@ -5553,27 +5549,14 @@ The runnable ID is the filename without extension. For example, \`get_user.ts\` | C# | \`.cs\` | \`myFunc.cs\` | | Java | \`.java\` | \`myFunc.java\` | -### Example Backend Runnable - -**backend/get_user.ts:** -\`\`\`typescript -import * as wmill from 'windmill-client'; - -export async function main(user_id: string) { - const sql = wmill.datatable(); - const user = await sql\`SELECT * FROM users WHERE id = \${user_id}\`.fetchOne(); - return user; -} -\`\`\` - -After creating, tell the user they can generate lock files by running: +After creating a runnable, tell the user they can generate lock files by running: \`\`\`bash wmill generate-metadata \`\`\` -### Optional YAML Configuration +### Optional YAML configuration -Add a \`.yaml\` file to configure fields or static values: +Add a \`.yaml\` file alongside the code to configure fields or static values: **backend/get_user.yaml:** \`\`\`yaml @@ -5584,7 +5567,7 @@ fields: value: "default_user" \`\`\` -### Referencing Existing Scripts +### Referencing existing scripts To use an existing Windmill script instead of inline code: @@ -5600,32 +5583,9 @@ type: flow path: f/my_folder/my_flow \`\`\` -### Calling Backend from Frontend +## Data tables — \`raw_app.yaml\` config -Import from the auto-generated \`wmill.ts\`: - -\`\`\`typescript -import { backend } from './wmill'; - -// Call a backend runnable -const user = await backend.get_user({ user_id: '123' }); -\`\`\` - -The \`wmill.ts\` file provides type-safe access to all backend runnables. - -## Data Tables - -Raw apps can query Windmill datatables (PostgreSQL databases managed by Windmill). - -### Critical Rules - -1. **ONLY USE WHITELISTED TABLES**: You can ONLY query tables listed in \`raw_app.yaml\` → \`data.tables\`. Tables not in this list are NOT accessible. - -2. **ADD TABLES BEFORE USING**: To use a new table, first add it to \`data.tables\` in \`raw_app.yaml\`. - -3. **USE CONFIGURED DATATABLE/SCHEMA**: Check the app's \`raw_app.yaml\` for the default datatable and schema. - -### Configuration in raw_app.yaml +The \`data\` block in \`raw_app.yaml\` controls which tables the app can query. \`\`\`yaml data: @@ -5637,9 +5597,153 @@ data: \`\`\` **Table reference formats:** -- \`\` - All tables in the datatable -- \`/\` - Specific table in public schema -- \`/:
\` - Table in specific schema +- \`\` — All tables in the datatable +- \`/
\` — Specific table in public schema +- \`/:
\` — Table in specific schema + +## SQL Migrations (sql_to_apply/) + +The \`sql_to_apply/\` folder is for creating/modifying database tables during development. + +### Workflow + +1. Create \`.sql\` files in \`sql_to_apply/\` +2. Run \`wmill app dev\` — the dev server watches this folder +3. When SQL files change, a modal appears in the browser to confirm execution +4. After creating tables, **add them to \`data.tables\`** in \`raw_app.yaml\` + +### Example migration + +**sql_to_apply/001_create_users.sql:** +\`\`\`sql +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + name TEXT, + created_at TIMESTAMP DEFAULT NOW() +); +\`\`\` + +After applying, add to \`raw_app.yaml\`: +\`\`\`yaml +data: + tables: + - main/users +\`\`\` + +### Migration best practices + +- **Use idempotent SQL**: \`CREATE TABLE IF NOT EXISTS\`, etc. +- **Number files**: \`001_\`, \`002_\` for ordering +- **Always whitelist tables** after creation +- This folder is NOT synced — it's for local development only + +## CLI Commands + +\`wmill app new\` is the exception: you run it yourself, with flags, per the "Creating a Raw App" section above. + +For everything else, tell the user which command fits their intent and let them run it — these touch the workspace or local lock files, and the user should consent each time: + +| Command | Description | +|---------|-------------| +| \`wmill app dev\` | Start dev server with live reload (see the \`preview\` skill for the full open-the-app-in-the-IDE-pane procedure). | +| \`wmill app generate-agents\` | Refresh AGENTS.md and DATATABLES.md | +| \`wmill generate-metadata\` | Generate lock files for backend runnables | +| \`wmill sync push\` | Deploy app to Windmill | +| \`wmill sync pull\` | Pull latest from Windmill | + + + +# Windmill Raw Apps + +Raw apps let you build custom frontends with React, Svelte, or Vue that connect to Windmill backend runnables and datatables. + +## App shape + +A raw app has three logical parts: + +- **Frontend** — bundled with esbuild from \`index.tsx\` as the entrypoint. Files include the entrypoint, components (\`App.tsx\`), styles, etc. +- **Backend runnables** — server-side scripts the frontend calls, each addressed by a unique key. +- **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. + +## Frontend + +### Entrypoint + +\`index.tsx\` is the bundling entrypoint. It typically renders a top-level \`App\` component. The bundler is esbuild. + +### Generated bindings (\`wmill.d.ts\` / \`wmill.ts\`) + +The 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. + +### Calling backend runnables + +Import the generated bindings and call the runnable like a function: + +\`\`\`typescript +import { backend } from './wmill'; + +// Call a backend runnable +const user = await backend.get_user({ user_id: '123' }); +\`\`\` + +The frontend cannot reach datatables, workspace items, or external services on its own — it goes through \`backend.(args)\` for everything server-side. + +## Backend runnables + +Each runnable has a unique key (used to call it from the frontend) and one of four types: + +| Type | What it is | +|---|---| +| \`inline\` | Custom code stored on the app itself. Most common for app-specific logic. | +| \`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. | + +### Inline runnables + +Inline runnables carry their own source code. For file-based raw apps, the runnable language is determined by the backend file extension. The script must expose a \`main\` function as its entrypoint. + +**TypeScript example** (\`backend/get_user.ts\`): + +\`\`\`typescript +import * as wmill from 'windmill-client'; + +export async function main(user_id: string) { + const sql = wmill.datatable(); + const user = await sql\`SELECT * FROM users WHERE id = \${user_id}\`.fetchOne(); + return user; +} +\`\`\` + +**Python example** (\`backend/get_user.py\`): + +\`\`\`python +import wmill + +def main(user_id: str): + db = wmill.datatable() + user = db.query('SELECT * FROM users WHERE id = $1', user_id).fetch_one() + return user +\`\`\` + +### Path runnables (script / flow / hubscript) + +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. + +### Static inputs + +\`staticInputs\` is an optional \`Record\` for arguments not overridable from the frontend. Useful with path runnables to pre-fill some args while leaving the rest to the frontend caller. + +## Data Tables + +Data tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the \`wmill\` client; the frontend never queries them directly. + +### Critical rules + +1. **Whitelisted tables only**: a runnable can only query tables listed in the app's \`data.tables\` config. Tables not in this list are not accessible. +2. **Add tables before using**: queries against unlisted tables fail at runtime. When you introduce a new table, register it in \`data.tables\` first. +3. **Use the configured datatable/schema**: the app's \`data\` config sets the default datatable and schema; reference them consistently across runnables. ### Querying in TypeScript (Bun/Deno) @@ -5680,65 +5784,13 @@ def main(user_id: str): return user \`\`\` -## SQL Migrations (sql_to_apply/) - -The \`sql_to_apply/\` folder is for creating/modifying database tables during development. - -### Workflow - -1. Create \`.sql\` files in \`sql_to_apply/\` -2. Run \`wmill app dev\` - the dev server watches this folder -3. When SQL files change, a modal appears in the browser to confirm execution -4. After creating tables, **add them to \`data.tables\`** in \`raw_app.yaml\` - -### Example Migration - -**sql_to_apply/001_create_users.sql:** -\`\`\`sql -CREATE TABLE IF NOT EXISTS users ( - id SERIAL PRIMARY KEY, - email TEXT NOT NULL UNIQUE, - name TEXT, - created_at TIMESTAMP DEFAULT NOW() -); -\`\`\` - -After applying, add to \`raw_app.yaml\`: -\`\`\`yaml -data: - tables: - - main/users -\`\`\` - -### Migration Best Practices - -- **Use idempotent SQL**: \`CREATE TABLE IF NOT EXISTS\`, etc. -- **Number files**: \`001_\`, \`002_\` for ordering -- **Always whitelist tables** after creation -- This folder is NOT synced - it's for local development only - -## CLI Commands - -\`wmill app new\` is the exception: you run it yourself, with flags, per the "Creating a Raw App" section above. - -For everything else, tell the user which command fits their intent and let them run it — these touch the workspace or local lock files, and the user should consent each time: - -| Command | Description | -|---------|-------------| -| \`wmill app dev\` | Start dev server with live reload (see the \`preview\` skill for the full open-the-app-in-the-IDE-pane procedure). | -| \`wmill app generate-agents\` | Refresh AGENTS.md and DATATABLES.md | -| \`wmill generate-metadata\` | Generate lock files for backend runnables | -| \`wmill sync push\` | Deploy app to Windmill | -| \`wmill sync pull\` | Pull latest from Windmill | - ## Best Practices -1. **Check DATATABLES.md** for existing tables before creating new ones -2. **Use parameterized queries** - never concatenate user input into SQL -3. **Keep runnables focused** - one function per file -4. **Use descriptive IDs** - \`get_user.ts\` not \`a.ts\` -5. **Always whitelist tables** - add to \`data.tables\` before querying -6. **Generate locks** - tell the user to run \`wmill generate-metadata\` after adding/modifying backend runnables +1. **Check existing tables** before creating new ones — reuse beats schema growth. +2. **Use parameterized queries** — never concatenate user input into SQL. +3. **Keep runnables focused** — one function per runnable; small surface area. +4. **Use descriptive keys** — \`get_user\`, not \`a\`. +5. **Always whitelist tables** — adding a runnable that queries a new table requires the table to be in \`data.tables\` first. `, "triggers": `--- name: triggers diff --git a/cli/src/main.ts b/cli/src/main.ts index 372087c69a..24772ad250 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -79,7 +79,7 @@ export { token, }; -export const VERSION = "1.699.0"; +export const VERSION = "1.702.1"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/cli/test/inline_scripts_failure_preprocessor_unit.test.ts b/cli/test/inline_scripts_failure_preprocessor_unit.test.ts index 78af37a02e..a5d1298f15 100644 --- a/cli/test/inline_scripts_failure_preprocessor_unit.test.ts +++ b/cli/test/inline_scripts_failure_preprocessor_unit.test.ts @@ -574,3 +574,68 @@ describe("extractInlineScripts with mapping preserves file paths", () => { expect(lockScript!.path).toBe("my.inline_script.lock"); }); }); + +// --------------------------------------------------------------------------- +// failOnInlineDirective option (GIT-871 / #9140) +// --------------------------------------------------------------------------- + +describe("failOnInlineDirective option", () => { + test("default behavior: yaml-parsed module with !inline content extracts without throwing", () => { + // Simulates flow_metadata / dev callers: yaml-parsed local flow whose + // rawscript.content is the literal `!inline foo.ts` directive (the + // legitimate on-disk shape after extraction). + const mod = makeRawscriptModule("a", "!inline a.inline_script.ts", "bun"); + expect(() => + extractInlineScripts([mod], {}, "/", "bun"), + ).not.toThrow(); + }); + + test("yaml-parsed !inline content round-trips as the script's body", () => { + const mod = makeRawscriptModule("a", "!inline a.inline_script.ts", "bun"); + const scripts = extractInlineScripts([mod], {}, "/", "bun"); + const script = scripts.find((s) => !s.is_lock); + expect(script).toBeDefined(); + expect(script!.content).toBe("!inline a.inline_script.ts"); + }); + + test("opt-in: failOnInlineDirective=true throws on !inline content", () => { + // Simulates the sync-pull call site: rawscript came from the backend's + // flow_version.value, so `!inline ...` content means the row is corrupt. + const mod = makeRawscriptModule("failure", "!inline Handle_error.ts", "bun"); + expect(() => + extractInlineScripts([mod], {}, "/", "bun", undefined, { + failOnInlineDirective: true, + }), + ).toThrow(/corrupted inline script/); + }); + + test("opt-in: real script content still extracts cleanly", () => { + const mod = makeRawscriptModule( + "failure", + 'export function main() { return 1; }', + "bun", + ); + expect(() => + extractInlineScripts([mod], {}, "/", "bun", undefined, { + failOnInlineDirective: true, + }), + ).not.toThrow(); + }); + + test("opt-in: throws for nested rawscript inside branchall", () => { + const inner = makeRawscriptModule("inner", "!inline poisoned.ts", "bun"); + const outer: FlowModule = { + id: "branch", + value: { + type: "branchall" as const, + branches: [{ summary: "b1", expr: "true", modules: [inner], skip_failure: false, parallel: false }], + parallel: false, + }, + }; + expect(() => + extractInlineScripts([outer], {}, "/", "bun", undefined, { + failOnInlineDirective: true, + }), + ).toThrow(/corrupted inline script/); + }); +}); diff --git a/cli/test/sync_pull_push.test.ts b/cli/test/sync_pull_push.test.ts index e31c4e1084..4b5b8e5389 100644 --- a/cli/test/sync_pull_push.test.ts +++ b/cli/test/sync_pull_push.test.ts @@ -111,6 +111,10 @@ kind: script function createFlowFixture(name: string): Record { const flowSuffix = getFolderSuffix("flow"); const metadataFile = getMetadataFileName("flow", "yaml"); + // !inline paths are resolved relative to the flow folder (see + // pushFlow's fileReader in cli/src/commands/flow/flow.ts), so the + // path inside the directive must NOT include the flow folder prefix. + const scriptFile = "a.ts"; return { metadata: { @@ -122,7 +126,7 @@ value: - id: a value: type: rawscript - content: "!inline ${name}${flowSuffix}/a.ts" + content: "!inline ${scriptFile}" language: bun input_transforms: {} schema: @@ -133,7 +137,7 @@ schema: `, }, inlineScript: { - path: `${name}${flowSuffix}/a.ts`, + path: `${name}${flowSuffix}/${scriptFile}`, content: `export async function main() {\n return "Hello from flow ${name}";\n}`, }, }; diff --git a/cli/windmill-utils-internal/src/inline-scripts/extractor.ts b/cli/windmill-utils-internal/src/inline-scripts/extractor.ts index e472372a99..f556b32c57 100644 --- a/cli/windmill-utils-internal/src/inline-scripts/extractor.ts +++ b/cli/windmill-utils-internal/src/inline-scripts/extractor.ts @@ -20,13 +20,28 @@ function extractRawscriptInline( rawscript: RawScript, mapping: Record, separator: string, - assigner: PathAssigner + assigner: PathAssigner, + failOnInlineDirective: boolean ): InlineScript[] { const [basePath, ext] = assigner.assignPath(summary ?? id, rawscript.language); const mappedPath = mapping[id]; const path = mappedPath ?? basePath + ext; const language = rawscript.language; const content = rawscript.content; + // Opt-in defensive guard: when extracting from backend-shaped data (i.e. + // sync pull), a rawscript whose content is itself an `!inline ...` directive + // means the backend was poisoned by a prior push that sent the unresolved + // directive as the script body (GIT-871 / #9140). Refuse to write it back + // to disk. Off by default because callers that operate on YAML-parsed local + // flows (flow_metadata, dev) legitimately see `!inline foo.ts` as content. + if (failOnInlineDirective && typeof content === "string" && content.startsWith("!inline ")) { + throw new Error( + `Refusing to extract corrupted inline script for module '${id}': ` + + `rawscript.content is the literal string \`${content.split("\n")[0]}\` ` + + `instead of script source. The backend's flow_version.value is corrupt — ` + + `re-push from a known-good local copy to repair it.` + ); + } const r = [{ path: path, content: content, language, is_lock: false}]; rawscript.content = "!inline " + path.replaceAll(separator, "/"); const lock = rawscript.lock; @@ -50,6 +65,15 @@ function extractRawscriptInline( export interface ExtractInlineScriptsOptions { /** When true, skip the .inline_script. suffix in file names */ skipInlineScriptSuffix?: boolean; + /** + * When true, throw if a `rawscript.content` is itself an `!inline ...` + * directive. Set this only at the sync-pull call site, where the input + * comes from the backend's `flow_version.value` and `!inline ...` content + * means the row is corrupt (GIT-871 / #9140). Leave off for callers that + * pass YAML-parsed local flows — the directive is the legitimate on-disk + * shape there. + */ + failOnInlineDirective?: boolean; } /** @@ -74,6 +98,7 @@ export function extractInlineScripts( ): InlineScript[] { // Create pathAssigner only if not provided (top-level call), but reuse it for nested calls const assigner = pathAssigner ?? newPathAssigner(defaultTs ?? "bun", { skipInlineScriptSuffix: options?.skipInlineScriptSuffix }); + const failOnInlineDirective = options?.failOnInlineDirective ?? false; return modules.flatMap((m) => { if (m.value.type == "rawscript") { @@ -83,7 +108,8 @@ export function extractInlineScripts( m.value, mapping, separator, - assigner + assigner, + failOnInlineDirective ); } else if (m.value.type == "forloopflow") { return extractInlineScripts( @@ -91,11 +117,12 @@ export function extractInlineScripts( mapping, separator, defaultTs, - assigner + assigner, + options ); } else if (m.value.type == "branchall") { return m.value.branches.flatMap((b) => - extractInlineScripts(b.modules, mapping, separator, defaultTs, assigner) + extractInlineScripts(b.modules, mapping, separator, defaultTs, assigner, options) ); } else if (m.value.type == "whileloopflow") { return extractInlineScripts( @@ -103,7 +130,8 @@ export function extractInlineScripts( mapping, separator, defaultTs, - assigner + assigner, + options ); } else if (m.value.type == "branchone") { return [ @@ -113,7 +141,8 @@ export function extractInlineScripts( mapping, separator, defaultTs, - assigner + assigner, + options ) ), ...extractInlineScripts( @@ -121,7 +150,8 @@ export function extractInlineScripts( mapping, separator, defaultTs, - assigner + assigner, + options ), ]; } else if (m.value.type == "aiagent") { @@ -138,7 +168,8 @@ export function extractInlineScripts( toolValue, mapping, separator, - assigner + assigner, + failOnInlineDirective ); }); } else { diff --git a/docs/windmill-ai-refactor-plan.md b/docs/windmill-ai-refactor-plan.md index 78b82ddcc6..0e8449d897 100644 --- a/docs/windmill-ai-refactor-plan.md +++ b/docs/windmill-ai-refactor-plan.md @@ -142,7 +142,7 @@ Move from `windmill-worker/src/ai/sse.rs` to `windmill-ai/src/sse.rs`: --- -### Step 5: Move provider implementations to windmill-ai +### Step 5: Move provider implementations to windmill-ai ✅ Move from `windmill-worker/src/ai/providers/` to `windmill-ai/src/providers/`: - `anthropic.rs` — `AnthropicQueryBuilder` @@ -159,7 +159,7 @@ Move utility functions providers depend on: --- -### Step 6: Move image_handler to windmill-ai +### Step 6: Move image_handler to windmill-ai ✅ Move from `windmill-worker/src/ai/image_handler.rs` to `windmill-ai/src/image_handler.rs`: - `download_and_encode_s3_image` — no signature change needed diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0ce5dc49ef..fd6d47790d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.699.0", + "version": "1.702.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.699.0", + "version": "1.702.1", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -149,7 +149,7 @@ "tar": "^7.5.4", "tslib": "^2.6.1", "typescript": "^5.5.0", - "vite": "^8.0.0", + "vite": "^8.0.13", "vite-plugin-mkcert": "^2.0.0", "vitest": "^4.1.0", "vitest-browser-svelte": "^2.0.1" @@ -841,22 +841,20 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", - "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", - "dev": true, + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", - "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", - "dev": true, + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "license": "MIT", "optional": true, "dependencies": { @@ -864,10 +862,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", - "dev": true, + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "license": "MIT", "optional": true, "dependencies": { @@ -1354,20 +1351,21 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", - "dev": true, + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@noble/hashes": { @@ -1421,20 +1419,10 @@ "node": ">= 8" } }, - "node_modules/@oxc-project/runtime": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz", - "integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@oxc-project/types": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", - "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", + "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", "devOptional": true, "license": "MIT", "funding": { @@ -1508,13 +1496,12 @@ "license": "SEE LICENSE IN LICENSE" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", + "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1525,13 +1512,12 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1542,13 +1528,12 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz", - "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", + "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1559,13 +1544,12 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz", - "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1576,13 +1560,12 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz", - "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1593,13 +1576,15 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", "cpu": [ "arm64" ], - "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1610,13 +1595,15 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz", - "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", "cpu": [ "arm64" ], - "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1627,13 +1614,15 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", "cpu": [ "ppc64" ], - "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1644,13 +1633,15 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", "cpu": [ "s390x" ], - "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1661,13 +1652,15 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", "cpu": [ "x64" ], - "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1678,13 +1671,15 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz", - "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", "cpu": [ "x64" ], - "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1695,13 +1690,12 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", + "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1712,30 +1706,30 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz", - "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", + "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz", - "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1746,13 +1740,12 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz", - "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1763,9 +1756,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", - "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "devOptional": true, "license": "MIT" }, @@ -2055,10 +2048,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "license": "MIT", "optional": true, "dependencies": { @@ -6834,7 +6826,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -7333,7 +7325,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7354,7 +7345,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7375,7 +7365,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7396,7 +7385,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7417,7 +7405,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7438,7 +7425,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7459,7 +7445,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7480,7 +7465,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7501,7 +7485,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7522,7 +7505,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7543,7 +7525,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9662,9 +9643,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "devOptional": true, "funding": [ { @@ -11052,14 +11033,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", - "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", + "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.115.0", - "@rolldown/pluginutils": "1.0.0-rc.9" + "@oxc-project/types": "=0.130.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -11068,21 +11049,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.9", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", - "@rolldown/binding-darwin-x64": "1.0.0-rc.9", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" + "@rolldown/binding-android-arm64": "1.0.1", + "@rolldown/binding-darwin-arm64": "1.0.1", + "@rolldown/binding-darwin-x64": "1.0.1", + "@rolldown/binding-freebsd-x64": "1.0.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", + "@rolldown/binding-linux-arm64-gnu": "1.0.1", + "@rolldown/binding-linux-arm64-musl": "1.0.1", + "@rolldown/binding-linux-ppc64-gnu": "1.0.1", + "@rolldown/binding-linux-s390x-gnu": "1.0.1", + "@rolldown/binding-linux-x64-gnu": "1.0.1", + "@rolldown/binding-linux-x64-musl": "1.0.1", + "@rolldown/binding-openharmony-arm64": "1.0.1", + "@rolldown/binding-wasm32-wasi": "1.0.1", + "@rolldown/binding-win32-arm64-msvc": "1.0.1", + "@rolldown/binding-win32-x64-msvc": "1.0.1" } }, "node_modules/run-parallel": { @@ -12112,21 +12093,6 @@ } } }, - "node_modules/svelte-check/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/svelte-eslint-parser": { "version": "0.43.0", "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", @@ -12648,14 +12614,14 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "devOptional": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -12683,9 +12649,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "devOptional": true, "license": "MIT", "engines": { @@ -12857,7 +12823,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -13131,18 +13097,17 @@ } }, "node_modules/vite": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz", - "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", + "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", "devOptional": true, "license": "MIT", "dependencies": { - "@oxc-project/runtime": "0.115.0", "lightningcss": "^1.32.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.9", - "tinyglobby": "^0.2.15" + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.1", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" @@ -13158,8 +13123,8 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.0.0-alpha.31", - "esbuild": "^0.27.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", @@ -13241,9 +13206,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "devOptional": true, "license": "MIT", "engines": { diff --git a/frontend/package.json b/frontend/package.json index 82c7ad8fa9..5dbb1d4a3c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.699.0", + "version": "1.702.1", "scripts": { "dev": "vite dev", "build": "vite build", @@ -70,7 +70,7 @@ "tar": "^7.5.4", "tslib": "^2.6.1", "typescript": "^5.5.0", - "vite": "^8.0.0", + "vite": "^8.0.13", "vite-plugin-mkcert": "^2.0.0", "vitest": "^4.1.0", "vitest-browser-svelte": "^2.0.1" diff --git a/frontend/src/lib/components/BreadcrumbSegment.svelte b/frontend/src/lib/components/BreadcrumbSegment.svelte new file mode 100644 index 0000000000..8b18503f75 --- /dev/null +++ b/frontend/src/lib/components/BreadcrumbSegment.svelte @@ -0,0 +1,76 @@ + + + + + {#snippet trigger()} + {#if withChevron}{label}{:else}{label}{/if} + {/snippet} + {#snippet content()} + + {/snippet} + diff --git a/frontend/src/lib/components/DisplayResult.svelte b/frontend/src/lib/components/DisplayResult.svelte index ff4196443d..8ac06662d8 100644 --- a/frontend/src/lib/components/DisplayResult.svelte +++ b/frontend/src/lib/components/DisplayResult.svelte @@ -187,11 +187,6 @@ : `data:text/json;charset=utf-8,${encodeURIComponent(toJsonStr(result))}` ) let resultDownloadName = $derived(`${filename ?? 'result'}.json`) - async function onResultDownload(e: MouseEvent) { - if (!resultApiPath || !shouldDownloadViaClient()) return - e.preventDefault() - await downloadViaClient(resultApiPath, resultDownloadName) - } function checkIfS3(result: any, keys: string[]) { return keys.includes('s3') && typeof result.s3 === 'string' @@ -1020,13 +1015,17 @@ {:else} {#if largeObject}
- Download {filename ? '' : 'as JSON'} - + >{#if resultApiPath && shouldDownloadViaClient()} + + {:else} + + Download {filename ? '' : 'as JSON'} + + {/if} {#if download_as_csv} convertJsonToCsv(result)} diff --git a/frontend/src/lib/components/DisplayResultControlBar.svelte b/frontend/src/lib/components/DisplayResultControlBar.svelte index 95d0a3414e..d45ca3f92d 100644 --- a/frontend/src/lib/components/DisplayResultControlBar.svelte +++ b/frontend/src/lib/components/DisplayResultControlBar.svelte @@ -3,6 +3,7 @@ import { Download, InfoIcon, ClipboardCopy, Expand } from 'lucide-svelte' import Popover from './Popover.svelte' import { copyToClipboard } from '$lib/utils' + import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile' import type { DisplayResultUi } from './custom_ui' import { createEventDispatcher } from 'svelte' @@ -37,21 +38,38 @@ return 'error stringifying object: ' + e.toString() } } + + let resultApiPath = $derived( + workspaceId && jobId + ? nodeId + ? `/w/${workspaceId}/jobs/result_by_id/${jobId}/${nodeId}` + : `/w/${workspaceId}/jobs_u/completed/get_result/${jobId}` + : undefined + ) + let downloadName = $derived(`${filename ?? 'result'}.json`)
{#if customUi?.disableDownload !== true} - - - + {#if resultApiPath && shouldDownloadViaClient()} + + {:else} + + + + {/if} {/if} {#if disableTooltips !== true} diff --git a/frontend/src/lib/components/DynamicInput.svelte b/frontend/src/lib/components/DynamicInput.svelte index 692272d50e..f4e2ba9c75 100644 --- a/frontend/src/lib/components/DynamicInput.svelte +++ b/frontend/src/lib/components/DynamicInput.svelte @@ -25,6 +25,7 @@ import { type DynamicInput } from '$lib/utils' import { deepEqual } from 'fast-equals' import { untrack } from 'svelte' + import { getHelperEntrypointArgs } from '$lib/infer' interface Props { value?: any @@ -48,7 +49,9 @@ }) let resultJobLoader: JobLoader | undefined = $state() - let _items = usePromise(getItemsFromOptions, { clearValueOnRefresh: false }) + // loadInit:false — the $effect below owns the first refresh once + // resultJobLoader is bound; without this the promise is kicked off twice. + let _items = usePromise(getItemsFromOptions, { clearValueOnRefresh: false, loadInit: false }) let items = $derived(_items.value) let filterText: string = $state('') @@ -125,9 +128,43 @@ }, 1000) }) + // Parameter names declared by the helper function. When known, we restrict + // the change-detection to only those keys so typing in unrelated form fields + // no longer retriggers the dynselect job. `undefined` means we couldn't + // determine the signature → fall back to a full-args comparison. + let helperParams = $state | undefined>(undefined) + + $effect(() => { + const script = helperScript + const ep = entrypoint + if (!script) { + helperParams = undefined + return + } + let cancelled = false + void getHelperEntrypointArgs(script, ep || undefined).then((params) => { + if (!cancelled) helperParams = params + }) + return () => { + cancelled = true + } + }) + + function filterArgs(args: Record | undefined) { + if (!args || !helperParams) return args + const filtered: Record = {} + for (const k of helperParams) { + if (k in args) filtered[k] = args[k] + } + return filtered + } + $effect(() => { ;[filterText, entrypoint, helperScript] - if (resultJobLoader && (open || neverLoaded || !deepEqual(lastArgs, nargs))) { + if ( + resultJobLoader && + (open || neverLoaded || !deepEqual(filterArgs(lastArgs), filterArgs(nargs))) + ) { neverLoaded = false lastArgs = $state.snapshot(otherArgs) _items.refresh() diff --git a/frontend/src/lib/components/EditorHeader.svelte b/frontend/src/lib/components/EditorHeader.svelte new file mode 100644 index 0000000000..e52210bb06 --- /dev/null +++ b/frontend/src/lib/components/EditorHeader.svelte @@ -0,0 +1,247 @@ + + +
+ +
+ + + + {#if pathEditable} + pathPopoverOpen, setPathPopoverOpen} + > + {#snippet trigger()} +
+ + +
+ +
+
diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 6c135a70b5..f2e37bbeda 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -20,6 +20,7 @@ readFieldsRecursively, replaceFalseWithUndefined, isMac, + userPathPrefix, type Item, type StateStore, type Value @@ -46,7 +47,6 @@ import { GroupEditor, setGroupEditorContext } from './graph/groupEditor.svelte' import { cleanFlow } from './flows/utils.svelte' import { - Calendar, Save, DiffIcon, HistoryIcon, @@ -71,7 +71,7 @@ import { resetAllTodos, skipAllTodos } from '$lib/tutorialUtils' import { tutorialsToDo } from '$lib/stores' import { getTutorialIndex } from '$lib/tutorials/config' - import SummaryPathDisplay from './SummaryPathDisplay.svelte' + import EditorHeader from './EditorHeader.svelte' import type { FlowBuilderWhitelabelCustomUi } from './custom_ui' import FlowYamlEditor from './flows/header/FlowYamlEditor.svelte' import { type TriggerContext, type ScheduleTrigger } from './triggers' @@ -130,7 +130,8 @@ onDetails, onSaveDraftError, onSaveDraftOnlyAtNewPath, - onHistoryRestore + onHistoryRestore, + onNavigate }: FlowBuilderProps = $props() let initialPathStore = writable(initialPath) @@ -147,13 +148,7 @@ }) // used for new flows for captures - let fakeInitialPath = - 'u/' + - ($userStore?.username?.includes('@') - ? $userStore!.username.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '') - : $userStore?.username) + - '/' + - generateRandomString(12) + let fakeInitialPath = userPathPrefix($userStore?.username) + generateRandomString(12) // Used by multiplayer deploy collision warning let deployedValue: Value | undefined = $state(undefined) // Value to diff against @@ -203,6 +198,11 @@ savedValue: savedFlow, modifiedValue: { ...flowStore.val, + // `$pathStore` is the live-edited path (the pen popover binds it). + // `flowStore.val.path` doesn't track those edits, so without this the + // rename wouldn't show up in the diff and the unsaved-changes warning + // wouldn't fire when leaving with a pending rename. + path: $pathStore, draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot()) } } @@ -760,16 +760,13 @@ return } - switch (event.key) { - case 'Z': - if (event.ctrlKey || event.metaKey) { - handleRedo() - event.preventDefault() - } - break + // Only lowercase single-char keys — named keys like `ArrowDown` must + // stay PascalCase to match their switch cases. + switch (event.key.length === 1 ? event.key.toLowerCase() : event.key) { case 'z': if (event.ctrlKey || event.metaKey) { - handleUndo() + if (event.shiftKey) handleRedo() + else handleUndo() event.preventDefault() } break @@ -822,7 +819,10 @@ if (savedFlow?.draft_only === false || savedFlow?.draft_only === undefined) { dropdownItems.push({ label: 'Exit & see details', - onClick: () => onDetails?.({ path: $pathStore }) + // Use the deployed path, not the live `$pathStore` — the latter + // reflects local rename edits that haven't been deployed yet, + // which would land the user on a 404 details page. + onClick: () => onDetails?.({ path: initialPath }) }) } @@ -1025,9 +1025,20 @@ untrack(() => saveSessionDraft()) } }) + // Sync `$pathStore` from `flowStore.val.path` (which `initFlow` populates + // from the loaded flow — including the draft's rename, when there is one). + // This effect only tracks `flowStore.val.path`, so popover edits that go + // straight to `$pathStore` don't trigger it and aren't overwritten. + // Replaces the previous `$pathStore = initialPath` push (added in #2536 for + // the VSCode extension), which silently dropped any draft-renamed path + // because `initialPath` is the URL, not the loaded path. $effect.pre(() => { - initialPath && ($pathStore = initialPath) + // `flowStore.val` is typed `OpenFlow` here but `initFlow` actually puts a + // `Flow` (with `path`) in it. + const p = (flowStore.val as Flow | undefined)?.path + if (p) untrack(() => ($pathStore = p)) }) + $effect.pre(() => { selectedId && untrack(() => select(selectedId)) }) @@ -1150,38 +1161,15 @@
-
- + onNavigate?.(item)} />
- -
{#if $enterpriseLicense && !newFlow} diff --git a/frontend/src/lib/components/InputError.svelte b/frontend/src/lib/components/InputError.svelte index f075521740..a431140afa 100644 --- a/frontend/src/lib/components/InputError.svelte +++ b/frontend/src/lib/components/InputError.svelte @@ -2,7 +2,7 @@ import { slide } from 'svelte/transition' interface Props { - error: string + error?: string | undefined } let { error }: Props = $props() diff --git a/frontend/src/lib/components/InstanceSetting.svelte b/frontend/src/lib/components/InstanceSetting.svelte index 4964fb9c1d..703b833a2f 100644 --- a/frontend/src/lib/components/InstanceSetting.svelte +++ b/frontend/src/lib/components/InstanceSetting.svelte @@ -699,6 +699,31 @@ {/each}
+
+ + +

+ Comma-separated host patterns that job HTTP clients should bypass the tracing + proxy for — those hosts will not be traced. Use this for clients that pin their + own CA (kubectl, helm, terraform providers, aws cli for EKS, etc.) which would + otherwise fail with x509: certificate signed by unknown authority. + Independent of the worker's own NO_PROXY env, which governs the proxy's + upstream relay (e.g. through a corporate proxy). +

+
{/if}
{:else if setting.fieldType == 'object_store_config'} diff --git a/frontend/src/lib/components/JobArgs.svelte b/frontend/src/lib/components/JobArgs.svelte index 68ca3c078f..77c94de11e 100644 --- a/frontend/src/lib/components/JobArgs.svelte +++ b/frontend/src/lib/components/JobArgs.svelte @@ -13,6 +13,7 @@ import HighlightTheme from './HighlightTheme.svelte' import { deepEqual } from 'fast-equals' import { isWindmillTooBigObject } from './job_args' + import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile' interface Props { id?: string | undefined @@ -27,6 +28,10 @@ let runLocally: Drawer | undefined = $state() let jsonStr = $state('') + const argsDownloadName = 'windmill-args.json' + let argsApiPath = $derived(id && workspace ? `/w/${workspace}/jobs_u/get_args/${id}` : undefined) + let argsDataHref = $derived(`data:text/json;charset=utf-8,${encodeURIComponent(jsonStr)}`) + function pythonCode() { return ` if __name__ == "__main__": @@ -60,10 +65,12 @@ ${Object.entries(args) {#if args && typeof args === 'object' && deepEqual( Object.keys(args ?? {}), ['reason'] ) && args['reason'] == 'PREPROCESSOR_ARGS_ARE_DISCARDED'} Preprocessor args are discarded {:else if id && workspace && args && typeof args === 'object' && deepEqual( Object.keys(args ?? {}), ['reason'] ) && args['reason'] == 'WINDMILL_TOO_BIG'} - The args are too big in size to be able to fetch alongside job. Please download the JSON file to view them. + The args are too big in size to be able to fetch alongside job. Please {#if shouldDownloadViaClient()}{:else}download the JSON file to view them{/if}. {:else}
@@ -120,17 +127,26 @@ ${Object.entries(args) {#snippet actions()} - + {#if argsApiPath && shouldDownloadViaClient()} + + {:else} + + {/if} + {:else} + + JSON is too large to be displayed in full. + + {/if} +
{:else} {/if} diff --git a/frontend/src/lib/components/LabelsInput.svelte b/frontend/src/lib/components/LabelsInput.svelte index e357f62895..933fe38ea1 100644 --- a/frontend/src/lib/components/LabelsInput.svelte +++ b/frontend/src/lib/components/LabelsInput.svelte @@ -1,5 +1,6 @@ + + (x.summary ? `${x.summary} (${x.path})` : x.path)} + opts={{}} +/> + +{#snippet leafRow(it: Item, secondary: string, baseClass: string)} + {@const key = leafKey(it)} + {@const isHl = key === highlightedKey} + {@const isCur = isCurrent(it)} + +{/snippet} + + +
(mouseActive = true)} +> +
+ +
+ + {#if scope} + {@const s = scope} + + {/if} + +
+ {#if isSearching} + {@const total = (searchedItems ?? []).length} + {@const anyKindLoading = kinds.some((k) => loadingKind[k])} + {#if !searchedItems || anyKindLoading} + +
+ Searching… +
+ {:else if total === 0} +
No matches
+ {:else} + {#each kinds as k (k)} + {@const results = searchResultsByKind[k]} + {#if results.length > 0} +
+ {KIND_LABEL[k]} +
+
    + {#each results as it (leafKey(it))} +
  • {@render leafRow(it, it.path, 'py-1.5')}
  • + {/each} +
+ {/if} + {/each} + {/if} + {:else if scopeLoading && entries.length === 0} +
+ Loading… +
+ {:else if entries.length === 0} +
Empty
+ {:else} +
+ {#each entries as entry (entry.key)} + {@const isHl = entry.key === highlightedKey} + {#if entry.type === 'leaf'} + {@render leafRow( + entry.item, + scope?.dir ? entry.item.path.slice(scope.dir.length + 1) : entry.item.path, + 'py-1.5' + )} + {:else} + + {/if} + {/each} +
+ {/if} +
+
+ + diff --git a/frontend/src/lib/components/apps/editor/AppEditor.svelte b/frontend/src/lib/components/apps/editor/AppEditor.svelte index e919f7d484..158e4be8df 100644 --- a/frontend/src/lib/components/apps/editor/AppEditor.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditor.svelte @@ -877,7 +877,7 @@ {/snippet} {#if $mode === 'preview'} - +
{/if} - +
- import { Drawer, DrawerContent, UndoRedo } from '$lib/components/common' + import { Drawer, DrawerContent } from '$lib/components/common' import Button from '$lib/components/common/button/Button.svelte' import Toggle from '$lib/components/Toggle.svelte' import { AppService, DraftService, type Policy } from '$lib/gen' import { redo, undo } from '$lib/history.svelte' import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores' - import type { Item } from '$lib/utils' + import { isMac, type Item, userPathPrefix } from '$lib/utils' + import { random_adj } from '$lib/components/random_positive_adjetive' import { AlignHorizontalSpaceAround, BellOff, @@ -24,6 +25,8 @@ Sun, Moon, SunMoon, + Undo, + Redo, Zap, Globe } from 'lucide-svelte' @@ -52,7 +55,9 @@ import AppReportsDrawer from './AppReportsDrawer.svelte' import DebugPanel from './contextPanel/DebugPanel.svelte' - import Summary from '$lib/components/Summary.svelte' + import EditorHeader from '$lib/components/EditorHeader.svelte' + import { editPathFor, invalidate as invalidatePicker } from '$lib/components/workspacePicker' + import { goto } from '$app/navigation' import HideButton from './settingsPanel/HideButton.svelte' import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte' @@ -119,7 +124,20 @@ onHideBottomPanel }: Props = $props() - let newEditedPath = $state('') + /** Mirror of the path the user is editing in the pen popover. Initialized + * once from `newPath` (or a synthesized path for new apps) and only + * updated by user input from then on — we deliberately do NOT sync from + * `newPath` afterwards so the user's in-flight rename isn't clobbered by + * a parent reload that re-supplies the saved path. The fallback chain at + * read sites (`newEditedPath || savedApp?.draft?.path || savedApp?.path`) + * handles the case where `newEditedPath` is briefly empty before the + * synthesized initialization runs — falls through to the saved path so + * rename detection still works. */ + let newEditedPath = $state( + untrack(() => + newApp ? userPathPrefix($userStore?.username) + random_adj() + '_app' : (newPath ?? '') + ) + ) let deployedValue: Value | undefined = $state(undefined) // Value to diff against let deployedBy: string | undefined = $state(undefined) // Author let confirmCallback: () => void = $state(() => {}) // What happens when user clicks `override` in warning @@ -283,6 +301,7 @@ preserve_on_behalf_of: preserveOnBehalfOf || undefined } }) + invalidatePicker($workspaceStore!, 'app') savedApp = { summary: $summary, value: structuredClone($state.snapshot($app)), @@ -511,19 +530,12 @@ lock = true - switch (event.key) { - case 'Z': - if (event.ctrlKey || event.metaKey) { - const napp = redo(history) - for (const key in napp) { - $app[key] = napp[key] - } - event.preventDefault() - } - break + // Only lowercase single-char keys — named keys (`ArrowDown`, etc.) must + // stay PascalCase to match their switch cases. + switch (event.key.length === 1 ? event.key.toLowerCase() : event.key) { case 'z': if (event.ctrlKey || event.metaKey) { - const napp = undo(history, $app) + const napp = event.shiftKey ? redo(history) : undo(history, $app) for (const key in napp) { $app[key] = napp[key] } @@ -558,7 +570,37 @@ lock = false } + const mod = isMac() ? '⌘' : 'Ctrl+' + + function handleUndo() { + const napp = undo(history, $app) + for (const key in napp) { + $app[key] = napp[key] + } + } + function handleRedo() { + const napp = redo(history) + for (const key in napp) { + $app[key] = napp[key] + } + } + let moreItems = $derived([ + { + displayName: 'Undo', + icon: Undo, + action: () => handleUndo(), + disabled: $history?.index === 0, + shortcut: `${mod}Z` + }, + { + displayName: 'Redo', + icon: Redo, + action: () => handleRedo(), + disabled: $history && $history?.index === $history.history.length - 1, + shortcut: `${mod}⇧Z`, + separatorBottom: true + }, { displayName: 'Deployment history', icon: History, @@ -890,28 +932,17 @@
- + goto(editPathFor(item))} + />
- { - const napp = undo(history, $app) - for (const key in napp) { - $app[key] = napp[key] - } - }} - on:redo={() => { - const napp = redo(history) - for (const key in napp) { - $app[key] = napp[key] - } - }} - /> - {#if $app} (summary = v)} + textClass="text-xs font-semibold text-emphasis" +/> +``` + +The current value isn't bound — `onSave` is fired with the trimmed draft +whenever it differs from the prior `value`, including with `''` when the +user clears the field. Callers that want to reject empty commits should +guard inside their `onSave` handler. The parent owns the canonical state; +this component just proposes new values. +--> + + +{#if editing} + + + +{:else} + +{/if} + + diff --git a/frontend/src/lib/components/common/button/Button.svelte b/frontend/src/lib/components/common/button/Button.svelte index 077994d842..b739076cee 100644 --- a/frontend/src/lib/components/common/button/Button.svelte +++ b/frontend/src/lib/components/common/button/Button.svelte @@ -385,7 +385,11 @@ {@render children?.()} {/if} {#if endIcon?.icon} - + {/if} {#if shortCut && !shortCut.hide}
+ {/if} {#if shortCut && !shortCut.hide} {@const Icon = shortCut.Icon} diff --git a/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte b/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte index 20ebe04c85..1305d611c1 100644 --- a/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte +++ b/frontend/src/lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte @@ -58,11 +58,8 @@ open = true } if (savedValue && modifiedValue) { - const draftOrDeployed = cleanValueProperties({ - ...((savedValue.draft || savedValue) ?? {}), - path: undefined - }) - const current = cleanValueProperties({ ...(modifiedValue ?? {}), path: undefined }) + const draftOrDeployed = cleanValueProperties((savedValue.draft || savedValue) ?? {}) + const current = cleanValueProperties(modifiedValue ?? {}) if ( orderedJsonStringify(replaceFalseWithUndefined(draftOrDeployed)) === diff --git a/frontend/src/lib/components/common/fileDownload/FileDownload.svelte b/frontend/src/lib/components/common/fileDownload/FileDownload.svelte index 0cb48eb7e1..8d6529764b 100644 --- a/frontend/src/lib/components/common/fileDownload/FileDownload.svelte +++ b/frontend/src/lib/components/common/fileDownload/FileDownload.svelte @@ -26,26 +26,26 @@ ) let href = $derived(`${base}/api${apiPath}`) - async function onclick(e: MouseEvent) { - if (!shouldDownloadViaClient()) return - e.preventDefault() - await downloadViaClient(apiPath, filename) - } + const sharedClass = `relative center-center flex w-full text-center font-normal text-primary text-sm +border border-dashed border-gray-400 hover:border-blue-500 +focus-within:border-blue-500 hover:bg-blue-50 dark:hover:bg-frost-900 focus-within:bg-blue-50 +duration-200 rounded-lg p-1 gap-2` {#if s3object && s3object?.s3} - - - - {s3object?.storage ? `s3://${s3object.storage}/${s3object.s3}` : `s3:///${s3object.s3}`} - - + {#if shouldDownloadViaClient()} + + {:else} + + + + {s3object?.storage ? `s3://${s3object.storage}/${s3object.s3}` : `s3:///${s3object.s3}`} + + + {/if} {/if} diff --git a/frontend/src/lib/components/common/table/RowIcon.svelte b/frontend/src/lib/components/common/table/RowIcon.svelte index 7906b01d33..67a707ff1e 100644 --- a/frontend/src/lib/components/common/table/RowIcon.svelte +++ b/frontend/src/lib/components/common/table/RowIcon.svelte @@ -53,9 +53,10 @@ | 'email_trigger' /** For 'trigger' kind, specifies the specific trigger type (routes, schedules, etc.) */ triggerKind?: string | undefined + size?: number } - let { kind, triggerKind = undefined }: Props = $props() + let { kind, triggerKind = undefined, size = 16 }: Props = $props() // Map per-kind backend names (e.g. `kafka_trigger`) to the legacy short // names the icon switch already handles, so we don't have to duplicate cases. @@ -79,44 +80,44 @@
{#if effectiveKind === 'flow'} - + {:else if effectiveKind === 'app' || effectiveKind === 'raw_app'} - + {:else if effectiveKind === 'script'} - + {:else if effectiveKind === 'variable'} - + {:else if effectiveKind === 'resource'} - + {:else if effectiveKind === 'resource_type'} -
+
{:else if effectiveKind === 'folder'} - + {:else if effectiveKind === 'schedule' || effectiveKind === 'schedules'} - + {:else if effectiveKind === 'routes'} - + {:else if effectiveKind === 'websockets'} - + {:else if effectiveKind === 'postgres'} - + {:else if effectiveKind === 'kafka'} - + {:else if effectiveKind === 'nats'} - + {:else if effectiveKind === 'mqtt'} - + {:else if effectiveKind === 'sqs'} - + {:else if effectiveKind === 'gcp'} - + {:else if effectiveKind === 'azure'} - + {:else if effectiveKind === 'emails'} - + {:else if effectiveKind === 'trigger'} - + {:else} -
+
{/if}
diff --git a/frontend/src/lib/components/copilot/CustomAIPrompts.svelte b/frontend/src/lib/components/copilot/CustomAIPrompts.svelte index eb4685a4d1..4c7cfbe47b 100644 --- a/frontend/src/lib/components/copilot/CustomAIPrompts.svelte +++ b/frontend/src/lib/components/copilot/CustomAIPrompts.svelte @@ -1,5 +1,5 @@
@@ -29,7 +30,7 @@