diff --git a/.agents/skills/ai-chat/SKILL.md b/.agents/skills/ai-chat/SKILL.md index f670b2ffb7..490c459db9 100644 --- a/.agents/skills/ai-chat/SKILL.md +++ b/.agents/skills/ai-chat/SKILL.md @@ -11,6 +11,15 @@ authoring and the full run reference. Run the affected mode **before** your change and **after**, same model(s), same cases. +## Where guidance goes + +What the model should know about Windmill itself (flow shapes, groups, data tables, app +access, secrets…) belongs in `system_prompts/base/` or `system_prompts/languages/`, which also +feed the CLI's skills — not in a `*/core.ts` prompt builder, where only this chat would see it. +The builders add tool plumbing and runtime values only. Scope a sentence to one consumer with +`` / ``, keep chat tool names out of the shared files, and +rerun `python system_prompts/generate.py`. `system_prompts/README.md` has the mechanics. + ## Measure the window first, and cumulative second Optimize **`finalContextTokens`** (window occupancy — what drives overflow and diff --git a/.agents/skills/local-review-codex/SKILL.md b/.agents/skills/local-review-codex/SKILL.md index ec1277fe3a..3483453f66 100644 --- a/.agents/skills/local-review-codex/SKILL.md +++ b/.agents/skills/local-review-codex/SKILL.md @@ -1,6 +1,6 @@ --- name: local-review-codex -description: Run the CI Codex PR review locally against this branch's unpushed work (committed + uncommitted) before pushing. Same policy and reasoning effort as the codex-pr-review GitHub action, on a newer model. +description: Run the CI Codex PR review locally against this branch's unpushed work (committed + uncommitted) before pushing. Same policy, model and reasoning effort as the codex-pr-review GitHub action. --- # Local Codex Review (pre-push) @@ -11,18 +11,18 @@ before the PR exists. Use this before `git push` on a non-trivial change. **Correspondence with CI** — identical: - Policy: `REVIEW.md` (severity triage, public-surface checklist, AGENTS.md compliance, test coverage). +- Model: `gpt-6.1-sol`. - Reasoning effort: `model_reasoning_effort="xhigh"`. - Output: markdown starting with `## Codex Review`, findings tagged P0 / P1 / P2 with file:line. **Differences from CI** — local-only: -- Model is `gpt-6-astra`; CI stays on `gpt-5.6-sol`. Not an oversight to reconcile: `gpt-6-astra` is confirmed on the ChatGPT auth `codex login` uses locally, while CI authenticates with `OPENAI_API_KEY` (`codex-pr-review.yml` prefers it over `CODEX_AUTH_JSON`) and that tier is unverified for the model. Move CI once API access is confirmed, or once CI switches to `CODEX_AUTH_JSON`. - Scope is the current branch vs `main` at the merge-base, **including uncommitted changes** (CI reviews a pushed PR diff). - Sandbox is `read-only` (CI uses `danger-full-access` on an ephemeral runner). Codex reads the diff and files but cannot modify your working tree. - Fresh context is inherent: `codex exec` is a separate cold process, so it does not anchor on the current chat session — the same reason `local-review` insists on a subagent. ## Prerequisites -- `codex` CLI **>= 0.153.4** installed and authed via `codex login` (an `OPENAI_API_KEY` in the environment takes priority and may not reach `gpt-6-astra` — see the model note above). Older CLIs reject the model with "requires a newer version of Codex"; `run.sh` checks the version up front. Upgrade with `npm install --global @openai/codex@0.153.4` (may need `sudo` for a global install). This matches the pin in `.github/workflows/codex-pr-review.yml` — the CLI version is the same on both sides, only the model differs. +- `codex` CLI **>= 0.159.3** installed and authed via `codex login` or an `OPENAI_API_KEY` in the environment (which takes priority). Older CLIs reject the model with "requires a newer version of Codex"; `run.sh` checks the version up front. Upgrade with `npm install --global @openai/codex@0.159.3` (may need `sudo` for a global install). This matches the pin in `.github/workflows/codex-pr-review.yml` — the CLI version is the same on both sides. - `git fetch` the base ref if it's stale, so the merge-base is accurate. ## Run diff --git a/.agents/skills/local-review-codex/run.sh b/.agents/skills/local-review-codex/run.sh index 948d3820eb..e1fef4ce8e 100755 --- a/.agents/skills/local-review-codex/run.sh +++ b/.agents/skills/local-review-codex/run.sh @@ -1,17 +1,14 @@ #!/usr/bin/env bash # Local Codex review — mirrors the .github/workflows/codex-pr-review.yml CI job, # but scoped to this branch's unpushed work (committed + uncommitted) so you can -# review before pushing. Same policy (REVIEW.md) and reasoning effort (xhigh) as CI. -# -# The model deliberately differs from CI: gpt-6-astra is confirmed available on the -# ChatGPT auth `codex login` uses here, but CI authenticates with OPENAI_API_KEY and -# that tier is unverified for it, so codex-pr-review.yml stays on gpt-5.6-sol. +# review before pushing. Same policy (REVIEW.md), model and reasoning effort (xhigh) +# as CI. # # Usage: run.sh [BASE_REF] (BASE_REF defaults to "main") set -euo pipefail -MODEL="gpt-6-astra" -CODEX_MIN="0.153.4" +MODEL="gpt-6.1-sol" +CODEX_MIN="0.159.3" BASE_REF="${1:-main}" REPO_ROOT="$(git rev-parse --show-toplevel)" diff --git a/.agents/skills/svelte-frontend/SKILL.md b/.agents/skills/svelte-frontend/SKILL.md index 6aceedc25c..ddeb8e59a1 100644 --- a/.agents/skills/svelte-frontend/SKILL.md +++ b/.agents/skills/svelte-frontend/SKILL.md @@ -119,8 +119,8 @@ Form components (TextInput, Toggle, Select, etc.) should use the unified size sy New user-facing UX is the main source of `feature_usage` counters — propose them in the plan, not as a separate question, and read `docs/feature-telemetry.md` first. `logFeatureUsage()` from `$lib/utils/featureUsage` is only half the change: the `(feature, kind)` pair must also be -registered in the backend allowlist or every event is silently discarded, and the disclosure copy -in `InstanceSettings.svelte` must name what you added. +registered in the backend allowlist or every event is silently discarded. The disclosure copy in +`InstanceSettings.svelte` stays at the category level; see the recipe's step 3. ## Svelte MCP Server diff --git a/.github/workflows/ai-evals-scheduled.yml b/.github/workflows/ai-evals-scheduled.yml new file mode 100644 index 0000000000..964e5d0cab --- /dev/null +++ b/.github/workflows/ai-evals-scheduled.yml @@ -0,0 +1,171 @@ +name: AI Evals (scheduled) + +# Full ai_evals suites on current models, posted to the AI evals dashboard in +# the windmill-prod workspace (f/ai/ai_evals_dashboard) so quality is tracked +# over time. Weekly, since one pass of every suite at --runs 3 costs about 50M +# tokens per model: every suite runs on MODELS, and global (the mode users get) +# also runs on GLOBAL_EXTRA_MODELS, one flagship per other provider. Run it by +# hand to measure a branch against main. +on: + schedule: + - cron: "0 3 * * 1" + workflow_dispatch: + inputs: + modes: + description: "Space-separated modes" + default: "global flow app script cli" + models: + description: "Space-separated model aliases (bun run cli -- models)" + default: "sonnet-5.5" + global_extra_models: + description: "Extra model aliases for global mode only" + default: "gpt-6-astra gemini-3.8-flash" + runs: + description: "Runs per case" + default: "3" + reasoning: + description: "Reasoning effort for frontend modes (empty: the product default)" + default: "" + +concurrency: + group: ai-evals-scheduled-${{ github.ref }} + +env: + MODELS: ${{ inputs.models || 'sonnet-5.5' }} + GLOBAL_EXTRA_MODELS: ${{ inputs.global_extra_models || 'gpt-6-astra gemini-3.8-flash' }} + RUNS: ${{ inputs.runs || '3' }} + REASONING: ${{ inputs.reasoning }} + INGEST_URL: https://app.windmill.dev/api/r/f/ai/ingest_ai_eval_run + +jobs: + setup: + runs-on: ubuntu-latest + outputs: + modes: ${{ steps.modes.outputs.modes }} + steps: + - id: modes + env: + MODES: ${{ inputs.modes || 'global flow app script cli' }} + run: | + echo "modes=$(jq -cn --arg m "$MODES" '$m | split(" ") + | map(select(IN("global", "flow", "app", "script", "cli")))')" >> "$GITHUB_OUTPUT" + + evals: + needs: setup + runs-on: ubicloud-standard-16 + timeout-minutes: 330 + strategy: + fail-fast: false + matrix: + mode: ${{ fromJSON(needs.setup.outputs.modes) }} + services: + postgres: + image: postgres:16 + ports: + - 5432:5432 + env: + POSTGRES_DB: windmill + POSTGRES_PASSWORD: changeme + options: >- + --health-cmd pg_isready --health-interval 10s --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v4 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + cache-workspaces: backend + toolchain: 1.97.0 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.0 + + - uses: actions/setup-node@v7 + with: + node-version: "24" + + - name: Build Windmill + working-directory: ./backend + env: + SQLX_OFFLINE: true + CARGO_BUILD_JOBS: 12 + RUSTFLAGS: "" + run: cargo build --features quickjs + + - name: Start Windmill + working-directory: ./backend + env: + DATABASE_URL: postgres://postgres:changeme@localhost:5432/windmill + RUST_LOG: info + run: | + mkdir -p ../ai_evals/logs + ./target/debug/windmill > ../ai_evals/logs/windmill.log 2>&1 & + for i in $(seq 1 60); do + curl -sf http://localhost:8000/api/version > /dev/null 2>&1 && break + sleep 2 + done + curl -sf http://localhost:8000/api/version > /dev/null || { tail -50 ../ai_evals/logs/windmill.log; exit 1; } + + - name: Install frontend deps + generate client + working-directory: ./frontend + run: | + npm ci + npm run generate-backend-client + + - name: Install CLI deps + generate CLI client + working-directory: ./cli + run: bun install && ./gen_wm_client.sh && ./windmill-utils-internal/gen_wm_client.sh + + - name: Run ${{ matrix.mode }} evals + working-directory: ./ai_evals + env: + MODE: ${{ matrix.mode }} + WMILL_AI_EVAL_BACKEND_URL: http://localhost:8000 + WMILL_AI_EVAL_BACKEND_WORKSPACE: integration-tests + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + GEMINI_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + run: | + bun install + mkdir -p results + models="$MODELS" + [ "$MODE" = global ] && models="$models $GLOBAL_EXTRA_MODELS" + reasoning=() + [ -n "$REASONING" ] && [ "$MODE" != cli ] && reasoning=(--reasoning "$REASONING") + for m in $models; do + bun run cli -- run "$MODE" --model "$m" --runs "$RUNS" "${reasoning[@]}" \ + --output "$PWD/results/$MODE-$m.json" || echo "::warning::$MODE on $m errored" + done + + - name: Post results to the dashboard + if: always() + working-directory: ./ai_evals + env: + MODE: ${{ matrix.mode }} + INGEST_TOKEN: ${{ secrets.AI_EVALS_INGEST_TOKEN }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + [ -n "$INGEST_TOKEN" ] || { echo "::warning::AI_EVALS_INGEST_TOKEN is not set"; exit 0; } + shopt -s nullglob + for f in results/"$MODE"-*.json; do + # Keep what the dashboard reads; the full traces stay in the run's artifacts. + jq -c --arg ref "$GITHUB_REF" --arg trigger "$GITHUB_EVENT_NAME" --arg url "$RUN_URL" ' + {result_json: (del(.cases[].prompt, .cases[].initialPath, .cases[].expectedPath) + | .cases[].attempts[] |= {attempt, passed, durationMs, toolCallCount, judgeScore, + judgeSummary, error, tokenUsage, checks: [.checks[]? | {name, passed}]}), + git_ref: $ref, trigger: $trigger, run_url: $url}' "$f" \ + | curl -sf --retry 3 -X POST "$INGEST_URL" \ + -H "Authorization: Bearer $INGEST_TOKEN" -H 'content-type: application/json' \ + --data-binary @- && echo " <- $f" || echo "::warning::failed to post $f" + done + + - name: Archive logs and results + uses: actions/upload-artifact@v4 + if: always() + with: + name: ai-evals-${{ matrix.mode }} + path: | + ai_evals/logs + ai_evals/results diff --git a/.github/workflows/ai-evals-test.yml b/.github/workflows/ai-evals-test.yml index 573bc77df9..84b488ae46 100644 --- a/.github/workflows/ai-evals-test.yml +++ b/.github/workflows/ai-evals-test.yml @@ -147,7 +147,7 @@ jobs: mkdir -p results # One cheap model per provider (anthropic/openai/googleai/deepseek). fail=0 - for m in haiku 4o gemini-3-flash-preview deepseek-v4-flash; do + for m in haiku gpt-6-luna gemini-3.8-flash deepseek-flash; do echo "::group::global-test1-script-create ($m)" if ! bun run cli -- run global global-test1-script-create \ --model "$m" --execution-only --output "$PWD/results/ci-$m.json"; then diff --git a/.github/workflows/claude-plan.yml b/.github/workflows/claude-plan.yml index bc81801417..b3798c76f5 100644 --- a/.github/workflows/claude-plan.yml +++ b/.github/workflows/claude-plan.yml @@ -49,7 +49,7 @@ jobs: allowed_bots: 'windmill-internal-app[bot]' trigger_phrase: '/plan' claude_args: | - --model claude-opus-5 + --model claude-opus-5-5 --system-prompt "# Claude Planning Mode You are operating in PLANNING MODE ONLY. Your role is to create detailed, structured plans without making any code changes. diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 5e9aab00d3..5250c5a04b 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -93,4 +93,4 @@ jobs: } claude_args: | --allowedTools "Bash,WebFetch,WebSearch" - --model claude-opus-5 + --model claude-opus-5-5 diff --git a/.github/workflows/codex-pr-review.yml b/.github/workflows/codex-pr-review.yml index 55036776c6..2d70ad7f7a 100644 --- a/.github/workflows/codex-pr-review.yml +++ b/.github/workflows/codex-pr-review.yml @@ -222,7 +222,7 @@ jobs: - name: Install Codex CLI if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' - run: npm install --global @openai/codex@0.153.4 + run: npm install --global @openai/codex@0.159.3 - name: Configure Codex auth if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' @@ -359,7 +359,7 @@ jobs: # e.g. a GitHub Action's index.js, which then runs with our credentials. codex exec \ -C "$GITHUB_WORKSPACE" \ - -m gpt-5.6-sol \ + -m gpt-6.1-sol \ -c 'model_reasoning_effort="xhigh"' \ -s "$SANDBOX_MODE" \ -o "$RUNNER_TEMP/codex-final-message.md" \ diff --git a/.github/workflows/pr-ready-review.yml b/.github/workflows/pr-ready-review.yml index 5e5c6a8c61..90714cba53 100644 --- a/.github/workflows/pr-ready-review.yml +++ b/.github/workflows/pr-ready-review.yml @@ -208,4 +208,4 @@ jobs: ${{ env.REVIEW_PROMPT }} claude_args: | --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)" - --model claude-opus-5 + --model claude-opus-5-5 diff --git a/.github/workflows/publish_extra.yml b/.github/workflows/publish_extra.yml index 863c850266..c33d806183 100644 --- a/.github/workflows/publish_extra.yml +++ b/.github/workflows/publish_extra.yml @@ -57,7 +57,7 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Build test image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . file: ./docker/DockerfileExtra diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 6046035ab7..78bc4dfc98 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.819.0" + ".": "1.820.0" } diff --git a/AGENTS.md b/AGENTS.md index 858e9329ff..8451a0a600 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,10 +29,16 @@ Open-source platform for internal tools, workflows, API integrations, background - **Agent workers**: `docs/agent-worker-e2e.md` — building and running one locally. An agent reaches the DB only through the API, so `Connection::Http` paths are never taken by a plain `cargo run`; a normal build cannot start one at all. +- **External instance data tables**: `docs/external-instance-datatables.md` — the cluster Windmill + administers behind `external_instance` data tables and Ducklake catalogs: its invariants (one + lifecycle lock, managed-object markers, the setup gate, per-cluster roles, fork copy ownership) + and how to run one locally - **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow - **Operator write rights**: `docs/operator-write-rights.md` — which `operator_settings` flags are enforced rather than cosmetic, and why a right that is granted-unless-withdrawn needs `Option` fields and a jsonb merge rather than a serde default +- **Operator builder rights**: `docs/operator-builder-rights.md` — the workspace setting that lets + operators compose flows, what the composition check must cover, and why it costs a full seat - **Auth surface**: `docs/auth-surface.md` — credential precedence, session/cache invalidation scope, which token labels email their owner at expiry, how OAuth login matches `login_type`, and that every superadmin route refuses `$WM_TOKEN`. Read before designing anything that creates @@ -43,11 +49,12 @@ Open-source platform for internal tools, workflows, API integrations, background - **Backend patterns**: use the `rust-backend` skill when writing Rust code - **Frontend patterns**: use the `svelte-frontend` skill when writing Svelte code. Do NOT edit svelte files unless you have read that skill. - **Frontend UUIDs**: do not call `crypto.randomUUID()` in frontend code. Import `randomUUID` from `$lib/utils/uuid` instead. -- **Code review**: review the current PR or branch against the shared review policy in `REVIEW.md` (severity triage, public-surface checklist, AGENTS.md compliance, test-coverage assessment). The skill at `.agents/skills/local-review/SKILL.md` orchestrates it. All three CLIs auto-discover the same SKILL — Claude reads `.claude/skills/` (symlinked to the canonical `.agents/skills/` file), Codex and Pi read `.agents/skills/` directly. Invoke with `/local-review` in Claude Code, `$local-review` (or `/skills` selector) in Codex, or `pi --skill local-review` / `/skill:local-review` in Pi. For a Codex-driven pass that mirrors the `codex-pr-review` GitHub action against your unpushed work (committed + uncommitted) before you push, use `/local-review-codex` (`.agents/skills/local-review-codex/`) — same `REVIEW.md` policy and `xhigh` reasoning, on `gpt-6-astra` rather than the action's `gpt-5.6-sol`; requires the `codex` CLI >= 0.153.4. +- **Code review**: review the current PR or branch against the shared review policy in `REVIEW.md` (severity triage, public-surface checklist, AGENTS.md compliance, test-coverage assessment). The skill at `.agents/skills/local-review/SKILL.md` orchestrates it. All three CLIs auto-discover the same SKILL — Claude reads `.claude/skills/` (symlinked to the canonical `.agents/skills/` file), Codex and Pi read `.agents/skills/` directly. Invoke with `/local-review` in Claude Code, `$local-review` (or `/skills` selector) in Codex, or `pi --skill local-review` / `/skill:local-review` in Pi. For a Codex-driven pass that mirrors the `codex-pr-review` GitHub action against your unpushed work (committed + uncommitted) before you push, use `/local-review-codex` (`.agents/skills/local-review-codex/`) — same `REVIEW.md` policy, `gpt-6.1-sol` model and `xhigh` reasoning as the action; requires the `codex` CLI >= 0.159.3. - **Domain guides**: `.claude/skills/native-trigger/` - **Brand/UI guidelines**: `frontend/brand-guidelines.md` - **Domain vocabulary**: `CONTEXT.md` — the words this codebase uses for its own concepts (step, step setting, trigger step, …). Name things the way it does. - **CLI commands**: when adding/modifying/removing a command, subcommand, option, or description in `cli/src/commands/`, run `python system_prompts/generate.py` to refresh `system_prompts/auto-generated/` and `cli/src/guidance/skills.gen.ts`. The CLI docs the agents use to operate `wmill` are derived from the source — stale generated files give agents the wrong flags. +- **AI guidance** (chat prompts and CLI skills): what the AI knows about Windmill itself — how flows, scripts, apps, pipelines and resources work — is written once, in `system_prompts/base/` and `system_prompts/languages/`, and `system_prompts/generate.py` builds both the chat's `$system_prompts` exports and the CLI's skills from it. Add or change such guidance there, never inline in one consumer: a sentence only one side should see goes in a `` / `` block, and a new file reaches both through the `TOPICS` table. The chat prompt builders under `frontend/src/lib/components/copilot/chat/` add only tool plumbing and runtime values, and shared markdown names no chat tool, since it reaches every chat mode. Rerun `generate.py` after editing; `system_prompts/README.md` has the details. - **Session recorder**: `frontend/src/lib/components/recording/` is also the recorder `wmill app dev --recording` serves, vendored into the CLI as `cli/src/commands/app/devRecorderBundle.gen.ts`. After changing `rawAppSnapshot.ts` or `rawAppRecording.svelte.ts`, run `bun run gen:dev-recorder` from `cli/` (`cli/test/dev_recorder_bundle_unit.test.ts` fails otherwise). - **Raw-app policy**: `frontend/src/lib/components/raw_apps/rawAppPolicy.ts` also derives the policy the server's raw-app deploy stores, vendored into the bundle job as `backend/windmill-api/src/apps_raw_policy.gen.js`. After changing it or anything it imports, run `bun run gen:app-policy` from `cli/` (`cli/test/app_policy_bundle_unit.test.ts` fails otherwise). It rides in the job rather than being read from the CLI the job runs because the images install `windmill-cli` unpinned, so an image can carry one older than its server. diff --git a/CHANGELOG.md b/CHANGELOG.md index 93b0e0dd9c..d1a8762b7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## [1.820.0](https://github.com/windmill-labs/windmill/compare/v1.819.0...v1.820.0) (2026-09-29) + + +### Features + +* add pull_batch to claim jobs for many waiting workers at once ([#11350](https://github.com/windmill-labs/windmill/issues/11350)) ([9f40cdc](https://github.com/windmill-labs/windmill/commit/9f40cdca6258a9218770ebe1e7b89179e7d6667f)) +* add tree view to the schedules page ([#11360](https://github.com/windmill-labs/windmill/issues/11360)) ([e68ff0d](https://github.com/windmill-labs/windmill/commit/e68ff0d969edaacafc0fc5e246ac3945ae28109b)) +* **ai-agent:** add compaction memory that summarizes older context ([#10928](https://github.com/windmill-labs/windmill/issues/10928)) ([c1b59f7](https://github.com/windmill-labs/windmill/commit/c1b59f70dd0e1f4a7597d31cff031ce6c0e7d0ab)) +* detect and alert when a schedule skips occurrences ([#10917](https://github.com/windmill-labs/windmill/issues/10917)) ([97fa55b](https://github.com/windmill-labs/windmill/commit/97fa55b719a874067d1a81a1d680a42d1f1a4c98)) +* external instance cluster for data tables and Ducklake catalogs ([#11197](https://github.com/windmill-labs/windmill/issues/11197)) ([8741843](https://github.com/windmill-labs/windmill/commit/8741843d2e884783eed1c00bbc2293e526f3e80d)) +* refuse OIDC tokens to previews that impersonate a path their user cannot write ([#11396](https://github.com/windmill-labs/windmill/issues/11396)) ([d1260d7](https://github.com/windmill-labs/windmill/commit/d1260d7c5a3d132d4427079b794452309e03e28e)) +* restart perpetual runs on the version a deploy makes runnable ([#11200](https://github.com/windmill-labs/windmill/issues/11200)) ([cf5c49c](https://github.com/windmill-labs/windmill/commit/cf5c49c3dca2201f739fb872d68bcd6c0c7665f7)) +* run turns in several flow chat conversations at once ([#11202](https://github.com/windmill-labs/windmill/issues/11202)) ([f367eaf](https://github.com/windmill-labs/windmill/commit/f367eaf6d0258649ca0b3d47b1270af9a3fcd484)) +* share AI guidance with the CLI skills and lint flow groups ([#11397](https://github.com/windmill-labs/windmill/issues/11397)) ([ede4103](https://github.com/windmill-labs/windmill/commit/ede4103b00c6ada3b5ea8ba3e98bf6b78a791d63)) +* show native web search in the assistant settings modal ([#11394](https://github.com/windmill-labs/windmill/issues/11394)) ([235410c](https://github.com/windmill-labs/windmill/commit/235410c1e417bb64b432c96efed870cab74cb364)) +* stop cross-origin isolating the raw app editor ([#11411](https://github.com/windmill-labs/windmill/issues/11411)) ([6064aec](https://github.com/windmill-labs/windmill/commit/6064aecdecc49facde3870446b570abd8e8124b2)) + + +### Bug Fixes + +* bound a resumed session fork's wait, keep its intent while in flight ([#11413](https://github.com/windmill-labs/windmill/issues/11413)) ([e6df8d7](https://github.com/windmill-labs/windmill/commit/e6df8d78d496cf9640bd5a1e9d7e8f4c2cc2adf7)) +* create workspace forks in the background, with progress ([#11406](https://github.com/windmill-labs/windmill/issues/11406)) ([054109d](https://github.com/windmill-labs/windmill/commit/054109d8559cc0a41ef361786c4d1d99eab9c502)) +* drop caller-supplied _MODULES at push for every job ([#11418](https://github.com/windmill-labs/windmill/issues/11418)) ([c5f59d6](https://github.com/windmill-labs/windmill/commit/c5f59d6ccc71d08fc34ede47e7a77b35a197b9db)) +* keep new-menu submenus open while crossing the gutter ([#11414](https://github.com/windmill-labs/windmill/issues/11414)) ([2f87bc6](https://github.com/windmill-labs/windmill/commit/2f87bc6a2d237382641ad3f0e7a8df36f0b74c15)) +* keep secret variable values hidden when toggling secret ([#11383](https://github.com/windmill-labs/windmill/issues/11383)) ([da5c58d](https://github.com/windmill-labs/windmill/commit/da5c58de2a0c297cfa81a601f9f543f46107e3f6)) +* refuse flow preview restarts from runs the caller cannot read ([#11407](https://github.com/windmill-labs/windmill/issues/11407)) ([c34abf7](https://github.com/windmill-labs/windmill/commit/c34abf7330e7f3fbbf5b68e325f0f0991c6c60d5)) +* report a worker's last job when it ran under one poll interval ([#11399](https://github.com/windmill-labs/windmill/issues/11399)) ([797147e](https://github.com/windmill-labs/windmill/commit/797147ea7a4a911d3c82e9ac9cdaf066ad2795d6)) +* run custom schedule handlers as the schedule ([#11398](https://github.com/windmill-labs/windmill/issues/11398)) ([bda00c6](https://github.com/windmill-labs/windmill/commit/bda00c6cad2de2ce0927651fe0e53653bdd20a96)) +* surface raw app build errors to the session AI ([#11415](https://github.com/windmill-labs/windmill/issues/11415)) ([50ffad5](https://github.com/windmill-labs/windmill/commit/50ffad52d765497fa3a561ebe8842b40fc8bd58e)) +* turn the default raw app into a feature tour ([#11417](https://github.com/windmill-labs/windmill/issues/11417)) ([83b8954](https://github.com/windmill-labs/windmill/commit/83b8954ae26f8d453d38e867e62366341cfe7729)) + ## [1.819.0](https://github.com/windmill-labs/windmill/compare/v1.818.0...v1.819.0) (2026-09-28) diff --git a/CONTEXT.md b/CONTEXT.md index fbc2592cc9..054960607c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -48,8 +48,26 @@ _Avoid_: thread, session (that names an AI session, a different thing), chat (th One question and the answer to it: the run the question started, the handle that stops it, and the rows it is writing. At most one per conversation, and the chat is held for its whole length — from the moment the question takes the chat, before it has a job, until it is ended. +The server holds the same rule: a question sent to a conversation whose turn is still running +is refused, whoever sends it. Several conversations of one flow can each have a turn running. _Avoid_: request, exchange, message round +**Running turn**: +A turn whose run has not finished. Which conversations have one is the server's to say, so a +chat opened after the turn started — a reload, another tab — still sees it and follows it. +_Avoid_: busy, active, in flight + +**Queued message**: +A question typed into a conversation while its turn runs, sent when that turn ends answered. +At most one per conversation; typing another adds to it. A turn that fails, or is stopped, +hands it back to the composer instead of sending it. +_Avoid_: pending message (a pending message is one already sent and not yet confirmed) + +**Unread**: +The answers that arrived in a conversation while it was not the one shown. Counted per open +chat and forgotten on reload. +_Avoid_: new messages, notifications + **Transcript**: The rows a conversation's chat holds. Not the conversation: it is the newest page plus whatever older pages the reader has scrolled back through, so a question it cannot answer diff --git a/ai_evals/README.md b/ai_evals/README.md index 603f28e5f9..bff4294e52 100644 --- a/ai_evals/README.md +++ b/ai_evals/README.md @@ -77,31 +77,20 @@ Public CLI surface: - `--verbose`: stream assistant output for frontend runs - `--skip-judge`: skip LLM judge scoring for the run - `--execution-only`: only require the model/proxy/frontend loop to complete; skip validators, tool expectations, backend artifact validation, and judge scoring +- `--reasoning `: reasoning effort for frontend modes (`off`, `low`, `medium`, `high`, `max`, …); without it the product's default applies (`high` on models that can reason). The effort is appended to the recorded model label (`anthropic:claude-sonnet-5-5@max`) - `--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 ## Models -Use `bun run cli -- models` to see the current aliases. - -Today: - -- `haiku` -- `sonnet` -- `opus` -- `4o` -- `gpt-5.5` -- `gemini-3-flash-preview` -- `gemini-3.1-pro-preview` -- `deepseek-v4-flash` -- `deepseek-v4-pro` +Use `bun run cli -- models` to see the current aliases; `core/models.ts` is the list. Notes: - the command also prints accepted alias spellings such as `gpt-4o`, `gpt-55`, `claude-opus-4.6`, and `claude-haiku-4.5` - frontend modes (`flow`, `script`, `app`, `global`) can use Anthropic, OpenAI, Gemini, and DeepSeek-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`; use `--skip-judge` for deterministic-only runs +- the judge model is separate and currently defaults to `claude-sonnet-5-5`; use `--skip-judge` for deterministic-only runs ## Case Format diff --git a/ai_evals/adapters/frontend/core/script/scriptEvalRunner.ts b/ai_evals/adapters/frontend/core/script/scriptEvalRunner.ts index b9c80f3554..0ff352f3d4 100644 --- a/ai_evals/adapters/frontend/core/script/scriptEvalRunner.ts +++ b/ai_evals/adapters/frontend/core/script/scriptEvalRunner.ts @@ -15,6 +15,7 @@ import { runEval } from "../shared"; import type { ModeRunContext } from "../../../../core/types"; import type { TokenUsage, ToolCallDetail } from "../shared/types"; import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings"; +import { evalReasoningEffort } from "../shared/providerConfig"; export interface ScriptEvalResult { success: boolean; @@ -41,14 +42,15 @@ export interface ScriptEvalOptions { function resolveModelProvider( model: string, provider?: AIProvider, -): AIProviderModel { +): AIProviderModel & { reasoning?: string } { + const reasoning = evalReasoningEffort(); if (provider) { - return { provider, model }; + return { provider, model, reasoning }; } if (model.startsWith("claude")) { - return { provider: "anthropic", model }; + return { provider: "anthropic", model, reasoning }; } - return { provider: "openai", model }; + return { provider: "openai", model, reasoning }; } export async function runScriptEval( diff --git a/ai_evals/adapters/frontend/core/shared/providerConfig.ts b/ai_evals/adapters/frontend/core/shared/providerConfig.ts index 9d9e315217..49d5cb6ef8 100644 --- a/ai_evals/adapters/frontend/core/shared/providerConfig.ts +++ b/ai_evals/adapters/frontend/core/shared/providerConfig.ts @@ -12,6 +12,12 @@ export interface EvalClients { export interface ResolvedEvalModelProvider { provider: FrontendEvalProvider; model: string; + reasoning?: string; +} + +/** `run --reasoning` hands the effort to the frontend runtime through the environment. */ +export function evalReasoningEffort(): string | undefined { + return process.env.WMILL_AI_EVAL_REASONING || undefined; } export interface WindmillAiProxyClientConfig { @@ -73,6 +79,13 @@ export function createEvalClients(input: { export function resolveEvalModelProvider( model: string, provider?: FrontendEvalProvider, +): ResolvedEvalModelProvider { + return { ...resolveProvider(model, provider), reasoning: evalReasoningEffort() }; +} + +function resolveProvider( + model: string, + provider?: FrontendEvalProvider, ): ResolvedEvalModelProvider { if (provider) { return { provider, model }; diff --git a/ai_evals/adapters/frontend/mockBackend.ts b/ai_evals/adapters/frontend/mockBackend.ts index 90df0cf870..2f47e85be6 100644 --- a/ai_evals/adapters/frontend/mockBackend.ts +++ b/ai_evals/adapters/frontend/mockBackend.ts @@ -1,4 +1,7 @@ import { randomUUID } from 'node:crypto' +import confluenceFixture from '../../fixtures/frontend/global/hub/confluence.json' +import servicenowFixture from '../../fixtures/frontend/global/hub/servicenow.json' +import outreachFixture from '../../fixtures/frontend/global/hub/outreach.json' import type { AppWithLastVersion, CompletedJob, @@ -250,9 +253,7 @@ export function listBenchmarkApps(workspace: string): ListableApp[] | null { } export function getBenchmarkAppByPath(workspace: string, path: string): AppWithLastVersion | null { - const app = benchmarkWorkspaceRunnables - .get(workspace) - ?.apps?.find((entry) => entry.path === path) + const app = benchmarkWorkspaceRunnables.get(workspace)?.apps?.find((entry) => entry.path === path) return app ? buildBenchmarkApp(app) : null } @@ -448,7 +449,10 @@ export function createBenchmarkCompletedJob(input: { labels: input.label ? [input.label] : undefined } - benchmarkJobs.set(benchmarkJobKey(input.workspace, jobId), { workspace: input.workspace, job }) + benchmarkJobs.set(benchmarkJobKey(input.workspace, jobId), { + workspace: input.workspace, + job + }) return jobId } @@ -532,7 +536,13 @@ export function getBenchmarkFlowAllResults(workspace: string, jobId: string) { */ const benchmarkDrafts = new Map< string, - { workspace: string; kind: UserDraftItemKind; path: string; value: unknown; createdAt: string } + { + workspace: string + kind: UserDraftItemKind + path: string + value: unknown + createdAt: string + } >() // Counter-based timestamps: deterministic run-to-run (same event order → same @@ -614,7 +624,9 @@ export function getBenchmarkDraftForUser(input: { }): GetDraftForUserResponse { const entry = benchmarkDrafts.get(benchmarkDraftKey(input.workspace, input.kind, input.path)) if (!entry) { - throw Object.assign(new Error(`no draft for "${input.path}"`), { status: 404 }) + throw Object.assign(new Error(`no draft for "${input.path}"`), { + status: 404 + }) } return { value: entry.value, created_at: entry.createdAt } } @@ -635,7 +647,11 @@ export function getBenchmarkOwnDraft(input: { /** Whether a deployed benchmark item exists for a draft row's kind+path — * drives `draft_only`, which production computes against the deployed tables. */ -function benchmarkDeployedExists(workspace: string, kind: UserDraftItemKind, path: string): boolean { +function benchmarkDeployedExists( + workspace: string, + kind: UserDraftItemKind, + path: string +): boolean { if (kind === 'script') return Boolean(getBenchmarkScriptByPath(workspace, path)) if (kind === 'flow') return Boolean(getBenchmarkFlowByPath(workspace, path)) if (kind === 'app' || kind === 'raw_app') return Boolean(getBenchmarkAppByPath(workspace, path)) @@ -762,10 +778,11 @@ export function runBenchmarkDatatableSql(input: { * shape `pollJobResult` consumes. The job is created synchronously before * polling, so it is always present and completed. */ -export function getBenchmarkCompletedJobResultMaybe(input: { - workspace: string - id: string -}): { success: boolean; completed: boolean; result: unknown } { +export function getBenchmarkCompletedJobResultMaybe(input: { workspace: string; id: string }): { + success: boolean + completed: boolean + result: unknown +} { const job = getBenchmarkCompletedJob(input.workspace, input.id) if (!job) { throw new Error(`Job "${input.id}" not found in benchmark workspace`) @@ -896,7 +913,9 @@ export function previewBenchmarkSchedule(input: { }): Record { const schedule = input.requestBody?.schedule if (typeof schedule !== 'string' || schedule.trim().split(/\s+/).length !== 6) { - throw new Error(`schedule must use a six-field cron expression, got ${JSON.stringify(schedule)}`) + throw new Error( + `schedule must use a six-field cron expression, got ${JSON.stringify(schedule)}` + ) } return { @@ -928,7 +947,9 @@ export function createBenchmarkHttpTrigger(input: { typeof input.requestBody.route_path === 'string' && input.requestBody.route_path.startsWith('/') ) { - throw new Error(`HTTP trigger route_path must not start with /, got "${input.requestBody.route_path}"`) + throw new Error( + `HTTP trigger route_path must not start with /, got "${input.requestBody.route_path}"` + ) } return { path: input.requestBody.path, @@ -1024,7 +1045,39 @@ function buildBenchmarkApp(app: BenchmarkWorkspaceApp): AppWithLastVersion { * hub tools throw and no case can exercise hub reuse. Serving fixtures rather * than the live hub also keeps assertions on script content stable as the real * hub republishes new versions. */ +const BENCHMARK_INTEGRATION_META_PATH = /^\/api\/integrations\/hub\/([^/]+)\/meta$/ + +/** One integration lifted verbatim from the content repo — its shipped scripts, its + * authored meta.json and its resource type. Hand-written fixtures make both routes to + * an integration's conventions look equally cheap; a real one is the only way to tell + * whether reading the metadata beats reading a script. */ +const REAL_HUB_INTEGRATIONS = [ + confluenceFixture, + servicenowFixture, + outreachFixture +] as unknown as Array<{ + app: string + display_name: string + description: string + docs_url: string + curated: boolean | null + meta: unknown + resource_type: { name: string; description: string; schema: unknown } + scripts: Array<{ + version_id: number + app: string + summary: string + description: string + terms: string + kind: string + language: string + content: string + schema: unknown + }> +}> + const BENCHMARK_HUB_SCRIPTS = [ + ...REAL_HUB_INTEGRATIONS.flatMap((integration) => integration.scripts), { version_id: 22235, app: 'holded', @@ -1112,6 +1165,87 @@ export async function main(discord_webhook: DiscordWebhook, message: string) { message: { type: 'string' } } } + }, + // Baremetrics carries no annotation script, on purpose: it stands for an + // integration whose conventions (an `apiKey` resource, bearer auth, the /v1 + // base) are only learnable by reading a script that does something else. + { + version_id: 8995, + app: 'baremetrics', + summary: 'List Sources', + description: 'List all the sources attached to a Baremetrics account.', + terms: 'baremetrics sources list revenue metrics', + language: 'bunnative', + content: `//native +type Baremetrics = { + apiKey: string; +}; +export async function main(resource: Baremetrics) { + const response = await fetch("https://api.baremetrics.com/v1/sources", { + method: "GET", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: \`Bearer \${resource.apiKey}\`, + }, + }); + if (!response.ok) { + throw new Error(\`\${response.status} \${await response.text()}\`); + } + return await response.json(); +} +`, + schema: { + type: 'object', + required: ['resource'], + properties: { + resource: { type: 'object', format: 'resource-baremetrics' } + } + } + }, + { + version_id: 8996, + app: 'baremetrics', + summary: 'Create Customer', + description: 'Create a customer on a Baremetrics source.', + terms: 'baremetrics customer create source', + language: 'bunnative', + content: `//native +type Baremetrics = { + apiKey: string; +}; +export async function main( + resource: Baremetrics, + sourceId: string, + body: { oid: string; name?: string; email?: string }, +) { + const response = await fetch( + \`https://api.baremetrics.com/v1/\${sourceId}/customers\`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: \`Bearer \${resource.apiKey}\`, + }, + body: JSON.stringify(body), + }, + ); + if (!response.ok) { + throw new Error(\`\${response.status} \${await response.text()}\`); + } + return await response.json(); +} +`, + schema: { + type: 'object', + required: ['resource', 'sourceId', 'body'], + properties: { + resource: { type: 'object', format: 'resource-baremetrics' }, + sourceId: { type: 'string' }, + body: { type: 'object' } + } + } } ] @@ -1121,20 +1255,26 @@ export async function main(discord_webhook: DiscordWebhook, message: string) { * integration, or overlapping on three meaningful words. A looser bar answers * "send a Slack message" with the Discord fixture, handing an unrelated case a * plausible-looking wrong integration. */ -function searchBenchmarkHubScripts(text: string) { +function searchBenchmarkHubScripts(text: string, app: string | null, kind: string | null) { const tokens = new Set( text .toLowerCase() .split(/[^a-z0-9]+/) .filter((token) => token.length > 2) ) - return BENCHMARK_HUB_SCRIPTS.map((script) => { - const words = new Set( - `${script.app} ${script.summary} ${script.terms}`.toLowerCase().split(/[^a-z0-9]+/) - ) - const score = [...tokens].filter((token) => words.has(token)).length - return { script, score, namesApp: tokens.has(script.app) } - }) + // The real endpoint filters by app and kind before ranking, so honour both here — + // otherwise a narrowed search silently returns other integrations' scripts, and a + // trigger answers a search production would never have shown it to. + return BENCHMARK_HUB_SCRIPTS.filter( + (script) => (!app || script.app === app) && (!kind || (script.kind ?? 'script') === kind) + ) + .map((script) => { + const words = new Set( + `${script.app} ${script.summary} ${script.terms}`.toLowerCase().split(/[^a-z0-9]+/) + ) + const score = [...tokens].filter((token) => words.has(token)).length + return { script, score, namesApp: tokens.has(script.app) } + }) .filter((entry) => entry.namesApp || entry.score >= 3) .sort((a, b) => b.score - a.score) .map(({ script }, index) => ({ @@ -1142,12 +1282,104 @@ function searchBenchmarkHubScripts(text: string) { id: script.version_id, version_id: script.version_id, summary: script.summary, + description: script.description ?? null, app: script.app, - kind: 'script', + kind: script.kind ?? 'script', score: 1 - index * 0.01 })) } +/** Listing an integration is unranked and description-bearing, matching the hub's + * top-scripts endpoint — that asymmetry with the semantic search is the whole + * reason the chat browses by app when no script matches the task. */ +function listBenchmarkHubScriptsByApp(app: string | null, kind: string | null) { + return BENCHMARK_HUB_SCRIPTS.filter( + (script) => (!app || script.app === app) && (!kind || (script.kind ?? 'script') === kind) + ).map((script) => ({ + id: script.version_id, + ask_id: script.version_id, + version_id: script.version_id, + summary: script.summary, + description: script.description ?? null, + app: script.app, + kind: script.kind ?? 'script', + views: 0, + votes: 0 + })) +} + +/** What `/integrations/hub//meta` serves. Carries the integration's conventions + * and none of the endpoints a case asks the model to write, so the tool shortens the + * path to that knowledge without supplying answers. */ +/** The integrations a case writes against, none of which the content repo documents — + * the same majority shape as the live hub, where 18 of ~216 carry a meta.json. Their + * conventions live in their shipped scripts, which is what these cases exercise. */ +const BENCHMARK_HUB_INTEGRATION_META: Record< + string, + { + display_name: string + description: string + docs_url: string + curated: boolean | null + meta: unknown + } +> = { + baremetrics: { + display_name: 'Baremetrics', + description: 'Subscription analytics for recurring-revenue businesses.', + docs_url: 'https://developers.baremetrics.com/reference', + curated: null, + meta: null + }, + holded: { + display_name: 'Holded', + description: 'Invoicing, accounting and CRM for small businesses.', + docs_url: 'https://developers.holded.com/reference', + curated: null, + meta: null + } +} + +/** Mirrors the hub's own derivation: hosts seen in the shipped scripts, whether they + * call the provider directly, and how many there are of each kind. */ +function benchmarkDerivedFacts(app: string) { + const scripts = BENCHMARK_HUB_SCRIPTS.filter((script) => script.app === app) + const hosts = new Map() + const languages: Record = {} + for (const script of scripts) { + languages[script.language] = (languages[script.language] ?? 0) + 1 + for (const match of script.content.matchAll(/https?:\/\/([a-zA-Z0-9._-]+)/g)) { + hosts.set(match[1], (hosts.get(match[1]) ?? 0) + 1) + } + } + return { + api_hosts: [...hosts.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([host, count]) => ({ host, count })), + style: 'fetch', + languages, + script_counts: { + total: scripts.length, + by_kind: scripts.reduce>((acc, script) => { + const kind = script.kind ?? 'script' + acc[kind] = (acc[kind] ?? 0) + 1 + return acc + }, {}) + }, + top_scripts: scripts.map((script) => ({ + path: `hub/${script.version_id}/${script.app}/${script.summary.toLowerCase().replaceAll(/\s+/g, '_')}`, + ask_id: script.version_id, + version_id: script.version_id, + summary: script.summary, + description: script.description ?? null, + kind: script.kind ?? 'script', + language: script.language, + views: 0, + votes: 0 + })) + } +} + /** The hub keys a script by its version id; the app and slug segments that * follow are descriptive, so match on the id exactly as the real hub does. */ function getBenchmarkHubScript(path: string) { @@ -1190,6 +1422,9 @@ export function hasBenchmarkApiHandler(url: string): boolean { return ( path === '/api/workers/list' || path === '/api/embeddings/query_hub_scripts' || + path === '/api/scripts/hub/top' || + path === '/api/integrations/hub/list' || + BENCHMARK_INTEGRATION_META_PATH.test(path) || path.startsWith('/api/scripts/hub/get_full/') || BENCHMARK_AI_MODELS_PATH.test(path) ) @@ -1213,8 +1448,81 @@ export function handleBenchmarkApiFetch(url: string, init?: RequestInit): Respon return Response.json({ data: (seed?.models ?? []).map((id) => ({ id })) }) } if (path === '/api/embeddings/query_hub_scripts') { - const text = new URLSearchParams(url.split('?')[1] ?? '').get('text') ?? '' - return Response.json(searchBenchmarkHubScripts(text)) + const params = new URLSearchParams(url.split('?')[1] ?? '') + return Response.json( + searchBenchmarkHubScripts(params.get('text') ?? '', params.get('app'), params.get('kind')) + ) + } + if (path === '/api/scripts/hub/top') { + const params = new URLSearchParams(url.split('?')[1] ?? '') + return Response.json({ + asks: listBenchmarkHubScriptsByApp(params.get('app'), params.get('kind')) + }) + } + const integrationMeta = BENCHMARK_INTEGRATION_META_PATH.exec(path) + if (integrationMeta) { + const app = decodeURIComponent(integrationMeta[1]) + const real = REAL_HUB_INTEGRATIONS.find((integration) => integration.app === app) + if (real) { + return Response.json({ + app, + display_name: real.display_name, + description: real.description, + docs_url: real.docs_url, + curated: real.curated, + metadata_source: real.meta ? 'curated' : 'derived', + meta: real.meta, + meta_updated_at: null, + derived: benchmarkDerivedFacts(app), + resource_types: [{ id: 1, ...real.resource_type }] + }) + } + const entry = BENCHMARK_HUB_INTEGRATION_META[app] + if (!entry) { + return Response.json({ error: 'integration not found' }, { status: 404 }) + } + return Response.json({ + app, + display_name: entry.display_name, + description: entry.description, + docs_url: entry.docs_url, + curated: entry.curated, + metadata_source: entry.meta ? 'curated' : 'derived', + meta: entry.meta, + meta_updated_at: null, + derived: benchmarkDerivedFacts(app), + resource_types: [ + { + id: 1, + name: app, + description: `${entry.display_name} credentials`, + schema: { + type: 'object', + required: ['apiKey'], + properties: { + apiKey: { + type: 'string', + description: `${entry.display_name} API key` + } + } + } + } + ] + }) + } + if (path === '/api/integrations/hub/list') { + const apps = [...new Set(BENCHMARK_HUB_SCRIPTS.map((script) => script.app))].sort() + // Read off both fixtures the metadata endpoint serves from, or the list would + // call an integration undocumented and then hand back its authored notes. + const documented = new Set([ + ...REAL_HUB_INTEGRATIONS.filter((integration) => integration.meta).map( + (integration) => integration.app + ), + ...Object.entries(BENCHMARK_HUB_INTEGRATION_META) + .filter(([, entry]) => entry.meta) + .map(([app]) => app) + ]) + return Response.json(apps.map((name) => ({ name, documented: documented.has(name) }))) } if (path.startsWith('/api/scripts/hub/get_full/')) { const script = getBenchmarkHubScript(path) diff --git a/ai_evals/adapters/frontend/mockBackendApi.test.ts b/ai_evals/adapters/frontend/mockBackendApi.test.ts index 85bd1f3320..b3829e56da 100644 --- a/ai_evals/adapters/frontend/mockBackendApi.test.ts +++ b/ai_evals/adapters/frontend/mockBackendApi.test.ts @@ -16,3 +16,21 @@ describe('benchmark API fetch handlers', () => { expect(body[0]).toMatchObject({ worker: expect.any(String), worker_group: expect.any(String) }) }) }) + +// A benchmark whose list calls an integration undocumented while its metadata endpoint +// hands back authored notes teaches the model the flag means nothing. +describe('benchmark hub integration list', () => { + it('flags exactly the integrations whose metadata carries authored notes', async () => { + const listed = (await handleBenchmarkApiFetch('/api/integrations/hub/list').json()) as Array<{ + name: string + documented: boolean + }> + expect(listed.length).toBeGreaterThan(0) + + for (const { name, documented } of listed) { + const res = handleBenchmarkApiFetch(`/api/integrations/hub/${name}/meta`) + const authored = res.status === 200 && !!((await res.json()) as { meta?: unknown }).meta + expect(authored).toBe(documented) + } + }) +}) diff --git a/ai_evals/adapters/frontend/vitestAdapter.test.ts b/ai_evals/adapters/frontend/vitestAdapter.test.ts index 0c8a22a987..9adb4d05ac 100644 --- a/ai_evals/adapters/frontend/vitestAdapter.test.ts +++ b/ai_evals/adapters/frontend/vitestAdapter.test.ts @@ -1,10 +1,14 @@ import { expect, it, vi } from 'vitest' // @ts-ignore - Node.js fs/promises -import { mkdir, writeFile } from 'fs/promises' +import { mkdir, readFile, writeFile } from 'fs/promises' // @ts-ignore - Node.js path import { dirname, resolve } from 'path' +// @ts-ignore - Node.js url +import { fileURLToPath } from 'url' import { handleBenchmarkApiFetch, hasBenchmarkApiHandler } from './mockBackend' +const FRONTEND_DIR = fileURLToPath(new URL('../../../frontend/', import.meta.url)) + // Some tools reach the backend by relative fetch('/api/...'), which has no meaning // in the vitest environment — serve the ones the benchmark handles. // Every other relative fetch keeps its normal behavior (it fails the same way @@ -24,9 +28,30 @@ globalThis.fetch = (async (input: unknown, init?: RequestInit) => { if (typeof url === 'string' && hasBenchmarkApiHandler(url)) { return handleBenchmarkApiFetch(url, init) } + // The parsers behind inferArgs load their wasm from a vite `?url` path, which only a dev + // server serves. Unserved, every script schema infers as empty, and the tools then tell the + // model its arguments are undeclared, so it rewrites a correct script until it runs out of turns. + const wasmPath = typeof url === 'string' ? wasmFilePath(url) : undefined + if (wasmPath) { + return new Response(await readFile(wasmPath), { + headers: { 'Content-Type': 'application/wasm' } + }) + } return ORIGINAL_FETCH(input as Parameters[0], init) }) as typeof fetch +function wasmFilePath(url: string): string | undefined { + const raw = url.split(/[?#]/)[0] + if (!raw.endsWith('.wasm')) return undefined + const pathname = decodeURIComponent(raw) + // Located past whatever base path the config prefixes. + const fs = pathname.indexOf('/@fs/') + if (fs !== -1) return pathname.slice(fs + '/@fs'.length) + const modules = pathname.indexOf('/node_modules/') + if (modules !== -1) return resolve(FRONTEND_DIR, `.${pathname.slice(modules)}`) + return undefined +} + vi.mock('monaco-editor', () => ({ editor: {}, languages: {}, @@ -640,6 +665,13 @@ benchmarkIt( async () => { const { resetBenchmarkMockBackend } = await import('./mockBackend') resetBenchmarkMockBackend() + // The tools swallow inference failures, so a wasm the fetch stub stops serving would + // only show up as empty schemas the model chases, never as an error. + const { inferArgs } = await import('$lib/infer') + const { emptySchema } = await import('$lib/utils') + const probe = emptySchema() + await inferArgs('bun', 'export async function main(name: string) {}', probe) + expect(Object.keys(probe.properties)).toEqual(['name']) const { runFrontendBenchmarkFromEnv } = await import('./benchmarkRunner') try { const payload = await runFrontendBenchmarkFromEnv() diff --git a/ai_evals/bun.lock b/ai_evals/bun.lock index eaed1db99a..798022d49a 100644 --- a/ai_evals/bun.lock +++ b/ai_evals/bun.lock @@ -5,8 +5,8 @@ "": { "name": "windmill-ai-evals", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.25", - "@anthropic-ai/sdk": "^0.39.0", + "@anthropic-ai/claude-agent-sdk": "^0.3.284", + "@anthropic-ai/sdk": "^0.129.0", "commander": "^14.0.3", "openai": "^6.9.1", "yaml": "^2.8.3", @@ -18,66 +18,44 @@ }, }, "packages": { - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.87", "", { "dependencies": { "@anthropic-ai/sdk": "^0.74.0", "@modelcontextprotocol/sdk": "^1.27.1" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-WWmgBPxPhBOvNT0ujI8vPTI2lK+w5YEkEZ/y1mH0EDkK/0kBnxVJNhCtG5vnueiAViwLoUOFn66pbkDiivijdA=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.284", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.284", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.284", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.284", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.284", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.284", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.284", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.284", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.284" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-NSoJwEq6nFSf8dtaacYx37QdGgqApI3eHFUlxclMLYi8irb6ZJwEaUnPnLUCyAyg9W/tMkgZC0GXWLRCc01I0w=="], - "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.39.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg=="], + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.284", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gKY9MUjY83398uCiPLHsd87kyzu7agIM7ApqWpJkpSINep6hxx4rNoR8bUbNWg49Aoe/PW/DJkQByAQuNzR9rg=="], - "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.284", "", { "os": "darwin", "cpu": "x64" }, "sha512-P+q6Z7sKeYz99uE7RJc4au1IK+4SiWe69pyHY3QXMnjZq8hSFU/5Mo8iSurLk9jRlihsMltV1rLA5naouYyCAQ=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.284", "", { "os": "linux", "cpu": "arm64" }, "sha512-LDpuYDaz+pCdG29iy1pw4P1D2YVtpZOb4rWOPxxSpg2fFyUeQ5WrfnuszOxhDWpNh1k1ekDiKm3A9OzGcrkdtA=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.284", "", { "os": "linux", "cpu": "arm64" }, "sha512-U3/uAv1TS8sMWsgWQSxYbdND5AaRXXpP5RxHU1oYC5ZizlZE8/xAVum9dj1QX3rVZJg8EzsqZveq/AkEv5ABfQ=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.284", "", { "os": "linux", "cpu": "x64" }, "sha512-yGytBCCwJvWeg1FzFpgnLM9BOM2vKVExtv6pMJHMtF82J5yhKHcxodRaYlQVd/HzJJ2nruKb9EnrhRBZ//4yRw=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.284", "", { "os": "linux", "cpu": "x64" }, "sha512-4x0Q8CFkCNbeENE7pRhuUmkwh/sQfTq1yWi4ZE56stve9pgoKzu6ZDa1SEtvK1YIooGE/qM5rVS8xFLE1gbQ0w=="], + + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.284", "", { "os": "win32", "cpu": "arm64" }, "sha512-zOXkdPHhElyxyFJ6eY5R+YGUswqBDZ+ZHNaRmQGJ28RkQYgwG5imNy/nfFR+/CPGlMnH9ZzbGc2rKYApm1K4Tg=="], + + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.284", "", { "os": "win32", "cpu": "x64" }, "sha512-VGaFRDCOPloj5IjvJTyy5JsSl9sE2HR3W+A6f/cjgh23/7Y5vK7/ka+1JQD+AkoGO9tbHqP2O6god3IBrLvOaw=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.129.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-MH7LB20kNpGLUpPTe2OG5XvL/KhX8HUO9XyBaFjgFk6TLS4Xjt0VPIuMKywe7POKKPslX+QxSlVok9e+8kG5Nw=="], + + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], "@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="], - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], - - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], - - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], - - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], - - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], - - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], - - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], - - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], - - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], - - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], - - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], - - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], - - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], - - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], - - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], - - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], - "@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - - "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], - - "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], - "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], - "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], @@ -88,8 +66,6 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], - "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], @@ -106,8 +82,6 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], - "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -122,14 +96,10 @@ "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], - "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], - "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], - "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], - "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], @@ -140,16 +110,12 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], - "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], - - "form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="], - - "formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="], - "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], @@ -164,16 +130,12 @@ "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], - "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], - "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -208,10 +170,6 @@ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], - - "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -262,30 +220,24 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "standardwebhooks": ["standardwebhooks@1.1.1", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], - "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], - - "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], - - "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], @@ -295,19 +247,5 @@ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], - - "@anthropic-ai/claude-agent-sdk/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.74.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-srbJV7JKsc5cQ6eVuFzjZO7UR3xEPJqPamHFIe29bs38Ij2IripoAhC0S5NslNbaFUYqBKypmmpzMTpqfHEUDw=="], - - "@types/node-fetch/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], - - "bun-types/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], - - "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - - "@types/node-fetch/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - - "bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - - "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], } } diff --git a/ai_evals/cases/cli.yaml b/ai_evals/cases/cli.yaml index d57425e827..b7abcf7f14 100644 --- a/ai_evals/cases/cli.yaml +++ b/ai_evals/cases/cli.yaml @@ -11,12 +11,6 @@ forbiddenSkills: - write-script-python3 - write-flow - orderedAssistantMentions: - - wmill generate-metadata - - wmill sync push - orderedProposedCommands: - - wmill generate-metadata - - wmill sync push forbiddenExecutedCommands: - ^wmill generate-metadata - ^wmill sync push @@ -38,12 +32,6 @@ - write-flow forbiddenSkills: - write-script-python3 - orderedAssistantMentions: - - wmill generate-metadata - - wmill sync push - orderedProposedCommands: - - wmill generate-metadata - - wmill sync push forbiddenExecutedCommands: - ^wmill generate-metadata - ^wmill sync push @@ -65,12 +53,6 @@ forbiddenSkills: - write-script-bun - write-flow - orderedAssistantMentions: - - wmill generate-metadata - - wmill sync push - orderedProposedCommands: - - wmill generate-metadata - - wmill sync push forbiddenExecutedCommands: - ^wmill generate-metadata - ^wmill sync push @@ -114,12 +96,6 @@ - write-flow requiredSkillsBeforeFirstMutation: - write-flow - orderedAssistantMentions: - - wmill generate-metadata - - wmill sync push - orderedProposedCommands: - - wmill generate-metadata - - wmill sync push forbiddenExecutedCommands: - ^wmill generate-metadata - ^wmill sync push diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index 96aa3ab983..476218a066 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -2312,6 +2312,105 @@ - email skipJudge: true +# `List Pages` takes a cursor without ever following one, so where the next cursor +# comes from (`_links.next`) is stated in the authored metadata and in no script body. +# Paginating is therefore the one task where reading scripts is not an equal route. +- id: global-hub4-confluence-paginate-all-pages + prompt: |- + I need a script that returns every page in one of my Confluence spaces, not just the + first batch — it should keep fetching until there are no more. + Save it as a draft at `f/evals/global/confluence_all_pages` and leave it as an AI + draft; do not deploy it. + initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json + runtime: + maxTurns: 14 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: script + path: f/evals/global/confluence_all_pages + valueIncludes: + - /wiki/ + - Basic + toolExpect: + requiredToolsUsed: + - write_script + # Either route to the integration's conventions is fine; the checklist is where the + # difference between them has to show up. + requiredToolsAnyOf: + - - search_hub_scripts + - get_hub_integration + forbiddenToolsUsed: + - deploy_workspace_item + judgeChecklist: + - the script loops until the API stops returning a next link, rather than making a single request + - the next cursor is read from the response's `_links.next` or the Link header, not from an invented field such as `next_cursor`, `nextPageToken` or `offset` + - it authenticates with HTTP Basic built from the Confluence resource's email and API token rather than a bearer token + - the script takes a Confluence resource as an input parameter rather than loose credential strings + - the result stays an AI draft and is not deployed + +# Unlike the Confluence case, both facts this needs — the {"result"} envelope and +# PATCH-not-PUT — are visible in ServiceNow's own shipped scripts. That makes it the +# control for whether the metadata earns its place when the scripts already answer. +- id: global-hub5-servicenow-assign-incident + prompt: |- + Write me a script that assigns an existing ServiceNow incident to a person, looking + that person up by their email address. + Save it as a draft at `f/evals/global/servicenow_assign_incident` and leave it as an + AI draft; do not deploy it. + initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json + runtime: + maxTurns: 14 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: script + path: f/evals/global/servicenow_assign_incident + toolExpect: + requiredToolsUsed: + - write_script + requiredToolsAnyOf: + - - search_hub_scripts + - get_hub_integration + forbiddenToolsUsed: + - deploy_workspace_item + judgeChecklist: + - it looks the person up in the sys_user table by email rather than assuming a sys_id was supplied + - it reads records out of the `result` envelope the ServiceNow API wraps responses in + - it sets assigned_to to the user's sys_id rather than to the raw email address + - it updates the incident with a partial update (PATCH) rather than a full replace that would clear the fields it did not send + - the result stays an AI draft and is not deployed + +# Outreach is JSON:API. Its content type and payload envelope are both stated in the +# metadata and visible in the shipped update scripts, so this is a second control. +- id: global-hub6-outreach-reschedule-task + prompt: |- + Write me a script that moves an existing Outreach task to a new due date. + Save it as a draft at `f/evals/global/outreach_reschedule_task` and leave it as an AI + draft; do not deploy it. + initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json + runtime: + maxTurns: 14 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: script + path: f/evals/global/outreach_reschedule_task + toolExpect: + requiredToolsUsed: + - write_script + requiredToolsAnyOf: + - - search_hub_scripts + - get_hub_integration + forbiddenToolsUsed: + - deploy_workspace_item + judgeChecklist: + - the request sends the JSON:API content type application/vnd.api+json for its body + - the payload wraps the change as data.type and data.attributes rather than sending bare top-level fields + - it identifies the task by id inside the payload rather than only in the URL, and treats the id as a number + - it updates the existing task with PATCH rather than creating a new one + - the result stays an AI draft and is not deployed + # --- Windmill Hub reuse (search_hub_scripts + read_workspace_item on a hub/ path) --- # Holded's API is obscure enough that a model writing from memory cannot reproduce # its endpoint and `key` auth header — so the draft's fidelity to the published @@ -2358,6 +2457,78 @@ - the document type, document id, and recipient emails are inputs to the script - the result stays an AI draft and is not deployed +# Same task as global-hub1 with the hub never mentioned: this is what tells us the +# model reaches for the hub on its own rather than only when pointed at it. +- id: global-hub2-checks-hub-unprompted + prompt: |- + I want to email one of my Holded invoices to a customer from Windmill. + Save it as a draft script at `f/evals/global/holded_send_document` and leave it as + an AI draft; do not deploy it. + initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json + runtime: + maxTurns: 12 + validate: + draftCountExactly: 1 + requiredDrafts: + # No language assertion: the Holded script's body is `//native`, so a faithful + # fork lands on bun or bunnative depending on how literally it is copied. + - type: script + path: f/evals/global/holded_send_document + valueIncludes: + - api.holded.com/api/invoicing/v1/documents + - mailTemplateId + toolExpect: + requiredToolsUsed: + - search_hub_scripts + - write_script + forbiddenToolsUsed: + - deploy_workspace_item + judgeChecklist: + - the draft sends an existing Holded document by email rather than creating one + - the request targets Holded's document send endpoint, not an invented URL + - authentication uses Holded's own key header rather than a bearer token + - the result stays an AI draft and is not deployed + +# The fixture hub carries two Baremetrics scripts, neither for annotations, so nothing +# does what is asked. Its conventions — an `apiKey` resource, bearer auth, the +# api.baremetrics.com/v1 base — are only knowable by reading one of those near-misses. +- id: global-hub3-integration-as-example + prompt: |- + Write me a script that adds an annotation to my Baremetrics account. + Save it as a draft at `f/evals/global/baremetrics_create_annotation` and leave it as + an AI draft; do not deploy it. + initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json + runtime: + maxTurns: 14 + validate: + draftCountExactly: 1 + requiredDrafts: + # No language assertion: matching the hub script's own dialect is the point, + # and Baremetrics' scripts are `//native`, so both bun and bunnative are right. + - type: script + path: f/evals/global/baremetrics_create_annotation + valueIncludes: + - api.baremetrics.com/v1 + - apiKey + toolExpect: + requiredToolsUsed: + - search_hub_scripts + - write_script + # Reading a near-miss script and reading the integration's own metadata are two + # routes to the same conventions, so requiring either keeps the case about the + # outcome rather than about which route the model took. + requiredToolsAnyOf: + - - read_workspace_item + - get_hub_integration + forbiddenToolsUsed: + - deploy_workspace_item + judgeChecklist: + - the script takes a Baremetrics resource as an input parameter rather than a bare API key string + - the resource type it declares carries an apiKey field, matching how other Baremetrics hub scripts take credentials + - the request authenticates with a bearer token built from that resource + - the script posts an annotation rather than reading data back + - the result stays an AI draft and is not deployed + # The value of a secret variable is unreadable, so a metadata-only edit must leave # it untouched: passing any `value` here means inventing one, which silently # replaces the real secret at deploy. diff --git a/ai_evals/cli/index.ts b/ai_evals/cli/index.ts index 504b8a8d13..8a5f63c8b2 100644 --- a/ai_evals/cli/index.ts +++ b/ai_evals/cli/index.ts @@ -108,6 +108,10 @@ async function main() { "--record", "append a compact summary line to ai_evals/history/.jsonl", ) + .option( + "--reasoning ", + "reasoning effort for frontend modes (e.g. off, low, medium, high, max); default: the product's", + ) .option( "--backend-validation ", `backend smoke validation (${BACKEND_VALIDATION_MODES.join(", ")})`, @@ -126,6 +130,7 @@ async function main() { executionOnly?: boolean; record?: boolean; backendValidation?: string; + reasoning?: string; }, ) => { await handleRun({ @@ -140,6 +145,7 @@ async function main() { executionOnly: options.executionOnly ?? false, record: options.record ?? false, backendValidation: options.backendValidation, + reasoning: options.reasoning, }); }, ); @@ -190,6 +196,7 @@ async function handleRun(input: { executionOnly: boolean; record: boolean; backendValidation?: string; + reasoning?: string; }) { if (input.record && input.caseIds.length > 0) { throw new Error( @@ -217,6 +224,13 @@ async function handleRun(input: { "--backend-validation currently supports only flow and script modes", ); } + if (input.reasoning) { + if (input.mode === "cli") { + throw new Error("--reasoning only applies to frontend modes"); + } + // The frontend runtime runs in a child process, which inherits it. + process.env.WMILL_AI_EVAL_REASONING = input.reasoning; + } if (input.mode !== "cli") { await assertWindmillBackendReachable(resolveWindmillBackendSettings()); } @@ -257,6 +271,10 @@ async function handleRun(input: { backendValidation, }); + if (input.reasoning && result.runModel) { + result.runModel = `${result.runModel}@${input.reasoning}`; + } + const resolvedOutputPath = models.length === 1 ? resolveRunOutputPath(input.mode, input.outputPath) diff --git a/ai_evals/core/judge.ts b/ai_evals/core/judge.ts index 5e67510027..f1df60f286 100644 --- a/ai_evals/core/judge.ts +++ b/ai_evals/core/judge.ts @@ -1,7 +1,7 @@ import Anthropic from "@anthropic-ai/sdk"; import type { EvalMode, JudgeResult } from "./types"; -export const DEFAULT_JUDGE_MODEL = "claude-sonnet-4-6"; +export const DEFAULT_JUDGE_MODEL = "claude-sonnet-5-5"; const JUDGE_TOOL_NAME = "submit_judgement"; @@ -29,6 +29,7 @@ export async function judgeOutput(input: { const system = [ "You evaluate benchmark outputs for Windmill AI generation.", + `Always answer by calling the ${JUDGE_TOOL_NAME} tool.`, "Deterministic checks already run separately. Focus on whether the final output satisfies the user request.", "If expected state is provided, treat it as a valid example and reward semantically equivalent outputs.", "If a checklist is provided, treat it as the explicit acceptance criteria for this case.", @@ -69,8 +70,8 @@ export async function judgeOutput(input: { try { const response = await client.messages.create({ model, - max_tokens: 1024, - temperature: 0, + // The judge thinks by default, and thinking shares this budget with the verdict. + max_tokens: 16000, system, messages: [{ role: "user", content: user }], tools: [ @@ -93,11 +94,8 @@ export async function judgeOutput(input: { }, }, ], - tool_choice: { - type: "tool", - name: JUDGE_TOOL_NAME, - disable_parallel_tool_use: true, - }, + // Current models refuse a forced tool_choice ("tool"/"any"). + tool_choice: { type: "auto", disable_parallel_tool_use: true }, }); const toolUseBlock = response.content.find( diff --git a/ai_evals/core/models.test.ts b/ai_evals/core/models.test.ts index ba53c24592..d51d61d224 100644 --- a/ai_evals/core/models.test.ts +++ b/ai_evals/core/models.test.ts @@ -34,6 +34,10 @@ describe("resolveEvalModel", () => { it("supports DeepSeek aliases for frontend evals", () => { expect(resolveEvalModel("flow", "deepseek").frontend).toEqual({ + provider: "deepseek", + model: "deepseek-flash", + }); + expect(resolveEvalModel("flow", "deepseek-v4-flash").frontend).toEqual({ provider: "deepseek", model: "deepseek-v4-flash", }); diff --git a/ai_evals/core/models.ts b/ai_evals/core/models.ts index 295cd36135..06689c36e1 100644 --- a/ai_evals/core/models.ts +++ b/ai_evals/core/models.ts @@ -78,6 +78,32 @@ export const EVAL_MODELS: EvalModelSpec[] = [ model: "opus", }, }, + { + id: "sonnet-5.5", + label: "Claude Sonnet 5.5", + aliases: ["sonnet-5.5", "claude-sonnet-5.5", "claude-sonnet-5-5"], + frontend: { + provider: "anthropic", + model: "claude-sonnet-5-5", + }, + cli: { + provider: "anthropic", + model: "claude-sonnet-5-5", + }, + }, + { + id: "opus-5.5", + label: "Claude Opus 5.5", + aliases: ["opus-5.5", "claude-opus-5.5", "claude-opus-5-5"], + frontend: { + provider: "anthropic", + model: "claude-opus-5-5", + }, + cli: { + provider: "anthropic", + model: "claude-opus-5-5", + }, + }, { id: "4o", label: "GPT-4o", @@ -96,6 +122,51 @@ export const EVAL_MODELS: EvalModelSpec[] = [ model: "gpt-5.5", }, }, + { + id: "gpt-5.6-sol", + label: "GPT-5.6 Sol", + aliases: ["gpt-5.6-sol"], + frontend: { + provider: "openai", + model: "gpt-5.6-sol", + }, + }, + { + id: "gpt-6-astra", + label: "GPT-6 Astra", + aliases: ["gpt-6-astra", "gpt-6"], + frontend: { + provider: "openai", + model: "gpt-6-astra", + }, + }, + { + id: "gpt-6-sol", + label: "GPT-6 Sol", + aliases: ["gpt-6-sol"], + frontend: { + provider: "openai", + model: "gpt-6-sol", + }, + }, + { + id: "gpt-6-luna", + label: "GPT-6 Luna", + aliases: ["gpt-6-luna"], + frontend: { + provider: "openai", + model: "gpt-6-luna", + }, + }, + { + id: "gemini-3.8-flash", + label: "Gemini 3.8 Flash", + aliases: ["gemini-3.8-flash"], + frontend: { + provider: "googleai", + model: "gemini-3.8-flash", + }, + }, { id: "gemini-3-flash-preview", label: "Gemini 3 Flash Preview", @@ -121,12 +192,21 @@ export const EVAL_MODELS: EvalModelSpec[] = [ { id: "deepseek-v4-flash", label: "DeepSeek V4 Flash", - aliases: ["deepseek", "deepseek-v4", "deepseek-v4-flash"], + aliases: ["deepseek-v4", "deepseek-v4-flash"], frontend: { provider: "deepseek", model: "deepseek-v4-flash", }, }, + { + id: "deepseek-flash", + label: "DeepSeek V4.1 Flash", + aliases: ["deepseek", "deepseek-flash", "deepseek-v4.1-flash"], + frontend: { + provider: "deepseek", + model: "deepseek-flash", + }, + }, { id: "deepseek-v4-pro", label: "DeepSeek V4 Pro", diff --git a/ai_evals/fixtures/frontend/global/hub/confluence.json b/ai_evals/fixtures/frontend/global/hub/confluence.json new file mode 100644 index 0000000000..29b5c0f11a --- /dev/null +++ b/ai_evals/fixtures/frontend/global/hub/confluence.json @@ -0,0 +1,983 @@ +{ + "app": "confluence", + "display_name": "Confluence", + "description": "Atlassian's team workspace: pages, blog posts and spaces.", + "docs_url": "https://developer.atlassian.com/cloud/confluence/rest/v2/intro/", + "curated": true, + "meta": { + "api_docs": "https://developer.atlassian.com/cloud/confluence/rest/v2/intro/", + "shipped_surfaces": [ + "pages", + "blogposts", + "spaces", + "search", + "users" + ], + "validation": { + "status": "live_validated", + "method": "smoke test via `bun --env-file=.env.test -e` (+ `bun test` with the windmill-client fake for the trigger) against a free Confluence Cloud site (a test site) with an API token. All 13 actions + the trigger returned 2xx: full page lifecycle (create -> get -> update -> list -> delete), blog post create/update/delete, list_spaces, search_content (CQL), get_current_user, and the new_or_updated_page polling trigger.", + "sources": { + "pages": "HIGH \u2014 Pipedream actions + official v2 OpenAPI; create/get/update/list/delete all run live (2xx)", + "blogposts": "HIGH \u2014 Pipedream actions + official v2 OpenAPI; create/update/delete all run live (2xx)", + "spaces": "HIGH \u2014 Pipedream list-spaces + official v2 OpenAPI; list_spaces run live (2xx)", + "search": "HIGH \u2014 Pipedream search-content + official v1 REST; search_content (CQL) run live (2xx)", + "users": "HIGH \u2014 v1 /wiki/rest/api/user/current run live (2xx) with Basic auth", + "trigger": "HIGH \u2014 new_or_updated_page e2e-tested under bun test with the auto-fake; first run sets the watermark, a subsequent run emits a freshly created page" + }, + "low_confidence_fields": [] + }, + "pagination": { + "pattern": "cursor", + "request_params": { + "cursor": "cursor", + "limit": "limit" + }, + "response_fields": { + "next": "_links.next" + }, + "default_limit": 25, + "notes": "v2 endpoints return an opaque cursor inside the `_links.next` relative URL (?cursor=...) and also in the Link response header. The v1 /search endpoint paginates the same way." + }, + "gotchas": [ + "Two API versions are in play: pages / blog posts / spaces use the v2 REST API (/wiki/api/v2); CQL search and current-user use the v1 REST API (/wiki/rest/api).", + "Auth is HTTP Basic with the Atlassian account email as the username and an API token (NOT the account password) as the password.", + "v2 query params are kebab-case (body-format, created-date, get-draft, root-level); v2 JSON request-body fields are camelCase (spaceId, parentId).", + "Updating a page or blog post requires version.number = currentVersion + 1; the update_* scripts fetch the current version and increment it automatically.", + "spaceId / parentId in v2 request bodies are numeric IDs passed as strings, not space keys.", + "DELETE returns 204 with an empty body and trashes by default; pass purge=true to permanently remove an item that is already in the trash." + ], + "enums": { + "page_status": [ + "current", + "archived", + "deleted", + "trashed", + "draft", + "historical" + ], + "body_representation": [ + "storage", + "atlas_doc_format" + ], + "body_format_read": [ + "storage", + "atlas_doc_format", + "view", + "export_view", + "anonymous_export_view", + "styled_view", + "editor" + ], + "space_type": [ + "global", + "collaboration", + "knowledge_base", + "personal" + ], + "page_sort": [ + "id", + "-id", + "created-date", + "-created-date", + "modified-date", + "-modified-date", + "title", + "-title" + ] + }, + "errors": { + "401": "Authentication failed \u2014 verify the email + API token pair and that the token has not been revoked.", + "404": "Resource not found, or the authenticating account lacks permission to view it (Confluence often returns 404 rather than 403 for content the user cannot access)." + } + }, + "resource_type": { + "name": "confluence", + "description": "Confluence Cloud credentials. Authenticate with an Atlassian account email and an API token (HTTP Basic auth) against your site base URL.", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "apiToken": { + "description": "API token for your Atlassian account. Create one at https://id.atlassian.com/manage-profile/security/api-tokens", + "type": "string" + }, + "baseUrl": { + "description": "Base URL of your Confluence Cloud site, e.g. https://your-domain.atlassian.net (no trailing slash, no /wiki).", + "type": "string" + }, + "email": { + "description": "Email of the Atlassian account used to authenticate.", + "type": "string" + } + }, + "required": [ + "email", + "apiToken", + "baseUrl" + ], + "type": "object" + } + }, + "scripts": [ + { + "version_id": 28765, + "app": "confluence", + "summary": "Create Blog Post", + "description": "Create a new blog post in a space.", + "terms": "confluence create blog post create a new blog post in a space.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Create Blog Post\n * Create a new blog post in a space. The body is supplied as a content string in the chosen representation (default \"storage\", i.e. Confluence storage HTML).\n */\nexport async function main(\n auth: RT.Confluence,\n space_id: string,\n title: string,\n body: string,\n status: \"current\" | \"draft\" | undefined,\n representation: \"storage\" | \"atlas_doc_format\" | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/api/v2/blogposts`)\n\n const payload = {\n spaceId: space_id,\n status: status ?? \"current\",\n title,\n body: {\n representation: representation ?? \"storage\",\n value: body,\n },\n }\n\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n \"Content-Type\": \"application/json\",\n Accept: \"application/json\",\n },\n body: JSON.stringify(payload),\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "body": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "representation": { + "default": null, + "description": "", + "enum": [ + "storage", + "atlas_doc_format" + ], + "originalType": "enum", + "type": "string" + }, + "space_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "status": { + "default": null, + "description": "", + "enum": [ + "current", + "draft" + ], + "originalType": "enum", + "type": "string" + }, + "title": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth", + "space_id", + "title", + "body" + ], + "type": "object" + } + }, + { + "version_id": 28766, + "app": "confluence", + "summary": "Create Page", + "description": "Create a new page in a space.", + "terms": "confluence create page create a new page in a space.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Create Page\n * Create a new page in a space. The body is supplied as a content string in the chosen representation (default \"storage\", i.e. Confluence storage HTML).\n */\nexport async function main(\n auth: RT.Confluence,\n space_id: string,\n title: string,\n body: string,\n status: \"current\" | \"draft\" | undefined,\n parent_id: string | undefined,\n representation: \"storage\" | \"atlas_doc_format\" | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/api/v2/pages`)\n\n const payload = {\n spaceId: space_id,\n status: status ?? \"current\",\n title,\n parentId: parent_id,\n body: {\n representation: representation ?? \"storage\",\n value: body,\n },\n }\n\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n \"Content-Type\": \"application/json\",\n Accept: \"application/json\",\n },\n body: JSON.stringify(payload),\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "body": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "parent_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "representation": { + "default": null, + "description": "", + "enum": [ + "storage", + "atlas_doc_format" + ], + "originalType": "enum", + "type": "string" + }, + "space_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "status": { + "default": null, + "description": "", + "enum": [ + "current", + "draft" + ], + "originalType": "enum", + "type": "string" + }, + "title": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth", + "space_id", + "title", + "body" + ], + "type": "object" + } + }, + { + "version_id": 28767, + "app": "confluence", + "summary": "Delete Blog Post", + "description": "Delete a blog post by its ID (moved to trash by default; purge to permanently remove).", + "terms": "confluence delete blog post delete a blog post by its id (moved to trash by default; purge to permanently remove).", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Delete Blog Post\n * Delete a blog post by its ID. By default the post is moved to the trash; set purge to permanently remove an already-trashed post.\n */\nexport async function main(\n auth: RT.Confluence,\n blog_post_id: string,\n purge: boolean | undefined,\n draft: boolean | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/api/v2/blogposts/${blog_post_id}`)\n if (purge !== undefined) url.searchParams.append(\"purge\", String(purge))\n if (draft !== undefined) url.searchParams.append(\"draft\", String(draft))\n\n const response = await fetch(url, {\n method: \"DELETE\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n if (response.status === 204) return { success: true }\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "blog_post_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "draft": { + "default": null, + "description": "", + "type": "boolean" + }, + "purge": { + "default": null, + "description": "", + "type": "boolean" + } + }, + "required": [ + "auth", + "blog_post_id" + ], + "type": "object" + } + }, + { + "version_id": 28768, + "app": "confluence", + "summary": "Delete Page", + "description": "Delete a page by its ID (moved to trash by default; purge to permanently remove).", + "terms": "confluence delete page delete a page by its id (moved to trash by default; purge to permanently remove).", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Delete Page\n * Delete a page by its ID. By default the page is moved to the trash; set purge to permanently remove an already-trashed page.\n */\nexport async function main(\n auth: RT.Confluence,\n page_id: string,\n purge: boolean | undefined,\n draft: boolean | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/api/v2/pages/${page_id}`)\n if (purge !== undefined) url.searchParams.append(\"purge\", String(purge))\n if (draft !== undefined) url.searchParams.append(\"draft\", String(draft))\n\n const response = await fetch(url, {\n method: \"DELETE\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n if (response.status === 204) return { success: true }\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "draft": { + "default": null, + "description": "", + "type": "boolean" + }, + "page_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "purge": { + "default": null, + "description": "", + "type": "boolean" + } + }, + "required": [ + "auth", + "page_id" + ], + "type": "object" + } + }, + { + "version_id": 28769, + "app": "confluence", + "summary": "Get Current User", + "description": "Returns the profile of the user that the API token authenticates as.", + "terms": "confluence get current user returns the profile of the user that the api token authenticates as.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Get Current User\n * Returns the profile of the user that the API token authenticates as (account ID, display name, email).\n */\nexport async function main(auth: RT.Confluence) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/rest/api/user/current`)\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28770, + "app": "confluence", + "summary": "Get Page by ID", + "description": "Retrieve a single page by its ID, optionally returning its body in a specific format or a previous version.", + "terms": "confluence get page by id retrieve a single page by its id, optionally returning its body in a specific format or a previous version.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Get Page by ID\n * Retrieve a single page by its ID, optionally returning its body in a specific format or a previous version.\n */\nexport async function main(\n auth: RT.Confluence,\n page_id: string,\n body_format:\n | \"storage\"\n | \"atlas_doc_format\"\n | \"view\"\n | \"export_view\"\n | \"anonymous_export_view\"\n | \"styled_view\"\n | \"editor\"\n | undefined,\n version: number | undefined,\n get_draft: boolean | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/api/v2/pages/${page_id}`)\n if (body_format !== undefined)\n url.searchParams.append(\"body-format\", body_format)\n if (version !== undefined) url.searchParams.append(\"version\", String(version))\n if (get_draft !== undefined)\n url.searchParams.append(\"get-draft\", String(get_draft))\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "body_format": { + "default": null, + "description": "", + "enum": [ + "storage", + "atlas_doc_format", + "view", + "export_view", + "anonymous_export_view", + "styled_view", + "editor" + ], + "originalType": "enum", + "type": "string" + }, + "get_draft": { + "default": null, + "description": "", + "type": "boolean" + }, + "page_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "version": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth", + "page_id" + ], + "type": "object" + } + }, + { + "version_id": 28771, + "app": "confluence", + "summary": "Get Pages in Space", + "description": "Retrieve a paginated list of pages within a given space.", + "terms": "confluence get pages in space retrieve a paginated list of pages within a given space.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Get Pages in Space\n * Retrieve a paginated list of pages within a given space.\n */\nexport async function main(\n auth: RT.Confluence,\n space_id: string,\n status: (\"current\" | \"archived\" | \"deleted\" | \"trashed\")[] | undefined,\n title: string | undefined,\n sort:\n | \"id\"\n | \"-id\"\n | \"created-date\"\n | \"-created-date\"\n | \"modified-date\"\n | \"-modified-date\"\n | \"title\"\n | \"-title\"\n | undefined,\n body_format: \"storage\" | \"atlas_doc_format\" | undefined,\n cursor: string | undefined,\n limit: number | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/api/v2/spaces/${space_id}/pages`)\n for (const s of status ?? []) url.searchParams.append(\"status\", s)\n if (title !== undefined && title !== \"\")\n url.searchParams.append(\"title\", title)\n if (sort !== undefined) url.searchParams.append(\"sort\", sort)\n if (body_format !== undefined)\n url.searchParams.append(\"body-format\", body_format)\n if (cursor !== undefined && cursor !== \"\")\n url.searchParams.append(\"cursor\", cursor)\n if (limit !== undefined) url.searchParams.append(\"limit\", String(limit))\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "body_format": { + "default": null, + "description": "", + "enum": [ + "storage", + "atlas_doc_format" + ], + "originalType": "enum", + "type": "string" + }, + "cursor": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "limit": { + "default": null, + "description": "", + "type": "number" + }, + "sort": { + "default": null, + "description": "", + "enum": [ + "id", + "-id", + "created-date", + "-created-date", + "modified-date", + "-modified-date", + "title", + "-title" + ], + "originalType": "enum", + "type": "string" + }, + "space_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "status": { + "default": null, + "description": "", + "items": { + "enum": [ + "current", + "archived", + "deleted", + "trashed" + ], + "type": "string" + }, + "originalType": "enum[]", + "type": "array" + }, + "title": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth", + "space_id" + ], + "type": "object" + } + }, + { + "version_id": 28772, + "app": "confluence", + "summary": "List Blog Posts", + "description": "Retrieve a paginated list of blog posts on the site, with optional filtering and sorting.", + "terms": "confluence list blog posts retrieve a paginated list of blog posts on the site, with optional filtering and sorting.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Blog Posts\n * Retrieve a paginated list of blog posts on the site, with optional filtering and sorting.\n */\nexport async function main(\n auth: RT.Confluence,\n status: (\"current\" | \"archived\" | \"deleted\" | \"trashed\")[] | undefined,\n title: string | undefined,\n sort:\n | \"id\"\n | \"-id\"\n | \"created-date\"\n | \"-created-date\"\n | \"modified-date\"\n | \"-modified-date\"\n | undefined,\n body_format: \"storage\" | \"atlas_doc_format\" | undefined,\n cursor: string | undefined,\n limit: number | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/api/v2/blogposts`)\n for (const s of status ?? []) url.searchParams.append(\"status\", s)\n if (title !== undefined && title !== \"\")\n url.searchParams.append(\"title\", title)\n if (sort !== undefined) url.searchParams.append(\"sort\", sort)\n if (body_format !== undefined)\n url.searchParams.append(\"body-format\", body_format)\n if (cursor !== undefined && cursor !== \"\")\n url.searchParams.append(\"cursor\", cursor)\n if (limit !== undefined) url.searchParams.append(\"limit\", String(limit))\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "body_format": { + "default": null, + "description": "", + "enum": [ + "storage", + "atlas_doc_format" + ], + "originalType": "enum", + "type": "string" + }, + "cursor": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "limit": { + "default": null, + "description": "", + "type": "number" + }, + "sort": { + "default": null, + "description": "", + "enum": [ + "id", + "-id", + "created-date", + "-created-date", + "modified-date", + "-modified-date" + ], + "originalType": "enum", + "type": "string" + }, + "status": { + "default": null, + "description": "", + "items": { + "enum": [ + "current", + "archived", + "deleted", + "trashed" + ], + "type": "string" + }, + "originalType": "enum[]", + "type": "array" + }, + "title": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28773, + "app": "confluence", + "summary": "List Pages", + "description": "Retrieve a paginated list of pages on the site, with optional filtering and sorting.", + "terms": "confluence list pages retrieve a paginated list of pages on the site, with optional filtering and sorting.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Pages\n * Retrieve a paginated list of pages on the site, with optional filtering and sorting.\n */\nexport async function main(\n auth: RT.Confluence,\n status: (\"current\" | \"archived\" | \"deleted\" | \"trashed\")[] | undefined,\n title: string | undefined,\n sort:\n | \"id\"\n | \"-id\"\n | \"created-date\"\n | \"-created-date\"\n | \"modified-date\"\n | \"-modified-date\"\n | \"title\"\n | \"-title\"\n | undefined,\n body_format: \"storage\" | \"atlas_doc_format\" | undefined,\n cursor: string | undefined,\n limit: number | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/api/v2/pages`)\n for (const s of status ?? []) url.searchParams.append(\"status\", s)\n if (title !== undefined && title !== \"\")\n url.searchParams.append(\"title\", title)\n if (sort !== undefined) url.searchParams.append(\"sort\", sort)\n if (body_format !== undefined)\n url.searchParams.append(\"body-format\", body_format)\n if (cursor !== undefined && cursor !== \"\")\n url.searchParams.append(\"cursor\", cursor)\n if (limit !== undefined) url.searchParams.append(\"limit\", String(limit))\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "body_format": { + "default": null, + "description": "", + "enum": [ + "storage", + "atlas_doc_format" + ], + "originalType": "enum", + "type": "string" + }, + "cursor": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "limit": { + "default": null, + "description": "", + "type": "number" + }, + "sort": { + "default": null, + "description": "", + "enum": [ + "id", + "-id", + "created-date", + "-created-date", + "modified-date", + "-modified-date", + "title", + "-title" + ], + "originalType": "enum", + "type": "string" + }, + "status": { + "default": null, + "description": "", + "items": { + "enum": [ + "current", + "archived", + "deleted", + "trashed" + ], + "type": "string" + }, + "originalType": "enum[]", + "type": "array" + }, + "title": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28774, + "app": "confluence", + "summary": "List Spaces", + "description": "Retrieve a paginated list of spaces on the site, with optional filtering by key, type, and status.", + "terms": "confluence list spaces retrieve a paginated list of spaces on the site, with optional filtering by key, type, and status.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Spaces\n * Retrieve a paginated list of spaces on the site, with optional filtering by key, type, and status.\n */\nexport async function main(\n auth: RT.Confluence,\n keys: string[] | undefined,\n type: \"global\" | \"collaboration\" | \"knowledge_base\" | \"personal\" | undefined,\n status: \"current\" | \"archived\" | undefined,\n sort: \"id\" | \"-id\" | \"key\" | \"-key\" | \"name\" | \"-name\" | undefined,\n cursor: string | undefined,\n limit: number | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/api/v2/spaces`)\n if (keys !== undefined && keys.length > 0)\n url.searchParams.append(\"keys\", keys.join(\",\"))\n if (type !== undefined) url.searchParams.append(\"type\", type)\n if (status !== undefined) url.searchParams.append(\"status\", status)\n if (sort !== undefined) url.searchParams.append(\"sort\", sort)\n if (cursor !== undefined && cursor !== \"\")\n url.searchParams.append(\"cursor\", cursor)\n if (limit !== undefined) url.searchParams.append(\"limit\", String(limit))\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "cursor": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "keys": { + "default": null, + "description": "", + "items": { + "type": "string" + }, + "originalType": "string[]", + "type": "array" + }, + "limit": { + "default": null, + "description": "", + "type": "number" + }, + "sort": { + "default": null, + "description": "", + "enum": [ + "id", + "-id", + "key", + "-key", + "name", + "-name" + ], + "originalType": "enum", + "type": "string" + }, + "status": { + "default": null, + "description": "", + "enum": [ + "current", + "archived" + ], + "originalType": "enum", + "type": "string" + }, + "type": { + "default": null, + "description": "", + "enum": [ + "global", + "collaboration", + "knowledge_base", + "personal" + ], + "originalType": "enum", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28775, + "app": "confluence", + "summary": "Search Content (CQL)", + "description": "Search for content using the Confluence Query Language (CQL).", + "terms": "confluence search content (cql) search for content using the confluence query language (cql).", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Search Content (CQL)\n * Search for content using the Confluence Query Language (CQL), e.g. `type=page AND space=ENG AND text ~ \"release notes\"`.\n */\nexport async function main(\n auth: RT.Confluence,\n cql: string,\n cursor: string | undefined,\n limit: number | undefined,\n expand: string | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const url = new URL(`${base}/wiki/rest/api/search`)\n url.searchParams.append(\"cql\", cql)\n if (cursor !== undefined && cursor !== \"\")\n url.searchParams.append(\"cursor\", cursor)\n if (limit !== undefined) url.searchParams.append(\"limit\", String(limit))\n if (expand !== undefined && expand !== \"\")\n url.searchParams.append(\"expand\", expand)\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "cql": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "cursor": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "expand": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "limit": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth", + "cql" + ], + "type": "object" + } + }, + { + "version_id": 28776, + "app": "confluence", + "summary": "Update Blog Post", + "description": "Update a blog post's title, body, or status. The current version is fetched automatically and incremented.", + "terms": "confluence update blog post update a blog post's title, body, or status. the current version is fetched automatically and incremented.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Update Blog Post\n * Update a blog post's title, body, or status. The current version is fetched automatically and incremented; omitted fields keep their existing values.\n */\nexport async function main(\n auth: RT.Confluence,\n blog_post_id: string,\n title: string | undefined,\n body: string | undefined,\n status: \"current\" | \"draft\" | \"archived\" | undefined,\n representation: \"storage\" | \"atlas_doc_format\" | undefined,\n version_message: string | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const repr = representation ?? \"storage\"\n const authHeader = \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`)\n\n // Fetch the current blog post to obtain the version number and any fields not being changed.\n const currentRes = await fetch(\n `${base}/wiki/api/v2/blogposts/${blog_post_id}?body-format=${repr}`,\n {\n method: \"GET\",\n headers: { Authorization: authHeader, Accept: \"application/json\" },\n }\n )\n if (!currentRes.ok) {\n throw new Error(`${currentRes.status} ${await currentRes.text()}`)\n }\n const current = (await currentRes.json()) as {\n title: string\n status: string\n version: { number: number }\n body?: { [key: string]: { value?: string } }\n }\n\n const payload = {\n id: blog_post_id,\n status: status ?? current.status,\n title: title ?? current.title,\n body: {\n representation: repr,\n value: body ?? current.body?.[repr]?.value ?? \"\",\n },\n version: {\n number: current.version.number + 1,\n message: version_message,\n },\n }\n\n const response = await fetch(\n `${base}/wiki/api/v2/blogposts/${blog_post_id}`,\n {\n method: \"PUT\",\n headers: {\n Authorization: authHeader,\n \"Content-Type\": \"application/json\",\n Accept: \"application/json\",\n },\n body: JSON.stringify(payload),\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "blog_post_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "body": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "representation": { + "default": null, + "description": "", + "enum": [ + "storage", + "atlas_doc_format" + ], + "originalType": "enum", + "type": "string" + }, + "status": { + "default": null, + "description": "", + "enum": [ + "current", + "draft", + "archived" + ], + "originalType": "enum", + "type": "string" + }, + "title": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "version_message": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth", + "blog_post_id" + ], + "type": "object" + } + }, + { + "version_id": 28777, + "app": "confluence", + "summary": "Update Page", + "description": "Update a page's title, body, or status. The current version is fetched automatically and incremented.", + "terms": "confluence update page update a page's title, body, or status. the current version is fetched automatically and incremented.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Update Page\n * Update a page's title, body, or status. The current version is fetched automatically and incremented; omitted fields keep their existing values.\n */\nexport async function main(\n auth: RT.Confluence,\n page_id: string,\n title: string | undefined,\n body: string | undefined,\n status: \"current\" | \"draft\" | \"archived\" | undefined,\n representation: \"storage\" | \"atlas_doc_format\" | undefined,\n version_message: string | undefined\n) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const repr = representation ?? \"storage\"\n const authHeader = \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`)\n\n // Fetch the current page to obtain the version number and any fields not being changed.\n const currentRes = await fetch(\n `${base}/wiki/api/v2/pages/${page_id}?body-format=${repr}`,\n {\n method: \"GET\",\n headers: { Authorization: authHeader, Accept: \"application/json\" },\n }\n )\n if (!currentRes.ok) {\n throw new Error(`${currentRes.status} ${await currentRes.text()}`)\n }\n const current = (await currentRes.json()) as {\n title: string\n status: string\n version: { number: number }\n body?: { [key: string]: { value?: string } }\n }\n\n const payload = {\n id: page_id,\n status: status ?? current.status,\n title: title ?? current.title,\n body: {\n representation: repr,\n value: body ?? current.body?.[repr]?.value ?? \"\",\n },\n version: {\n number: current.version.number + 1,\n message: version_message,\n },\n }\n\n const response = await fetch(`${base}/wiki/api/v2/pages/${page_id}`, {\n method: \"PUT\",\n headers: {\n Authorization: authHeader,\n \"Content-Type\": \"application/json\",\n Accept: \"application/json\",\n },\n body: JSON.stringify(payload),\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "body": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "representation": { + "default": null, + "description": "", + "enum": [ + "storage", + "atlas_doc_format" + ], + "originalType": "enum", + "type": "string" + }, + "status": { + "default": null, + "description": "", + "enum": [ + "current", + "draft", + "archived" + ], + "originalType": "enum", + "type": "string" + }, + "title": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "version_message": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth", + "page_id" + ], + "type": "object" + } + }, + { + "version_id": 28778, + "app": "confluence", + "summary": "New or Updated Page", + "description": "Emits pages created or updated since the last poll, tracked via Windmill state. Optionally restrict to a single space.", + "terms": "confluence new or updated page emits pages created or updated since the last poll, tracked via windmill state. optionally restrict to a single space.", + "kind": "trigger", + "language": "bunnative", + "content": "//native\n\nimport * as wmill from \"windmill-client\"\n\n/**\n * New or Updated Page\n * Emits pages created or updated since the last poll, tracked via Windmill state. Optionally restrict to a single space.\n */\nexport async function main(auth: RT.Confluence, space_id: string | undefined) {\n const base = auth.baseUrl.replace(/\\/$/, \"\")\n const lastChecked: number = (await wmill.getState()) ?? 0\n\n const path = space_id\n ? `/wiki/api/v2/spaces/${space_id}/pages`\n : `/wiki/api/v2/pages`\n const url = new URL(`${base}${path}`)\n url.searchParams.append(\"sort\", \"-modified-date\")\n url.searchParams.append(\"limit\", \"50\")\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: \"Basic \" + btoa(`${auth.email}:${auth.apiToken}`),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n const data = (await response.json()) as {\n results?: { version?: { createdAt?: string } }[]\n }\n const pages = data.results ?? []\n\n const ts = (p: { version?: { createdAt?: string } }) =>\n p.version?.createdAt ? Date.parse(p.version.createdAt) : 0\n const maxTs = pages.reduce((m, p) => Math.max(m, ts(p)), 0)\n\n // First run: set the watermark to the newest page seen and don't emit a backlog.\n if (!lastChecked) {\n if (maxTs) await wmill.setState(maxTs)\n return []\n }\n\n const fresh = pages\n .filter((p) => ts(p) > lastChecked)\n .sort((a, b) => ts(a) - ts(b))\n\n if (maxTs > lastChecked) {\n await wmill.setState(maxTs)\n }\n\n return fresh\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-confluence", + "type": "object" + }, + "space_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + } + ] +} \ No newline at end of file diff --git a/ai_evals/fixtures/frontend/global/hub/outreach.json b/ai_evals/fixtures/frontend/global/hub/outreach.json new file mode 100644 index 0000000000..b63aad6849 --- /dev/null +++ b/ai_evals/fixtures/frontend/global/hub/outreach.json @@ -0,0 +1,1055 @@ +{ + "app": "outreach", + "display_name": "Outreach", + "description": "Sales engagement: prospects, sequences and tasks.", + "docs_url": "https://developers.outreach.io/api/", + "curated": true, + "meta": { + "api_docs": "https://developers.outreach.io/api/", + "shipped_surfaces": [ + "prospects", + "accounts", + "sequences", + "sequence_states", + "mailings", + "tasks", + "mailboxes" + ], + "validation": { + "status": "not_live_validated", + "method": "static \u2014 shapes derived from the official OpenAPI spec (https://api.outreach.io/api/v2/schema/openapi.json) + Pipedream actions; filters/pagination cross-checked against the spec's per-relationship filter notes and Airbyte source-outreach's live incremental-sync params; smoke test blocked: Outreach has no self-serve signup (demo-request only) and the developer portal requires an existing org admin account. Production-connector cross-validation pass (2026-06): checked ActivePieces (no outreach piece), n8n (no Outreach node), Microsoft PowerPlatformConnectors (no Outreach connector); corroborated against estuary/connectors source-outreach (newFilterSyntax [gte]/[lte] datetime cursors, page[size]/page[after] from links.next), singer-io/tap-outreach a.k.a. Stitch (old-syntax filter[updatedAt]=..inf + sort + count=false, links.next pagination), Supaglue (supaglue-labs, via fork: old-syntax ..inf with millisecond ISO datetimes; JSON:API create bodies for account/prospect/sequenceState incl. emails[] attribute and prospect/sequence/mailbox relationships), and friggframework api-module-library (task create/update with polymorphic subject relationship, live-test fixture). No contradictions found; no script changes required.", + "sources": { + "prospects": "MEDIUM \u2014 official OpenAPI spec + Pipedream create-prospect action; create body (attributes incl. emails[], account relationship) corroborated by Supaglue's production mapper; list filters corroborated by the Stitch tap; not run live", + "accounts": "MEDIUM \u2014 official OpenAPI spec + Pipedream create-account action; create body (name/domain attributes, owner relationship) corroborated by Supaglue; list filters corroborated by the Stitch tap; not run live", + "sequences": "MEDIUM \u2014 official OpenAPI spec; reads corroborated by Supaglue (sequences/sequenceSteps) and the Stitch tap; not run live", + "sequence_states": "MEDIUM \u2014 official OpenAPI spec + Pipedream add-prospect-sequence action; create body (prospect/sequence/mailbox relationships, mailbox required) matches Supaglue's production mapper exactly; not run live", + "mailings": "MEDIUM \u2014 official OpenAPI spec; list + updatedAt filter corroborated by the Stitch tap and Estuary; not run live", + "tasks": "MEDIUM \u2014 official OpenAPI spec; polymorphic subject relationship on create/update corroborated by friggframework's live-test fixture; list filters corroborated by the Stitch tap; not run live", + "mailboxes": "MEDIUM \u2014 official OpenAPI spec + Pipedream mailbox options resolver; list corroborated by the Stitch tap; not run live" + }, + "low_confidence_fields": [] + }, + "pagination": { + "pattern": "cursor", + "request_params": { + "limit": "page[size]", + "cursor": "page[after]" + }, + "response_fields": { + "next": "links.next (full URL; the page[after] value is its query param)" + }, + "default_limit": 50 + }, + "gotchas": [ + "JSON:API \u2014 every request with a body must send Content-Type: application/vnd.api+json or the API returns 415.", + "Write payloads wrap fields as { data: { type, attributes, relationships } }; ids are integers.", + "Filters: filter[attr]=value; multi filter[id]=1,2,3; ranges filter[updatedAt]=2024-01-01..inf (full ISO datetimes work too \u2014 Stitch's tap and Supaglue both send filter[updatedAt]=..inf in production; range bounds are inclusive); relationship filters filter[prospect][id]=123 (only [id] is reliably supported \u2014 attribute-based relationship filters were deprecated May 2023); sort=-createdAt for descending.", + "New filter syntax (newFilterSyntax=true) replaces ranges with filter[attr][gte]/[lte] and value lists with filter[attr][]=v \u2014 required when values contain ',' or '..'; Airbyte's connector uses it for datetime cursors, so prefer it for timestamp comparisons.", + "Task\u2194prospect association is set via the polymorphic `subject` relationship (type account|opportunity|prospect); the `prospect` relationship on tasks is readonly/derived.", + "Action endpoints (POST \u2026/tasks/{id}/actions/markComplete, \u2026/sequenceStates/{id}/actions/{pause|resume|finish}) take no request body and return 200 with the updated resource.", + "OAuth access tokens last 2h; refresh is grant_type=refresh_token at the same https://api.outreach.io/oauth/token endpoint, so Windmill auto-refresh works.", + "Scopes are ., e.g. prospects.all sequenceStates.all. All scopes registered in oauth_connect.json are enumerated verbatim in Outreach's official SDK (getoutreach/extensibility-sdk, src/manifest/api/Scopes.ts)." + ], + "enums": { + "task_action": [ + "action_item", + "call", + "email", + "in_person" + ], + "task_state": [ + "pending", + "incomplete", + "complete" + ], + "mailing_state": [ + "bounced", + "delivered", + "delivering", + "drafted", + "failed", + "opened", + "placeholder", + "queued", + "replied", + "scheduled" + ] + }, + "errors": { + "415": "Missing/wrong Content-Type \u2014 JSON:API requires application/vnd.api+json on requests with a body.", + "502": "Bad Gateway on list endpoints usually means the requested page[size] was too large to serve in time \u2014 retry with a smaller page[size] (Estuary's connector halves it and retries).", + "422": "Validation error \u2014 response carries errors[] with title/detail and a source.pointer to the offending attribute." + } + }, + "resource_type": { + "name": "outreach", + "description": "OAuth2 access token for the Outreach REST API (api.outreach.io). Connect with OAuth so the token refreshes automatically \u2014 Outreach has no long-lived API keys.", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "token": { + "description": "OAuth2 access token, filled automatically when you connect with OAuth (recommended \u2014 tokens are refreshed for you). Pasting a token minted from your own Outreach OAuth app (https://developers.outreach.io/api/oauth/) also works but it expires after 2 hours, so only do that for one-off testing.", + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + } + }, + "scripts": [ + { + "version_id": 28686, + "app": "outreach", + "summary": "Add Prospect to Sequence", + "description": "Adds an existing prospect to a sequence, sending from the selected mailbox.", + "terms": "outreach add prospect to sequence adds an existing prospect to a sequence, sending from the selected mailbox.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nexport type DynSelect_sequence = string\n\n// Resolver: lists the org's sequences for the dropdown.\nexport async function sequence(auth: RT.Outreach) {\n const url = new URL(\"https://api.outreach.io/api/v2/sequences\")\n url.searchParams.append(\"sort\", \"name\")\n url.searchParams.append(\"page[size]\", \"100\")\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n const { data } = (await response.json()) as {\n data: { id: number; attributes: { name: string } }[]\n }\n return data.map((s) => ({ value: String(s.id), label: s.attributes.name }))\n}\n\nexport type DynSelect_mailbox = string\n\n// Resolver: lists the org's sending mailboxes for the dropdown.\nexport async function mailbox(auth: RT.Outreach) {\n const url = new URL(\"https://api.outreach.io/api/v2/mailboxes\")\n url.searchParams.append(\"page[size]\", \"100\")\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n const { data } = (await response.json()) as {\n data: { id: number; attributes: { email: string } }[]\n }\n return data.map((m) => ({ value: String(m.id), label: m.attributes.email }))\n}\n\n/**\n * Add Prospect to Sequence\n * Adds an existing prospect to a sequence, sending from the selected mailbox.\n */\nexport async function main(\n auth: RT.Outreach,\n prospect_id: number,\n sequence: DynSelect_sequence,\n mailbox: DynSelect_mailbox\n) {\n const response = await fetch(\n \"https://api.outreach.io/api/v2/sequenceStates\",\n {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${auth.token}`,\n \"Content-Type\": \"application/vnd.api+json\",\n Accept: \"application/vnd.api+json\",\n },\n body: JSON.stringify({\n data: {\n type: \"sequenceState\",\n relationships: {\n prospect: { data: { type: \"prospect\", id: prospect_id } },\n sequence: { data: { type: \"sequence\", id: Number(sequence) } },\n mailbox: { data: { type: \"mailbox\", id: Number(mailbox) } },\n },\n },\n }),\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "mailbox": { + "default": null, + "description": "", + "format": "dynselect-mailbox", + "type": "object" + }, + "prospect_id": { + "default": null, + "description": "", + "type": "number" + }, + "sequence": { + "default": null, + "description": "", + "format": "dynselect-sequence", + "type": "object" + } + }, + "required": [ + "auth", + "prospect_id", + "sequence", + "mailbox" + ], + "type": "object" + } + }, + { + "version_id": 28687, + "app": "outreach", + "summary": "Complete Task", + "description": "Marks a task as complete.", + "terms": "outreach complete task marks a task as complete.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Complete Task\n * Marks a task as complete.\n */\nexport async function main(auth: RT.Outreach, task_id: number) {\n const response = await fetch(\n `https://api.outreach.io/api/v2/tasks/${task_id}/actions/markComplete`,\n {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "task_id": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth", + "task_id" + ], + "type": "object" + } + }, + { + "version_id": 28688, + "app": "outreach", + "summary": "Create Account", + "description": "Creates a company account in Outreach.", + "terms": "outreach create account creates a company account in outreach.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Create Account\n * Creates a company account in Outreach.\n */\nexport async function main(\n auth: RT.Outreach,\n name: string,\n domain: string | undefined,\n additional_attributes: { [key: string]: any } | undefined\n) {\n const attributes: { [key: string]: any } = { ...additional_attributes, name }\n if (domain !== undefined && domain !== \"\") {\n attributes.domain = domain\n }\n\n const response = await fetch(\"https://api.outreach.io/api/v2/accounts\", {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${auth.token}`,\n \"Content-Type\": \"application/vnd.api+json\",\n Accept: \"application/vnd.api+json\",\n },\n body: JSON.stringify({ data: { type: \"account\", attributes } }),\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "additional_attributes": { + "default": null, + "description": "", + "properties": {}, + "type": "object" + }, + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "domain": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "name": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth", + "name" + ], + "type": "object" + } + }, + { + "version_id": 28689, + "app": "outreach", + "summary": "Create Prospect", + "description": "Creates a new prospect in Outreach, optionally linked to an account.", + "terms": "outreach create prospect creates a new prospect in outreach, optionally linked to an account.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Create Prospect\n * Creates a new prospect in Outreach, optionally linked to an account.\n */\nexport async function main(\n auth: RT.Outreach,\n first_name: string | undefined,\n last_name: string | undefined,\n emails: string[] | undefined,\n title: string | undefined,\n company: string | undefined,\n account_id: number | undefined,\n additional_attributes: { [key: string]: any } | undefined\n) {\n const attributes: { [key: string]: any } = { ...additional_attributes }\n if (first_name !== undefined && first_name !== \"\") {\n attributes.firstName = first_name\n }\n if (last_name !== undefined && last_name !== \"\") {\n attributes.lastName = last_name\n }\n if (emails !== undefined && emails.length > 0) {\n attributes.emails = emails\n }\n if (title !== undefined && title !== \"\") {\n attributes.title = title\n }\n if (company !== undefined && company !== \"\") {\n attributes.company = company\n }\n\n const data: { [key: string]: any } = { type: \"prospect\", attributes }\n if (account_id !== undefined) {\n data.relationships = {\n account: { data: { type: \"account\", id: account_id } },\n }\n }\n\n const response = await fetch(\"https://api.outreach.io/api/v2/prospects\", {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${auth.token}`,\n \"Content-Type\": \"application/vnd.api+json\",\n Accept: \"application/vnd.api+json\",\n },\n body: JSON.stringify({ data }),\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "account_id": { + "default": null, + "description": "", + "type": "number" + }, + "additional_attributes": { + "default": null, + "description": "", + "properties": {}, + "type": "object" + }, + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "company": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "emails": { + "default": null, + "description": "", + "items": { + "type": "string" + }, + "originalType": "string[]", + "type": "array" + }, + "first_name": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "last_name": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "title": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28690, + "app": "outreach", + "summary": "Create Task", + "description": "Creates a manual task, optionally tied to a prospect and assigned to an owner.", + "terms": "outreach create task creates a manual task, optionally tied to a prospect and assigned to an owner.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Create Task\n * Creates a manual task, optionally tied to a prospect and assigned to an owner.\n */\nexport async function main(\n auth: RT.Outreach,\n action: \"action_item\" | \"call\" | \"email\" | \"in_person\",\n due_at: string,\n note: string | undefined,\n prospect_id: number | undefined,\n owner_id: number | undefined\n) {\n const attributes: { [key: string]: any } = { action, dueAt: due_at }\n if (note !== undefined && note !== \"\") {\n attributes.note = note\n }\n\n const relationships: { [key: string]: any } = {}\n if (prospect_id !== undefined) {\n // the writable association is `subject`; the `prospect` relationship is readonly\n relationships.subject = { data: { type: \"prospect\", id: prospect_id } }\n }\n if (owner_id !== undefined) {\n relationships.owner = { data: { type: \"user\", id: owner_id } }\n }\n\n const response = await fetch(\"https://api.outreach.io/api/v2/tasks\", {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${auth.token}`,\n \"Content-Type\": \"application/vnd.api+json\",\n Accept: \"application/vnd.api+json\",\n },\n body: JSON.stringify({ data: { type: \"task\", attributes, relationships } }),\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "action": { + "default": null, + "description": "", + "enum": [ + "action_item", + "call", + "email", + "in_person" + ], + "originalType": "enum", + "type": "string" + }, + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "due_at": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "note": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "owner_id": { + "default": null, + "description": "", + "type": "number" + }, + "prospect_id": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth", + "action", + "due_at" + ], + "type": "object" + } + }, + { + "version_id": 28691, + "app": "outreach", + "summary": "Get Account", + "description": "Retrieves a single account by its ID.", + "terms": "outreach get account retrieves a single account by its id.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Get Account\n * Retrieves a single account by its ID.\n */\nexport async function main(auth: RT.Outreach, account_id: number) {\n const response = await fetch(\n `https://api.outreach.io/api/v2/accounts/${account_id}`,\n {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "account_id": { + "default": null, + "description": "", + "type": "number" + }, + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + } + }, + "required": [ + "auth", + "account_id" + ], + "type": "object" + } + }, + { + "version_id": 28692, + "app": "outreach", + "summary": "Get Mailing", + "description": "Retrieves a single mailing by its ID, including engagement attributes.", + "terms": "outreach get mailing retrieves a single mailing by its id, including engagement attributes.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Get Mailing\n * Retrieves a single mailing by its ID, including engagement attributes (opens, clicks, replies).\n */\nexport async function main(auth: RT.Outreach, mailing_id: number) {\n const response = await fetch(\n `https://api.outreach.io/api/v2/mailings/${mailing_id}`,\n {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "mailing_id": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth", + "mailing_id" + ], + "type": "object" + } + }, + { + "version_id": 28693, + "app": "outreach", + "summary": "Get Prospect", + "description": "Retrieves a single prospect by its ID.", + "terms": "outreach get prospect retrieves a single prospect by its id.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Get Prospect\n * Retrieves a single prospect by its ID.\n */\nexport async function main(auth: RT.Outreach, prospect_id: number) {\n const response = await fetch(\n `https://api.outreach.io/api/v2/prospects/${prospect_id}`,\n {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "prospect_id": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth", + "prospect_id" + ], + "type": "object" + } + }, + { + "version_id": 28694, + "app": "outreach", + "summary": "Get Sequence", + "description": "Retrieves a single sequence by its ID.", + "terms": "outreach get sequence retrieves a single sequence by its id.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Get Sequence\n * Retrieves a single sequence by its ID.\n */\nexport async function main(auth: RT.Outreach, sequence_id: number) {\n const response = await fetch(\n `https://api.outreach.io/api/v2/sequences/${sequence_id}`,\n {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "sequence_id": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth", + "sequence_id" + ], + "type": "object" + } + }, + { + "version_id": 28695, + "app": "outreach", + "summary": "List Accounts", + "description": "Lists accounts with optional filters and cursor pagination.", + "terms": "outreach list accounts lists accounts with optional filters and cursor pagination.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Accounts\n * Lists accounts with optional filters (name, domain, updatedAt range like \"2024-01-01..inf\") and cursor pagination.\n */\nexport async function main(\n auth: RT.Outreach,\n filter_name: string | undefined,\n filter_domain: string | undefined,\n filter_updated_at: string | undefined,\n sort: string | undefined,\n page_size: number | undefined,\n page_after: string | undefined\n) {\n const url = new URL(\"https://api.outreach.io/api/v2/accounts\")\n if (filter_name !== undefined && filter_name !== \"\") {\n url.searchParams.append(\"filter[name]\", filter_name)\n }\n if (filter_domain !== undefined && filter_domain !== \"\") {\n url.searchParams.append(\"filter[domain]\", filter_domain)\n }\n if (filter_updated_at !== undefined && filter_updated_at !== \"\") {\n url.searchParams.append(\"filter[updatedAt]\", filter_updated_at)\n }\n if (sort !== undefined && sort !== \"\") {\n url.searchParams.append(\"sort\", sort)\n }\n if (page_size !== undefined) {\n url.searchParams.append(\"page[size]\", String(page_size))\n }\n if (page_after !== undefined && page_after !== \"\") {\n url.searchParams.append(\"page[after]\", page_after)\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "filter_domain": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "filter_name": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "filter_updated_at": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_after": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_size": { + "default": null, + "description": "", + "type": "number" + }, + "sort": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28696, + "app": "outreach", + "summary": "List Mailboxes", + "description": "Lists the org's sending mailboxes, optionally filtered by email address.", + "terms": "outreach list mailboxes lists the org's sending mailboxes, optionally filtered by email address.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Mailboxes\n * Lists the org's sending mailboxes, optionally filtered by email address.\n */\nexport async function main(\n auth: RT.Outreach,\n filter_email: string | undefined,\n page_size: number | undefined,\n page_after: string | undefined\n) {\n const url = new URL(\"https://api.outreach.io/api/v2/mailboxes\")\n if (filter_email !== undefined && filter_email !== \"\") {\n url.searchParams.append(\"filter[email]\", filter_email)\n }\n if (page_size !== undefined) {\n url.searchParams.append(\"page[size]\", String(page_size))\n }\n if (page_after !== undefined && page_after !== \"\") {\n url.searchParams.append(\"page[after]\", page_after)\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "filter_email": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_after": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_size": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28697, + "app": "outreach", + "summary": "List Mailings", + "description": "Lists mailings (sent/scheduled emails), filterable by prospect, state or updatedAt range.", + "terms": "outreach list mailings lists mailings (sent/scheduled emails), filterable by prospect, state or updatedat range.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Mailings\n * Lists mailings (sent/scheduled emails), filterable by prospect, state or updatedAt range.\n */\nexport async function main(\n auth: RT.Outreach,\n filter_prospect_id: number | undefined,\n filter_state:\n | (\n | \"bounced\"\n | \"delivered\"\n | \"delivering\"\n | \"drafted\"\n | \"failed\"\n | \"opened\"\n | \"placeholder\"\n | \"queued\"\n | \"replied\"\n | \"scheduled\"\n )\n | undefined,\n filter_updated_at: string | undefined,\n sort: string | undefined,\n page_size: number | undefined,\n page_after: string | undefined\n) {\n const url = new URL(\"https://api.outreach.io/api/v2/mailings\")\n if (filter_prospect_id !== undefined) {\n url.searchParams.append(\"filter[prospect][id]\", String(filter_prospect_id))\n }\n if (filter_state !== undefined) {\n url.searchParams.append(\"filter[state]\", filter_state)\n }\n if (filter_updated_at !== undefined && filter_updated_at !== \"\") {\n url.searchParams.append(\"filter[updatedAt]\", filter_updated_at)\n }\n if (sort !== undefined && sort !== \"\") {\n url.searchParams.append(\"sort\", sort)\n }\n if (page_size !== undefined) {\n url.searchParams.append(\"page[size]\", String(page_size))\n }\n if (page_after !== undefined && page_after !== \"\") {\n url.searchParams.append(\"page[after]\", page_after)\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "filter_prospect_id": { + "default": null, + "description": "", + "type": "number" + }, + "filter_state": { + "default": null, + "description": "", + "enum": [ + "bounced", + "delivered", + "delivering", + "drafted", + "failed", + "opened", + "placeholder", + "queued", + "replied", + "scheduled" + ], + "originalType": "enum", + "type": "string" + }, + "filter_updated_at": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_after": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_size": { + "default": null, + "description": "", + "type": "number" + }, + "sort": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28698, + "app": "outreach", + "summary": "List Prospects", + "description": "Lists prospects with optional filters and cursor pagination.", + "terms": "outreach list prospects lists prospects with optional filters and cursor pagination.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Prospects\n * Lists prospects with optional filters (email, name, updatedAt range like \"2024-01-01..inf\") and cursor pagination.\n */\nexport async function main(\n auth: RT.Outreach,\n filter_emails: string | undefined,\n filter_first_name: string | undefined,\n filter_last_name: string | undefined,\n filter_updated_at: string | undefined,\n sort: string | undefined,\n page_size: number | undefined,\n page_after: string | undefined\n) {\n const url = new URL(\"https://api.outreach.io/api/v2/prospects\")\n if (filter_emails !== undefined && filter_emails !== \"\") {\n url.searchParams.append(\"filter[emails]\", filter_emails)\n }\n if (filter_first_name !== undefined && filter_first_name !== \"\") {\n url.searchParams.append(\"filter[firstName]\", filter_first_name)\n }\n if (filter_last_name !== undefined && filter_last_name !== \"\") {\n url.searchParams.append(\"filter[lastName]\", filter_last_name)\n }\n if (filter_updated_at !== undefined && filter_updated_at !== \"\") {\n url.searchParams.append(\"filter[updatedAt]\", filter_updated_at)\n }\n if (sort !== undefined && sort !== \"\") {\n url.searchParams.append(\"sort\", sort)\n }\n if (page_size !== undefined) {\n url.searchParams.append(\"page[size]\", String(page_size))\n }\n if (page_after !== undefined && page_after !== \"\") {\n url.searchParams.append(\"page[after]\", page_after)\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "filter_emails": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "filter_first_name": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "filter_last_name": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "filter_updated_at": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_after": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_size": { + "default": null, + "description": "", + "type": "number" + }, + "sort": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28699, + "app": "outreach", + "summary": "List Sequence States", + "description": "Lists sequence states (prospect \u2194 sequence memberships), filterable by prospect, sequence or state.", + "terms": "outreach list sequence states lists sequence states (prospect \u2194 sequence memberships), filterable by prospect, sequence or state.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Sequence States\n * Lists sequence states (prospect \u2194 sequence memberships), filterable by prospect, sequence or state.\n */\nexport async function main(\n auth: RT.Outreach,\n filter_prospect_id: number | undefined,\n filter_sequence_id: number | undefined,\n filter_state: string | undefined,\n page_size: number | undefined,\n page_after: string | undefined\n) {\n const url = new URL(\"https://api.outreach.io/api/v2/sequenceStates\")\n if (filter_prospect_id !== undefined) {\n url.searchParams.append(\"filter[prospect][id]\", String(filter_prospect_id))\n }\n if (filter_sequence_id !== undefined) {\n url.searchParams.append(\"filter[sequence][id]\", String(filter_sequence_id))\n }\n if (filter_state !== undefined && filter_state !== \"\") {\n url.searchParams.append(\"filter[state]\", filter_state)\n }\n if (page_size !== undefined) {\n url.searchParams.append(\"page[size]\", String(page_size))\n }\n if (page_after !== undefined && page_after !== \"\") {\n url.searchParams.append(\"page[after]\", page_after)\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "filter_prospect_id": { + "default": null, + "description": "", + "type": "number" + }, + "filter_sequence_id": { + "default": null, + "description": "", + "type": "number" + }, + "filter_state": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_after": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_size": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28700, + "app": "outreach", + "summary": "List Sequences", + "description": "Lists sequences with an optional name filter and cursor pagination.", + "terms": "outreach list sequences lists sequences with an optional name filter and cursor pagination.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Sequences\n * Lists sequences with an optional name filter and cursor pagination.\n */\nexport async function main(\n auth: RT.Outreach,\n filter_name: string | undefined,\n sort: string | undefined,\n page_size: number | undefined,\n page_after: string | undefined\n) {\n const url = new URL(\"https://api.outreach.io/api/v2/sequences\")\n if (filter_name !== undefined && filter_name !== \"\") {\n url.searchParams.append(\"filter[name]\", filter_name)\n }\n if (sort !== undefined && sort !== \"\") {\n url.searchParams.append(\"sort\", sort)\n }\n if (page_size !== undefined) {\n url.searchParams.append(\"page[size]\", String(page_size))\n }\n if (page_after !== undefined && page_after !== \"\") {\n url.searchParams.append(\"page[after]\", page_after)\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "filter_name": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_after": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_size": { + "default": null, + "description": "", + "type": "number" + }, + "sort": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28701, + "app": "outreach", + "summary": "List Tasks", + "description": "Lists tasks, filterable by state, task type, prospect or dueAt range.", + "terms": "outreach list tasks lists tasks, filterable by state, task type, prospect or dueat range.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * List Tasks\n * Lists tasks, filterable by state, task type, prospect or dueAt range (e.g. \"2024-01-01..inf\").\n */\nexport async function main(\n auth: RT.Outreach,\n filter_state: (\"pending\" | \"incomplete\" | \"complete\") | undefined,\n filter_task_type: string | undefined,\n filter_prospect_id: number | undefined,\n filter_due_at: string | undefined,\n sort: string | undefined,\n page_size: number | undefined,\n page_after: string | undefined\n) {\n const url = new URL(\"https://api.outreach.io/api/v2/tasks\")\n if (filter_state !== undefined) {\n url.searchParams.append(\"filter[state]\", filter_state)\n }\n if (filter_task_type !== undefined && filter_task_type !== \"\") {\n url.searchParams.append(\"filter[taskType]\", filter_task_type)\n }\n if (filter_prospect_id !== undefined) {\n url.searchParams.append(\"filter[prospect][id]\", String(filter_prospect_id))\n }\n if (filter_due_at !== undefined && filter_due_at !== \"\") {\n url.searchParams.append(\"filter[dueAt]\", filter_due_at)\n }\n if (sort !== undefined && sort !== \"\") {\n url.searchParams.append(\"sort\", sort)\n }\n if (page_size !== undefined) {\n url.searchParams.append(\"page[size]\", String(page_size))\n }\n if (page_after !== undefined && page_after !== \"\") {\n url.searchParams.append(\"page[after]\", page_after)\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "filter_due_at": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "filter_prospect_id": { + "default": null, + "description": "", + "type": "number" + }, + "filter_state": { + "default": null, + "description": "", + "enum": [ + "pending", + "incomplete", + "complete" + ], + "originalType": "enum", + "type": "string" + }, + "filter_task_type": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_after": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "page_size": { + "default": null, + "description": "", + "type": "number" + }, + "sort": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28702, + "app": "outreach", + "summary": "Pause, Resume or Finish Sequence State", + "description": "Performs a lifecycle action on a prospect's sequence state.", + "terms": "outreach pause, resume or finish sequence state performs a lifecycle action on a prospect's sequence state.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Pause, Resume or Finish Sequence State\n * Performs a lifecycle action on a prospect's sequence state.\n */\nexport async function main(\n auth: RT.Outreach,\n sequence_state_id: number,\n action: \"pause\" | \"resume\" | \"finish\"\n) {\n const response = await fetch(\n `https://api.outreach.io/api/v2/sequenceStates/${sequence_state_id}/actions/${action}`,\n {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "action": { + "default": null, + "description": "", + "enum": [ + "pause", + "resume", + "finish" + ], + "originalType": "enum", + "type": "string" + }, + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "sequence_state_id": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth", + "sequence_state_id", + "action" + ], + "type": "object" + } + }, + { + "version_id": 28703, + "app": "outreach", + "summary": "Update Account", + "description": "Updates attributes of an existing account.", + "terms": "outreach update account updates attributes of an existing account.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Update Account\n * Updates attributes of an existing account (e.g. { \"industry\": \"Manufacturing\" }).\n */\nexport async function main(\n auth: RT.Outreach,\n account_id: number,\n attributes: { [key: string]: any }\n) {\n const response = await fetch(\n `https://api.outreach.io/api/v2/accounts/${account_id}`,\n {\n method: \"PATCH\",\n headers: {\n Authorization: `Bearer ${auth.token}`,\n \"Content-Type\": \"application/vnd.api+json\",\n Accept: \"application/vnd.api+json\",\n },\n body: JSON.stringify({\n data: { type: \"account\", id: account_id, attributes },\n }),\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "account_id": { + "default": null, + "description": "", + "type": "number" + }, + "attributes": { + "default": null, + "description": "", + "properties": {}, + "type": "object" + }, + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + } + }, + "required": [ + "auth", + "account_id", + "attributes" + ], + "type": "object" + } + }, + { + "version_id": 28704, + "app": "outreach", + "summary": "Update Prospect", + "description": "Updates attributes of an existing prospect.", + "terms": "outreach update prospect updates attributes of an existing prospect.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\n/**\n * Update Prospect\n * Updates attributes of an existing prospect (e.g. { \"title\": \"VP Sales\", \"occupation\": \"Sales\" }).\n */\nexport async function main(\n auth: RT.Outreach,\n prospect_id: number,\n attributes: { [key: string]: any }\n) {\n const response = await fetch(\n `https://api.outreach.io/api/v2/prospects/${prospect_id}`,\n {\n method: \"PATCH\",\n headers: {\n Authorization: `Bearer ${auth.token}`,\n \"Content-Type\": \"application/vnd.api+json\",\n Accept: \"application/vnd.api+json\",\n },\n body: JSON.stringify({\n data: { type: \"prospect\", id: prospect_id, attributes },\n }),\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "attributes": { + "default": null, + "description": "", + "properties": {}, + "type": "object" + }, + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + }, + "prospect_id": { + "default": null, + "description": "", + "type": "number" + } + }, + "required": [ + "auth", + "prospect_id", + "attributes" + ], + "type": "object" + } + }, + { + "version_id": 28705, + "app": "outreach", + "summary": "New Mailing", + "description": "Returns mailings created since the last run.", + "terms": "outreach new mailing returns mailings created since the last run.", + "kind": "trigger", + "language": "bunnative", + "content": "//native\n\nimport * as wmill from \"windmill-client\"\n\n/**\n * New Mailing\n * Returns mailings created since the last run (first run sets the watermark and returns nothing).\n */\nexport async function main(auth: RT.Outreach) {\n const lastChecked: string | undefined = await wmill.getState()\n\n const url = new URL(\"https://api.outreach.io/api/v2/mailings\")\n url.searchParams.append(\"sort\", \"-createdAt\")\n url.searchParams.append(\"page[size]\", \"100\")\n url.searchParams.append(\"count\", \"false\")\n if (lastChecked) {\n url.searchParams.append(\"newFilterSyntax\", \"true\")\n url.searchParams.append(\"filter[createdAt][gte]\", lastChecked)\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n const { data } = (await response.json()) as {\n data: { id: number; attributes: { createdAt: string } }[]\n }\n\n const newItems = lastChecked\n ? data.filter((m) => m.attributes.createdAt > lastChecked)\n : []\n const newWatermark =\n data[0]?.attributes.createdAt ?? lastChecked ?? new Date().toISOString()\n await wmill.setState(newWatermark)\n\n return newItems\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28706, + "app": "outreach", + "summary": "New Task", + "description": "Returns tasks created since the last run.", + "terms": "outreach new task returns tasks created since the last run.", + "kind": "trigger", + "language": "bunnative", + "content": "//native\n\nimport * as wmill from \"windmill-client\"\n\n/**\n * New Task\n * Returns tasks created since the last run (first run sets the watermark and returns nothing).\n */\nexport async function main(auth: RT.Outreach) {\n const lastChecked: string | undefined = await wmill.getState()\n\n const url = new URL(\"https://api.outreach.io/api/v2/tasks\")\n url.searchParams.append(\"sort\", \"-createdAt\")\n url.searchParams.append(\"page[size]\", \"100\")\n url.searchParams.append(\"count\", \"false\")\n if (lastChecked) {\n url.searchParams.append(\"newFilterSyntax\", \"true\")\n url.searchParams.append(\"filter[createdAt][gte]\", lastChecked)\n }\n\n const response = await fetch(url, {\n headers: {\n Authorization: `Bearer ${auth.token}`,\n Accept: \"application/vnd.api+json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n const { data } = (await response.json()) as {\n data: { id: number; attributes: { createdAt: string } }[]\n }\n\n const newItems = lastChecked\n ? data.filter((t) => t.attributes.createdAt > lastChecked)\n : []\n const newWatermark =\n data[0]?.attributes.createdAt ?? lastChecked ?? new Date().toISOString()\n await wmill.setState(newWatermark)\n\n return newItems\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-outreach", + "type": "object" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + } + ] +} \ No newline at end of file diff --git a/ai_evals/fixtures/frontend/global/hub/servicenow.json b/ai_evals/fixtures/frontend/global/hub/servicenow.json new file mode 100644 index 0000000000..02121cf8ea --- /dev/null +++ b/ai_evals/fixtures/frontend/global/hub/servicenow.json @@ -0,0 +1,672 @@ +{ + "app": "servicenow", + "display_name": "ServiceNow", + "description": "IT service management: incidents, records and attachments.", + "docs_url": "https://www.servicenow.com/docs/bundle/zurich-api-reference/page/integrate/inbound-rest/concept/c_RESTAPI.html", + "curated": true, + "meta": { + "api_docs": "https://www.servicenow.com/docs/bundle/zurich-api-reference/page/integrate/inbound-rest/concept/c_RESTAPI.html", + "shipped_surfaces": [ + "table", + "stats", + "attachment", + "import_set", + "user" + ], + "validation": { + "status": "live_validated", + "method": "All surfaces run live against a ServiceNow PDI (a developer instance, Zurich) on 2026-06-04 with Basic auth: Table CRUD (create / get / update PATCH+PUT / delete on incident, delete verified by 404), list_records (encoded query + fields + pagination), aggregate (Stats group-by count), get_current_user, list_tables, and the table/fields/group_by dynselect resolvers. Attachment upload (binary /file) -> list -> download -> delete round-trip with the downloaded bytes byte-for-byte matching the upload. insert_import_set + get_import_set_result against imp_user (transform status surfaced). Trigger e2e-tested via bun test with the windmill-client fake. All created records cleaned up.", + "sources": { + "table_crud": "HIGH - run live (create/get/update PATCH & PUT/delete on incident)", + "aggregate_stats": "HIGH - run live (/api/now/stats group-by count on incident)", + "attachment": "HIGH - run live (binary /file upload -> list -> download -> delete round-trip; downloaded bytes verified equal)", + "import_set": "HIGH - run live (POST /api/now/import/imp_user + GET result; top-level {import_set,staging_table,result} envelope, transform status surfaced)", + "user": "HIGH - run live (/api/now/ui/user/current_user -> admin sys_user)", + "trigger": "HIGH - e2e-tested live via bun test (first run sets watermark + no backlog, second run emits the new record)", + "dropdowns": "HIGH - run live (sys_db_object table list; sys_dictionary super_class inheritance walk confirmed - incident resolves 106 fields incl. task-inherited short_description/priority/state)" + }, + "low_confidence_fields": [ + "trigger datetime comparison assumes the integration user's timezone is UTC/GMT (validated with the PDI admin user, whose TZ is UTC); a non-UTC integration user could shift the sys_updated_on> watermark boundary by the offset" + ] + }, + "pagination": { + "pattern": "offset", + "request_params": { + "offset": "sysparm_offset", + "limit": "sysparm_limit" + }, + "response_fields": { + "next": "Link (response header, rel=\"next\")", + "total": "X-Total-Count (response header)" + }, + "default_limit": 10000, + "notes": "Table API pages with sysparm_offset + sysparm_limit. Each page response carries a Link header (RFC 5988) with rel=\"first\"/\"prev\"/\"next\"/\"last\" full URLs, plus X-Total-Count with the total match count. Add sysparm_no_count=true to skip the count on huge tables. The list_records action exposes limit+offset and returns one page; iterate offset until fewer than limit rows return." + }, + "gotchas": [ + "Base path is {instance_url}/api/now; instance_url lives on the resource. Auth is Basic (username:password) OR Bearer token \u2014 scripts send Bearer when auth.token is set, otherwise Basic. Both hit identical paths.", + "Table/Attachment/Stats reads/writes return a {\"result\": ...} envelope: list/stats -> result is an array, get/create/update -> result is an object. EXCEPTION: the Import Set API (POST /api/now/import/{staging}, GET .../{sys_id}) returns a TOP-LEVEL object {import_set, staging_table, result:[...]} with NO outer 'result' wrapper. Scripts return the raw JSON (envelope intact).", + "Datetimes are 'YYYY-MM-DD HH:MM:SS' in UTC with NO trailing 'Z'. Used directly in encoded queries, e.g. sys_updated_on>2026-01-01 00:00:00.", + "sysparm_query is an ENCODED query string: '^' = AND clause separator, '^OR' = OR, operators =, !=, LIKE, >=, <=, IN, STARTSWITH, ORDERBY, ORDERBYDESC. A literal '^' inside a value must be doubled to '^^'. Example: active=true^priority=1^ORDERBYDESCsys_updated_on.", + "System fields sys_id, sys_created_on, sys_updated_on, sys_created_by are auto-managed; do not send them on create/update.", + "DELETE (record and attachment) returns 204 No Content with an empty body \u2014 guard with `if (response.status === 204) return { success: true }` before response.json().", + "Update: PATCH partial-updates; PUT replaces the whole record (clears omitted fields). update_record sends PUT when replace=true, PATCH otherwise.", + "Attachment upload uses POST /api/now/attachment/file with table_name, table_sys_id, file_name in the QUERY STRING and the raw file bytes as the body (Content-Type = the file's MIME type), NOT multipart. Returns 201. Download is GET /api/now/attachment/{sys_id}/file (raw bytes).", + "Import Set transform runs SYNCHRONOUSLY. A result[].status of 'error' is an application-level failure even on HTTP 200/201 \u2014 inspect result[].status / error_message, don't trust the HTTP code alone.", + "Table-name DROPDOWN comes from GET /api/now/table/sys_db_object?sysparm_fields=name,label (use the 'name' as the value). A table's COLUMNS come from sys_dictionary \u2014 but sys_dictionary stores each field once, under the table where it is DEFINED, so name= returns ONLY that table's own fields. Extended tables (incident/change_request/sc_task all extend task) inherit columns like short_description/priority/assigned_to/state that live on the parent. To get the full column set, walk sys_db_object.super_class up the hierarchy (GET sys_db_object?sysparm_query=name=&sysparm_fields=super_class.name, repeat) and query GET sys_dictionary?sysparm_query=nameIN
,^elementISNOTEMPTY&sysparm_fields=element,column_label (elementISNOTEMPTY drops the collection/meta row).", + "Table API enforces row- and field-level ACLs: a 200 can omit fields the user can't read; inaccessible tables/ops return 403." + ], + "enums": { + "sysparm_display_value": [ + "true", + "false", + "all" + ], + "sysparm_view": [ + "desktop", + "mobile", + "both" + ], + "import_set_result_status": [ + "inserted", + "updated", + "ignored", + "error" + ], + "incident_state_note": "incident.state and priority/urgency/impact are instance-configurable; defaults: state 1=New,2=In Progress,3=On Hold,6=Resolved,7=Closed,8=Canceled; priority/urgency/impact 1-5. On task/incident, priority is CALCULATED from impact+urgency (a priority value sent on create/update is recomputed/ignored) \u2014 set impact+urgency instead. Never hardcode for generic actions." + }, + "errors": { + "401": "Authentication failed or OAuth token expired/invalid. Check username/password or refresh the bearer token.", + "403": "ACL denies the table or operation, or the user lacks the required role (e.g. rest_api_explorer / table ACL). Response: {\"error\":{\"message\":\"User Not Authorized\"}}.", + "404": "No Record found (bad sys_id) or Invalid table (bad/inaccessible table name, common Import Set staging-table error).", + "400": "Bad encoded query, invalid field name, or malformed request body." + } + }, + "resource_type": { + "name": "servicenow", + "description": "ServiceNow instance credential. Set instance_url to your instance base (https://.service-now.com). Authenticate with EITHER Basic auth (username + password) OR an OAuth 2.0 bearer token \u2014 scripts send a Bearer header when token is set, otherwise Basic auth.", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "order": [ + "instance_url", + "username", + "password", + "token" + ], + "properties": { + "instance_url": { + "default": "", + "description": "Instance base URL, e.g. https://dev12345.service-now.com (no trailing slash). Every REST call is made against this host.", + "placeholder": "https://dev12345.service-now.com", + "type": "string" + }, + "password": { + "default": "", + "description": "Basic auth password. Leave blank when using an OAuth bearer token.", + "password": true, + "type": "string" + }, + "token": { + "default": "", + "description": "OAuth 2.0 access token (Bearer). Populated automatically when you connect via Windmill's ServiceNow OAuth flow (admin configures the instance + client id/secret in instance settings), or paste one manually. Provide this instead of username/password to authenticate with OAuth.", + "password": true, + "type": "string" + }, + "username": { + "default": "", + "description": "Basic auth username. Leave blank when using an OAuth bearer token.", + "type": "string" + } + }, + "required": [ + "instance_url" + ], + "type": "object" + } + }, + "scripts": [ + { + "version_id": 28665, + "app": "servicenow", + "summary": "Aggregate Records", + "description": "Count records on a table (Aggregate API), optionally filtered by an encoded query and grouped by a field.", + "terms": "servicenow aggregate records count records on a table (aggregate api), optionally filtered by an encoded query and grouped by a field.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nexport type DynSelect_table = string\nexport type DynSelect_group_by = string\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n// Dropdown of the instance's tables (sys_db_object).\nexport async function table(auth: RT.Servicenow) {\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_fields=name,label&sysparm_limit=10000`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { name: string; label: string }[]\n }\n return result\n .filter((t) => t.name)\n .map((t) => ({ value: t.name, label: `${t.label} (${t.name})` }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n// Walk the table's inheritance chain (sys_db_object.super_class) so the group-by\n// options include fields inherited from parent tables, not just fields defined\n// on the chosen table (sys_dictionary stores each field under its defining\n// table \u2014 e.g. incident extends task, so priority/state live on task).\n// Best-effort: any failure falls back to just the table itself.\nasync function tableChain(auth: RT.Servicenow, table: string) {\n const chain: string[] = []\n let current: string | undefined = table\n while (current && !chain.includes(current)) {\n chain.push(current)\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_query=name=${current}&sysparm_fields=super_class.name&sysparm_limit=1`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) break\n const { result } = (await response.json()) as {\n result: { \"super_class.name\"?: string }[]\n }\n current = result[0]?.[\"super_class.name\"] || undefined\n }\n return chain\n}\n\n// Dependent select: the chosen table's columns (sys_dictionary) to group by,\n// including columns inherited from parent tables.\nexport async function group_by(auth: RT.Servicenow, table: DynSelect_table) {\n if (!table) return []\n const chain = await tableChain(auth, table)\n const url = new URL(`${auth.instance_url}/api/now/table/sys_dictionary`)\n url.searchParams.append(\n \"sysparm_query\",\n `nameIN${chain.join(\",\")}^elementISNOTEMPTY`\n )\n url.searchParams.append(\"sysparm_fields\", \"element,column_label\")\n url.searchParams.append(\"sysparm_limit\", \"10000\")\n\n const response = await fetch(url, {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { element: string; column_label: string }[]\n }\n return result\n .filter((f) => f.element)\n .map((f) => ({\n value: f.element,\n label: `${f.column_label} (${f.element})`,\n }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n/**\n * Aggregate Records\n * Count records on a table (Aggregate API), optionally filtered by an encoded query and grouped by a field. Returns ServiceNow's stats envelope with the count per group.\n */\nexport async function main(\n auth: RT.Servicenow,\n table: DynSelect_table,\n query: string | undefined,\n group_by: DynSelect_group_by | undefined\n) {\n const url = new URL(`${auth.instance_url}/api/now/stats/${table}`)\n url.searchParams.append(\"sysparm_count\", \"true\")\n if (query !== undefined && query !== \"\") {\n url.searchParams.append(\"sysparm_query\", query)\n }\n if (group_by !== undefined && group_by !== \"\") {\n url.searchParams.append(\"sysparm_group_by\", group_by)\n }\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "group_by": { + "default": null, + "description": "", + "format": "dynselect-group_by", + "type": "object" + }, + "query": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "table": { + "default": null, + "description": "", + "format": "dynselect-table", + "type": "object" + } + }, + "required": [ + "auth", + "table" + ], + "type": "object" + } + }, + { + "version_id": 28666, + "app": "servicenow", + "summary": "Create Record", + "description": "Insert a record into any table (incident, change_request, sys_user, cmdb_ci, or a custom table). Pick the table from a live dropdown and pass a column/value map.", + "terms": "servicenow create record insert a record into any table (incident, change_request, sys_user, cmdb_ci, or a custom table). pick the table from a live dropdown and pass a column/value map.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nexport type DynSelect_table = string\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n// Dropdown of the instance's tables (sys_db_object).\nexport async function table(auth: RT.Servicenow) {\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_fields=name,label&sysparm_limit=10000`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { name: string; label: string }[]\n }\n return result\n .filter((t) => t.name)\n .map((t) => ({ value: t.name, label: `${t.label} (${t.name})` }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n/**\n * Create Record\n * Insert a record into any table (incident, change_request, sys_user, cmdb_ci, or a custom table). Pick the table from a live dropdown and pass a column/value map.\n */\nexport async function main(\n auth: RT.Servicenow,\n table: DynSelect_table,\n body: { [key: string]: any }\n) {\n const url = new URL(`${auth.instance_url}/api/now/table/${table}`)\n\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: authHeader(auth),\n \"Content-Type\": \"application/json\",\n Accept: \"application/json\",\n },\n body: JSON.stringify(body),\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "body": { + "default": null, + "description": "", + "properties": {}, + "type": "object" + }, + "table": { + "default": null, + "description": "", + "format": "dynselect-table", + "type": "object" + } + }, + "required": [ + "auth", + "table", + "body" + ], + "type": "object" + } + }, + { + "version_id": 28667, + "app": "servicenow", + "summary": "Delete Attachment", + "description": "Delete an attachment by sys_id. Returns {success, sys_id} (ServiceNow replies 204 No Content).", + "terms": "servicenow delete attachment delete an attachment by sys_id. returns {success, sys_id} (servicenow replies 204 no content).", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n/**\n * Delete Attachment\n * Delete an attachment by its sys_id. ServiceNow returns 204 No Content on success, so this returns {success, sys_id}.\n */\nexport async function main(auth: RT.Servicenow, attachment_sys_id: string) {\n const response = await fetch(\n `${auth.instance_url}/api/now/attachment/${attachment_sys_id}`,\n {\n method: \"DELETE\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return { success: true, sys_id: attachment_sys_id }\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "attachment_sys_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + } + }, + "required": [ + "auth", + "attachment_sys_id" + ], + "type": "object" + } + }, + { + "version_id": 28668, + "app": "servicenow", + "summary": "Delete Record", + "description": "Delete a record by sys_id. Returns {success, sys_id} (ServiceNow replies 204 No Content).", + "terms": "servicenow delete record delete a record by sys_id. returns {success, sys_id} (servicenow replies 204 no content).", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nexport type DynSelect_table = string\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n// Dropdown of the instance's tables (sys_db_object).\nexport async function table(auth: RT.Servicenow) {\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_fields=name,label&sysparm_limit=10000`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { name: string; label: string }[]\n }\n return result\n .filter((t) => t.name)\n .map((t) => ({ value: t.name, label: `${t.label} (${t.name})` }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n/**\n * Delete Record\n * Delete a record by sys_id. ServiceNow returns 204 No Content on success, so this returns {success, sys_id}.\n */\nexport async function main(\n auth: RT.Servicenow,\n table: DynSelect_table,\n sys_id: string\n) {\n const url = new URL(`${auth.instance_url}/api/now/table/${table}/${sys_id}`)\n\n const response = await fetch(url, {\n method: \"DELETE\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return { success: true, sys_id }\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "sys_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "table": { + "default": null, + "description": "", + "format": "dynselect-table", + "type": "object" + } + }, + "required": [ + "auth", + "table", + "sys_id" + ], + "type": "object" + } + }, + { + "version_id": 28670, + "app": "servicenow", + "summary": "Get Current User", + "description": "Return the sys_user record of the authenticated user.", + "terms": "servicenow get current user return the sys_user record of the authenticated user.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n/**\n * Get Current User\n * Return the sys_user record of the authenticated user (resolves current_user, then fetches the full record from sys_user).\n */\nexport async function main(auth: RT.Servicenow) {\n const currentResponse = await fetch(\n `${auth.instance_url}/api/now/ui/user/current_user`,\n {\n method: \"GET\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!currentResponse.ok) {\n throw new Error(`${currentResponse.status} ${await currentResponse.text()}`)\n }\n const { result } = (await currentResponse.json()) as {\n result: { user_sys_id: string }\n }\n\n const userResponse = await fetch(\n `${auth.instance_url}/api/now/table/sys_user/${result.user_sys_id}`,\n {\n method: \"GET\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!userResponse.ok) {\n throw new Error(`${userResponse.status} ${await userResponse.text()}`)\n }\n\n return await userResponse.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28671, + "app": "servicenow", + "summary": "Get Import Set Result", + "description": "Fetch the outcome of a prior import row by the staging-row sys_id returned by Insert Into Import Set (result.sys_id).", + "terms": "servicenow get import set result fetch the outcome of a prior import row by the staging-row sys_id returned by insert into import set (result.sys_id).", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n/**\n * Get Import Set Result\n * Fetch the outcome of a prior import row by the staging-row sys_id returned by Insert Into Import Set (result.sys_id), returning the transform result (target table, status, target record link).\n */\nexport async function main(\n auth: RT.Servicenow,\n staging_table: string,\n sys_id: string\n) {\n const url = new URL(\n `${auth.instance_url}/api/now/import/${staging_table}/${sys_id}`\n )\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "staging_table": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "sys_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth", + "staging_table", + "sys_id" + ], + "type": "object" + } + }, + { + "version_id": 28672, + "app": "servicenow", + "summary": "Get Record", + "description": "Retrieve a single record by sys_id. Optionally restrict the returned columns, and choose raw vs. display values.", + "terms": "servicenow get record retrieve a single record by sys_id. optionally restrict the returned columns, and choose raw vs. display values.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nexport type DynSelect_table = string\nexport type DynMultiselect_fields = string[]\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n// Dropdown of the instance's tables (sys_db_object).\nexport async function table(auth: RT.Servicenow) {\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_fields=name,label&sysparm_limit=10000`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { name: string; label: string }[]\n }\n return result\n .filter((t) => t.name)\n .map((t) => ({ value: t.name, label: `${t.label} (${t.name})` }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n// Walk the table's inheritance chain (sys_db_object.super_class) so the column\n// list includes fields inherited from parent tables, not just fields defined on\n// the chosen table (sys_dictionary stores each field under its defining table \u2014\n// e.g. incident extends task, so short_description/priority live on task).\n// Best-effort: any failure falls back to just the table itself.\nasync function tableChain(auth: RT.Servicenow, table: string) {\n const chain: string[] = []\n let current: string | undefined = table\n while (current && !chain.includes(current)) {\n chain.push(current)\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_query=name=${current}&sysparm_fields=super_class.name&sysparm_limit=1`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) break\n const { result } = (await response.json()) as {\n result: { \"super_class.name\"?: string }[]\n }\n current = result[0]?.[\"super_class.name\"] || undefined\n }\n return chain\n}\n\n// Dependent multi-select: the chosen table's columns (sys_dictionary), including\n// columns inherited from parent tables.\nexport async function fields(auth: RT.Servicenow, table: DynSelect_table) {\n if (!table) return []\n const chain = await tableChain(auth, table)\n const url = new URL(`${auth.instance_url}/api/now/table/sys_dictionary`)\n url.searchParams.append(\n \"sysparm_query\",\n `nameIN${chain.join(\",\")}^elementISNOTEMPTY`\n )\n url.searchParams.append(\"sysparm_fields\", \"element,column_label\")\n url.searchParams.append(\"sysparm_limit\", \"10000\")\n\n const response = await fetch(url, {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { element: string; column_label: string }[]\n }\n return result\n .filter((f) => f.element)\n .map((f) => ({\n value: f.element,\n label: `${f.column_label} (${f.element})`,\n }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n/**\n * Get Record\n * Retrieve a single record by sys_id. Optionally restrict the returned columns, and choose raw vs. display values.\n */\nexport async function main(\n auth: RT.Servicenow,\n table: DynSelect_table,\n sys_id: string,\n fields: DynMultiselect_fields | undefined,\n display_value: \"false\" | \"true\" | \"all\" | undefined\n) {\n const url = new URL(`${auth.instance_url}/api/now/table/${table}/${sys_id}`)\n if (fields && fields.length > 0) {\n url.searchParams.append(\"sysparm_fields\", fields.join(\",\"))\n }\n if (display_value !== undefined && display_value !== \"\") {\n url.searchParams.append(\"sysparm_display_value\", display_value)\n }\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "display_value": { + "default": null, + "description": "", + "enum": [ + "false", + "true", + "all" + ], + "originalType": "enum", + "type": "string" + }, + "fields": { + "default": null, + "description": "", + "format": "dynmultiselect-fields", + "type": "object" + }, + "sys_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "table": { + "default": null, + "description": "", + "format": "dynselect-table", + "type": "object" + } + }, + "required": [ + "auth", + "table", + "sys_id" + ], + "type": "object" + } + }, + { + "version_id": 28673, + "app": "servicenow", + "summary": "Insert Into Import Set", + "description": "Push a row into an import set staging table; the transform map runs synchronously. Inspect result[].status.", + "terms": "servicenow insert into import set push a row into an import set staging table; the transform map runs synchronously. inspect result[].status.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n/**\n * Insert Into Import Set\n * Push a row into an import set staging table; the associated transform map runs synchronously. Inspect result[].status (inserted/updated/ignored/error) \u2014 a transform error returns 200/201 with status \"error\".\n */\nexport async function main(\n auth: RT.Servicenow,\n staging_table: string,\n body: { [key: string]: any }\n) {\n const url = new URL(`${auth.instance_url}/api/now/import/${staging_table}`)\n\n const response = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: authHeader(auth),\n \"Content-Type\": \"application/json\",\n Accept: \"application/json\",\n },\n body: JSON.stringify(body),\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "body": { + "default": null, + "description": "", + "properties": {}, + "type": "object" + }, + "staging_table": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth", + "staging_table", + "body" + ], + "type": "object" + } + }, + { + "version_id": 28674, + "app": "servicenow", + "summary": "List Attachments", + "description": "List attachment metadata, optionally filtered with an encoded query (e.g. table_name=incident^table_sys_id=).", + "terms": "servicenow list attachments list attachment metadata, optionally filtered with an encoded query (e.g. table_name=incident^table_sys_id=).", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n/**\n * List Attachments\n * List attachment metadata (sys_id, file_name, content_type, size, download_link). Filter with an encoded query, e.g. table_name=incident^table_sys_id=.\n */\nexport async function main(\n auth: RT.Servicenow,\n query: string | undefined,\n limit: number | undefined\n) {\n const url = new URL(`${auth.instance_url}/api/now/attachment`)\n if (query !== undefined && query !== \"\") {\n url.searchParams.append(\"sysparm_query\", query)\n }\n url.searchParams.append(\"sysparm_limit\", String(limit ?? 100))\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "limit": { + "default": null, + "description": "", + "type": "number" + }, + "query": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28675, + "app": "servicenow", + "summary": "List Records", + "description": "Query records from any table with an encoded query (sysparm_query), pagination, column selection, and raw/display values.", + "terms": "servicenow list records query records from any table with an encoded query (sysparm_query), pagination, column selection, and raw/display values.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nexport type DynSelect_table = string\nexport type DynMultiselect_fields = string[]\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n// Dropdown of the instance's tables (sys_db_object).\nexport async function table(auth: RT.Servicenow) {\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_fields=name,label&sysparm_limit=10000`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { name: string; label: string }[]\n }\n return result\n .filter((t) => t.name)\n .map((t) => ({ value: t.name, label: `${t.label} (${t.name})` }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n// Walk the table's inheritance chain (sys_db_object.super_class) so the column\n// list includes fields inherited from parent tables, not just fields defined on\n// the chosen table (sys_dictionary stores each field under its defining table \u2014\n// e.g. incident extends task, so short_description/priority live on task).\n// Best-effort: any failure falls back to just the table itself.\nasync function tableChain(auth: RT.Servicenow, table: string) {\n const chain: string[] = []\n let current: string | undefined = table\n while (current && !chain.includes(current)) {\n chain.push(current)\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_query=name=${current}&sysparm_fields=super_class.name&sysparm_limit=1`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) break\n const { result } = (await response.json()) as {\n result: { \"super_class.name\"?: string }[]\n }\n current = result[0]?.[\"super_class.name\"] || undefined\n }\n return chain\n}\n\n// Dependent multi-select: the chosen table's columns (sys_dictionary), including\n// columns inherited from parent tables.\nexport async function fields(auth: RT.Servicenow, table: DynSelect_table) {\n if (!table) return []\n const chain = await tableChain(auth, table)\n const url = new URL(`${auth.instance_url}/api/now/table/sys_dictionary`)\n url.searchParams.append(\n \"sysparm_query\",\n `nameIN${chain.join(\",\")}^elementISNOTEMPTY`\n )\n url.searchParams.append(\"sysparm_fields\", \"element,column_label\")\n url.searchParams.append(\"sysparm_limit\", \"10000\")\n\n const response = await fetch(url, {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { element: string; column_label: string }[]\n }\n return result\n .filter((f) => f.element)\n .map((f) => ({\n value: f.element,\n label: `${f.column_label} (${f.element})`,\n }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n/**\n * List Records\n * Query records from any table with an encoded query (sysparm_query), pagination, column selection, and raw/display values. Returns one page; iterate offset until fewer than limit rows return.\n */\nexport async function main(\n auth: RT.Servicenow,\n table: DynSelect_table,\n query: string | undefined,\n limit: number | undefined,\n offset: number | undefined,\n fields: DynMultiselect_fields | undefined,\n display_value: \"false\" | \"true\" | \"all\" | undefined\n) {\n const url = new URL(`${auth.instance_url}/api/now/table/${table}`)\n if (query !== undefined && query !== \"\") {\n url.searchParams.append(\"sysparm_query\", query)\n }\n url.searchParams.append(\"sysparm_limit\", String(limit ?? 100))\n if (offset !== undefined) {\n url.searchParams.append(\"sysparm_offset\", String(offset))\n }\n if (fields && fields.length > 0) {\n url.searchParams.append(\"sysparm_fields\", fields.join(\",\"))\n }\n if (display_value !== undefined && display_value !== \"\") {\n url.searchParams.append(\"sysparm_display_value\", display_value)\n }\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "display_value": { + "default": null, + "description": "", + "enum": [ + "false", + "true", + "all" + ], + "originalType": "enum", + "type": "string" + }, + "fields": { + "default": null, + "description": "", + "format": "dynmultiselect-fields", + "type": "object" + }, + "limit": { + "default": null, + "description": "", + "type": "number" + }, + "offset": { + "default": null, + "description": "", + "type": "number" + }, + "query": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "table": { + "default": null, + "description": "", + "format": "dynselect-table", + "type": "object" + } + }, + "required": [ + "auth", + "table" + ], + "type": "object" + } + }, + { + "version_id": 28676, + "app": "servicenow", + "summary": "List Tables", + "description": "List the instance tables (sys_db_object) with API name, label, and sys_id. Optionally filter by a name/label substring.", + "terms": "servicenow list tables list the instance tables (sys_db_object) with api name, label, and sys_id. optionally filter by a name/label substring.", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n/**\n * List Tables\n * List the instance's tables (sys_db_object) with their API name, label, and sys_id. Optionally filter by a substring matched against name or label.\n */\nexport async function main(\n auth: RT.Servicenow,\n name_filter: string | undefined\n) {\n const url = new URL(`${auth.instance_url}/api/now/table/sys_db_object`)\n url.searchParams.append(\"sysparm_fields\", \"name,label,sys_id\")\n if (name_filter !== undefined && name_filter !== \"\") {\n url.searchParams.append(\n \"sysparm_query\",\n `nameLIKE${name_filter}^ORlabelLIKE${name_filter}`\n )\n }\n url.searchParams.append(\"sysparm_limit\", \"10000\")\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "name_filter": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + } + }, + "required": [ + "auth" + ], + "type": "object" + } + }, + { + "version_id": 28677, + "app": "servicenow", + "summary": "Update Record", + "description": "Update a record by sys_id with a column/value map. PATCH (partial) by default; set replace=true to PUT (full overwrite).", + "terms": "servicenow update record update a record by sys_id with a column/value map. patch (partial) by default; set replace=true to put (full overwrite).", + "kind": "script", + "language": "bunnative", + "content": "//native\n\nexport type DynSelect_table = string\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n// Dropdown of the instance's tables (sys_db_object).\nexport async function table(auth: RT.Servicenow) {\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_fields=name,label&sysparm_limit=10000`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { name: string; label: string }[]\n }\n return result\n .filter((t) => t.name)\n .map((t) => ({ value: t.name, label: `${t.label} (${t.name})` }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n/**\n * Update Record\n * Update a record by sys_id with a column/value map. PATCH (partial update) by default; set replace=true to PUT (full overwrite that clears omitted fields).\n */\nexport async function main(\n auth: RT.Servicenow,\n table: DynSelect_table,\n sys_id: string,\n body: { [key: string]: any },\n replace: boolean | undefined\n) {\n const url = new URL(`${auth.instance_url}/api/now/table/${table}/${sys_id}`)\n\n const response = await fetch(url, {\n method: replace ? \"PUT\" : \"PATCH\",\n headers: {\n Authorization: authHeader(auth),\n \"Content-Type\": \"application/json\",\n Accept: \"application/json\",\n },\n body: JSON.stringify(body),\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n return await response.json()\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "body": { + "default": null, + "description": "", + "properties": {}, + "type": "object" + }, + "replace": { + "default": null, + "description": "", + "type": "boolean" + }, + "sys_id": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "table": { + "default": null, + "description": "", + "format": "dynselect-table", + "type": "object" + } + }, + "required": [ + "auth", + "table", + "sys_id", + "body" + ], + "type": "object" + } + }, + { + "version_id": 28679, + "app": "servicenow", + "summary": "New or Updated Record", + "description": "Emits records whose timestamp is newer than the last poll, tracked via Windmill state. Defaults to sys_updated_on.", + "terms": "servicenow new or updated record emits records whose timestamp is newer than the last poll, tracked via windmill state. defaults to sys_updated_on.", + "kind": "trigger", + "language": "bunnative", + "content": "//native\n\nimport * as wmill from \"windmill-client\"\n\nexport type DynSelect_table = string\n\nfunction authHeader(auth: RT.Servicenow) {\n return auth.token\n ? `Bearer ${auth.token}`\n : `Basic ${btoa(`${auth.username}:${auth.password}`)}`\n}\n\n// Dropdown of the instance's tables (sys_db_object).\nexport async function table(auth: RT.Servicenow) {\n const response = await fetch(\n `${auth.instance_url}/api/now/table/sys_db_object?sysparm_fields=name,label&sysparm_limit=10000`,\n {\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n }\n )\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n const { result } = (await response.json()) as {\n result: { name: string; label: string }[]\n }\n return result\n .filter((t) => t.name)\n .map((t) => ({ value: t.name, label: `${t.label} (${t.name})` }))\n .sort((a, b) => a.label.localeCompare(b.label))\n}\n\n/**\n * New or Updated Record\n * Emits records from a table whose timestamp is newer than the last poll, tracked via Windmill state. Defaults to sys_updated_on (set timestamp_field to sys_created_on for new-only). Optionally narrow with an extra encoded query.\n */\nexport async function main(\n auth: RT.Servicenow,\n table: DynSelect_table,\n timestamp_field: \"sys_updated_on\" | \"sys_created_on\" | undefined,\n query: string | undefined\n) {\n const tsField =\n timestamp_field && timestamp_field !== \"\"\n ? timestamp_field\n : \"sys_updated_on\"\n const lastChecked: string | undefined = await wmill.getState()\n\n // ServiceNow datetimes are 'YYYY-MM-DD HH:MM:SS' in UTC (no trailing Z).\n const nowUtc = new Date().toISOString().slice(0, 19).replace(\"T\", \" \")\n\n // First run: set the watermark to now and don't emit a backlog.\n if (!lastChecked) {\n await wmill.setState(nowUtc)\n return []\n }\n\n let encoded = `${tsField}>${lastChecked}`\n if (query !== undefined && query !== \"\") {\n encoded += `^${query}`\n }\n encoded += `^ORDERBY${tsField}`\n\n const url = new URL(`${auth.instance_url}/api/now/table/${table}`)\n url.searchParams.append(\"sysparm_query\", encoded)\n url.searchParams.append(\"sysparm_limit\", \"200\")\n\n const response = await fetch(url, {\n method: \"GET\",\n headers: {\n Authorization: authHeader(auth),\n Accept: \"application/json\",\n },\n })\n\n if (!response.ok) {\n throw new Error(`${response.status} ${await response.text()}`)\n }\n\n const { result } = (await response.json()) as {\n result: Array<{ [key: string]: any }>\n }\n\n if (result.length > 0) {\n await wmill.setState(result[result.length - 1][tsField])\n }\n\n return result\n}\n", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "auth": { + "default": null, + "description": "", + "format": "resource-servicenow", + "type": "object" + }, + "query": { + "default": null, + "description": "", + "originalType": "string", + "type": "string" + }, + "table": { + "default": null, + "description": "", + "format": "dynselect-table", + "type": "object" + }, + "timestamp_field": { + "default": null, + "description": "", + "enum": [ + "sys_updated_on", + "sys_created_on" + ], + "originalType": "enum", + "type": "string" + } + }, + "required": [ + "auth", + "table" + ], + "type": "object" + } + } + ] +} \ No newline at end of file diff --git a/ai_evals/modes/cli.ts b/ai_evals/modes/cli.ts index 3ce7cf1c16..205cdfa9c4 100644 --- a/ai_evals/modes/cli.ts +++ b/ai_evals/modes/cli.ts @@ -17,8 +17,12 @@ import type { BenchmarkArtifactFile, CliTrace, ModeRunner } from "../core/types" const IGNORE_WORKSPACE_FILES = new Set([ ".claude", + ".agents", "AGENTS.md", + "AGENTS.wmill.md", "CLAUDE.md", + // the guidance has the agent create one for every new folder + "folder.meta.yaml", "rt.d.ts", ".wmill-benchmark-bin", ".wmill-benchmark-wmill-invocations.log", @@ -89,7 +93,7 @@ export function createCliModeRunner( const run = await runPromptAndCapture( renderedPrompt, workspaceDir, - context.evalCase?.runtime?.maxTurns ?? 6, + context.evalCase?.runtime?.maxTurns ?? 12, modelConfig ); const workspaceFiles = await readDirectoryFiles(workspaceDir, { ignore: IGNORE_WORKSPACE_FILES }); diff --git a/ai_evals/package.json b/ai_evals/package.json index 6720c910cb..424e49ffae 100644 --- a/ai_evals/package.json +++ b/ai_evals/package.json @@ -8,8 +8,8 @@ "test:frontend-graph": "cd ../frontend && node_modules/.bin/vitest run --project server --config ../ai_evals/adapters/frontend/vitest.unit.config.ts" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.25", - "@anthropic-ai/sdk": "^0.39.0", + "@anthropic-ai/claude-agent-sdk": "^0.3.284", + "@anthropic-ai/sdk": "^0.129.0", "commander": "^14.0.3", "openai": "^6.9.1", "yaml": "^2.8.3" diff --git a/backend/.sqlx/query-094a07eaa5714ec739786717f110ea539e84c33de23476e042a4a1a7cb529dac.json b/backend/.sqlx/query-094a07eaa5714ec739786717f110ea539e84c33de23476e042a4a1a7cb529dac.json new file mode 100644 index 0000000000..e0174943d6 --- /dev/null +++ b/backend/.sqlx/query-094a07eaa5714ec739786717f110ea539e84c33de23476e042a4a1a7cb529dac.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COALESCE((operator_settings->>'builder_flows')::boolean, false) AS \"flows!\",\n COALESCE((operator_settings->>'manage_schedules')::boolean, true) AS \"schedules!\",\n COALESCE((operator_settings->>'manage_triggers')::boolean, true) AS \"triggers!\"\n FROM workspace_settings WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "flows!", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "schedules!", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "triggers!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + null, + null + ] + }, + "hash": "094a07eaa5714ec739786717f110ea539e84c33de23476e042a4a1a7cb529dac" +} diff --git a/backend/.sqlx/query-11b5d006acf040b2461b9539863b1e511c23e1a5f9be97da066a6808a49f2092.json b/backend/.sqlx/query-0a18205b64e68529b7f90bc1c5db2cbea9ee5eaa962970b65a92597fd5e62707.json similarity index 52% rename from backend/.sqlx/query-11b5d006acf040b2461b9539863b1e511c23e1a5f9be97da066a6808a49f2092.json rename to backend/.sqlx/query-0a18205b64e68529b7f90bc1c5db2cbea9ee5eaa962970b65a92597fd5e62707.json index bc821f2b7b..a3707d28c5 100644 --- a/backend/.sqlx/query-11b5d006acf040b2461b9539863b1e511c23e1a5f9be97da066a6808a49f2092.json +++ b/backend/.sqlx/query-0a18205b64e68529b7f90bc1c5db2cbea9ee5eaa962970b65a92597fd5e62707.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n WITH job_info AS (\n SELECT id, kind::text AS kind, parent_job\n FROM v2_job\n WHERE id = $1 AND workspace_id = $2\n )\n SELECT\n q.id AS \"id!\",\n s.flow_status,\n q.suspend AS \"suspend!\",\n j.runnable_path AS script_path,\n j.permissioned_as_email AS email,\n (ji.kind IN ('flow', 'flowpreview', 'singlestepflow')) AS \"is_flow_level!\",\n (ji.kind NOT IN ('flow', 'flowpreview', 'singlestepflow') AND q.id = ji.id) AS \"is_wac!\"\n FROM job_info ji\n JOIN v2_job_queue q ON q.id = CASE\n WHEN ji.kind IN ('flow', 'flowpreview', 'singlestepflow') THEN ji.id\n ELSE COALESCE(ji.parent_job, ji.id)\n END\n JOIN v2_job j ON j.id = q.id\n LEFT JOIN v2_job_status s ON s.id = q.id\n FOR UPDATE OF q\n ", + "query": "\n WITH job_info AS (\n SELECT id, kind::text AS kind, parent_job\n FROM v2_job\n WHERE id = $1 AND workspace_id = $2\n )\n SELECT\n q.id AS \"id!\",\n q.suspend AS \"suspend!\",\n j.runnable_path AS script_path,\n j.permissioned_as_email AS email,\n (ji.kind IN ('flow', 'flowpreview', 'singlestepflow')) AS \"is_flow_level!\",\n (ji.kind NOT IN ('flow', 'flowpreview', 'singlestepflow') AND q.id = ji.id) AS \"is_wac!\"\n FROM job_info ji\n JOIN v2_job_queue q ON q.id = CASE\n WHEN ji.kind IN ('flow', 'flowpreview', 'singlestepflow') THEN ji.id\n ELSE COALESCE(ji.parent_job, ji.id)\n END\n JOIN v2_job j ON j.id = q.id\n FOR UPDATE OF q\n ", "describe": { "columns": [ { @@ -10,31 +10,26 @@ }, { "ordinal": 1, - "name": "flow_status", - "type_info": "Jsonb" - }, - { - "ordinal": 2, "name": "suspend!", "type_info": "Int4" }, { - "ordinal": 3, + "ordinal": 2, "name": "script_path", "type_info": "Varchar" }, { - "ordinal": 4, + "ordinal": 3, "name": "email", "type_info": "Varchar" }, { - "ordinal": 5, + "ordinal": 4, "name": "is_flow_level!", "type_info": "Bool" }, { - "ordinal": 6, + "ordinal": 5, "name": "is_wac!", "type_info": "Bool" } @@ -47,7 +42,6 @@ }, "nullable": [ false, - true, false, true, false, @@ -55,5 +49,5 @@ null ] }, - "hash": "11b5d006acf040b2461b9539863b1e511c23e1a5f9be97da066a6808a49f2092" + "hash": "0a18205b64e68529b7f90bc1c5db2cbea9ee5eaa962970b65a92597fd5e62707" } diff --git a/backend/.sqlx/query-0c2bf6925de6dd4d9f8d47d7fdd9e7801129570106b5acd49c0f708398606771.json b/backend/.sqlx/query-0c2bf6925de6dd4d9f8d47d7fdd9e7801129570106b5acd49c0f708398606771.json new file mode 100644 index 0000000000..a7b61d7282 --- /dev/null +++ b/backend/.sqlx/query-0c2bf6925de6dd4d9f8d47d7fdd9e7801129570106b5acd49c0f708398606771.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH RECURSIVE descendants AS (\n SELECT id FROM v2_job WHERE parent_job = $1 AND workspace_id = $2\n UNION ALL\n SELECT j.id FROM v2_job j JOIN descendants d ON j.parent_job = d.id\n WHERE j.workspace_id = $2\n ) SELECT d.id AS \"id!\" FROM descendants d JOIN v2_job_queue q ON q.id = d.id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "0c2bf6925de6dd4d9f8d47d7fdd9e7801129570106b5acd49c0f708398606771" +} diff --git a/backend/.sqlx/query-0f141ca6a58901dee1ddcf25694d76a4e5702c0324a27ef3a5740ef5d98d8946.json b/backend/.sqlx/query-0f141ca6a58901dee1ddcf25694d76a4e5702c0324a27ef3a5740ef5d98d8946.json deleted file mode 100644 index 0dbcf04c6e..0000000000 --- a/backend/.sqlx/query-0f141ca6a58901dee1ddcf25694d76a4e5702c0324a27ef3a5740ef5d98d8946.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT\n schedule.path, t.jobs FROM schedule,\n LATERAL(SELECT ARRAY(\n SELECT json_build_object('id', id, 'success', status = 'success', 'duration_ms', duration_ms)\n FROM v2_job_completed c JOIN v2_job j USING (id)\n WHERE trigger_kind = 'schedule'\n AND trigger = schedule.path\n AND c.workspace_id = $1\n AND j.workspace_id = $1\n AND parent_job IS NULL AND runnable_path = schedule.script_path\n AND status <> 'skipped'\n ORDER BY completed_at DESC\n LIMIT 20\n ) AS jobs) t\n WHERE workspace_id = $1 AND NOT starts_with(schedule.path, $4)\n ORDER BY edited_at DESC\n LIMIT $2 OFFSET $3", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "path", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "jobs", - "type_info": "JsonArray" - } - ], - "parameters": { - "Left": [ - "Text", - "Int8", - "Int8", - "Text" - ] - }, - "nullable": [ - false, - null - ] - }, - "hash": "0f141ca6a58901dee1ddcf25694d76a4e5702c0324a27ef3a5740ef5d98d8946" -} diff --git a/backend/.sqlx/query-141725e44ccd3f9cc0b5eb070ea3de94b89a4940901e11c48c5f2014ff28086a.json b/backend/.sqlx/query-141725e44ccd3f9cc0b5eb070ea3de94b89a4940901e11c48c5f2014ff28086a.json new file mode 100644 index 0000000000..7aab03b025 --- /dev/null +++ b/backend/.sqlx/query-141725e44ccd3f9cc0b5eb070ea3de94b89a4940901e11c48c5f2014ff28086a.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH RECURSIVE up AS (\n SELECT id, parent_job, kind, runnable_id, runnable_path, 0 AS depth\n FROM v2_job WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT p.id, p.parent_job, p.kind, p.runnable_id, p.runnable_path, u.depth + 1\n FROM v2_job p JOIN up u ON p.id = u.parent_job\n WHERE p.workspace_id = $2 AND u.kind <> 'flow' AND u.depth < 100\n )\n SELECT EXISTS (SELECT 1 FROM up WHERE kind = 'flow'\n AND runnable_id = (SELECT f.versions[array_upper(f.versions, 1)]\n FROM flow f WHERE f.path = up.runnable_path AND f.workspace_id = $2))\n AND NOT EXISTS (SELECT 1 FROM up WHERE kind <> 'flow' AND (\n EXISTS (SELECT 1 FROM v2_job_completed c WHERE c.id = up.id\n AND jsonb_typeof(c.flow_status->'restarted_from') = 'object')\n OR EXISTS (SELECT 1 FROM v2_job_status st WHERE st.id = up.id\n AND jsonb_typeof(st.flow_status->'restarted_from') = 'object')))\n AS \"current!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "current!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "141725e44ccd3f9cc0b5eb070ea3de94b89a4940901e11c48c5f2014ff28086a" +} diff --git a/backend/.sqlx/query-2effe885aafaa0c5fc1f969b8c6ffbd38c53fb586251ba780b75a007e5a47ed6.json b/backend/.sqlx/query-1c51aab269efb558640ea502e63ebed4c3d8b025947becd1c614757e7e29a5d8.json similarity index 95% rename from backend/.sqlx/query-2effe885aafaa0c5fc1f969b8c6ffbd38c53fb586251ba780b75a007e5a47ed6.json rename to backend/.sqlx/query-1c51aab269efb558640ea502e63ebed4c3d8b025947becd1c614757e7e29a5d8.json index 6d948ee8ae..6c4d73699b 100644 --- a/backend/.sqlx/query-2effe885aafaa0c5fc1f969b8c6ffbd38c53fb586251ba780b75a007e5a47ed6.json +++ b/backend/.sqlx/query-1c51aab269efb558640ea502e63ebed4c3d8b025947becd1c614757e7e29a5d8.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO schedule (\n workspace_id, path, schedule, timezone, edited_by, script_path,\n is_flow, args, enabled, email, permissioned_as,\n on_failure, on_failure_times, on_failure_exact, on_failure_extra_args,\n on_recovery, on_recovery_times, on_recovery_extra_args,\n on_success, on_success_extra_args,\n ws_error_handler_muted, retry, summary, no_flow_overlap,\n tag, paused_until, cron_version, description, dynamic_skip, labels\n ) VALUES (\n $1, $2, $3, $4, $5, $6,\n $7, $8, $9, $10, $11,\n $12, $13, $14, $15,\n $16, $17, $18,\n $19, $20,\n $21, $22, $23, $24,\n $25, $26, $27, $28, $29, $30\n )\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels\n ", + "query": "\n INSERT INTO schedule (\n workspace_id, path, schedule, timezone, edited_by, script_path,\n is_flow, args, enabled, email, permissioned_as,\n on_failure, on_failure_times, on_failure_exact, on_failure_extra_args,\n on_recovery, on_recovery_times, on_recovery_extra_args,\n on_success, on_success_extra_args,\n ws_error_handler_muted, retry, summary, no_flow_overlap,\n tag, paused_until, cron_version, description, dynamic_skip, labels\n ) VALUES (\n $1, $2, $3, $4, $5, $6,\n $7, $8, $9, $10, $11,\n $12, $13, $14, $15,\n $16, $17, $18,\n $19, $20,\n $21, $22, $23, $24,\n $25, $26, $27, $28, $29, $30\n )\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels,\n late_run_streak\n ", "describe": { "columns": [ { @@ -167,6 +167,11 @@ "ordinal": 32, "name": "labels", "type_info": "TextArray" + }, + { + "ordinal": 33, + "name": "late_run_streak", + "type_info": "Int4" } ], "parameters": { @@ -236,8 +241,9 @@ true, true, true, - true + true, + false ] }, - "hash": "2effe885aafaa0c5fc1f969b8c6ffbd38c53fb586251ba780b75a007e5a47ed6" + "hash": "1c51aab269efb558640ea502e63ebed4c3d8b025947becd1c614757e7e29a5d8" } diff --git a/backend/.sqlx/query-1d27895aa42ccbb542479b19baefd62790205b529ab0d8af36f18c470e8bb838.json b/backend/.sqlx/query-1d27895aa42ccbb542479b19baefd62790205b529ab0d8af36f18c470e8bb838.json new file mode 100644 index 0000000000..be045d9e75 --- /dev/null +++ b/backend/.sqlx/query-1d27895aa42ccbb542479b19baefd62790205b529ab0d8af36f18c470e8bb838.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT restart_unless_cancelled FROM script WHERE hash = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "restart_unless_cancelled", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "1d27895aa42ccbb542479b19baefd62790205b529ab0d8af36f18c470e8bb838" +} diff --git a/backend/.sqlx/query-1d346a14ad5586af347b8e7ac413500a39efa20e4915ffa56fd40537597db36e.json b/backend/.sqlx/query-1d346a14ad5586af347b8e7ac413500a39efa20e4915ffa56fd40537597db36e.json deleted file mode 100644 index e8ffcfb95b..0000000000 --- a/backend/.sqlx/query-1d346a14ad5586af347b8e7ac413500a39efa20e4915ffa56fd40537597db36e.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT 'schedule' AS \"kind!\", COUNT(*)::BIGINT AS \"count!\" FROM schedule\n UNION ALL SELECT 'http', COUNT(*)::BIGINT FROM http_trigger\n UNION ALL SELECT 'websocket', COUNT(*)::BIGINT FROM websocket_trigger\n UNION ALL SELECT 'kafka', COUNT(*)::BIGINT FROM kafka_trigger\n UNION ALL SELECT 'nats', COUNT(*)::BIGINT FROM nats_trigger\n UNION ALL SELECT 'postgres', COUNT(*)::BIGINT FROM postgres_trigger\n UNION ALL SELECT 'mqtt', COUNT(*)::BIGINT FROM mqtt_trigger\n UNION ALL SELECT 'sqs', COUNT(*)::BIGINT FROM sqs_trigger\n UNION ALL SELECT 'gcp', COUNT(*)::BIGINT FROM gcp_trigger\n UNION ALL SELECT 'azure', COUNT(*)::BIGINT FROM azure_trigger\n UNION ALL SELECT 'amqp', COUNT(*)::BIGINT FROM amqp_trigger\n UNION ALL SELECT 'email', COUNT(*)::BIGINT FROM email_trigger\n -- Grouped, not a single 'native' key: these fire as nextcloud/google/github,\n -- so a lone key would not line up with the `trigger`/`fired` series.\n UNION ALL SELECT service_name::text, COUNT(*)::BIGINT FROM native_trigger GROUP BY service_name\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "kind!", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "count!", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - null, - null - ] - }, - "hash": "1d346a14ad5586af347b8e7ac413500a39efa20e4915ffa56fd40537597db36e" -} diff --git a/backend/.sqlx/query-201c4d014dd71da5a38ba832f836ca71b0fe6dbee8645449e97517aeb4ef9655.json b/backend/.sqlx/query-201c4d014dd71da5a38ba832f836ca71b0fe6dbee8645449e97517aeb4ef9655.json deleted file mode 100644 index 2a8d0ab26f..0000000000 --- a/backend/.sqlx/query-201c4d014dd71da5a38ba832f836ca71b0fe6dbee8645449e97517aeb4ef9655.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH RECURSIVE lineage AS (\n SELECT id, parent_job, kind, runnable_path, runnable_id, trigger_kind, trigger,\n 0 AS depth\n FROM v2_job WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT p.id, p.parent_job, p.kind, p.runnable_path, p.runnable_id, p.trigger_kind,\n p.trigger, l.depth + 1\n FROM v2_job p JOIN lineage l ON p.id = l.parent_job\n WHERE p.workspace_id = $2 AND l.depth < 100\n )\n SELECT id AS \"id!\", parent_job, kind AS \"kind!: JobKind\", runnable_path,\n trigger_kind::text AS trigger_kind,\n -- A restart takes its flow version from the request, so a trusted flow path\n -- can carry another flow's code: the version must belong to that path.\n CASE kind\n WHEN 'flow' THEN EXISTS (SELECT 1 FROM flow_version fv\n WHERE fv.id = runnable_id AND fv.path = runnable_path AND fv.workspace_id = $2)\n WHEN 'script' THEN EXISTS (SELECT 1 FROM script s\n WHERE s.hash = runnable_id AND s.path = runnable_path AND s.workspace_id = $2)\n -- An app editor preview also runs an app script, at an app path it does not\n -- have to own; only deployed-app runs are stamped with their app.\n WHEN 'appscript' THEN COALESCE(trigger_kind = 'app'\n AND starts_with(runnable_path, trigger || '/'), false)\n ELSE true\n END AS \"origin_verified!\"\n FROM lineage ORDER BY depth", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id!", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "parent_job", - "type_info": "Uuid" - }, - { - "ordinal": 2, - "name": "kind!: JobKind", - "type_info": { - "Custom": { - "name": "job_kind", - "kind": { - "Enum": [ - "script", - "preview", - "flow", - "dependencies", - "flowpreview", - "script_hub", - "identity", - "flowdependencies", - "http", - "graphql", - "postgresql", - "noop", - "appdependencies", - "deploymentcallback", - "singlestepflow", - "flowscript", - "flownode", - "appscript", - "aiagent", - "unassigned_script", - "unassigned_flow", - "unassigned_singlestepflow" - ] - } - } - } - }, - { - "ordinal": 3, - "name": "runnable_path", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "trigger_kind", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "origin_verified!", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Text" - ] - }, - "nullable": [ - null, - null, - null, - null, - null, - null - ] - }, - "hash": "201c4d014dd71da5a38ba832f836ca71b0fe6dbee8645449e97517aeb4ef9655" -} diff --git a/backend/.sqlx/query-298e0e30afdc973c517a9b4ea99f5dd1616a62c6ecbaaea312c686038d21e6fd.json b/backend/.sqlx/query-298e0e30afdc973c517a9b4ea99f5dd1616a62c6ecbaaea312c686038d21e6fd.json new file mode 100644 index 0000000000..9d396e3a33 --- /dev/null +++ b/backend/.sqlx/query-298e0e30afdc973c517a9b4ea99f5dd1616a62c6ecbaaea312c686038d21e6fd.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2 AND hash = $3)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "298e0e30afdc973c517a9b4ea99f5dd1616a62c6ecbaaea312c686038d21e6fd" +} diff --git a/backend/.sqlx/query-2bf039a2880555c5e6eec9a29c4a380aa832d72d2aaf5b109ee9554f1e9da6a4.json b/backend/.sqlx/query-2bf039a2880555c5e6eec9a29c4a380aa832d72d2aaf5b109ee9554f1e9da6a4.json new file mode 100644 index 0000000000..46d609ecb4 --- /dev/null +++ b/backend/.sqlx/query-2bf039a2880555c5e6eec9a29c4a380aa832d72d2aaf5b109ee9554f1e9da6a4.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT c.id AS \"id!\", u.job_id AS \"job_id!\", u.created_seq AS \"user_seq!\"\n FROM unnest($1::uuid[]) AS c(id)\n CROSS JOIN LATERAL (\n SELECT job_id, created_seq\n FROM flow_conversation_message\n WHERE conversation_id = c.id AND message_type = 'user'\n ORDER BY created_seq DESC\n LIMIT 1\n ) u\n WHERE u.job_id IS NOT NULL\n AND EXISTS (SELECT 1 FROM v2_job_queue q WHERE q.id = u.job_id)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "job_id!", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "user_seq!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "UuidArray" + ] + }, + "nullable": [ + null, + true, + false + ] + }, + "hash": "2bf039a2880555c5e6eec9a29c4a380aa832d72d2aaf5b109ee9554f1e9da6a4" +} diff --git a/backend/.sqlx/query-819c4d78f10979a83529155c3f101f42f6958742bc68ab384944fa3c5cba094c.json b/backend/.sqlx/query-2d73fe76e45becbcae7a9d4384bbb2f9739cab232b8f3e357e27dc32bb35c3a8.json similarity index 62% rename from backend/.sqlx/query-819c4d78f10979a83529155c3f101f42f6958742bc68ab384944fa3c5cba094c.json rename to backend/.sqlx/query-2d73fe76e45becbcae7a9d4384bbb2f9739cab232b8f3e357e27dc32bb35c3a8.json index b25b301a04..23b00521d2 100644 --- a/backend/.sqlx/query-819c4d78f10979a83529155c3f101f42f6958742bc68ab384944fa3c5cba094c.json +++ b/backend/.sqlx/query-2d73fe76e45becbcae7a9d4384bbb2f9739cab232b8f3e357e27dc32bb35c3a8.json @@ -1,12 +1,17 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM script WHERE workspace_id = $1 AND path = ANY($2) RETURNING path", + "query": "DELETE FROM script WHERE workspace_id = $1 AND path = ANY($2) RETURNING path, hash", "describe": { "columns": [ { "ordinal": 0, "name": "path", "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "hash", + "type_info": "Int8" } ], "parameters": { @@ -16,8 +21,9 @@ ] }, "nullable": [ + false, false ] }, - "hash": "819c4d78f10979a83529155c3f101f42f6958742bc68ab384944fa3c5cba094c" + "hash": "2d73fe76e45becbcae7a9d4384bbb2f9739cab232b8f3e357e27dc32bb35c3a8" } diff --git a/backend/.sqlx/query-39793465641124401a732744777bc3553985b757b48962e9976b5da5b812bd05.json b/backend/.sqlx/query-39793465641124401a732744777bc3553985b757b48962e9976b5da5b812bd05.json new file mode 100644 index 0000000000..e1fe716eab --- /dev/null +++ b/backend/.sqlx/query-39793465641124401a732744777bc3553985b757b48962e9976b5da5b812bd05.json @@ -0,0 +1,49 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n schedule.path, t.jobs, schedule.late_run_streak, schedule.missed_occurrences, schedule.last_missed_at FROM schedule,\n LATERAL(SELECT ARRAY(\n SELECT json_build_object('id', id, 'success', status = 'success', 'duration_ms', duration_ms)\n FROM v2_job_completed c JOIN v2_job j USING (id)\n WHERE trigger_kind = 'schedule'\n AND trigger = schedule.path\n AND c.workspace_id = $1\n AND j.workspace_id = $1\n AND parent_job IS NULL AND runnable_path = schedule.script_path\n AND status <> 'skipped'\n ORDER BY completed_at DESC\n LIMIT 20\n ) AS jobs) t\n WHERE workspace_id = $1 AND NOT starts_with(schedule.path, $4)\n ORDER BY edited_at DESC\n LIMIT $2 OFFSET $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "jobs", + "type_info": "JsonArray" + }, + { + "ordinal": 2, + "name": "late_run_streak", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "missed_occurrences", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "last_missed_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8", + "Int8", + "Text" + ] + }, + "nullable": [ + false, + null, + false, + false, + true + ] + }, + "hash": "39793465641124401a732744777bc3553985b757b48962e9976b5da5b812bd05" +} diff --git a/backend/.sqlx/query-3dccc8e745f4a0973541088f66172e74af6828c1ab52cf7a6fd10b305deade85.json b/backend/.sqlx/query-3dccc8e745f4a0973541088f66172e74af6828c1ab52cf7a6fd10b305deade85.json new file mode 100644 index 0000000000..c6e572863a --- /dev/null +++ b/backend/.sqlx/query-3dccc8e745f4a0973541088f66172e74af6828c1ab52cf7a6fd10b305deade85.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM flow WHERE workspace_id = $1 AND path = $2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "3dccc8e745f4a0973541088f66172e74af6828c1ab52cf7a6fd10b305deade85" +} diff --git a/backend/.sqlx/query-451f303f4a24d848ba4ccdc2441ca7cb85bdfc66110b6534260f8642f91f006a.json b/backend/.sqlx/query-451f303f4a24d848ba4ccdc2441ca7cb85bdfc66110b6534260f8642f91f006a.json new file mode 100644 index 0000000000..8209f67c75 --- /dev/null +++ b/backend/.sqlx/query-451f303f4a24d848ba4ccdc2441ca7cb85bdfc66110b6534260f8642f91f006a.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE v2_job_queue SET started_at = now() WHERE id = $1 RETURNING started_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "started_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true + ] + }, + "hash": "451f303f4a24d848ba4ccdc2441ca7cb85bdfc66110b6534260f8642f91f006a" +} diff --git a/backend/.sqlx/query-466b2c68074202146da10d4b0757128fa332a52e3b451aac307234d02a51de8f.json b/backend/.sqlx/query-466b2c68074202146da10d4b0757128fa332a52e3b451aac307234d02a51de8f.json new file mode 100644 index 0000000000..814659877f --- /dev/null +++ b/backend/.sqlx/query-466b2c68074202146da10d4b0757128fa332a52e3b451aac307234d02a51de8f.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE schedule SET late_run_streak = 0\n WHERE workspace_id = $1 AND path = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "466b2c68074202146da10d4b0757128fa332a52e3b451aac307234d02a51de8f" +} diff --git a/backend/.sqlx/query-4a257470121ee6bffd9c68e88aabb7563c0ba31233854c8922f9449da7b43e9e.json b/backend/.sqlx/query-4a257470121ee6bffd9c68e88aabb7563c0ba31233854c8922f9449da7b43e9e.json new file mode 100644 index 0000000000..465eac3a37 --- /dev/null +++ b/backend/.sqlx/query-4a257470121ee6bffd9c68e88aabb7563c0ba31233854c8922f9449da7b43e9e.json @@ -0,0 +1,77 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n j.runnable_path as script_path, j.args AS \"args: sqlx::types::Json>>\",\n j.tag AS \"tag!\", j.priority, j.kind AS \"kind!: JobKind\"\n FROM v2_job j\n WHERE j.id = $1 and j.workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "script_path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "args: sqlx::types::Json>>", + "type_info": "Jsonb" + }, + { + "ordinal": 2, + "name": "tag!", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "priority", + "type_info": "Int2" + }, + { + "ordinal": 4, + "name": "kind!: JobKind", + "type_info": { + "Custom": { + "name": "job_kind", + "kind": { + "Enum": [ + "script", + "preview", + "flow", + "dependencies", + "flowpreview", + "script_hub", + "identity", + "flowdependencies", + "http", + "graphql", + "postgresql", + "noop", + "appdependencies", + "deploymentcallback", + "singlestepflow", + "flowscript", + "flownode", + "appscript", + "aiagent", + "unassigned_script", + "unassigned_flow", + "unassigned_singlestepflow" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + true, + true, + false, + true, + false + ] + }, + "hash": "4a257470121ee6bffd9c68e88aabb7563c0ba31233854c8922f9449da7b43e9e" +} diff --git a/backend/.sqlx/query-4a6fb702794ce250a8217034ae9b0b719f61fe0cb8a7a2c76be4b7422a7138a0.json b/backend/.sqlx/query-4a6fb702794ce250a8217034ae9b0b719f61fe0cb8a7a2c76be4b7422a7138a0.json new file mode 100644 index 0000000000..552f2f57de --- /dev/null +++ b/backend/.sqlx/query-4a6fb702794ce250a8217034ae9b0b719f61fe0cb8a7a2c76be4b7422a7138a0.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT late_run_streak FROM schedule WHERE workspace_id = $1 AND path = $2 FOR SHARE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "late_run_streak", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "4a6fb702794ce250a8217034ae9b0b719f61fe0cb8a7a2c76be4b7422a7138a0" +} diff --git a/backend/.sqlx/query-4bc47050c74a02ab3169c3165898b2af07e995de71564d867b171f97f719fbde.json b/backend/.sqlx/query-4bc47050c74a02ab3169c3165898b2af07e995de71564d867b171f97f719fbde.json new file mode 100644 index 0000000000..9b2b79808d --- /dev/null +++ b/backend/.sqlx/query-4bc47050c74a02ab3169c3165898b2af07e995de71564d867b171f97f719fbde.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "4bc47050c74a02ab3169c3165898b2af07e995de71564d867b171f97f719fbde" +} diff --git a/backend/.sqlx/query-52f9582423689f098a337f88341239a136ee5132bc97f4b06cc1ee5beaa621e5.json b/backend/.sqlx/query-52f9582423689f098a337f88341239a136ee5132bc97f4b06cc1ee5beaa621e5.json deleted file mode 100644 index cc6a57c005..0000000000 --- a/backend/.sqlx/query-52f9582423689f098a337f88341239a136ee5132bc97f4b06cc1ee5beaa621e5.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT c.id IS NOT NULL AS \"completed!\",\n c.result AS \"result: sqlx::types::Json>\",\n c.status = 'success' AS \"success\",\n EXISTS(SELECT 1 FROM v2_job_queue WHERE id = $1) AS \"queued!\"\n FROM (SELECT 1) one\n LEFT JOIN v2_job_completed c ON c.id = $1 AND c.workspace_id = $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "completed!", - "type_info": "Bool" - }, - { - "ordinal": 1, - "name": "result: sqlx::types::Json>", - "type_info": "Jsonb" - }, - { - "ordinal": 2, - "name": "success", - "type_info": "Bool" - }, - { - "ordinal": 3, - "name": "queued!", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Text" - ] - }, - "nullable": [ - null, - true, - null, - null - ] - }, - "hash": "52f9582423689f098a337f88341239a136ee5132bc97f4b06cc1ee5beaa621e5" -} diff --git a/backend/.sqlx/query-5312b8db714139a94d7ff1c0794af063c36ac17e9d331cd9980b91b28d713c72.json b/backend/.sqlx/query-5312b8db714139a94d7ff1c0794af063c36ac17e9d331cd9980b91b28d713c72.json deleted file mode 100644 index 35b70a239c..0000000000 --- a/backend/.sqlx/query-5312b8db714139a94d7ff1c0794af063c36ac17e9d331cd9980b91b28d713c72.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT bool_and(operator) FROM (\n SELECT operator FROM usr WHERE email = $1 AND is_service_account IS false\n UNION ALL\n SELECT operator FROM workspace_invite WHERE email = $1\n ) t", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "bool_and", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "5312b8db714139a94d7ff1c0794af063c36ac17e9d331cd9980b91b28d713c72" -} diff --git a/backend/.sqlx/query-611dd43eb1b629db860c1b477444cb3dadcbf8058a24883bc22f143762c5ea3d.json b/backend/.sqlx/query-611dd43eb1b629db860c1b477444cb3dadcbf8058a24883bc22f143762c5ea3d.json new file mode 100644 index 0000000000..6e13ae6be1 --- /dev/null +++ b/backend/.sqlx/query-611dd43eb1b629db860c1b477444cb3dadcbf8058a24883bc22f143762c5ea3d.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE v2_job_queue SET canceled_by = $1, canceled_reason = $2, scheduled_for = now(), suspend = 0 WHERE id = $3 AND workspace_id = $4 AND canceled_by IS NULL RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Uuid", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "611dd43eb1b629db860c1b477444cb3dadcbf8058a24883bc22f143762c5ea3d" +} diff --git a/backend/.sqlx/query-640482b37fe2aa63a5082250170406bcbe888ddece7dec3330778591573031fb.json b/backend/.sqlx/query-640482b37fe2aa63a5082250170406bcbe888ddece7dec3330778591573031fb.json new file mode 100644 index 0000000000..72699120db --- /dev/null +++ b/backend/.sqlx/query-640482b37fe2aa63a5082250170406bcbe888ddece7dec3330778591573031fb.json @@ -0,0 +1,31 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE schedule SET late_run_streak = late_run_streak + 1,\n missed_occurrences = CASE WHEN late_run_streak = 0 THEN $3\n ELSE missed_occurrences + $3 END,\n last_missed_at = $4\n WHERE workspace_id = $1 AND path = $2\n RETURNING late_run_streak, missed_occurrences", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "late_run_streak", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "missed_occurrences", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int4", + "Timestamptz" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "640482b37fe2aa63a5082250170406bcbe888ddece7dec3330778591573031fb" +} diff --git a/backend/.sqlx/query-b44afcf1b9c047ac525638f0952c2cb01d65b1b46693331ac157dfca0dab6824.json b/backend/.sqlx/query-65e33a933ed8568c82f9b4e121b9ba4280123fe22a959d4d3d978ca767aeed29.json similarity index 88% rename from backend/.sqlx/query-b44afcf1b9c047ac525638f0952c2cb01d65b1b46693331ac157dfca0dab6824.json rename to backend/.sqlx/query-65e33a933ed8568c82f9b4e121b9ba4280123fe22a959d4d3d978ca767aeed29.json index 9187cc29b7..8e059f92ed 100644 --- a/backend/.sqlx/query-b44afcf1b9c047ac525638f0952c2cb01d65b1b46693331ac157dfca0dab6824.json +++ b/backend/.sqlx/query-65e33a933ed8568c82f9b4e121b9ba4280123fe22a959d4d3d978ca767aeed29.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT content AS \"content!: String\",\n lock AS \"lock: String\", language AS \"language: Option\", envs AS \"envs: Vec\", schema AS \"schema: String\", schema_validation AS \"schema_validation: bool\", codebase LIKE '%.tar' as use_tar, codebase LIKE '%.esm%' as is_esm, modules AS \"modules: serde_json::Value\" FROM script WHERE hash = $1 LIMIT 1", + "query": "SELECT content AS \"content!: String\",\n lock AS \"lock: String\", language AS \"language: Option\", envs AS \"envs: Vec\", schema AS \"schema: String\", schema_validation AS \"schema_validation: bool\", codebase LIKE '%.tar' as use_tar, codebase LIKE '%.esm%' as is_esm, modules AS \"modules: serde_json::Value\", deleted FROM script WHERE hash = $1 ORDER BY deleted LIMIT 1", "describe": { "columns": [ { @@ -80,6 +80,11 @@ "ordinal": 8, "name": "modules: serde_json::Value", "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "deleted", + "type_info": "Bool" } ], "parameters": { @@ -96,8 +101,9 @@ false, null, null, - true + true, + false ] }, - "hash": "b44afcf1b9c047ac525638f0952c2cb01d65b1b46693331ac157dfca0dab6824" + "hash": "65e33a933ed8568c82f9b4e121b9ba4280123fe22a959d4d3d978ca767aeed29" } diff --git a/backend/.sqlx/query-71ee2cb6661cca1fa4d8874a7f6d368347c59f36fd87df6dc7996152ccb84af0.json b/backend/.sqlx/query-71ee2cb6661cca1fa4d8874a7f6d368347c59f36fd87df6dc7996152ccb84af0.json deleted file mode 100644 index a03fab7081..0000000000 --- a/backend/.sqlx/query-71ee2cb6661cca1fa4d8874a7f6d368347c59f36fd87df6dc7996152ccb84af0.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id, name, enabled, pwd FROM datatable_role", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "enabled", - "type_info": "Bool" - }, - { - "ordinal": 3, - "name": "pwd", - "type_info": "Text" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false, - false, - true - ] - }, - "hash": "71ee2cb6661cca1fa4d8874a7f6d368347c59f36fd87df6dc7996152ccb84af0" -} diff --git a/backend/.sqlx/query-789c3f6d29f46fcb17a22fe97405a1754ccb8ddeb08c23795dfe14e9f332e9dc.json b/backend/.sqlx/query-789c3f6d29f46fcb17a22fe97405a1754ccb8ddeb08c23795dfe14e9f332e9dc.json new file mode 100644 index 0000000000..9702cbf61d --- /dev/null +++ b/backend/.sqlx/query-789c3f6d29f46fcb17a22fe97405a1754ccb8ddeb08c23795dfe14e9f332e9dc.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, status = 'success' AS \"success!\", result AS \"result: Json>\"\n FROM v2_job_completed WHERE workspace_id = $1 AND id = ANY($2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "success!", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "result: Json>", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "UuidArray" + ] + }, + "nullable": [ + false, + null, + true + ] + }, + "hash": "789c3f6d29f46fcb17a22fe97405a1754ccb8ddeb08c23795dfe14e9f332e9dc" +} diff --git a/backend/.sqlx/query-7904ac35748b1de2c7ff764f22787436d0fe74f99f298e5c399233f0d247f2ec.json b/backend/.sqlx/query-7904ac35748b1de2c7ff764f22787436d0fe74f99f298e5c399233f0d247f2ec.json new file mode 100644 index 0000000000..e103a91c1e --- /dev/null +++ b/backend/.sqlx/query-7904ac35748b1de2c7ff764f22787436d0fe74f99f298e5c399233f0d247f2ec.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH RECURSIVE chain(id, parent_job) AS (\n SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT j.id, j.parent_job FROM v2_job j\n JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2\n )\n SELECT\n CASE WHEN a.agent THEN regexp_replace(j.runnable_path, '\\.chat$', '')\n ELSE j.runnable_path END AS runnable_path,\n CASE\n WHEN a.agent THEN 'agents'\n WHEN j.kind IN ('script', 'script_hub', 'unassigned_script') THEN 'scripts'\n WHEN j.kind IN ('flow', 'unassigned_flow') THEN 'flows'\n WHEN j.kind IN ('singlestepflow', 'unassigned_singlestepflow') THEN\n CASE WHEN COALESCE(\n (SELECT m->'value'->>'type'\n FROM jsonb_array_elements(j.raw_flow->'modules') m\n WHERE m->>'id' IN ('a', 'main')\n LIMIT 1),\n 'script'\n ) = 'flow' THEN 'flows' ELSE 'scripts' END\n END AS scope_kind,\n CASE WHEN j.trigger_kind = 'app' THEN j.trigger END AS launched_by_app\n FROM v2_job j JOIN chain c ON c.id = j.id,\n LATERAL (SELECT j.kind = 'flowpreview'\n AND j.raw_flow->'modules'->1 IS NULL\n AND j.raw_flow->'modules'->0->>'id' = '__wm_agent_root' AS agent) a\n WHERE j.workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "runnable_path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "scope_kind", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "launched_by_app", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + null, + null, + null + ] + }, + "hash": "7904ac35748b1de2c7ff764f22787436d0fe74f99f298e5c399233f0d247f2ec" +} diff --git a/backend/.sqlx/query-4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56.json b/backend/.sqlx/query-793810c58d9e872da52d478db61c0a20126a207397cb9ed2dc18cd0b2baa57fa.json similarity index 79% rename from backend/.sqlx/query-4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56.json rename to backend/.sqlx/query-793810c58d9e872da52d478db61c0a20126a207397cb9ed2dc18cd0b2baa57fa.json index 5c0e45b0d4..b5afa669f5 100644 --- a/backend/.sqlx/query-4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56.json +++ b/backend/.sqlx/query-793810c58d9e872da52d478db61c0a20126a207397cb9ed2dc18cd0b2baa57fa.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE schedule SET\n schedule = $1,\n timezone = $2,\n args = $3,\n on_failure = $4,\n on_failure_times = $5,\n on_failure_exact = $6,\n on_failure_extra_args = $7,\n on_recovery = $8,\n on_recovery_times = $9,\n on_recovery_extra_args = $10,\n on_success = $11,\n on_success_extra_args = $12,\n ws_error_handler_muted = $13,\n retry = $14,\n summary = $15,\n no_flow_overlap = $16,\n tag = $17,\n paused_until = $18,\n path = $19,\n workspace_id = $20,\n cron_version = COALESCE($21, cron_version),\n description = $22,\n dynamic_skip = $23,\n email = $24,\n edited_by = $25,\n edited_at = now(),\n permissioned_as = $26,\n labels = COALESCE($27, labels)\n WHERE path = $19 AND workspace_id = $20\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels\n ", + "query": "\n UPDATE schedule SET\n schedule = $1,\n timezone = $2,\n args = $3,\n on_failure = $4,\n on_failure_times = $5,\n on_failure_exact = $6,\n on_failure_extra_args = $7,\n on_recovery = $8,\n on_recovery_times = $9,\n on_recovery_extra_args = $10,\n on_success = $11,\n on_success_extra_args = $12,\n ws_error_handler_muted = $13,\n retry = $14,\n summary = $15,\n no_flow_overlap = $16,\n tag = $17,\n paused_until = $18,\n path = $19,\n workspace_id = $20,\n cron_version = COALESCE($21, cron_version),\n description = $22,\n dynamic_skip = $23,\n email = $24,\n edited_by = $25,\n edited_at = now(),\n permissioned_as = $26,\n labels = COALESCE($27, labels),\n late_run_streak = 0,\n missed_occurrences = 0,\n last_missed_at = NULL\n WHERE path = $19 AND workspace_id = $20\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels,\n late_run_streak\n ", "describe": { "columns": [ { @@ -167,6 +167,11 @@ "ordinal": 32, "name": "labels", "type_info": "TextArray" + }, + { + "ordinal": 33, + "name": "late_run_streak", + "type_info": "Int4" } ], "parameters": { @@ -233,8 +238,9 @@ true, true, true, - true + true, + false ] }, - "hash": "4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56" + "hash": "793810c58d9e872da52d478db61c0a20126a207397cb9ed2dc18cd0b2baa57fa" } diff --git a/backend/.sqlx/query-79799b5a2e499df6c28e286c42b9ad2db940c2455ab19cc95e5198baf96d5629.json b/backend/.sqlx/query-79799b5a2e499df6c28e286c42b9ad2db940c2455ab19cc95e5198baf96d5629.json deleted file mode 100644 index c36b6fb361..0000000000 --- a/backend/.sqlx/query-79799b5a2e499df6c28e286c42b9ad2db940c2455ab19cc95e5198baf96d5629.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT ws.workspace_id AS \"workspace_id!\", dt.key AS \"datatable!\"\n FROM workspace_settings ws\n JOIN workspace w ON w.id = ws.workspace_id AND w.deleted = false\n CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt\n WHERE ws.workspace_id <> $1\n AND dt.value->'database'->>'resource_type' = 'instance'\n AND dt.value->'database'->>'resource_path' = $2\n ORDER BY ws.workspace_id, dt.key\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "workspace_id!", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "datatable!", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - false, - null - ] - }, - "hash": "79799b5a2e499df6c28e286c42b9ad2db940c2455ab19cc95e5198baf96d5629" -} diff --git a/backend/.sqlx/query-84ad6f24aa9ccdc6f6e705b7de1bb707637a9bb1475711f9f9f3f2a6e28cb169.json b/backend/.sqlx/query-84ad6f24aa9ccdc6f6e705b7de1bb707637a9bb1475711f9f9f3f2a6e28cb169.json deleted file mode 100644 index 510e47e50c..0000000000 --- a/backend/.sqlx/query-84ad6f24aa9ccdc6f6e705b7de1bb707637a9bb1475711f9f9f3f2a6e28cb169.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE v2_job_queue SET suspend = $1 WHERE id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int4", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "84ad6f24aa9ccdc6f6e705b7de1bb707637a9bb1475711f9f9f3f2a6e28cb169" -} diff --git a/backend/.sqlx/query-84fcddaf5bc61d607a6e6e5e31de7436b203a3baa7ef0509cb8e6c52270ae3a9.json b/backend/.sqlx/query-84fcddaf5bc61d607a6e6e5e31de7436b203a3baa7ef0509cb8e6c52270ae3a9.json deleted file mode 100644 index 557466e8b7..0000000000 --- a/backend/.sqlx/query-84fcddaf5bc61d607a6e6e5e31de7436b203a3baa7ef0509cb8e6c52270ae3a9.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH RECURSIVE chain(id, parent_job) AS (\n SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT j.id, j.parent_job FROM v2_job j\n JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2\n )\n SELECT j.runnable_path,\n CASE\n WHEN j.kind IN ('script', 'script_hub', 'unassigned_script') THEN 'scripts'\n WHEN j.kind IN ('flow', 'unassigned_flow') THEN 'flows'\n WHEN j.kind IN ('singlestepflow', 'unassigned_singlestepflow') THEN\n CASE WHEN COALESCE(\n (SELECT m->'value'->>'type'\n FROM jsonb_array_elements(j.raw_flow->'modules') m\n WHERE m->>'id' IN ('a', 'main')\n LIMIT 1),\n 'script'\n ) = 'flow' THEN 'flows' ELSE 'scripts' END\n END AS scope_kind,\n CASE WHEN j.trigger_kind = 'app' THEN j.trigger END AS launched_by_app\n FROM v2_job j JOIN chain c ON c.id = j.id\n WHERE j.workspace_id = $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "runnable_path", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "scope_kind", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "launched_by_app", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Text" - ] - }, - "nullable": [ - true, - null, - null - ] - }, - "hash": "84fcddaf5bc61d607a6e6e5e31de7436b203a3baa7ef0509cb8e6c52270ae3a9" -} diff --git a/backend/.sqlx/query-86af9d51a158ea5cb6161461ecddf2a63695f8cbf8af648da5a0a77a5b9d02ba.json b/backend/.sqlx/query-86af9d51a158ea5cb6161461ecddf2a63695f8cbf8af648da5a0a77a5b9d02ba.json deleted file mode 100644 index 7627d9828d..0000000000 --- a/backend/.sqlx/query-86af9d51a158ea5cb6161461ecddf2a63695f8cbf8af648da5a0a77a5b9d02ba.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO datatable_role (id, name, enabled, pwd) VALUES ($1, $2, $3, $4)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Bool", - "Text" - ] - }, - "nullable": [] - }, - "hash": "86af9d51a158ea5cb6161461ecddf2a63695f8cbf8af648da5a0a77a5b9d02ba" -} diff --git a/backend/.sqlx/query-872c06b3098cf59797bcaa90d6da5c6acbf5468c190fa72b6b6707d950267752.json b/backend/.sqlx/query-872c06b3098cf59797bcaa90d6da5c6acbf5468c190fa72b6b6707d950267752.json new file mode 100644 index 0000000000..3bdcb5e48c --- /dev/null +++ b/backend/.sqlx/query-872c06b3098cf59797bcaa90d6da5c6acbf5468c190fa72b6b6707d950267752.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT 'schedule' AS \"kind!\", COUNT(*)::BIGINT AS \"count!\", 0::BIGINT AS \"suspended!\" FROM schedule\n UNION ALL SELECT 'http', COUNT(*)::BIGINT, COUNT(*) FILTER (WHERE mode = 'suspended')::BIGINT FROM http_trigger\n UNION ALL SELECT 'websocket', COUNT(*)::BIGINT, COUNT(*) FILTER (WHERE mode = 'suspended')::BIGINT FROM websocket_trigger\n UNION ALL SELECT 'kafka', COUNT(*)::BIGINT, COUNT(*) FILTER (WHERE mode = 'suspended')::BIGINT FROM kafka_trigger\n UNION ALL SELECT 'nats', COUNT(*)::BIGINT, COUNT(*) FILTER (WHERE mode = 'suspended')::BIGINT FROM nats_trigger\n UNION ALL SELECT 'postgres', COUNT(*)::BIGINT, COUNT(*) FILTER (WHERE mode = 'suspended')::BIGINT FROM postgres_trigger\n UNION ALL SELECT 'mqtt', COUNT(*)::BIGINT, COUNT(*) FILTER (WHERE mode = 'suspended')::BIGINT FROM mqtt_trigger\n UNION ALL SELECT 'sqs', COUNT(*)::BIGINT, COUNT(*) FILTER (WHERE mode = 'suspended')::BIGINT FROM sqs_trigger\n UNION ALL SELECT 'gcp', COUNT(*)::BIGINT, COUNT(*) FILTER (WHERE mode = 'suspended')::BIGINT FROM gcp_trigger\n UNION ALL SELECT 'azure', COUNT(*)::BIGINT, COUNT(*) FILTER (WHERE mode = 'suspended')::BIGINT FROM azure_trigger\n UNION ALL SELECT 'amqp', COUNT(*)::BIGINT, COUNT(*) FILTER (WHERE mode = 'suspended')::BIGINT FROM amqp_trigger\n UNION ALL SELECT 'email', COUNT(*)::BIGINT, COUNT(*) FILTER (WHERE mode = 'suspended')::BIGINT FROM email_trigger\n -- Grouped, not a single 'native' key: these fire as nextcloud/google/github,\n -- so a lone key would not line up with the `trigger`/`fired` series.\n UNION ALL SELECT service_name::text, COUNT(*)::BIGINT, 0::BIGINT FROM native_trigger GROUP BY service_name\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "kind!", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "count!", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "suspended!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null, + null, + null + ] + }, + "hash": "872c06b3098cf59797bcaa90d6da5c6acbf5468c190fa72b6b6707d950267752" +} diff --git a/backend/.sqlx/query-a35164456ade8e79cb8f5418c8fe82c45be45409f881292f0d4a3316362ba1f4.json b/backend/.sqlx/query-a35164456ade8e79cb8f5418c8fe82c45be45409f881292f0d4a3316362ba1f4.json deleted file mode 100644 index aedbbf424e..0000000000 --- a/backend/.sqlx/query-a35164456ade8e79cb8f5418c8fe82c45be45409f881292f0d4a3316362ba1f4.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "a35164456ade8e79cb8f5418c8fe82c45be45409f881292f0d4a3316362ba1f4" -} diff --git a/backend/.sqlx/query-b9842d2d8abf382bd82d8fa1de012373638be391f884f81dc387ffc465badac6.json b/backend/.sqlx/query-b9842d2d8abf382bd82d8fa1de012373638be391f884f81dc387ffc465badac6.json deleted file mode 100644 index 4b3a2f33e9..0000000000 --- a/backend/.sqlx/query-b9842d2d8abf382bd82d8fa1de012373638be391f884f81dc387ffc465badac6.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT jsonb_object_keys(value->'databases') FROM global_settings\n WHERE name = 'custom_instance_pg_databases'", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "jsonb_object_keys", - "type_info": "Text" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - null - ] - }, - "hash": "b9842d2d8abf382bd82d8fa1de012373638be391f884f81dc387ffc465badac6" -} diff --git a/backend/.sqlx/query-be004608fc1de826803f266dfce96e09e634977478443b6372bca5e3dd0ce5ff.json b/backend/.sqlx/query-be004608fc1de826803f266dfce96e09e634977478443b6372bca5e3dd0ce5ff.json new file mode 100644 index 0000000000..dbd7e7c9ec --- /dev/null +++ b/backend/.sqlx/query-be004608fc1de826803f266dfce96e09e634977478443b6372bca5e3dd0ce5ff.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT NOT starts_with(COALESCE(label, ''), 'impersonation:') FROM token\n WHERE token_hash = $1 AND (expiration IS NULL OR expiration > now())", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "be004608fc1de826803f266dfce96e09e634977478443b6372bca5e3dd0ce5ff" +} diff --git a/backend/.sqlx/query-642ca096c1c151fc01445593c8d2ed879a9a7be6ff044a9ce1b581ba725665fe.json b/backend/.sqlx/query-ca54e732d521416cb2692c8c4406c63922f0eebcba2b2cefc4290c4baee5c071.json similarity index 73% rename from backend/.sqlx/query-642ca096c1c151fc01445593c8d2ed879a9a7be6ff044a9ce1b581ba725665fe.json rename to backend/.sqlx/query-ca54e732d521416cb2692c8c4406c63922f0eebcba2b2cefc4290c4baee5c071.json index a205bcbbe6..0df21a408a 100644 --- a/backend/.sqlx/query-642ca096c1c151fc01445593c8d2ed879a9a7be6ff044a9ce1b581ba725665fe.json +++ b/backend/.sqlx/query-ca54e732d521416cb2692c8c4406c63922f0eebcba2b2cefc4290c4baee5c071.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE schedule SET\n enabled = $1,\n email = $2\n WHERE path = $3 AND workspace_id = $4\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels\n ", + "query": "\n UPDATE schedule SET\n enabled = $1,\n email = $2,\n late_run_streak = 0,\n missed_occurrences = 0,\n last_missed_at = NULL\n WHERE path = $3 AND workspace_id = $4\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels,\n late_run_streak\n ", "describe": { "columns": [ { @@ -167,6 +167,11 @@ "ordinal": 32, "name": "labels", "type_info": "TextArray" + }, + { + "ordinal": 33, + "name": "late_run_streak", + "type_info": "Int4" } ], "parameters": { @@ -210,8 +215,9 @@ true, true, true, - true + true, + false ] }, - "hash": "642ca096c1c151fc01445593c8d2ed879a9a7be6ff044a9ce1b581ba725665fe" + "hash": "ca54e732d521416cb2692c8c4406c63922f0eebcba2b2cefc4290c4baee5c071" } diff --git a/backend/.sqlx/query-cbc521b505c3953dc624ae6cefe6999e6d0c732da83ebc74ef7678cacd22ad6b.json b/backend/.sqlx/query-cbc521b505c3953dc624ae6cefe6999e6d0c732da83ebc74ef7678cacd22ad6b.json new file mode 100644 index 0000000000..a2a87b680d --- /dev/null +++ b/backend/.sqlx/query-cbc521b505c3953dc624ae6cefe6999e6d0c732da83ebc74ef7678cacd22ad6b.json @@ -0,0 +1,38 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH active_users as (SELECT distinct username as email FROM audit_partitioned WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh') AND username NOT IN (SELECT email FROM usr WHERE is_service_account)),\n active_authors as (SELECT distinct t.email FROM usr t LEFT JOIN workspace_settings ws ON ws.workspace_id = t.workspace_id WHERE NOT (t.operator AND NOT COALESCE((ws.operator_settings->>'builder_flows')::boolean, false)) AND t.email IN (SELECT email FROM active_users)),\n active_authors_agg as (SELECT array_agg(email) as authors FROM active_authors),\n active_ops_agg as (SELECT array_agg(email) as operators from active_users WHERE email NOT IN (SELECT email FROM active_authors))\n SELECT active_authors_agg.authors, active_ops_agg.operators, array_length(active_authors_agg.authors, 1) as author_count, array_length(active_ops_agg.operators, 1) as operator_count FROM active_authors_agg, active_ops_agg", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "authors", + "type_info": "VarcharArray" + }, + { + "ordinal": 1, + "name": "operators", + "type_info": "VarcharArray" + }, + { + "ordinal": 2, + "name": "author_count", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "operator_count", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null, + null, + null, + null + ] + }, + "hash": "cbc521b505c3953dc624ae6cefe6999e6d0c732da83ebc74ef7678cacd22ad6b" +} diff --git a/backend/.sqlx/query-0c18351237816fe0c56e23801fcb8e70dbffcf08ed121e55c871f727c4ddf626.json b/backend/.sqlx/query-d267e03115ee74ab7ca7edd8a010e424819133ce38dab7300a5fca4c6f54536d.json similarity index 70% rename from backend/.sqlx/query-0c18351237816fe0c56e23801fcb8e70dbffcf08ed121e55c871f727c4ddf626.json rename to backend/.sqlx/query-d267e03115ee74ab7ca7edd8a010e424819133ce38dab7300a5fca4c6f54536d.json index ceb05001e4..f3958e713d 100644 --- a/backend/.sqlx/query-0c18351237816fe0c56e23801fcb8e70dbffcf08ed121e55c871f727c4ddf626.json +++ b/backend/.sqlx/query-d267e03115ee74ab7ca7edd8a010e424819133ce38dab7300a5fca4c6f54536d.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE worker_ping SET ping_at = now(), jobs_executed = $1, custom_tags = $2,\n occupancy_rate = $3, memory_usage = $4, wm_memory_usage = $5, vcpus = COALESCE($7, vcpus),\n memory = COALESCE($8, memory), occupancy_rate_15s = $9, occupancy_rate_5m = $10, occupancy_rate_30m = $11, native_mode = $12, ip = COALESCE($13, ip) WHERE worker = $6", + "query": "UPDATE worker_ping SET ping_at = now(), jobs_executed = $1, custom_tags = $2,\n occupancy_rate = $3, memory_usage = $4, wm_memory_usage = $5, vcpus = COALESCE($7, vcpus),\n memory = COALESCE($8, memory), occupancy_rate_15s = $9, occupancy_rate_5m = $10, occupancy_rate_30m = $11, native_mode = $12, ip = COALESCE($13, ip),\n current_job_id = COALESCE($14, current_job_id), current_job_workspace_id = COALESCE($15, current_job_workspace_id) WHERE worker = $6", "describe": { "columns": [], "parameters": { @@ -17,10 +17,12 @@ "Float4", "Float4", "Bool", + "Varchar", + "Uuid", "Varchar" ] }, "nullable": [] }, - "hash": "0c18351237816fe0c56e23801fcb8e70dbffcf08ed121e55c871f727c4ddf626" + "hash": "d267e03115ee74ab7ca7edd8a010e424819133ce38dab7300a5fca4c6f54536d" } diff --git a/backend/.sqlx/query-d48ca62c86b1af7a9dd2450c1c28dc45020a2a553d8874c49f9eafedea5a9d40.json b/backend/.sqlx/query-d48ca62c86b1af7a9dd2450c1c28dc45020a2a553d8874c49f9eafedea5a9d40.json deleted file mode 100644 index 49875acd78..0000000000 --- a/backend/.sqlx/query-d48ca62c86b1af7a9dd2450c1c28dc45020a2a553d8874c49f9eafedea5a9d40.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT dt.key AS \"datatable!\"\n FROM workspace_settings ws\n CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt\n WHERE ws.workspace_id = $1\n AND dt.key <> $2\n AND NOT dt.value ? 'permissions'\n AND dt.value->'database'->>'resource_type' = 'instance'\n AND dt.value->'database'->>'resource_path' = $3\n ORDER BY dt.key", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "datatable!", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "d48ca62c86b1af7a9dd2450c1c28dc45020a2a553d8874c49f9eafedea5a9d40" -} diff --git a/backend/.sqlx/query-dc37f9ad685a2f2bd781be8678e0548a860815f30e866362344434f23aa7c83c.json b/backend/.sqlx/query-dc37f9ad685a2f2bd781be8678e0548a860815f30e866362344434f23aa7c83c.json deleted file mode 100644 index 02d889c38b..0000000000 --- a/backend/.sqlx/query-dc37f9ad685a2f2bd781be8678e0548a860815f30e866362344434f23aa7c83c.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH active_users as (SELECT distinct username as email FROM audit_partitioned WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n active_authors as (SELECT distinct email FROM usr WHERE usr.operator IS false AND email IN (SELECT email FROM active_users)),\n active_authors_agg as (SELECT array_agg(email) as authors FROM active_authors),\n active_ops_agg as (SELECT array_agg(email) as operators from active_users WHERE email NOT IN (SELECT email FROM active_authors))\n SELECT active_authors_agg.authors, active_ops_agg.operators, array_length(active_authors_agg.authors, 1) as author_count, array_length(active_ops_agg.operators, 1) as operator_count FROM active_authors_agg, active_ops_agg", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "authors", - "type_info": "VarcharArray" - }, - { - "ordinal": 1, - "name": "operators", - "type_info": "VarcharArray" - }, - { - "ordinal": 2, - "name": "author_count", - "type_info": "Int4" - }, - { - "ordinal": 3, - "name": "operator_count", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - null, - null, - null, - null - ] - }, - "hash": "dc37f9ad685a2f2bd781be8678e0548a860815f30e866362344434f23aa7c83c" -} diff --git a/backend/.sqlx/query-df8586283178b0e684b9a0e359efb16e38fd68a5fe2b01cd77f74a725bbf37a3.json b/backend/.sqlx/query-df8586283178b0e684b9a0e359efb16e38fd68a5fe2b01cd77f74a725bbf37a3.json new file mode 100644 index 0000000000..3383951c13 --- /dev/null +++ b/backend/.sqlx/query-df8586283178b0e684b9a0e359efb16e38fd68a5fe2b01cd77f74a725bbf37a3.json @@ -0,0 +1,95 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT q.id AS \"id!\", j.created_by, j.permissioned_as, j.permissioned_as_email, j.trigger, j.trigger_kind AS \"trigger_kind: TriggerKindLabel\", j.preprocessed, j.args AS \"args: sqlx::types::Json>>\" FROM v2_job_queue q JOIN v2_job j USING (id) JOIN script s ON s.workspace_id = j.workspace_id AND s.hash = j.runnable_id WHERE j.workspace_id = $1 AND j.runnable_path = $2 AND j.kind = 'script' AND j.flow_step_id IS NULL AND j.runnable_id != $3 AND q.canceled_by IS NULL AND s.restart_unless_cancelled", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "permissioned_as", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "permissioned_as_email", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "trigger", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "trigger_kind: TriggerKindLabel", + "type_info": { + "Custom": { + "name": "job_trigger_kind", + "kind": { + "Enum": [ + "webhook", + "http", + "websocket", + "kafka", + "email", + "nats", + "schedule", + "app", + "ui", + "postgres", + "sqs", + "gcp", + "mqtt", + "nextcloud", + "google", + "ci_test", + "github", + "azure", + "asset", + "freshness", + "amqp" + ] + } + } + } + }, + { + "ordinal": 6, + "name": "preprocessed", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "args: sqlx::types::Json>>", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "df8586283178b0e684b9a0e359efb16e38fd68a5fe2b01cd77f74a725bbf37a3" +} diff --git a/backend/.sqlx/query-ee769c78ec0a782de18911cdcb655aad93cf23321cd0f42f7ffa5348bfbea26e.json b/backend/.sqlx/query-ee769c78ec0a782de18911cdcb655aad93cf23321cd0f42f7ffa5348bfbea26e.json new file mode 100644 index 0000000000..bb7e4fa36b --- /dev/null +++ b/backend/.sqlx/query-ee769c78ec0a782de18911cdcb655aad93cf23321cd0f42f7ffa5348bfbea26e.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS (SELECT 1 FROM v2_job_queue WHERE id = $1 AND workspace_id = $2 AND running)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "ee769c78ec0a782de18911cdcb655aad93cf23321cd0f42f7ffa5348bfbea26e" +} diff --git a/backend/.sqlx/query-ee92100fcd681515ba81f23970e64af7f33464879bef4d1eff682126dffeef64.json b/backend/.sqlx/query-ee92100fcd681515ba81f23970e64af7f33464879bef4d1eff682126dffeef64.json new file mode 100644 index 0000000000..3c52f8ba96 --- /dev/null +++ b/backend/.sqlx/query-ee92100fcd681515ba81f23970e64af7f33464879bef4d1eff682126dffeef64.json @@ -0,0 +1,125 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH RECURSIVE lineage AS (\n SELECT id, parent_job, kind, runnable_path, runnable_id, trigger_kind, trigger,\n permissioned_as, args, 0 AS depth\n FROM v2_job WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT p.id, p.parent_job, p.kind, p.runnable_path, p.runnable_id, p.trigger_kind,\n p.trigger, p.permissioned_as, p.args, l.depth + 1\n FROM v2_job p JOIN lineage l ON p.id = l.parent_job\n WHERE p.workspace_id = $2 AND l.depth < 100\n )\n SELECT id AS \"id!\", parent_job, kind AS \"kind!: JobKind\", runnable_path, runnable_id,\n trigger_kind::text AS trigger_kind, permissioned_as AS \"permissioned_as!\",\n -- A restart takes its flow version from the request, so a trusted flow path\n -- can carry another flow's code: the version must belong to that path.\n CASE kind\n WHEN 'flow' THEN EXISTS (SELECT 1 FROM flow_version fv\n WHERE fv.id = runnable_id AND fv.path = runnable_path AND fv.workspace_id = $2)\n WHEN 'script' THEN EXISTS (SELECT 1 FROM script s\n WHERE s.hash = runnable_id AND s.path = runnable_path AND s.workspace_id = $2\n AND NOT s.deleted)\n ELSE true\n END AS \"origin_verified!\",\n -- What `run/f` and `run/p` resolve the path to now; for scripts the predicate of\n -- `get_latest_deployed_script_hash`.\n COALESCE(CASE kind\n WHEN 'flow' THEN runnable_id = (SELECT f.versions[array_upper(f.versions, 1)]\n FROM flow f WHERE f.path = runnable_path AND f.workspace_id = $2)\n WHEN 'script' THEN runnable_id = (SELECT s.hash FROM script s\n WHERE s.path = runnable_path AND s.workspace_id = $2 AND NOT s.deleted\n AND s.lock IS NOT NULL AND s.lock_error_logs IS NULL\n ORDER BY s.created_at DESC LIMIT 1)\n END, false) AS \"current_version!\",\n EXISTS (SELECT 1 FROM v2_job_status st WHERE st.id = lineage.id\n AND jsonb_typeof(st.flow_status->'restarted_from') = 'object')\n OR EXISTS (SELECT 1 FROM v2_job_completed c WHERE c.id = lineage.id\n AND jsonb_typeof(c.flow_status->'restarted_from') = 'object')\n AS \"restarted!\",\n COALESCE(\n (SELECT st.flow_status->'restarted_from'->>'flow_job_id' FROM v2_job_status st\n WHERE st.id = lineage.id),\n (SELECT c.flow_status->'restarted_from'->>'flow_job_id' FROM v2_job_completed c\n WHERE c.id = lineage.id)\n ) AS restarted_from,\n -- Only deployed-app runs are stamped with their app; an app editor preview\n -- runs app code at an app path it does not have to own.\n COALESCE(trigger_kind = 'app' AND starts_with(runnable_path, trigger || '/'), false)\n AS \"app_stamped!\",\n -- A preview's modules come from its args, which its parent may have taken from\n -- the caller.\n COALESCE(jsonb_typeof(args->'_MODULES') = 'object', false) AS \"args_modules!\"\n FROM lineage ORDER BY depth", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "parent_job", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "kind!: JobKind", + "type_info": { + "Custom": { + "name": "job_kind", + "kind": { + "Enum": [ + "script", + "preview", + "flow", + "dependencies", + "flowpreview", + "script_hub", + "identity", + "flowdependencies", + "http", + "graphql", + "postgresql", + "noop", + "appdependencies", + "deploymentcallback", + "singlestepflow", + "flowscript", + "flownode", + "appscript", + "aiagent", + "unassigned_script", + "unassigned_flow", + "unassigned_singlestepflow" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "runnable_path", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "runnable_id", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "trigger_kind", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "permissioned_as!", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "origin_verified!", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "current_version!", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "restarted!", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "restarted_from", + "type_info": "Text" + }, + { + "ordinal": 11, + "name": "app_stamped!", + "type_info": "Bool" + }, + { + "ordinal": 12, + "name": "args_modules!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "hash": "ee92100fcd681515ba81f23970e64af7f33464879bef4d1eff682126dffeef64" +} diff --git a/backend/.sqlx/query-f3a740394038ddfced97254d8dcd4de49f63fb6dc4c8aab126dbe7cd611a862c.json b/backend/.sqlx/query-f3a740394038ddfced97254d8dcd4de49f63fb6dc4c8aab126dbe7cd611a862c.json new file mode 100644 index 0000000000..6ee5709618 --- /dev/null +++ b/backend/.sqlx/query-f3a740394038ddfced97254d8dcd4de49f63fb6dc4c8aab126dbe7cd611a862c.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value AS \"value: sqlx::types::Json\"\n FROM resource WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value: sqlx::types::Json", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "f3a740394038ddfced97254d8dcd4de49f63fb6dc4c8aab126dbe7cd611a862c" +} diff --git a/backend/.sqlx/query-f5c87b6fc1decd6dc53590fe4d16f59430530b8b7428e42d1777927e63395ff8.json b/backend/.sqlx/query-f5c87b6fc1decd6dc53590fe4d16f59430530b8b7428e42d1777927e63395ff8.json new file mode 100644 index 0000000000..197ecaf3d9 --- /dev/null +++ b/backend/.sqlx/query-f5c87b6fc1decd6dc53590fe4d16f59430530b8b7428e42d1777927e63395ff8.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE v2_job_queue SET worker = $1, running = true, started_at = now()\n WHERE id = $2 AND tag = ANY($3)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Uuid", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "f5c87b6fc1decd6dc53590fe4d16f59430530b8b7428e42d1777927e63395ff8" +} diff --git a/backend/.sqlx/query-f7753f8f7b40aebf66de607203f97681d06ac04f9d547b4c49c032d9e933a29e.json b/backend/.sqlx/query-f7753f8f7b40aebf66de607203f97681d06ac04f9d547b4c49c032d9e933a29e.json deleted file mode 100644 index 09ac527a72..0000000000 --- a/backend/.sqlx/query-f7753f8f7b40aebf66de607203f97681d06ac04f9d547b4c49c032d9e933a29e.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT\n j.runnable_path as script_path, j.args AS \"args: sqlx::types::Json>>\",\n j.tag AS \"tag!\", j.priority\n FROM v2_job j\n WHERE j.id = $1 and j.workspace_id = $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "script_path", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "args: sqlx::types::Json>>", - "type_info": "Jsonb" - }, - { - "ordinal": 2, - "name": "tag!", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "priority", - "type_info": "Int2" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Text" - ] - }, - "nullable": [ - true, - true, - false, - true - ] - }, - "hash": "f7753f8f7b40aebf66de607203f97681d06ac04f9d547b4c49c032d9e933a29e" -} diff --git a/backend/.sqlx/query-0e7d95f4913e5775651971d741a3b5c1ef5dfe079be5325abe2866d39a7fe5fb.json b/backend/.sqlx/query-fa85a22c77844bb2c538b802b0984d4d69aecdd8a65db02e9495b0d6011c5c1f.json similarity index 65% rename from backend/.sqlx/query-0e7d95f4913e5775651971d741a3b5c1ef5dfe079be5325abe2866d39a7fe5fb.json rename to backend/.sqlx/query-fa85a22c77844bb2c538b802b0984d4d69aecdd8a65db02e9495b0d6011c5c1f.json index 9548fa6997..6c54ede027 100644 --- a/backend/.sqlx/query-0e7d95f4913e5775651971d741a3b5c1ef5dfe079be5325abe2866d39a7fe5fb.json +++ b/backend/.sqlx/query-fa85a22c77844bb2c538b802b0984d4d69aecdd8a65db02e9495b0d6011c5c1f.json @@ -1,12 +1,12 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING path", + "query": "DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING hash", "describe": { "columns": [ { "ordinal": 0, - "name": "path", - "type_info": "Varchar" + "name": "hash", + "type_info": "Int8" } ], "parameters": { @@ -19,5 +19,5 @@ false ] }, - "hash": "0e7d95f4913e5775651971d741a3b5c1ef5dfe079be5325abe2866d39a7fe5fb" + "hash": "fa85a22c77844bb2c538b802b0984d4d69aecdd8a65db02e9495b0d6011c5c1f" } diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 9182843789..114fb76b67 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -14792,7 +14792,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "async-nats", @@ -14876,11 +14876,12 @@ dependencies = [ "windmill-worker-volumes", "windows-service", "windows-sys 0.52.0", + "winresource", ] [[package]] name = "windmill-ai" -version = "1.819.0" +version = "1.820.0" dependencies = [ "async-stream", "async-trait", @@ -14914,7 +14915,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.819.0" +version = "1.820.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14927,7 +14928,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "argon2", @@ -15067,7 +15068,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.819.0" +version = "1.820.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15090,7 +15091,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.819.0" +version = "1.820.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15107,7 +15108,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15133,7 +15134,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.819.0" +version = "1.820.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15143,7 +15144,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.819.0" +version = "1.820.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15160,7 +15161,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.819.0" +version = "1.820.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -15182,7 +15183,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15205,7 +15206,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.819.0" +version = "1.820.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15221,7 +15222,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.819.0" +version = "1.820.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15243,7 +15244,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.819.0" +version = "1.820.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15265,7 +15266,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.819.0" +version = "1.820.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15279,7 +15280,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "async-nats", @@ -15315,7 +15316,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15340,7 +15341,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.819.0" +version = "1.820.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15368,7 +15369,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15390,7 +15391,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.819.0" +version = "1.820.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15410,7 +15411,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15452,7 +15453,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15481,7 +15482,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.819.0" +version = "1.820.0" dependencies = [ "lazy_static", "serde", @@ -15493,7 +15494,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.819.0" +version = "1.820.0" dependencies = [ "argon2", "axum 0.8.9", @@ -15517,7 +15518,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.819.0" +version = "1.820.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15531,7 +15532,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.819.0" +version = "1.820.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15551,6 +15552,7 @@ dependencies = [ "strum", "tokio", "tokio-postgres", + "tokio-util", "tracing", "url", "uuid", @@ -15569,7 +15571,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.819.0" +version = "1.820.0" dependencies = [ "chrono", "lazy_static", @@ -15583,7 +15585,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15602,7 +15604,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.819.0" +version = "1.820.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -15660,6 +15662,7 @@ dependencies = [ "pin-project-lite", "pkcs1", "postgres-native-tls 0.5.3", + "postgres-protocol", "prometheus", "quick_cache", "rand 0.9.5", @@ -15714,7 +15717,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.819.0" +version = "1.820.0" dependencies = [ "chrono", "futures", @@ -15734,7 +15737,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.819.0" +version = "1.820.0" dependencies = [ "regex", "serde", @@ -15751,7 +15754,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15778,7 +15781,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "futures", @@ -15795,7 +15798,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.819.0" +version = "1.820.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15811,7 +15814,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "async-trait", @@ -15832,7 +15835,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "async-trait", @@ -15863,7 +15866,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "arc-swap", @@ -15888,7 +15891,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "async-stream", @@ -15924,7 +15927,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "futures", @@ -15942,7 +15945,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.819.0" +version = "1.820.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -15951,7 +15954,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "lazy_static", @@ -15963,7 +15966,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "serde_json", @@ -15975,7 +15978,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "gosyn", @@ -15987,7 +15990,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "lazy_static", @@ -15999,7 +16002,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "serde_json", @@ -16011,7 +16014,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "nu-parser", @@ -16022,7 +16025,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16033,7 +16036,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16045,7 +16048,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16056,7 +16059,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "async-recursion", @@ -16078,7 +16081,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "serde_json", @@ -16090,7 +16093,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "lazy_static", @@ -16104,7 +16107,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16121,7 +16124,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "lazy_static", @@ -16134,7 +16137,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "serde", @@ -16146,7 +16149,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "lazy_static", @@ -16164,7 +16167,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16180,7 +16183,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16196,7 +16199,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "lazy_static", @@ -16210,7 +16213,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "async-recursion", @@ -16249,7 +16252,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "const_format", @@ -16289,7 +16292,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.819.0" +version = "1.820.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -16300,7 +16303,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "async-recursion", @@ -16335,7 +16338,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "async-trait", @@ -16359,7 +16362,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "async-trait", @@ -16392,7 +16395,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "async-trait", @@ -16419,7 +16422,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "async-trait", @@ -16452,7 +16455,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "async-trait", @@ -16472,7 +16475,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "async-trait", @@ -16506,7 +16509,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "async-trait", @@ -16543,7 +16546,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "async-trait", @@ -16566,7 +16569,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "async-trait", @@ -16590,7 +16593,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "async-nats", @@ -16614,7 +16617,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "async-trait", @@ -16649,7 +16652,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "async-trait", @@ -16677,7 +16680,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "async-trait", @@ -16702,7 +16705,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -16721,7 +16724,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "async-once-cell", @@ -16839,7 +16842,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.819.0" +version = "1.820.0" dependencies = [ "bytes", "futures", @@ -17380,6 +17383,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winresource" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +dependencies = [ + "version_check", +] + [[package]] name = "winsafe" version = "0.0.19" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 77f512a3dd..9c09265dbd 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.819.0" +version = "1.820.0" authors.workspace = true edition.workspace = true @@ -88,7 +88,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.819.0" +version = "1.820.0" authors = ["Ruben Fiszel "] edition = "2021" @@ -329,6 +329,9 @@ strum.workspace = true windows-service = "0.7" windows-sys = { version = "0.52", features = ["Win32_System_Services", "Win32_System_Console", "Win32_Foundation"] } +[target.'cfg(windows)'.build-dependencies] +winresource = { version = "0.1.31", default-features = false } + [target.'cfg(not(target_env = "msvc"))'.dependencies] tikv-jemallocator = { optional = true, workspace = true } tikv-jemalloc-sys = { optional = true, workspace = true } @@ -519,7 +522,7 @@ serde_urlencoded = "^0" astral-tokio-tar = "^0.6.2" tempfile = "^3" x509-parser = "^0.16" -tokio-util = { version = "=0.7.17", features = ["io"] } +tokio-util = { version = "=0.7.17", features = ["io", "rt"] } json-pointer = "^0" itertools = "^0.14.0" regex = "^1" @@ -626,6 +629,7 @@ wasm-bindgen-test = "^0" convert_case = "0.6.0" getrandom = "0.2" tokio-postgres = {version = "^0.7", features = ["array-impls", "with-serde_json-1", "with-chrono-0_4", "with-uuid-1", "with-bit-vec-0_6"]} +postgres-protocol = "0.6" rust-postgres = { package = "tokio-postgres", git = "https://github.com/MaterializeInc/rust-postgres", rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe"} rust-postgres-native-tls = { package = "postgres-native-tls", git = "https://github.com/MaterializeInc/rust-postgres", features = ["runtime"], rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe" } bit-vec = "=0.6.3" diff --git a/backend/build.rs b/backend/build.rs index d5068697c7..88917b0492 100644 --- a/backend/build.rs +++ b/backend/build.rs @@ -2,4 +2,19 @@ fn main() { // trigger recompilation when a new migration is added println!("cargo:rerun-if-changed=migrations"); + + // Embed a VERSIONINFO resource so the exe's file properties carry the release + // version (from Cargo.toml), which Windows worker update scripts read. + // `cfg(windows)` is the host, matching the Windows-only build-dependency, so + // cross builds to Windows get no version resource. + #[cfg(windows)] + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { + winresource::WindowsResource::new() + .set("ProductName", "Windmill") + .set("FileDescription", "Windmill") + .set("CompanyName", "Windmill Labs, Inc.") + .set("LegalCopyright", "Copyright Windmill Labs, Inc.") + .compile() + .expect("failed to embed the Windows version resource"); + } } diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 263784f2aa..c2c253e8f0 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -421cf2a8b4f98b421e93c0fc7c1c378314a66e50 +ff4d04f17721d84fb9a1f655af50ff7e4e7dcbc7 diff --git a/backend/migrations/20260917133824_datatable_role_cluster.down.sql b/backend/migrations/20260917133824_datatable_role_cluster.down.sql new file mode 100644 index 0000000000..379f1e3d34 --- /dev/null +++ b/backend/migrations/20260917133824_datatable_role_cluster.down.sql @@ -0,0 +1,22 @@ +-- Roles on the external cluster are live logins there; dropping the column would forget them. +LOCK TABLE datatable_role; +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM datatable_role WHERE cluster <> 'instance') THEN + RAISE EXCEPTION 'datatable_role holds roles on the external instance cluster. Delete them in instance settings first.'; + END IF; + -- Before this, only data tables on Windmill's own cluster could be under roles, and a role + -- block left with just `admin` survives deleting every external role. + IF EXISTS ( + SELECT 1 FROM workspace_settings ws, + jsonb_each(CASE WHEN jsonb_typeof(ws.datatable->'datatables') = 'object' + THEN ws.datatable->'datatables' ELSE '{}'::jsonb END) dt + WHERE dt.value->'database'->>'resource_type' = 'external_instance' + AND dt.value ? 'permissions' + ) THEN + RAISE EXCEPTION 'external instance data tables are still under roles. Turn their roles off first.'; + END IF; +END $$; +ALTER TABLE datatable_role DROP CONSTRAINT datatable_role_cluster_name_key; +ALTER TABLE datatable_role ADD CONSTRAINT datatable_role_name_key UNIQUE (name); +ALTER TABLE datatable_role DROP COLUMN cluster; diff --git a/backend/migrations/20260917133824_datatable_role_cluster.up.sql b/backend/migrations/20260917133824_datatable_role_cluster.up.sql new file mode 100644 index 0000000000..d63f06f1f4 --- /dev/null +++ b/backend/migrations/20260917133824_datatable_role_cluster.up.sql @@ -0,0 +1,8 @@ +-- A data table role is a Postgres login on one cluster: Windmill's own ('instance'), or the external +-- instance cluster ('external_instance'). Role names are the cluster's own key, so they are unique +-- per cluster rather than across the instance. +ALTER TABLE datatable_role + ADD COLUMN cluster VARCHAR(20) NOT NULL DEFAULT 'instance' + CHECK (cluster IN ('instance', 'external_instance')); +ALTER TABLE datatable_role DROP CONSTRAINT datatable_role_name_key; +ALTER TABLE datatable_role ADD CONSTRAINT datatable_role_cluster_name_key UNIQUE (cluster, name); diff --git a/backend/migrations/20260925151151_schedule_missed_occurrences.down.sql b/backend/migrations/20260925151151_schedule_missed_occurrences.down.sql new file mode 100644 index 0000000000..b89d6340ae --- /dev/null +++ b/backend/migrations/20260925151151_schedule_missed_occurrences.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE schedule DROP COLUMN last_missed_at; +ALTER TABLE schedule DROP COLUMN missed_occurrences; +ALTER TABLE schedule DROP COLUMN late_run_streak; diff --git a/backend/migrations/20260925151151_schedule_missed_occurrences.up.sql b/backend/migrations/20260925151151_schedule_missed_occurrences.up.sql new file mode 100644 index 0000000000..469eed3a93 --- /dev/null +++ b/backend/migrations/20260925151151_schedule_missed_occurrences.up.sql @@ -0,0 +1,7 @@ +-- A late run finished (or, for flows, started) after the next occurrence was due, so that +-- occurrence was missed. late_run_streak counts late runs in a row and is reset by a run on +-- time; missed_occurrences is what the latest streak missed and last_missed_at when, both +-- kept after the streak ends so a recent one stays visible. An edit or toggle clears all. +ALTER TABLE schedule ADD COLUMN late_run_streak INTEGER NOT NULL DEFAULT 0; +ALTER TABLE schedule ADD COLUMN missed_occurrences INTEGER NOT NULL DEFAULT 0; +ALTER TABLE schedule ADD COLUMN last_missed_at TIMESTAMPTZ; diff --git a/backend/migrations/20260929092707_workspace_fork_creation.down.sql b/backend/migrations/20260929092707_workspace_fork_creation.down.sql new file mode 100644 index 0000000000..924855e129 --- /dev/null +++ b/backend/migrations/20260929092707_workspace_fork_creation.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS workspace_fork_creation; diff --git a/backend/migrations/20260929092707_workspace_fork_creation.up.sql b/backend/migrations/20260929092707_workspace_fork_creation.up.sql new file mode 100644 index 0000000000..8e3a678419 --- /dev/null +++ b/backend/migrations/20260929092707_workspace_fork_creation.up.sql @@ -0,0 +1,20 @@ +-- A fork being created in the background, and how that ended. The fork's own workspace row only +-- exists once the copy commits, so this is where a failure is reported to whoever polls for it. +-- `heartbeat_at` is refreshed while the copy runs: an unfinished row that stops being refreshed +-- belongs to a server that went away mid-copy. `step` names the part of the copy it is in. +-- `creation_id` names one attempt: the row is reused when a failed or abandoned one is retried, and +-- whoever polls reads only the attempt it started. +CREATE TABLE workspace_fork_creation ( + fork_workspace_id VARCHAR(50) PRIMARY KEY, + parent_workspace_id VARCHAR(50) NOT NULL, + creation_id UUID NOT NULL, + created_by VARCHAR(255) NOT NULL, + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + heartbeat_at TIMESTAMPTZ NOT NULL DEFAULT now(), + step TEXT, + finished_at TIMESTAMPTZ, + error TEXT +); + +GRANT ALL ON workspace_fork_creation TO windmill_user; +GRANT ALL ON workspace_fork_creation TO windmill_admin; diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index bc2498a943..0509e7f30a 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -18,7 +18,32 @@ "slack": { "auth_url": "https://slack.com/oauth/v2/authorize", "token_url": "https://slack.com/api/oauth.v2.access", - "scopes": ["chat:write", "chat:write.public", "channels:join", "files:write"] + "scopes": [ + "chat:write", + "chat:write.public", + "channels:join", + "files:write", + "users:read", + "users:read.email", + "channels:read", + "channels:history" + ], + "user_scopes": [ + "channels:history", + "groups:history", + "im:history", + "mpim:history", + "search:read", + "users:read", + "chat:write", + "users:read.email", + "channels:read", + "groups:read", + "im:read", + "mpim:read", + "files:read" + ], + "token_response_path": "authed_user" }, "supabase_wizard": { "auth_url": "https://api.supabase.com/v1/oauth/authorize", @@ -30,8 +55,8 @@ "token_url": "https://oauth2.googleapis.com/token", "scopes": ["https://www.googleapis.com/auth/spreadsheets"], "scope_options": [ - "https://www.googleapis.com/auth/spreadsheets", - "https://www.googleapis.com/auth/spreadsheets.readonly" + { "value": "https://www.googleapis.com/auth/spreadsheets", "label": "Read, edit, create and delete spreadsheets" }, + { "value": "https://www.googleapis.com/auth/spreadsheets.readonly", "label": "Read spreadsheets" } ], "extra_params": { "access_type": "offline", @@ -43,9 +68,9 @@ "token_url": "https://oauth2.googleapis.com/token", "scopes": ["https://www.googleapis.com/auth/drive"], "scope_options": [ - "https://www.googleapis.com/auth/drive.file", - "https://www.googleapis.com/auth/drive.readonly", - "https://www.googleapis.com/auth/drive" + { "value": "https://www.googleapis.com/auth/drive.file", "label": "Only files created or opened with this connection" }, + { "value": "https://www.googleapis.com/auth/drive.readonly", "label": "Read all Drive files" }, + { "value": "https://www.googleapis.com/auth/drive", "label": "Read, edit, create and delete all Drive files" } ], "extra_params": { "access_type": "offline", @@ -57,11 +82,11 @@ "token_url": "https://oauth2.googleapis.com/token", "scopes": ["https://www.googleapis.com/auth/gmail.send"], "scope_options": [ - "https://www.googleapis.com/auth/gmail.send", - "https://www.googleapis.com/auth/gmail.readonly", - "https://www.googleapis.com/auth/gmail.compose", - "https://www.googleapis.com/auth/gmail.modify", - "https://www.googleapis.com/auth/gmail.labels" + { "value": "https://www.googleapis.com/auth/gmail.send", "label": "Send mail only" }, + { "value": "https://www.googleapis.com/auth/gmail.readonly", "label": "Read and search mail" }, + { "value": "https://www.googleapis.com/auth/gmail.compose", "label": "Draft and send mail" }, + { "value": "https://www.googleapis.com/auth/gmail.modify", "label": "Read, search, label, draft and send mail" }, + { "value": "https://www.googleapis.com/auth/gmail.labels", "label": "Create, edit and delete labels" } ], "extra_params": { "access_type": "offline", @@ -73,10 +98,10 @@ "token_url": "https://oauth2.googleapis.com/token", "scopes": ["https://www.googleapis.com/auth/calendar.events"], "scope_options": [ - "https://www.googleapis.com/auth/calendar.events", - "https://www.googleapis.com/auth/calendar.events.readonly", - "https://www.googleapis.com/auth/calendar.readonly", - "https://www.googleapis.com/auth/calendar" + { "value": "https://www.googleapis.com/auth/calendar.events", "label": "Read and edit events on all calendars" }, + { "value": "https://www.googleapis.com/auth/calendar.events.readonly", "label": "Read events on all calendars" }, + { "value": "https://www.googleapis.com/auth/calendar.readonly", "label": "Read all calendars and events" }, + { "value": "https://www.googleapis.com/auth/calendar", "label": "Read, edit, share and delete all calendars" } ], "extra_params": { "access_type": "offline", @@ -88,10 +113,43 @@ "token_url": "https://oauth2.googleapis.com/token", "scopes": ["https://www.googleapis.com/auth/forms"], "scope_options": [ - "https://www.googleapis.com/auth/forms", - "https://www.googleapis.com/auth/forms.body", - "https://www.googleapis.com/auth/forms.body.readonly", - "https://www.googleapis.com/auth/forms.responses.readonly" + { "value": "https://www.googleapis.com/auth/forms", "label": "Read and manage forms in Drive" }, + { "value": "https://www.googleapis.com/auth/forms.body", "label": "Read, edit, create and delete forms" }, + { "value": "https://www.googleapis.com/auth/forms.body.readonly", "label": "Read forms" }, + { "value": "https://www.googleapis.com/auth/forms.responses.readonly", "label": "Read form responses" } + ], + "extra_params": { + "access_type": "offline", + "prompt": "consent" + } + }, + "gdocs": { + "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", + "token_url": "https://oauth2.googleapis.com/token", + "scopes": ["https://www.googleapis.com/auth/documents"], + "scope_options": [ + { "value": "https://www.googleapis.com/auth/documents", "label": "Read, edit, create and delete documents" }, + { "value": "https://www.googleapis.com/auth/documents.readonly", "label": "Read documents" }, + { "value": "https://www.googleapis.com/auth/drive.file", "label": "Only files created or opened with this connection" } + ], + "extra_params": { + "access_type": "offline", + "prompt": "consent" + } + }, + "gchat": { + "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", + "token_url": "https://oauth2.googleapis.com/token", + "scopes": [ + "https://www.googleapis.com/auth/chat.spaces.readonly", + "https://www.googleapis.com/auth/chat.messages.create" + ], + "scope_options": [ + { "value": "https://www.googleapis.com/auth/chat.spaces.readonly", "label": "Read spaces" }, + { "value": "https://www.googleapis.com/auth/chat.messages", "label": "Read, send, edit and delete messages" }, + { "value": "https://www.googleapis.com/auth/chat.messages.readonly", "label": "Read messages" }, + { "value": "https://www.googleapis.com/auth/chat.messages.create", "label": "Send messages only" }, + { "value": "https://www.googleapis.com/auth/chat.memberships.readonly", "label": "Read space members" } ], "extra_params": { "access_type": "offline", @@ -103,8 +161,8 @@ "token_url": "https://oauth2.googleapis.com/token", "scopes": ["https://www.googleapis.com/auth/cloud-platform"], "scope_options": [ - "https://www.googleapis.com/auth/cloud-platform", - "https://www.googleapis.com/auth/cloud-platform.read-only" + { "value": "https://www.googleapis.com/auth/cloud-platform", "label": "Read and manage all Google Cloud resources" }, + { "value": "https://www.googleapis.com/auth/cloud-platform.read-only", "label": "Read Google Cloud resources" } ], "extra_params": { "access_type": "offline", @@ -121,13 +179,13 @@ "https://www.googleapis.com/auth/admin.directory.orgunit" ], "scope_options": [ - "https://www.googleapis.com/auth/admin.directory.user", - "https://www.googleapis.com/auth/admin.directory.user.readonly", - "https://www.googleapis.com/auth/admin.directory.group", - "https://www.googleapis.com/auth/admin.directory.group.readonly", - "https://www.googleapis.com/auth/admin.directory.orgunit", - "https://www.googleapis.com/auth/admin.directory.orgunit.readonly", - "https://www.googleapis.com/auth/admin.directory.user.security" + { "value": "https://www.googleapis.com/auth/admin.directory.user", "label": "Read and manage users" }, + { "value": "https://www.googleapis.com/auth/admin.directory.user.readonly", "label": "Read users" }, + { "value": "https://www.googleapis.com/auth/admin.directory.group", "label": "Read and manage groups" }, + { "value": "https://www.googleapis.com/auth/admin.directory.group.readonly", "label": "Read groups" }, + { "value": "https://www.googleapis.com/auth/admin.directory.orgunit", "label": "Read and manage organizational units" }, + { "value": "https://www.googleapis.com/auth/admin.directory.orgunit.readonly", "label": "Read organizational units" }, + { "value": "https://www.googleapis.com/auth/admin.directory.user.security", "label": "Manage user security settings" } ], "extra_params": { "access_type": "offline", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 0ad36d8e81..40dcff21f8 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6216,7 +6216,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.819.0" +version = "1.820.0" dependencies = [ "aho-corasick", "anyhow", @@ -6302,7 +6302,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.819.0" +version = "1.820.0" dependencies = [ "proc-macro2", "quote", @@ -6314,7 +6314,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.819.0" +version = "1.820.0" dependencies = [ "convert_case", "serde", @@ -6323,7 +6323,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "lazy_static", @@ -6335,7 +6335,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "serde_json", @@ -6347,7 +6347,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "gosyn", @@ -6359,7 +6359,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "lazy_static", @@ -6371,7 +6371,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "serde_json", @@ -6383,7 +6383,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "nu-parser", @@ -6394,7 +6394,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6405,7 +6405,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6428,7 +6428,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "async-recursion", @@ -6450,7 +6450,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "serde_json", @@ -6462,7 +6462,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "lazy_static", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "convert_case", @@ -6493,7 +6493,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "lazy_static", @@ -6506,7 +6506,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "serde", @@ -6518,7 +6518,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "lazy_static", @@ -6536,7 +6536,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6552,7 +6552,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6568,7 +6568,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6600,7 +6600,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "lazy_static", @@ -6614,7 +6614,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.819.0" +version = "1.820.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 597b7621c7..ce27a75bc0 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.819.0" +version = "1.820.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/src/main.rs b/backend/src/main.rs index 257ad77d1d..23978d7e62 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1232,6 +1232,11 @@ Windmill Community Edition {GIT_VERSION} default_base_internal_url.clone() }; + // Deletions are delivered by `notify_event` to running processes only, so one that + // was down when a version was deleted would keep its code on disk: start from an + // empty script cache, refilled from the database. + windmill_common::cache::script::clear(); + initial_load( &conn, killpill_tx.clone(), @@ -1884,6 +1889,12 @@ async fn process_notify_event( ); windmill_common::workspaces::PUBLIC_APP_RATE_LIMIT_CACHE.remove(payload); } + windmill_common::SCRIPT_VERSION_DELETED_CHANNEL => { + match serde_json::from_str(payload) { + Ok(deleted) => windmill_api_scripts::scripts::evict_deleted_script_versions(deleted), + Err(e) => tracing::error!("Invalid script version deletion payload {payload}: {e}"), + } + } "notify_runnable_version_change" => { tracing::info!("Runnable version change detected: {}", payload); match payload.split(':').collect::>().as_slice() { diff --git a/backend/tests/ai_tool_queue.rs b/backend/tests/ai_tool_queue.rs new file mode 100644 index 0000000000..4d39176cc9 --- /dev/null +++ b/backend/tests/ai_tool_queue.rs @@ -0,0 +1,220 @@ +use axum::{ + extract::State, + routing::{get, post}, + Json, Router, +}; +use serde_json::{json, Value}; +use sqlx::{Pool, Postgres}; +use std::{sync::Arc, time::Duration}; +use tokio::sync::Notify; +use windmill_common::{flows::FlowValue, jobs::JobPayload}; +use windmill_test_utils::{ + completed_job, in_test_worker, listen_for_completed_jobs, ApiServer, RunJob, StreamFind, +}; + +async fn model(Json(body): Json) -> ([(&'static str, &'static str); 1], String) { + let finished = body["messages"] + .as_array() + .unwrap() + .iter() + .any(|m| m["role"] == "tool"); + let delta = if finished { + json!({"role":"assistant", "content":"done"}) + } else { + json!({"role":"assistant", "tool_calls": (0..3).map(|i| json!({ + "index":i, "id":format!("call_{i}"), "type":"function", + "function":{"name":format!("tool_{i}"), "arguments":"{}"} + })).collect::>()}) + }; + let event = json!({"choices":[{"index":0, "delta":delta, "finish_reason":null}]}); + let end = json!({"choices":[{"index":0, "delta":{}, "finish_reason":if finished {"stop"} else {"tool_calls"}}]}); + ( + [("content-type", "text/event-stream")], + format!("data: {event}\n\ndata: {end}\n\ndata: [DONE]\n\n"), + ) +} + +async fn run_batch( + db: Pool, + parallel: bool, + limited: bool, + unsupported: bool, +) -> anyhow::Result<()> { + std::env::set_var("ALLOW_PRIVATE_AI_BASE_URLS", "true"); + let server = ApiServer::start(db.clone()).await?; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let base = format!("http://{}", listener.local_addr()?); + let gate = Arc::new(Notify::new()); + let router = Router::new() + .route("/v1/chat/completions", post(model)) + .route( + "/first", + get(|State(gate): State>| async move { + gate.notified().await; + "ok" + }), + ) + .route( + "/second", + get(|State(gate): State>| async move { + gate.notify_one(); + "ok" + }), + ) + .with_state(gate); + let stub = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + let tools: Vec = (0..3).map(|i| { + let wait = if parallel && !limited && i < 2 { + format!("await fetch('{base}/{}');", if i == 0 {"first"} else {"second"}) + } else { String::new() }; + let finish = if i == 2 { "throw new Error('expected tool failure');".to_string() } + else { format!("return {i};") }; + json!({"id":format!("t{i}"),"summary":format!("tool_{i}"),"value":{ + "type":"rawscript", "language":"bun", "input_transforms":{}, + "tag": if unsupported && i != 1 { "unserved-tool-tag" } else { "bun" }, + "concurrent_limit": if limited { Some(1) } else { None }, + "custom_concurrency_key": if limited { Some("agent-tool-test") } else { None }, + "content":format!("export async function main() {{ {wait} await Bun.sleep({}); {finish} }}", if i == 0 {200} else {0}) + }}) + }).collect(); + let flow: FlowValue = serde_json::from_value( + json!({"modules":[{"id":"agent","timeout":if unsupported {Some(3)} else {None},"value":{ + "type":"aiagent", "tools":tools, "input_transforms":{ + "provider":{"type":"static","value":{"kind":"customai","model":"queue-test","resource":{"base_url":format!("{base}/v1")}}}, + "user_message":{"type":"static","value":"run the tools"}, + "max_iterations":{"type":"static","value":3} + } + }}]}), + )?; + let id = RunJob::from(JobPayload::RawFlow { + value: flow, + path: Some("u/test/agent_queue".into()), + restarted_from: None, + }) + .push(&db) + .await; + let notifications = listen_for_completed_jobs(&db).await; + let wait = async { + if parallel { + in_test_worker(&db, notifications.find(&id), server.addr.port()).await; + } else { + notifications.find(&id).await; + } + }; + tokio::time::timeout( + Duration::from_secs(45), + in_test_worker(&db, wait, server.addr.port()), + ) + .await?; + let result = completed_job(id, &db).await; + if unsupported { + assert!( + !result.success, + "agent must time out waiting for unsupported tools" + ); + let children: Vec<(String, bool, bool)> = sqlx::query_as( + "SELECT j.runnable_path, c.status = 'success', c.started_at IS NULL + FROM v2_job j JOIN v2_job_completed c USING(id) + WHERE j.parent_job IN (SELECT id FROM v2_job WHERE parent_job = $1) + ORDER BY j.runnable_path", + ) + .bind(id) + .fetch_all(&db) + .await?; + assert_eq!(children.len(), 3); + assert_eq!( + children.iter().map(|c| (c.1, c.2)).collect::>(), + vec![(false, true), (true, false), (false, true)], + "neither the first-tool reservation nor fallback may execute unsupported tags" + ); + stub.abort(); + server.close().await?; + return Ok(()); + } + assert!(result.success, "{:?}", result.result); + let result = result.json_result().expect("agent result"); + let messages: Vec<_> = result["messages"] + .as_array() + .unwrap() + .iter() + .filter(|m| m["role"] == "tool") + .collect(); + assert_eq!(messages.len(), 3); + for (index, message) in messages.iter().enumerate() { + assert_eq!(message["tool_call_id"], format!("call_{index}")); + } + assert_eq!(messages[0]["content"], "0"); + assert_eq!(messages[1]["content"], "1"); + assert!(messages[2]["content"] + .as_str() + .unwrap() + .contains("expected tool failure")); + let (parent_id, parent_worker): (uuid::Uuid, String) = sqlx::query_as( + "SELECT j.id, c.worker FROM v2_job j JOIN v2_job_completed c USING(id) WHERE j.parent_job = $1" + ).bind(id).fetch_one(&db).await?; + let children: Vec<(String, String, bool)> = sqlx::query_as( + "SELECT j.runnable_path, c.worker, c.status = 'success' FROM v2_job j JOIN v2_job_completed c USING(id) WHERE j.parent_job = $1 ORDER BY j.runnable_path" + ).bind(parent_id).fetch_all(&db).await?; + assert_eq!(children.len(), 3); + assert_eq!( + children[0].1, parent_worker, + "first tool must stay on the parent worker" + ); + assert_eq!( + children.iter().map(|c| c.2).collect::>(), + vec![true, true, false] + ); + if limited { + let overlapping: i64 = sqlx::query_scalar( + "SELECT count(*) FROM v2_job j1 JOIN v2_job_completed c1 ON c1.id = j1.id + JOIN v2_job j2 ON j2.parent_job = j1.parent_job AND j2.id > j1.id + JOIN v2_job_completed c2 ON c2.id = j2.id + WHERE j1.parent_job = $1 AND c1.started_at < c2.completed_at AND c2.started_at < c1.completed_at" + ).bind(parent_id).fetch_one(&db).await?; + assert_eq!( + overlapping, 0, + "the reserved first job must count toward the shared limit" + ); + } else if parallel { + assert_ne!( + children[1].1, parent_worker, + "second tool must unblock the first from another worker" + ); + } else { + assert!(children.iter().all(|child| child.1 == parent_worker)); + } + stub.abort(); + server.close().await?; + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +#[serial_test::serial] +async fn parent_drains_tools_without_another_worker(db: Pool) -> anyhow::Result<()> { + run_batch(db, false, false, false).await +} + +#[sqlx::test(fixtures("base"))] +#[serial_test::serial] +async fn parent_keeps_first_tool_while_another_worker_runs_siblings( + db: Pool, +) -> anyhow::Result<()> { + run_batch(db, true, false, false).await +} + +#[cfg(all(feature = "enterprise", feature = "private"))] +#[sqlx::test(fixtures("base"))] +#[serial_test::serial] +async fn reserved_tool_obeys_shared_concurrency_limit(db: Pool) -> anyhow::Result<()> { + run_batch(db, true, true, false).await +} + +#[sqlx::test(fixtures("base"))] +#[serial_test::serial] +async fn parent_leaves_unsupported_first_and_remaining_tools_queued( + db: Pool, +) -> anyhow::Result<()> { + run_batch(db, false, false, true).await +} diff --git a/backend/tests/caller_modules_strip.rs b/backend/tests/caller_modules_strip.rs new file mode 100644 index 0000000000..e6f96c04ec --- /dev/null +++ b/backend/tests/caller_modules_strip.rs @@ -0,0 +1,110 @@ +//! A caller's `_MODULES` never reaches a job: the worker builds that arg in as module code, +//! so on a deployed runnable it would run caller code as the runnable (and its +//! `on_behalf_of`). `push` drops it from `args` and `extra` alike and only sets it from a +//! preview's own `RawCode::modules`. + +use std::collections::HashMap; + +use serde_json::{json, value::RawValue}; +use sqlx::{Pool, Postgres}; +use windmill_common::{ + jobs::{JobPayload, RawCode}, + runnable_settings::{ConcurrencySettings, DebouncingSettings}, + scripts::{ScriptHash, ScriptLang, ScriptModule}, +}; +use windmill_queue::{PushArgs, PushIsolationLevel}; + +fn modules(v: &str) -> serde_json::Value { + json!({ "helper.ts": { "content": format!("export const v = \"{v}\""), "language": "bun" } }) +} + +/// Pushes `payload` with a caller `_MODULES` both in `args` and in `extra` (where webhook +/// query and headers land), and returns the stored `_MODULES`. +async fn stored_modules(db: &Pool, payload: JobPayload) -> Option { + let caller: Box = serde_json::value::to_raw_value(&modules("caller")).unwrap(); + let args = HashMap::from([("_MODULES".to_string(), caller.clone())]); + let extra = HashMap::from([("_MODULES".to_string(), caller)]); + let (id, tx) = windmill_queue::push( + db, + PushIsolationLevel::IsolatedRoot(db.clone()), + "test-workspace", + payload, + PushArgs { args: &args, extra: Some(extra) }, + "test-user", + "test@windmill.dev", + "u/test-user".to_string(), + None, + None, + None, + None, + None, + None, + None, + None, + false, + false, + None, + true, + None, + None, + None, + None, + None, + false, + None, + None, + None, + ) + .await + .expect("push must succeed"); + tx.commit().await.unwrap(); + sqlx::query_scalar::<_, Option>( + "SELECT args->'_MODULES' FROM v2_job WHERE id = $1", + ) + .bind(id) + .fetch_one(db) + .await + .unwrap() +} + +#[sqlx::test(fixtures("base"))] +async fn caller_modules_never_reach_a_job(db: Pool) { + let deployed = JobPayload::ScriptHash { + hash: ScriptHash(123412), + path: "f/system/hello".to_string(), + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + language: ScriptLang::Bun, + priority: None, + apply_preprocessor: false, + concurrency_settings: ConcurrencySettings::default(), + debouncing_settings: DebouncingSettings::default(), + labels: None, + }; + assert_eq!(stored_modules(&db, deployed).await, None); + + let preview = |modules: Option>| { + JobPayload::Code(RawCode { + hash: None, + content: "import { v } from \"./helper\"; export function main() { return v }" + .to_string(), + path: None, + language: ScriptLang::Bun, + lock: None, + concurrency_settings: ConcurrencySettings::default().into(), + debouncing_settings: DebouncingSettings::default(), + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + modules, + tag: None, + }) + }; + assert_eq!(stored_modules(&db, preview(None)).await, None); + let own = serde_json::from_value(modules("preview")).unwrap(); + assert_eq!( + stored_modules(&db, preview(Some(own))).await, + Some(modules("preview")) + ); +} diff --git a/backend/tests/datatable_instance_pg_disabled.rs b/backend/tests/datatable_instance_pg_disabled.rs new file mode 100644 index 0000000000..8d424052e6 --- /dev/null +++ b/backend/tests/datatable_instance_pg_disabled.rs @@ -0,0 +1,40 @@ +//! Turning Windmill's own Postgres off as a data table substrate takes the data tables already on +//! it out of use, not only new ones: resolution is the one path every job, API call and trigger +//! reaches a data table through. + +use sqlx::{Pool, Postgres}; +use windmill_common::workspaces::get_datatable_resource_from_db_unchecked; + +#[sqlx::test] +async fn test_instance_datatable_refused_once_instance_pg_disabled( + db: Pool, +) -> anyhow::Result<()> { + sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ('dtoff-ws', 'DTOFF', 'dtoff')") + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO workspace_settings (workspace_id, datatable) VALUES ('dtoff-ws', \ + '{\"datatables\": {\"main\": {\"database\": {\"resource_type\": \"instance\", \ + \"resource_path\": \"dt_dtoff\"}}}}')", + ) + .execute(&db) + .await?; + + let resolved = get_datatable_resource_from_db_unchecked(&db, "dtoff-ws", "main").await?; + assert_eq!(resolved["dbname"], "dt_dtoff"); + + sqlx::query( + "INSERT INTO global_settings (name, value) VALUES ('instance_pg_disabled', 'true')", + ) + .execute(&db) + .await?; + + let refused = get_datatable_resource_from_db_unchecked(&db, "dtoff-ws", "main") + .await + .expect_err("an instance data table must not resolve while Windmill's database is off"); + assert!( + refused.to_string().contains("disabled on this instance"), + "unexpected error: {refused}" + ); + Ok(()) +} diff --git a/backend/tests/deleted_script_version.rs b/backend/tests/deleted_script_version.rs new file mode 100644 index 0000000000..fdc49043b9 --- /dev/null +++ b/backend/tests/deleted_script_version.rs @@ -0,0 +1,56 @@ +//! Script data is cached by hash, and the hash leaves out the workspace, so forks and clones +//! share it: a deleted copy in one workspace must not stop the live copy in another. + +use sqlx::{Pool, Postgres}; +use windmill_common::{cache, scripts::ScriptHash, worker::Connection}; + +#[sqlx::test(fixtures("base"))] +async fn a_deleted_copy_does_not_shadow_a_live_one(db: Pool) { + const SHARED: i64 = 0x5de1_e7ed_0002; + sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ('fork', 'fork', 'test-user')") + .execute(&db) + .await + .unwrap(); + sqlx::query( + "INSERT INTO script (workspace_id, hash, path, summary, description, content, + created_by, language, lock, archived, deleted) + VALUES ('fork', $1, 'f/infra/tool', '', '', '', 'test-user', 'bash', '', true, true), + ('test-workspace', $1, 'f/infra/tool', '', '', 'echo shared', 'test-user', + 'bash', '', false, false)", + ) + .bind(SHARED) + .execute(&db) + .await + .unwrap(); + + let conn = Connection::Sql(db.clone()); + let (data, _) = cache::script::fetch(&conn, ScriptHash(SHARED)) + .await + .unwrap(); + assert_eq!(data.code, "echo shared"); +} + +#[sqlx::test(fixtures("base"))] +async fn a_large_deletion_is_split_across_events(db: Pool) { + let deleted = windmill_common::DeletedScriptVersions::new( + "test-workspace", + (0..501).map(|h| (format!("f/infra/s{}", h % 2), h)), + ); + let mut conn = db.acquire().await.unwrap(); + deleted.notify(&mut conn).await.unwrap(); + + let events: Vec = sqlx::query_scalar( + "SELECT payload FROM notify_event WHERE channel = $1 ORDER BY id", + ) + .bind(windmill_common::SCRIPT_VERSION_DELETED_CHANNEL) + .fetch_all(&db) + .await + .unwrap(); + let events: Vec = + events.iter().map(|e| serde_json::from_str(e).unwrap()).collect(); + assert_eq!(events.len(), 2); + let hashes: Vec = events.iter().flat_map(|e| e.hashes.clone()).collect(); + let paths: Vec = events.iter().flat_map(|e| e.paths.clone()).collect(); + assert_eq!(hashes, deleted.hashes); + assert_eq!(paths, deleted.paths); +} diff --git a/backend/tests/fixtures/job_provenance.sql b/backend/tests/fixtures/job_provenance.sql index 202c8f2df7..49eb4212d5 100644 --- a/backend/tests/fixtures/job_provenance.sql +++ b/backend/tests/fixtures/job_provenance.sql @@ -30,3 +30,34 @@ INSERT INTO v2_job (id, workspace_id, kind, runnable_path, trigger_kind, trigger VALUES ('3bb0c0de-0000-4000-8000-000000000007', 'test-workspace', 'appscript', 'f/t/app/comp', 'app', 'f/t/app', 'test-user', 'u/test-user', 'test@windmill.dev'), ('3bb0c0de-0000-4000-8000-000000000008', 'test-workspace', 'appscript', 'f/t/app/comp', NULL, NULL, 'test-user', 'u/test-user', 'test@windmill.dev'); + +-- Inline steps of the deployed flow f/t/agent run as previews (no flow node, or +-- DISABLE_FLOW_SCRIPT): a direct step, a step in a loop body, and a step whose path +-- leaves the flow's namespace. +INSERT INTO v2_job (id, workspace_id, kind, runnable_path, parent_job, created_by, permissioned_as, permissioned_as_email) +VALUES + ('3bb0c0de-0000-4000-8000-000000000009', 'test-workspace', 'preview', 'f/t/agent/a', '3bb0c0de-0000-4000-8000-000000000001', 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-00000000000a', 'test-workspace', 'flowpreview', 'f/t/agent/loop-0', '3bb0c0de-0000-4000-8000-000000000001', 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-00000000000b', 'test-workspace', 'preview', 'f/t/agent/loop-0/a', '3bb0c0de-0000-4000-8000-00000000000a', 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-00000000000c', 'test-workspace', 'preview', 'f/t/agentx', '3bb0c0de-0000-4000-8000-000000000001', 'test-user', 'u/test-user', 'test@windmill.dev'); + +-- Steps of the flow preview claiming f/t/agent: one nested in a branch, and one whose +-- path names another item. +INSERT INTO v2_job (id, workspace_id, kind, runnable_path, parent_job, created_by, permissioned_as, permissioned_as_email) +VALUES + ('3bb0c0de-0000-4000-8000-00000000000d', 'test-workspace', 'flowpreview', 'f/t/agent/branchone-1', '3bb0c0de-0000-4000-8000-000000000003', 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-00000000000e', 'test-workspace', 'preview', 'f/t/agent/branchone-1/a', '3bb0c0de-0000-4000-8000-00000000000d', 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-00000000000f', 'test-workspace', 'preview', 'f/prod/deploy', '3bb0c0de-0000-4000-8000-000000000003', 'test-user', 'u/test-user', 'test@windmill.dev'); + +-- A deployed app without app_script entries runs its inline script as a stamped preview. +INSERT INTO v2_job (id, workspace_id, kind, runnable_path, trigger_kind, trigger, created_by, permissioned_as, permissioned_as_email) +VALUES + ('3bb0c0de-0000-4000-8000-000000000010', 'test-workspace', 'preview', 'f/t/app/comp', 'app', 'f/t/app', 'test-user', 'u/test-user', 'test@windmill.dev'); + +-- A step of the deployed flow whose modules came in its args, and a flow preview at the +-- bare `f` whose step names another item. +INSERT INTO v2_job (id, workspace_id, kind, runnable_path, parent_job, args, created_by, permissioned_as, permissioned_as_email) +VALUES + ('3bb0c0de-0000-4000-8000-000000000011', 'test-workspace', 'preview', 'f/t/agent/m', '3bb0c0de-0000-4000-8000-000000000001', '{"_MODULES": {}}', 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-000000000012', 'test-workspace', 'flowpreview', 'f', NULL, NULL, 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-000000000013', 'test-workspace', 'preview', 'f/prod/deploy', '3bb0c0de-0000-4000-8000-000000000012', NULL, 'test-user', 'u/test-user', 'test@windmill.dev'); diff --git a/backend/tests/fixtures/job_provenance_versions.sql b/backend/tests/fixtures/job_provenance_versions.sql new file mode 100644 index 0000000000..5f0f204305 --- /dev/null +++ b/backend/tests/fixtures/job_provenance_versions.sql @@ -0,0 +1,92 @@ +-- Add-on fixture for job_provenance.rs (combine with `base.sql` and `job_provenance.sql`): +-- past versions of f/t/tool and f/t/agent, a deleted version of f/t/tool, and flow steps +-- of f/t/agent under a regular run and under restarts. + +INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock, created_at, archived) +VALUES ('test-workspace', 1111111110, 'f/t/tool', 'echo old tool', 'bash', 'script', 'test-user', '{}', '', '', '', NOW() - interval '1 day', true); + +INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock, created_at, archived, deleted) +VALUES ('test-workspace', 1111111109, 'f/t/tool', '', 'bash', 'script', 'test-user', '{}', '', '', '', NOW() - interval '2 days', true, true); + +INSERT INTO flow_version (id, workspace_id, path, value, schema, created_by, created_at) +VALUES (2222222221, 'test-workspace', 'f/t/agent', '{"modules":[]}', '{}', 'test-user', NOW() - interval '1 day'); + +UPDATE flow SET versions = ARRAY[2222222221::bigint, 2222222222::bigint] +WHERE workspace_id = 'test-workspace' AND path = 'f/t/agent'; + +INSERT INTO v2_job (id, workspace_id, kind, runnable_path, runnable_id, parent_job, created_by, permissioned_as, permissioned_as_email) +VALUES + -- the past tool version, run under the current flow version + ('3bb0c0de-0000-4000-8000-000000000101', 'test-workspace', 'script', 'f/t/tool', 1111111110, '3bb0c0de-0000-4000-8000-000000000001', 'test-user', 'u/test-user', 'test@windmill.dev'), + -- the current tool version, run under the past flow version + ('3bb0c0de-0000-4000-8000-000000000102', 'test-workspace', 'flow', 'f/t/agent', 2222222221, NULL, 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-000000000103', 'test-workspace', 'script', 'f/t/tool', 1111111111, '3bb0c0de-0000-4000-8000-000000000102', 'test-user', 'u/test-user', 'test@windmill.dev'), + -- the deleted tool version + ('3bb0c0de-0000-4000-8000-000000000104', 'test-workspace', 'script', 'f/t/tool', 1111111109, NULL, 'test-user', 'u/test-user', 'test@windmill.dev'), + -- under the current flow version: an inline step, a loop body and a step of that body + ('3bb0c0de-0000-4000-8000-000000000105', 'test-workspace', 'flowscript', 'f/t/agent/a', 4444444442, '3bb0c0de-0000-4000-8000-000000000001', 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-000000000106', 'test-workspace', 'flownode', 'f/t/agent/loop-0', 4444444444, '3bb0c0de-0000-4000-8000-000000000001', 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-000000000107', 'test-workspace', 'flowscript', 'f/t/agent/loop-0/b', 4444444446, '3bb0c0de-0000-4000-8000-000000000106', 'test-user', 'u/test-user', 'test@windmill.dev'), + -- an inline step under the past flow version + ('3bb0c0de-0000-4000-8000-000000000108', 'test-workspace', 'flowscript', 'f/t/agent/a', 4444444441, '3bb0c0de-0000-4000-8000-000000000102', 'test-user', 'u/test-user', 'test@windmill.dev'), + -- a loop body restarted on its own, and a step of it + ('3bb0c0de-0000-4000-8000-000000000109', 'test-workspace', 'flownode', 'f/t/agent/loop-0', 4444444443, NULL, 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-000000000110', 'test-workspace', 'flowscript', 'f/t/agent/loop-0/b', 4444444449, '3bb0c0de-0000-4000-8000-000000000109', 'test-user', 'u/test-user', 'test@windmill.dev'), + -- a body kept by a nested restart under the current flow version, and a step of it + ('3bb0c0de-0000-4000-8000-000000000111', 'test-workspace', 'flownode', 'f/t/agent/branchone-1', 4444444445, '3bb0c0de-0000-4000-8000-000000000001', 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-000000000112', 'test-workspace', 'flowscript', 'f/t/agent/branchone-1/c', 4444444447, '3bb0c0de-0000-4000-8000-000000000111', 'test-user', 'u/test-user', 'test@windmill.dev'); + +-- An inline body (a flow deployed before flow nodes runs it as a preview) kept by a nested +-- restart under the current flow version, and a step of it. +INSERT INTO v2_job (id, workspace_id, kind, runnable_path, parent_job, created_by, permissioned_as, permissioned_as_email) +VALUES + ('3bb0c0de-0000-4000-8000-000000000113', 'test-workspace', 'flowpreview', 'f/t/agent/branchone-0', '3bb0c0de-0000-4000-8000-000000000001', 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-000000000114', 'test-workspace', 'preview', 'f/t/agent/branchone-0/c', '3bb0c0de-0000-4000-8000-000000000113', 'test-user', 'u/test-user', 'test@windmill.dev'); + +-- The restarted bodies: two still running, one completed. +INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running) +VALUES + ('3bb0c0de-0000-4000-8000-000000000111', 'test-workspace', NOW(), true), + ('3bb0c0de-0000-4000-8000-000000000113', 'test-workspace', NOW(), true); + +INSERT INTO v2_job_status (id, flow_status) +VALUES + ('3bb0c0de-0000-4000-8000-000000000111', + '{"restarted_from": {"flow_job_id": "3bb0c0de-0000-4000-8000-0000000000ff", "step_id": "br"}}'), + ('3bb0c0de-0000-4000-8000-000000000113', + '{"restarted_from": {"flow_job_id": "3bb0c0de-0000-4000-8000-0000000000fd", "step_id": "br"}}'); + +INSERT INTO v2_job_completed (id, workspace_id, duration_ms, status, flow_status) +VALUES ('3bb0c0de-0000-4000-8000-000000000109', 'test-workspace', 0, 'success', + '{"restarted_from": {"flow_job_id": "3bb0c0de-0000-4000-8000-0000000000fe", "step_id": "l"}}'); + +-- Loop bodies restarted on their own from completed runs: one of the current flow version, +-- one of the past version, and one that was itself a restart; with a step of the first. +INSERT INTO v2_job (id, workspace_id, kind, runnable_path, runnable_id, parent_job, created_by, permissioned_as, permissioned_as_email) +VALUES + ('3bb0c0de-0000-4000-8000-000000000115', 'test-workspace', 'flownode', 'f/t/agent/loop-0', 4444444444, '3bb0c0de-0000-4000-8000-000000000001', 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-000000000116', 'test-workspace', 'flownode', 'f/t/agent/loop-0', 4444444444, NULL, 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-000000000117', 'test-workspace', 'flowscript', 'f/t/agent/loop-0/b', 4444444446, '3bb0c0de-0000-4000-8000-000000000116', 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-000000000118', 'test-workspace', 'flownode', 'f/t/agent/loop-0', 4444444443, '3bb0c0de-0000-4000-8000-000000000102', 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-000000000119', 'test-workspace', 'flownode', 'f/t/agent/loop-0', 4444444443, NULL, 'test-user', 'u/test-user', 'test@windmill.dev'), + ('3bb0c0de-0000-4000-8000-000000000120', 'test-workspace', 'flownode', 'f/t/agent/loop-0', 4444444443, NULL, 'test-user', 'u/test-user', 'test@windmill.dev'); + +INSERT INTO v2_job_completed (id, workspace_id, duration_ms, status) +VALUES + ('3bb0c0de-0000-4000-8000-000000000115', 'test-workspace', 0, 'success'), + ('3bb0c0de-0000-4000-8000-000000000118', 'test-workspace', 0, 'success'); + +INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running) +VALUES + ('3bb0c0de-0000-4000-8000-000000000116', 'test-workspace', NOW(), true), + ('3bb0c0de-0000-4000-8000-000000000119', 'test-workspace', NOW(), true), + ('3bb0c0de-0000-4000-8000-000000000120', 'test-workspace', NOW(), true); + +INSERT INTO v2_job_status (id, flow_status) +VALUES + ('3bb0c0de-0000-4000-8000-000000000116', + '{"restarted_from": {"flow_job_id": "3bb0c0de-0000-4000-8000-000000000115", "step_id": "b"}}'), + ('3bb0c0de-0000-4000-8000-000000000119', + '{"restarted_from": {"flow_job_id": "3bb0c0de-0000-4000-8000-000000000118", "step_id": "b"}}'), + ('3bb0c0de-0000-4000-8000-000000000120', + '{"restarted_from": {"flow_job_id": "3bb0c0de-0000-4000-8000-000000000109", "step_id": "b"}}'); diff --git a/backend/tests/fixtures/jobs_read_auth.sql b/backend/tests/fixtures/jobs_read_auth.sql index d7d11228aa..980d68b711 100644 --- a/backend/tests/fixtures/jobs_read_auth.sql +++ b/backend/tests/fixtures/jobs_read_auth.sql @@ -65,6 +65,32 @@ INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, resu ('15151515-1515-1515-1515-151515151515', 'test-workspace', 1000, 'success'::job_status, '{"wrapped": "WRAPPED_FLOW_RESULT"}'); +-- Scoped to an agent. An agent run is a preview of the one-step flow the run endpoint builds +-- (step `__wm_agent_root`), filed under the agent's path, or `.chat` for a chat turn. +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES ( + encode(sha256('RUN_SCOPED_AGENT_TOKEN'::bytea), 'hex'), 'RUN_AGENT', 'RUN_SCOPED_AGENT_TOKEN', + 'test2@windmill.dev', 'agent token', false, + ARRAY['jobs:run:agents:f/shared/agent1'] +); + +INSERT INTO public.v2_job ( + id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email, + kind, script_lang, runnable_path, tag, visible_to_owner, raw_flow +) VALUES + ('17171717-1717-1717-1717-171717171717', 'test-workspace', 'test-user-2', + '2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev', + 'flowpreview', 'deno', 'f/shared/agent1', 'flow', true, + '{"modules": [{"id": "__wm_agent_root", "value": {"type": "aiagent", "tools": []}}]}'), + ('18181818-1818-1818-1818-181818181818', 'test-workspace', 'test-user-2', + '2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev', + 'flowpreview', 'deno', 'f/shared/agent1.chat', 'flow', true, + '{"modules": [{"id": "__wm_agent_root", "value": {"type": "aiagent", "tools": []}}]}'); +INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, result) VALUES + ('17171717-1717-1717-1717-171717171717', 'test-workspace', 1000, 'success'::job_status, + '{"agent": "AGENT_RESULT"}'), + ('18181818-1818-1818-1818-181818181818', 'test-workspace', 1000, 'success'::job_status, + '{"agent": "AGENT_CHAT_RESULT"}'); + -- A token pairing an app scope with a run scope, as someone driving an app's components -- programmatically would build. `APP_INLINE_JOB` is an inline-script component run: no -- `jobs:run` scope can name its kind, so only the `apps:run` half puts it in reach. diff --git a/backend/tests/flow_conversation_running_turn.rs b/backend/tests/flow_conversation_running_turn.rs new file mode 100644 index 0000000000..392ff6f5cf --- /dev/null +++ b/backend/tests/flow_conversation_running_turn.rs @@ -0,0 +1,87 @@ +//! A conversation answers one message at a time: while the run its newest user message started +//! is queued or running, the list reports that turn and a run into the conversation is refused. +//! +//! Uses runtime `sqlx::query` (not the compile-time macros) so no offline query cache is +//! needed, matching v2_job_delete_orphans.rs. + +use sqlx::{Pool, Postgres}; +use uuid::Uuid; +use windmill_common::error::Error; +use windmill_common::flow_conversations::{get_or_create_conversation_with_id, running_turns}; +use windmill_test_utils::*; + +const WS: &str = "test-workspace"; +const CONV: Uuid = Uuid::from_u128(0x5eed); + +/// Takes the conversation for a turn, as a run does, without keeping what it writes. +async fn take_conversation(db: &Pool) -> windmill_common::error::Result { + let mut tx = db.begin().await?; + let result = + get_or_create_conversation_with_id(&mut tx, WS, "f/flow", "test-user", "t", CONV, false) + .await; + tx.rollback().await?; + result.map(|c| c.id) +} + +#[sqlx::test(fixtures("base"))] +async fn test_a_conversation_refuses_a_turn_while_one_runs( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + + let job_id = Uuid::new_v4(); + sqlx::query("INSERT INTO v2_job (id, workspace_id, kind) VALUES ($1, $2, 'flow')") + .bind(job_id) + .bind(WS) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for) VALUES ($1, $2, now())", + ) + .bind(job_id) + .bind(WS) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO flow_conversation (id, workspace_id, flow_path, created_by) + VALUES ($1, $2, 'f/flow', 'test-user')", + ) + .bind(CONV) + .bind(WS) + .execute(&db) + .await?; + let user_seq: i64 = sqlx::query_scalar( + "INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id) + VALUES ($1, 'user', 'hi', $2) RETURNING created_seq", + ) + .bind(CONV) + .bind(job_id) + .fetch_one(&db) + .await?; + + let running = running_turns(&db, &[CONV]).await?; + let turn = running + .get(&CONV) + .expect("the queued run is the conversation's running turn"); + assert_eq!((turn.job_id, turn.user_seq), (job_id, user_seq)); + + match take_conversation(&db).await { + Err(Error::Generic(status, body)) => { + assert_eq!(status.as_u16(), 409); + assert!( + body.contains(&job_id.to_string()), + "the refusal names the running job: {body}" + ); + } + other => panic!("expected a 409 while the first turn runs, got {other:?}"), + } + + // The run ends: it leaves the queue, and the conversation takes the next message. + sqlx::query("DELETE FROM v2_job_queue WHERE id = $1") + .bind(job_id) + .execute(&db) + .await?; + assert!(running_turns(&db, &[CONV]).await?.is_empty()); + assert_eq!(take_conversation(&db).await?, CONV); + Ok(()) +} diff --git a/backend/tests/inline_preview_auth.rs b/backend/tests/inline_preview_auth.rs index 8841719962..acf511c26c 100644 --- a/backend/tests/inline_preview_auth.rs +++ b/backend/tests/inline_preview_auth.rs @@ -18,7 +18,7 @@ //! //! This test pins down: //! - an Operator's own token is rejected by the operator guard (the core fix; -//! pre-fix this reached the inline executor instead of returning 401), +//! pre-fix this reached the inline executor instead of returning 403), //! - a regular non-operator passes the guard (the fix must not over-block the //! legitimate inline preview flow): in the test harness the worker inline //! utils are not registered, so a caller past the guard gets the distinct @@ -121,10 +121,10 @@ async fn test_inline_preview_authorization(db: Pool) -> anyhow::Result // 1. CORE REGRESSION: an Operator must be rejected by the operator guard. // Pre-fix this fell through to the inline executor (arbitrary code - // execution); post-fix it returns 401 with the operator guard message. + // execution); post-fix it returns 403 with the operator guard message. let (status, body) = post(&url, "OPERATOR_TOKEN", &inline_preview_body()).await; assert_eq!( - status, 401, + status, 403, "Operator must be rejected from inline preview (got {status}): {body}" ); assert!( @@ -139,7 +139,7 @@ async fn test_inline_preview_authorization(db: Pool) -> anyhow::Result // the operator guard did not reject it. let (status, body) = post(&url, "SECRET_TOKEN_2", &inline_preview_body()).await; assert_ne!( - status, 401, + status, 403, "non-operator must not be blocked by the operator guard (got {status}): {body}" ); assert!( @@ -155,7 +155,7 @@ async fn test_inline_preview_authorization(db: Pool) -> anyhow::Result operator_job_token(uuid::Uuid::parse_str(RUNNING_JOB_ID).unwrap()).await; let (status, body) = post(&url, &running_job_token, &datatable_query_body()).await; assert_ne!( - status, 401, + status, 403, "operator job token of a running job must pass the guard for a datatable query (got {status}): {body}" ); assert!( @@ -188,7 +188,7 @@ async fn test_inline_preview_authorization(db: Pool) -> anyhow::Result ] { let (status, body) = post(&url, &running_job_token, &payload).await; assert_eq!( - status, 401, + status, 403, "operator job token must be rejected for a {label} payload (got {status}): {body}" ); assert!( @@ -208,7 +208,7 @@ async fn test_inline_preview_authorization(db: Pool) -> anyhow::Result let token = operator_job_token(job_id).await; let (status, body) = post(&url, &token, &datatable_query_body()).await; assert_eq!( - status, 401, + status, 403, "operator job token of a {label} job must be rejected (got {status}): {body}" ); assert!( @@ -229,7 +229,7 @@ async fn test_inline_preview_authorization(db: Pool) -> anyhow::Result ); let (status, body) = post(&fallback_url, "OPERATOR_TOKEN", &datatable_query_body()).await; assert_eq!( - status, 401, + status, 403, "Operator must be rejected from the preview fallback (got {status}): {body}" ); assert!( @@ -246,7 +246,7 @@ async fn test_inline_preview_authorization(db: Pool) -> anyhow::Result let deferred_url = format!("{fallback_url}?{deferral}"); let (status, body) = post(&deferred_url, &running_job_token, &datatable_query_body()).await; assert_eq!( - status, 401, + status, 403, "operator job token must not schedule a deferred preview with {deferral} (got {status}): {body}" ); assert!( diff --git a/backend/tests/instance_config.rs b/backend/tests/instance_config.rs index 26a46d272e..52ebbee7e7 100644 --- a/backend/tests/instance_config.rs +++ b/backend/tests/instance_config.rs @@ -1609,6 +1609,52 @@ async fn declarative_sync_rejects_an_unusable_default_allowed_origins(db: Pool

) { + clear_settings_and_configs(&db).await; + let cluster = |host: &str, password: &str| serde_json::json!({ "host": host, "port": 5432, "user": "wm_admin", "password": password }); + sqlx::query( + "INSERT INTO global_settings (name, value) VALUES + ('external_instance_pg', $1), + ('external_instance_pg_state', '{\"databases\": {\"dt_a\": {\"success\": true}}}')", + ) + .bind(cluster("pg-a.internal", "one")) + .execute(&db) + .await + .unwrap(); + let current = BTreeMap::from([( + "external_instance_pg".to_string(), + cluster("pg-a.internal", "one"), + )]); + let sync = |value: serde_json::Value| { + let desired = BTreeMap::from([("external_instance_pg".to_string(), value)]); + let (db, current) = (db.clone(), current.clone()); + async move { + windmill_common::instance_config::sync_global_settings_declarative( + &db, ¤t, &desired, + ) + .await + } + }; + + let err = sync(cluster("pg-b.internal", "one")) + .await + .expect_err("another host must be refused while dt_a is registered"); + assert!(err.to_string().contains("dt_a"), "got: {err}"); + assert_eq!( + get_global_setting(&db, "external_instance_pg").await, + Some(cluster("pg-a.internal", "one")) + ); + + sync(cluster("PG-A.internal ", "two")) + .await + .expect("a new login on the same cluster must sync"); +} + /// The accent color is interpolated into a stylesheet every user loads, so the operator /// path must refuse anything but `#rrggbb` just like the settings API does. #[sqlx::test(fixtures("base"))] diff --git a/backend/tests/job_provenance.rs b/backend/tests/job_provenance.rs index 41de4f6f77..42ccf829e6 100644 --- a/backend/tests/job_provenance.rs +++ b/backend/tests/job_provenance.rs @@ -3,14 +3,33 @@ use sqlx::{Pool, Postgres}; use uuid::Uuid; -use windmill_common::job_provenance::job_provenance; +use windmill_common::{ + job_provenance::{job_provenance, ClaimedItem, ClaimedPath, JobProvenance}, + scripts::ScriptHash, +}; -async fn deployed(db: &Pool, job: &str) -> bool { +async fn provenance(db: &Pool, job: &str) -> JobProvenance { job_provenance(db, &Uuid::parse_str(job).unwrap(), "test-workspace") .await .unwrap() .unwrap() - .deployed +} + +async fn deployed(db: &Pool, job: &str) -> bool { + provenance(db, job).await.deployed +} + +async fn claims(db: &Pool, job: &str) -> Vec<(String, ClaimedItem)> { + provenance(db, job) + .await + .claimed_paths + .into_iter() + .map(|ClaimedPath { path, item, .. }| (path, item)) + .collect() +} + +fn claim(path: &str, item: ClaimedItem) -> (String, ClaimedItem) { + (path.to_string(), item) } #[sqlx::test(fixtures("base", "job_provenance"))] @@ -40,5 +59,94 @@ async fn flow_running_another_flows_version_is_not_deployed(db: Pool) #[sqlx::test(fixtures("base", "job_provenance"))] async fn app_script_is_deployed_only_from_a_deployed_app_run(db: Pool) { assert!(deployed(&db, "3bb0c0de-0000-4000-8000-000000000007").await); + assert!(deployed(&db, "3bb0c0de-0000-4000-8000-000000000010").await); assert!(!deployed(&db, "3bb0c0de-0000-4000-8000-000000000008").await); + assert_eq!( + claims(&db, "3bb0c0de-0000-4000-8000-000000000008").await, + [claim("f/t/app", ClaimedItem::App)] + ); +} + +/// Inline steps of a flow deployed before flow nodes run as previews, at paths the +/// worker derives under the flow's. +#[sqlx::test(fixtures("base", "job_provenance"))] +async fn preview_step_of_deployed_flow_is_deployed_within_its_path(db: Pool) { + for step in ["09", "0b"] { + let p = provenance(&db, &format!("3bb0c0de-0000-4000-8000-0000000000{step}")).await; + assert!(p.deployed && p.claimed_paths.is_empty(), "step {step}"); + } + assert!(!deployed(&db, "3bb0c0de-0000-4000-8000-00000000000c").await); + assert!(!deployed(&db, "3bb0c0de-0000-4000-8000-000000000011").await); + assert_eq!( + claims(&db, "3bb0c0de-0000-4000-8000-00000000000c").await, + [claim("f/t/agentx", ClaimedItem::Script)] + ); +} + +/// A token acts as every path its chain took from a request: the top-most preview, and +/// any step whose path leaves its parent's. +#[sqlx::test(fixtures("base", "job_provenance"))] +async fn claimed_paths_are_the_request_supplied_origins(db: Pool) { + let flow = || claim("f/t/agent", ClaimedItem::Flow); + assert_eq!(claims(&db, "3bb0c0de-0000-4000-8000-000000000004").await, [flow()]); + assert_eq!(claims(&db, "3bb0c0de-0000-4000-8000-00000000000e").await, [flow()]); + assert_eq!( + claims(&db, "3bb0c0de-0000-4000-8000-00000000000f").await, + [flow(), claim("f/prod/deploy", ClaimedItem::Script)] + ); + assert_eq!(claims(&db, "3bb0c0de-0000-4000-8000-000000000006").await, [flow()]); + assert!(claims(&db, "3bb0c0de-0000-4000-8000-000000000002").await.is_empty()); + // A parent whose path names no item cannot cover the claims below it. + assert_eq!( + claims(&db, "3bb0c0de-0000-4000-8000-000000000013").await, + [claim("f/prod/deploy", ClaimedItem::Script)] + ); +} + +/// Any past version can be run by hash or flow version id: it stays deployed, but only +/// the versions the paths currently resolve to are latest. +#[sqlx::test(fixtures("base", "job_provenance", "job_provenance_versions"))] +async fn only_current_versions_are_latest(db: Pool) { + let current = provenance(&db, "3bb0c0de-0000-4000-8000-000000000002").await; + assert!(current.deployed && current.latest); + let current_hash = ScriptHash(1111111111).to_string(); + assert_eq!(current.version.as_deref(), Some(current_hash.as_str())); + assert_eq!(current.root_version.as_deref(), Some("2222222222")); + + let past_tool = provenance(&db, "3bb0c0de-0000-4000-8000-000000000101").await; + assert!(past_tool.deployed && !past_tool.latest); + + let under_past_flow = provenance(&db, "3bb0c0de-0000-4000-8000-000000000103").await; + assert!(under_past_flow.deployed && !under_past_flow.latest); + assert_eq!(under_past_flow.root_version.as_deref(), Some("2222222221")); + + assert!(!deployed(&db, "3bb0c0de-0000-4000-8000-000000000104").await); +} + +/// Flow steps run what the version of the flow above them defines, except under a +/// restart, which rebuilds a body from a past run, under the current flow version or with +/// no flow above it at all: that body is current only if the run it came from was. +#[sqlx::test(fixtures("base", "job_provenance", "job_provenance_versions"))] +async fn flow_steps_are_latest_only_under_a_current_unrestarted_flow(db: Pool) { + for (job, latest) in [ + ("105", true), + ("106", true), + ("107", true), + ("108", false), + ("109", false), + ("110", false), + ("111", false), + ("112", false), + ("00a", true), + ("113", false), + ("114", false), + ("116", true), + ("117", true), + ("119", false), + ("120", false), + ] { + let p = provenance(&db, &format!("3bb0c0de-0000-4000-8000-000000000{job}")).await; + assert!(p.deployed, "job {job}"); + assert_eq!(p.latest, latest, "job {job}"); + } } diff --git a/backend/tests/jobs_read_auth.rs b/backend/tests/jobs_read_auth.rs index 9325f25652..fd2969a18f 100644 --- a/backend/tests/jobs_read_auth.rs +++ b/backend/tests/jobs_read_auth.rs @@ -456,6 +456,26 @@ async fn test_single_job_read_authorization(db: Pool) -> anyhow::Resul ); } + // An agent run and a chat turn of it belong to the agent, readable by the token scoped to + // it and by no flow-scoped one. + for (job, expected) in [ + ("17171717-1717-1717-1717-171717171717", "AGENT_RESULT"), + ("18181818-1818-1818-1818-181818181818", "AGENT_CHAT_RESULT"), + ] { + let path = format!("completed/get_result/{job}"); + let (status, body) = get(&base, &path, Some("RUN_SCOPED_AGENT_TOKEN")).await; + assert!( + status.is_success() && body.contains(expected), + "agent-scoped token must read its agent's run {job} (got {status}): {body}" + ); + let (status, body) = get(&base, &path, Some("RUN_SCOPED_TOKEN")).await; + assert_eq!( + status, + reqwest::StatusCode::NOT_FOUND, + "a flow-scoped token must not read an agent run {job} (got {status}): {body}" + ); + } + // An `apps:run:` scope is a start grant too: the inline-script component run it // launched — a kind no `jobs:run` scope can name — stays readable to a token scoped // to that app, and stays out of reach for one that is only scoped to run jobs. diff --git a/backend/tests/perpetual_deploy_restart.rs b/backend/tests/perpetual_deploy_restart.rs new file mode 100644 index 0000000000..6f9e82e5c6 --- /dev/null +++ b/backend/tests/perpetual_deploy_restart.rs @@ -0,0 +1,300 @@ +//! A deploy stops the perpetual runs at its path and starts them again on the version it made +//! runnable, unless that version no longer loops. + +use serde_json::{json, Value}; +use sqlx::{Pool, Postgres}; +use uuid::Uuid; +use windmill_queue::restart_perpetual_runs_on_new_version; + +const W_ID: &str = "test-workspace"; + +// Paths are distinct across tests: a path resolves to its deployed version through a +// process-wide cache, while each test runs against its own database. +async fn insert_version( + db: &Pool, + path: &str, + hash: i64, + age_s: f64, + perpetual: bool, +) -> anyhow::Result<()> { + insert_tagged_version(db, path, hash, age_s, perpetual, None).await +} + +async fn insert_tagged_version( + db: &Pool, + path: &str, + hash: i64, + age_s: f64, + perpetual: bool, + tag: Option<&str>, +) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO script (workspace_id, hash, path, summary, description, content, created_by, \ + language, lock, restart_unless_cancelled, tag, created_at) \ + VALUES ($1, $2, $3, '', '', 'echo', 'test-user', 'bash', '', $4, $5, \ + now() - make_interval(secs => $6))", + ) + .bind(W_ID) + .bind(hash) + .bind(path) + .bind(perpetual) + .bind(tag) + .bind(age_s) + .execute(db) + .await?; + Ok(()) +} + +/// A run of `hash` a worker has started. +async fn start_run(db: &Pool, path: &str, hash: i64) -> anyhow::Result { + start_run_as(db, path, hash, "test-user", "test@windmill.dev").await +} + +/// `username` and `email` name one identity, as they do on a real run, so what the replacement +/// inherits can be told apart from what the deployed version names. +async fn start_run_as( + db: &Pool, + path: &str, + hash: i64, + username: &str, + email: &str, +) -> anyhow::Result { + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, created_by, created_at, permissioned_as, \ + permissioned_as_email, kind, runnable_id, runnable_path, script_lang, tag, args, \ + visible_to_owner) \ + VALUES ($1, $2, $6, now(), 'u/' || $6, $5, 'script', $3, \ + $4, 'bash', 'bash', '{\"n\": 1}', true)", + ) + .bind(id) + .bind(W_ID) + .bind(hash) + .bind(path) + .bind(email) + .bind(username) + .execute(db) + .await?; + sqlx::query( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, started_at, tag) \ + VALUES ($1, $2, now(), true, now(), 'bash')", + ) + .bind(id) + .bind(W_ID) + .execute(db) + .await?; + Ok(id) +} + +#[sqlx::test(fixtures("base"))] +async fn a_deploy_restarts_the_runs_of_earlier_versions(db: Pool) -> anyhow::Result<()> { + let path = "u/test-user/restarted"; + insert_version(&db, path, 101, 60.0, true).await?; + let running = start_run(&db, path, 101).await?; + insert_version(&db, path, 102, 0.0, true).await?; + + restart_perpetual_runs_on_new_version(&db, W_ID, path, "test-user").await; + + let canceled: Option = + sqlx::query_scalar("SELECT canceled_reason FROM v2_job_queue WHERE id = $1") + .bind(running) + .fetch_one(&db) + .await?; + assert!( + canceled.is_some_and(|reason| reason.contains(path)), + "the run of the earlier version is canceled" + ); + + // Joined on the queue: a row in `v2_job` that never reached it would run nothing. + let (hash, args): (i64, Value) = sqlx::query_as( + "SELECT j.runnable_id, j.args FROM v2_job j JOIN v2_job_queue q USING (id) \ + WHERE j.workspace_id = $1 AND j.runnable_path = $2 AND j.id <> $3 \ + AND q.canceled_by IS NULL", + ) + .bind(W_ID) + .bind(path) + .bind(running) + .fetch_one(&db) + .await?; + assert_eq!(hash, 102, "the next run is queued on the deployed version"); + assert_eq!( + args, + json!({ "n": 1 }), + "with the arguments of the run it replaces" + ); + Ok(()) +} + +/// A run carries what started it until its own completion swaps in what the preprocessor returned, +/// so a replacement pushed without one would hand `main` the raw arguments, and every iteration +/// after it the same. +#[sqlx::test(fixtures("base"))] +async fn a_replacement_preprocesses_arguments_the_replaced_run_had_not( + db: Pool, +) -> anyhow::Result<()> { + let path = "u/test-user/preprocessed"; + insert_version(&db, path, 501, 60.0, true).await?; + let running = start_run(&db, path, 501).await?; + insert_version(&db, path, 502, 0.0, true).await?; + sqlx::query( + "UPDATE script SET has_preprocessor = true WHERE hash = ANY('{501,502}') AND workspace_id = $1", + ) + .bind(W_ID) + .execute(&db) + .await?; + sqlx::query("UPDATE v2_job SET preprocessed = false WHERE id = $1") + .bind(running) + .execute(&db) + .await?; + + restart_perpetual_runs_on_new_version(&db, W_ID, path, "test-user").await; + + let preprocessed: Option = sqlx::query_scalar( + "SELECT preprocessed FROM v2_job \ + WHERE workspace_id = $1 AND runnable_path = $2 AND id <> $3", + ) + .bind(W_ID) + .bind(path) + .bind(running) + .fetch_one(&db) + .await?; + assert_eq!( + preprocessed, + Some(false), + "the replacement is queued to go through the preprocessor" + ); + Ok(()) +} + +/// A version that names an identity runs as it, like any run of that version by path, rather than +/// as whoever started the loop. +#[sqlx::test(fixtures("base"))] +async fn a_replacement_runs_as_the_identity_the_deployed_version_names( + db: Pool, +) -> anyhow::Result<()> { + let path = "u/test-user/on-behalf-of"; + insert_version(&db, path, 601, 60.0, true).await?; + let running = start_run_as(&db, path, 601, "test-user-2", "test2@windmill.dev").await?; + insert_version(&db, path, 602, 0.0, true).await?; + sqlx::query( + "UPDATE script SET on_behalf_of = 'u/test-user' WHERE hash = 602 AND workspace_id = $1", + ) + .bind(W_ID) + .execute(&db) + .await?; + + restart_perpetual_runs_on_new_version(&db, W_ID, path, "test-user").await; + + let (permissioned_as, email): (String, String) = sqlx::query_as( + "SELECT permissioned_as, permissioned_as_email FROM v2_job \ + WHERE workspace_id = $1 AND runnable_path = $2 AND id <> $3", + ) + .bind(W_ID) + .bind(path) + .bind(running) + .fetch_one(&db) + .await?; + assert_eq!(permissioned_as, "u/test-user"); + assert_eq!( + email, "test@windmill.dev", + "resolved from the identity the version names, not the one the replaced run had" + ); + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn a_run_stays_on_its_version_when_it_may_not_use_the_deployed_tag( + db: Pool, +) -> anyhow::Result<()> { + let path = "u/test-user/tagged"; + insert_version(&db, path, 301, 60.0, true).await?; + // Runs as the fixture's user who is no superadmin, and no workspace tag allows this one. + let running = start_run_as(&db, path, 301, "test-user-2", "test2@windmill.dev").await?; + insert_tagged_version(&db, path, 302, 0.0, true, Some("restricted")).await?; + + restart_perpetual_runs_on_new_version(&db, W_ID, path, "test-user").await; + + let queued: Vec<(Uuid, Option)> = sqlx::query_as( + "SELECT q.id, q.canceled_reason FROM v2_job_queue q JOIN v2_job j USING (id) \ + WHERE j.workspace_id = $1 AND j.runnable_path = $2", + ) + .bind(W_ID) + .bind(path) + .fetch_all(&db) + .await?; + assert_eq!( + queued, + vec![(running, None)], + "a tag the run's identity may not use leaves it as it is" + ); + Ok(()) +} + +/// A `$args[...]` tag names a worker group only once the run's arguments fill it in, so it is +/// checked the way a push checks one rather than as the literal the version carries. +#[sqlx::test(fixtures("base"))] +async fn a_deployed_dynamic_tag_is_checked_against_what_it_resolves_to( + db: Pool, +) -> anyhow::Result<()> { + use windmill_common::worker::{CustomTags, CUSTOM_TAGS_PER_WORKSPACE}; + + let path = "u/test-user/dynamic-tag"; + insert_version(&db, path, 701, 60.0, true).await?; + // Runs as the fixture's user who is no superadmin, so only an allowed tag moves it. + let running = start_run_as(&db, path, 701, "test-user-2", "test2@windmill.dev").await?; + sqlx::query("UPDATE v2_job SET args = '{\"region\": \"eu\"}'::jsonb WHERE id = $1") + .bind(running) + .execute(&db) + .await?; + insert_tagged_version(&db, path, 702, 0.0, true, Some("$args[region]")).await?; + CUSTOM_TAGS_PER_WORKSPACE.store(std::sync::Arc::new(CustomTags::from( + vec!["eu".to_string()], + ))); + + restart_perpetual_runs_on_new_version(&db, W_ID, path, "test-user").await; + + let replacement: Option = sqlx::query_scalar( + "SELECT j.tag FROM v2_job j JOIN v2_job_queue q USING (id) \ + WHERE j.workspace_id = $1 AND j.runnable_path = $2 AND j.id <> $3 \ + AND q.canceled_by IS NULL", + ) + .bind(W_ID) + .bind(path) + .bind(running) + .fetch_optional(&db) + .await?; + CUSTOM_TAGS_PER_WORKSPACE.store(std::sync::Arc::new(CustomTags::default())); + assert_eq!( + replacement.as_deref(), + Some("eu"), + "the run moves, on the tag its arguments resolve to" + ); + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn a_deploy_that_stops_looping_leaves_the_runs_alone( + db: Pool, +) -> anyhow::Result<()> { + let path = "u/test-user/untouched"; + insert_version(&db, path, 201, 60.0, true).await?; + let running = start_run(&db, path, 201).await?; + insert_version(&db, path, 202, 0.0, false).await?; + + restart_perpetual_runs_on_new_version(&db, W_ID, path, "test-user").await; + + let queued: Vec<(Uuid, Option)> = sqlx::query_as( + "SELECT q.id, q.canceled_reason FROM v2_job_queue q JOIN v2_job j USING (id) \ + WHERE j.workspace_id = $1 AND j.runnable_path = $2", + ) + .bind(W_ID) + .bind(path) + .fetch_all(&db) + .await?; + assert_eq!( + queued, + vec![(running, None)], + "turning perpetual off leaves the run as it is" + ); + Ok(()) +} diff --git a/backend/tests/preview_restart_source_access.rs b/backend/tests/preview_restart_source_access.rs new file mode 100644 index 0000000000..b6a552af3d --- /dev/null +++ b/backend/tests/preview_restart_source_access.rs @@ -0,0 +1,77 @@ +//! A flow preview restarted from an earlier run copies that run's step results, so it must be +//! refused unless the caller can see every run the request names. + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_common::jobs::JobPayload; +use windmill_test_utils::*; + +fn identity_flow() -> serde_json::Value { + json!({ "modules": [ + { "id": "a", "value": { "type": "identity" } }, + { "id": "b", "value": { "type": "identity" } }, + ]}) +} + +async fn completed_run(db: &Pool, port: u16, username: &str, email: &str) -> String { + RunJob::from(JobPayload::RawFlow { + value: serde_json::from_value(identity_flow()).unwrap(), + path: None, + restarted_from: None, + }) + .as_user(username, email) + .run_until_complete(db, false, port) + .await + .id + .to_string() +} + +#[sqlx::test(fixtures("base"))] +async fn test_preview_restart_requires_source_read_access(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let admin_run = completed_run(&db, port, "test-user", "test@windmill.dev").await; + let own_run = completed_run(&db, port, "test-user-2", "test2@windmill.dev").await; + + let restart = |restarted_from: serde_json::Value| { + reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/jobs/run/preview_flow" + )) + .bearer_auth("SECRET_TOKEN_2") + .json( + &json!({ "args": {}, "value": identity_flow(), "restarted_from": restarted_from }), + ) + .send() + }; + + let resp = restart(json!({ "flow_job_id": admin_run, "step_id": "b" })).await?; + assert_eq!(resp.status(), 403, "{}", resp.text().await?); + + let resp = restart(json!({ + "flow_job_id": own_run, "step_id": "b", + "nested": { "flow_job_id": admin_run, "step_id": "b" }, + })) + .await?; + assert_eq!(resp.status(), 403, "{}", resp.text().await?); + + let resp = restart(json!({ "flow_job_id": own_run, "step_id": "b" })).await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + + // A run hidden on its own but inside a flow the caller can read is readable, as on the run page. + let child_run = completed_run(&db, port, "test-user", "test@windmill.dev").await; + sqlx::query("UPDATE v2_job SET parent_job = $1::uuid WHERE id = $2::uuid") + .bind(&own_run) + .bind(&child_run) + .execute(&db) + .await?; + let resp = restart(json!({ + "flow_job_id": own_run, "step_id": "b", + "nested": { "flow_job_id": child_run, "step_id": "b" }, + })) + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + Ok(()) +} diff --git a/backend/tests/python_jobs.rs b/backend/tests/python_jobs.rs index b7aa671b21..9616df267a 100644 --- a/backend/tests/python_jobs.rs +++ b/backend/tests/python_jobs.rs @@ -465,6 +465,37 @@ def main(x: int): ); } + #[test] + fn test_python_execd_async() { + let script = r#" +import asyncio + +_import_loop = asyncio.new_event_loop() +asyncio.set_event_loop(_import_loop) + +async def preprocessor(x: int): + return {"x": x * 2} + +async def main(x: int): + return {"x": x + 100, "same_loop": asyncio.get_running_loop() is _import_loop} +"#; + let results = run_py_raw_protocol_test( + &[("f/test/async", script)], + vec![ + ProtocolCmd::ExecdPreprocess { args: serde_json::json!({"x": 5}) }, + ProtocolCmd::Execd { args: serde_json::json!({"x": 7}) }, + ], + ); + assert_eq!( + results, + vec![ + DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 10})), + DedicatedWorkerResult::Success(serde_json::json!({"x": 110, "same_loop": true})), + DedicatedWorkerResult::Success(serde_json::json!({"x": 107, "same_loop": true})), + ] + ); + } + // ==================== Argument Transformation Tests ==================== #[test] @@ -760,6 +791,49 @@ def main(): Ok(()) } +#[cfg(feature = "python")] +#[sqlx::test(fixtures("base"))] +async fn test_python_async_main(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let content = r#" +import asyncio + +async def main(x: int): + await asyncio.sleep(0) + return x + 1 + "# + .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 = RunJob::from(job) + .arg("x", serde_json::json!(41)) + .run_until_complete(&db, false, port) + .await + .json_result() + .unwrap(); + + assert_eq!(result, serde_json::json!(42)); + Ok(()) +} + #[cfg(feature = "python")] #[sqlx::test(fixtures("base"))] async fn test_python_result_preserves_infinity_in_string(db: Pool) -> anyhow::Result<()> { diff --git a/backend/tests/suspend_resume.rs b/backend/tests/suspend_resume.rs index d112ca0b96..6fb5d68004 100644 --- a/backend/tests/suspend_resume.rs +++ b/backend/tests/suspend_resume.rs @@ -961,4 +961,118 @@ mod suspend_resume { assert_eq!(result, json!(42)); Ok(()) } + + /// Approvals that arrive concurrently for a step waiting on several events must each + /// decrement the suspend counter; a lost decrement parks the flow until its timeout. + #[cfg(feature = "deno_core")] + #[sqlx::test(fixtures("base"))] + async fn concurrent_approvals_all_decrement_suspend(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + const APPROVALS: u32 = 8; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let flow: FlowValue = serde_json::from_value(json!({ + "modules": [ + { + "id": "a", + "suspend": { "required_events": APPROVALS, "timeout": 86400 }, + "value": { + "type": "rawscript", + "language": "deno", + "content": "export async function main() { return 1 }", + "input_transforms": {}, + }, + }, + { + "id": "b", + "value": { + "type": "rawscript", + "language": "deno", + "content": "export function main(resumes) { return resumes.length }", + "input_transforms": { + "resumes": { "type": "javascript", "expr": "resumes" }, + }, + }, + }, + ], + })) + .unwrap(); + + let flow = + RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None }) + .push(&db) + .await; + + let completed = listen_for_completed_jobs(&db).await; + let queue = listen_for_queue(&db).await; + let db_ = db.clone(); + + in_test_worker( + &db, + async move { + let db = db_; + wait_until_flow_suspends(flow, queue, &db).await; + + let step: Uuid = sqlx::query_scalar( + "SELECT (flow_status->'modules'->0->>'job')::uuid \ + FROM v2_job_status WHERE id = $1", + ) + .bind(flow) + .fetch_one(&db) + .await + .unwrap(); + + let token = windmill_common::auth::create_token_for_owner( + &db, "test-workspace", "u/test-user", "", 100, "", &Uuid::nil(), None, None, + ) + .await + .unwrap(); + let client = reqwest::Client::new(); + let mut urls = vec![]; + for resume_id in 0..APPROVALS { + let secret = client + .get(format!( + "http://localhost:{port}/api/w/test-workspace/jobs/job_signature/{step}/{resume_id}?token={token}" + )) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .text() + .await + .unwrap(); + urls.push(format!( + "http://localhost:{port}/api/w/test-workspace/jobs_u/resume/{step}/{resume_id}/{secret}" + )); + } + + let responses = futures::future::join_all( + urls.iter().map(|url| client.post(url).json(&json!(null)).send()), + ) + .await; + for r in responses { + r.unwrap().error_for_status().unwrap(); + } + + tokio::time::timeout( + std::time::Duration::from_secs(30), + completed.find(&flow), + ) + .await + .expect("flow stayed suspended after every approval was recorded") + .unwrap(); + }, + port, + ) + .await; + + server.close().await.unwrap(); + + let result = completed_job(flow, &db).await.json_result().unwrap(); + assert_eq!(result, json!(APPROVALS)); + Ok(()) + } } diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index d7fb992280..e00ce1172b 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -4871,6 +4871,18 @@ async fn test_script_schedule_handlers(db: Pool) -> anyhow::Result<()> "a script was run after main job execution but was not schedule error handler" ); } + + let (handler_permissioned_as, schedule_permissioned_as): (String, String) = + sqlx::query_as( + "SELECT j.permissioned_as, s.permissioned_as FROM v2_job j, schedule s + WHERE j.id = $1 AND s.workspace_id = j.workspace_id + AND s.path = 'f/system/failing_script_schedule'", + ) + .bind(uuid) + .fetch_one(&db2) + .await + .unwrap(); + assert_eq!(handler_permissioned_as, schedule_permissioned_as); }, port, ) @@ -5786,7 +5798,10 @@ async fn test_scoped_custom_tag_pattern_admission(db: Pool) -> anyhow: "scoped pattern refused elsewhere", !allowed(&db, "other", "cpu-secret", "").await, ), - ("confined tag refused past the patterns it fits", confined.is_err()), + ( + "confined tag refused past the patterns it fits", + confined.is_err(), + ), ( "global pattern for the tags nothing confines", allowed(&db, "test-workspace", "gpu-large", "").await, diff --git a/backend/tests/worker_ping_ip.rs b/backend/tests/worker_ping_ip.rs index 8f98398b40..772f4193c3 100644 --- a/backend/tests/worker_ping_ip.rs +++ b/backend/tests/worker_ping_ip.rs @@ -1,5 +1,9 @@ use sqlx::{Pool, Postgres}; -use windmill_common::{external_ip::UNKNOWN_IP, worker::insert_ping_query}; +use uuid::Uuid; +use windmill_common::{ + external_ip::UNKNOWN_IP, + worker::{insert_ping_query, update_worker_ping_main_loop_query}, +}; async fn insert_ping(db: &Pool, worker: &str, ip: Option<&str>) -> anyhow::Result<()> { insert_ping_query( @@ -42,3 +46,47 @@ async fn unresolved_ip_keeps_the_reclaimed_rows_address(db: Pool) -> a assert_eq!(ip, UNKNOWN_IP); Ok(()) } + +async fn main_loop_ping( + db: &Pool, + worker: &str, + last_job: Option<(Uuid, &str)>, +) -> anyhow::Result<()> { + update_worker_ping_main_loop_query( + worker, + &[], + None, + None, + Some(1), + None, + None, + None, + None, + None, + None, + false, + None, + last_job, + db, + ) + .await +} + +/// The main-loop ping sends a job only once, so most pings carry none. Those must keep the last +/// job the row holds, whether this ping or the job poller wrote it. +#[sqlx::test] +async fn main_loop_ping_without_a_job_keeps_the_last_one(db: Pool) -> anyhow::Result<()> { + insert_ping(&db, "wk-last-job", None).await?; + let job_id = Uuid::new_v4(); + main_loop_ping(&db, "wk-last-job", Some((job_id, "admins"))).await?; + main_loop_ping(&db, "wk-last-job", None).await?; + let (current_job_id, workspace_id): (Option, Option) = sqlx::query_as( + "SELECT current_job_id, current_job_workspace_id FROM worker_ping WHERE worker = $1", + ) + .bind("wk-last-job") + .fetch_one(&db) + .await?; + assert_eq!(current_job_id, Some(job_id)); + assert_eq!(workspace_id.as_deref(), Some("admins")); + Ok(()) +} diff --git a/backend/windmill-ai/src/ai_google.rs b/backend/windmill-ai/src/ai_google.rs index b90bda9e1c..bb8ae83e13 100644 --- a/backend/windmill-ai/src/ai_google.rs +++ b/backend/windmill-ai/src/ai_google.rs @@ -303,7 +303,7 @@ pub struct GeminiUsageMetadata { /// 60 tool-use + 17 candidates + 52 thoughts against a `totalTokenCount` of 146, so /// leaving them out under-reports the input of every tool-using turn. Cached tokens /// are not added here, being already part of `promptTokenCount`. -fn gemini_prompt_tokens(usage: &GeminiUsageMetadata) -> i32 { +pub(crate) fn gemini_prompt_tokens(usage: &GeminiUsageMetadata) -> i32 { usage .prompt_token_count .unwrap_or(0) @@ -313,7 +313,7 @@ fn gemini_prompt_tokens(usage: &GeminiUsageMetadata) -> i32 { /// Output tokens as billed: Gemini counts thinking apart from `candidatesTokenCount` /// but charges it at the output rate, so a reply that thought would otherwise be /// reported as far cheaper than it was. -fn gemini_completion_tokens(usage: &GeminiUsageMetadata) -> i32 { +pub(crate) fn gemini_completion_tokens(usage: &GeminiUsageMetadata) -> i32 { usage .candidates_token_count .unwrap_or(0) @@ -1041,7 +1041,10 @@ mod tests { // through unchanged and the cached share is reported alongside it; thinking // is billed as output but counted apart from the candidates. assert_eq!(usage_chunk["usage"]["prompt_tokens"], 1000); - assert_eq!(usage_chunk["usage"]["prompt_tokens_details"]["cached_tokens"], 900); + assert_eq!( + usage_chunk["usage"]["prompt_tokens_details"]["cached_tokens"], + 900 + ); assert_eq!(usage_chunk["usage"]["completion_tokens"], 120); } diff --git a/backend/windmill-ai/src/lib.rs b/backend/windmill-ai/src/lib.rs index b6487c0ac5..e81d030ed3 100644 --- a/backend/windmill-ai/src/lib.rs +++ b/backend/windmill-ai/src/lib.rs @@ -6,6 +6,7 @@ pub mod ai_providers; pub mod ai_types; pub mod credentials; pub mod image_handler; +pub mod model_context; pub mod providers; pub mod proxy; pub mod query_builder; diff --git a/backend/windmill-ai/src/model_context.rs b/backend/windmill-ai/src/model_context.rs new file mode 100644 index 0000000000..4b7b8fd68d --- /dev/null +++ b/backend/windmill-ai/src/model_context.rs @@ -0,0 +1,165 @@ +//! What a model's context window holds, for the code that has to keep a conversation +//! inside it. + +/// Assumed window for a model that is not in the table. Conservative on purpose: a +/// guess that is too small only compacts earlier, which is recoverable, while one that +/// is too large overflows and the provider raises the context error itself. +pub const DEFAULT_CONTEXT_WINDOW: usize = 128_000; + +/// Context windows of the models we know, most specific entry first — the first name +/// found in the bare model id wins, so vendor-namespaced and date-suffixed ids +/// (`anthropic.claude-sonnet-4-6-…-v1:0`, `gpt-5.2-2026-01-01`) still resolve. +/// Conservative family fallbacks sit below the explicit entries. +/// +/// **Mirrors `MODEL_CONTEXT_WINDOWS` in +/// `frontend/src/lib/components/copilot/modelConfig.ts`**, which the AI session's own +/// compaction reads. Add a model to one and it must go in the other, or the same model +/// compacts at one size in a chat and another in an agent step. +const MODEL_CONTEXT_WINDOWS: &[(&str, usize)] = &[ + // Anthropic — Sonnet/Opus 4.6+ ship a 1M window at standard pricing (GA); + // Haiku, older Claude models (3.x, 4.0, 4.1, 4.5) and date-suffixed Claude 4 + // base ids (claude-sonnet-4-20250514) fall through to 200K + ("claude-fable-5", 1_000_000), + ("claude-mythos-5", 1_000_000), + ("claude-opus-5", 1_000_000), + ("claude-sonnet-5", 1_000_000), + ("claude-opus-4-8", 1_000_000), + ("claude-opus-4-7", 1_000_000), + ("claude-opus-4-6", 1_000_000), + ("claude-sonnet-4-6", 1_000_000), + ("claude", 200_000), + // OpenAI — gpt-5 covers the base family (-mini / -nano) and the 5.1/5.2 + // revisions, all 400K; 5.4/5.5 moved to 1M and 5.6 to 1.05M + ("gpt-5-6", 1_050_000), + ("gpt-5-5", 1_000_000), + ("gpt-5-4", 1_000_000), + ("gpt-5", 400_000), + ("gpt-4-1", 1_000_000), + ("gpt-4o", 128_000), + ("o4-mini", 200_000), + ("o3", 200_000), + // Google — the 2.5 / 3 / 3.1 Gemini families are all 1M + ("gemini-3-1", 1_000_000), + ("gemini-3", 1_000_000), + ("gemini-2-5", 1_000_000), + // DeepSeek — the V4 family (pro / flash) is 1M. The deepseek-chat / + // deepseek-reasoner aliases were retired 2026-07-24 but can still sit in a + // saved selection, so they keep resolving to the window they had. + ("deepseek-v4", 1_000_000), + ("deepseek-chat", 1_000_000), + ("deepseek-reasoner", 1_000_000), + ("deepseek", 128_000), + // Alibaba — Qwen3-Max is 256K. No qwen family fallback: variant windows range + // from 8K (character models) to 1M, too wide for even a conservative guess + ("qwen3-max", 256_000), + // Others — Mistral Medium 3.5 is 256K, reachable under both its version and + // the `-latest` alias. There is deliberately no `mistral-medium` family row: + // pinned older snapshots are 128K, and over-claiming a window overflows it. + ("mistral-medium-3-5", 256_000), + ("mistral-medium-latest", 256_000), + ("llama", 128_000), + ("codestral", 32_000), +]; + +/// The bare model id an entry is matched against: lowercased, the leading `~` and any +/// vendor path segment dropped, the `:`-suffixed route removed, and `.` collapsed to +/// `-`. Version separators differ by route to the same model — Anthropic writes +/// `claude-opus-4-8`, OpenRouter `anthropic/claude-opus-4.8` — so collapsing both sides +/// keeps one entry covering every route. +fn bare_model_id(model: &str) -> String { + let normalized = model.trim().to_lowercase(); + let normalized = normalized.strip_prefix('~').unwrap_or(&normalized); + let last = normalized.rsplit('/').next().unwrap_or(normalized); + let base = match last.find(':') { + Some(0) | None => last, + Some(colon) => &last[..colon], + }; + base.replace('.', "-") +} + +/// Whether this is one of OpenAI's reasoning models (gpt-5 and later, the o-series), +/// which think by default. Mirrors `requiresMaxCompletionTokens` in the frontend's +/// `modelConfig.ts`: the o-series match wants a digit after the `o` so that ids such +/// as `open-mistral-*` stay out. +pub fn is_openai_reasoning_model(model: &str) -> bool { + let id = bare_model_id(model); + id.starts_with("gpt-5") + || (id.starts_with('o') && id[1..].starts_with(|c: char| c.is_ascii_digit())) +} + +/// The window this model is known to hold, `None` for one not in the table. +fn known_model_context_window(model: &str) -> Option { + let id = bare_model_id(model); + MODEL_CONTEXT_WINDOWS + .iter() + .find(|(name, _)| matches_entry(&id, name)) + .map(|(_, window)| *window) +} + +/// The window to plan against: the model's where it is known, the conservative +/// assumption otherwise. +pub fn model_context_window(model: &str) -> usize { + known_model_context_window(model).unwrap_or(DEFAULT_CONTEXT_WINDOW) +} + +/// An entry matches anywhere in the id, so a vendor prefix or a date suffix does not +/// hide it. One ending on a version digit must not run into a longer version: +/// `gpt-4-1` would otherwise claim `gpt-4-1106-preview`. A family fallback ending on a +/// letter gets no such guard — a version welded straight onto the name (`llama3-1`) is +/// what it exists to catch. +fn matches_entry(id: &str, name: &str) -> bool { + let guard_digits = name.ends_with(|c: char| c.is_ascii_digit()); + id.match_indices(name).any(|(at, _)| { + !guard_digits + || !id[at + name.len()..] + .chars() + .next() + .is_some_and(|c| c.is_ascii_digit()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// One entry has to cover every route to the same model: the vendor path and dot + /// versions OpenRouter uses, Bedrock's prefixed and `-v1:0`-suffixed ids, and the + /// date suffixes the providers' own ids carry. + #[test] + fn every_route_to_a_model_resolves_to_one_window() { + for id in [ + "claude-opus-4-8", + "anthropic/claude-opus-4.8", + "~anthropic/claude-opus-4.8", + "anthropic.claude-opus-4-8-20260101-v1:0", + ] { + assert_eq!(known_model_context_window(id), Some(1_000_000), "{id}"); + } + // Falls through the explicit rows to the family fallback. + assert_eq!( + known_model_context_window("claude-3-5-haiku"), + Some(200_000) + ); + } + + /// The guard that keeps an entry ending on a version digit from claiming a longer + /// version of the same family. + #[test] + fn a_version_entry_does_not_claim_a_longer_version() { + assert_eq!(known_model_context_window("gpt-4.1"), Some(1_000_000)); + assert_eq!(known_model_context_window("gpt-4-1106-preview"), None); + assert_eq!(known_model_context_window("gpt-5-mini"), Some(400_000)); + assert_eq!(known_model_context_window("gpt-5.6"), Some(1_050_000)); + } + + /// An unknown model still gets a number, so nothing downstream has to carry a + /// "no limit" case that would let a conversation grow unbounded. + #[test] + fn an_unknown_model_falls_back_to_the_assumed_window() { + assert_eq!(known_model_context_window("some-local-llm"), None); + assert_eq!( + model_context_window("some-local-llm"), + DEFAULT_CONTEXT_WINDOW + ); + } +} diff --git a/backend/windmill-ai/src/providers/anthropic.rs b/backend/windmill-ai/src/providers/anthropic.rs index d274d91fa2..7acd2db939 100644 --- a/backend/windmill-ai/src/providers/anthropic.rs +++ b/backend/windmill-ai/src/providers/anthropic.rs @@ -782,7 +782,7 @@ impl QueryBuilder for AnthropicQueryBuilder { // Convert Anthropic usage to TokenUsage let usage = anthropic_usage.map(|u| { TokenUsage::from_input_output(u.input_tokens, u.output_tokens) - .with_cache(u.cache_read_input_tokens, u.cache_creation_input_tokens) + .with_cache_beside_input(u.cache_read_input_tokens, u.cache_creation_input_tokens) }); Ok(ParsedResponse::Text { diff --git a/backend/windmill-ai/src/providers/bedrock.rs b/backend/windmill-ai/src/providers/bedrock.rs index 412e057396..836244a666 100644 --- a/backend/windmill-ai/src/providers/bedrock.rs +++ b/backend/windmill-ai/src/providers/bedrock.rs @@ -1148,7 +1148,7 @@ impl BedrockQueryBuilder { Some(token_usage.output_tokens()), Some(token_usage.total_tokens()), ) - .with_cache( + .with_cache_beside_input( token_usage .cache_read_input_tokens() .map(|v| i32::try_from(v).unwrap_or(i32::MAX)), diff --git a/backend/windmill-ai/src/providers/google_ai.rs b/backend/windmill-ai/src/providers/google_ai.rs index ce37e45df7..97d0383ba4 100644 --- a/backend/windmill-ai/src/providers/google_ai.rs +++ b/backend/windmill-ai/src/providers/google_ai.rs @@ -1,10 +1,11 @@ use crate::{ ai_google::{ - gemini_event_to_openai_sse_chunks, gemini_response_to_openai, openai_messages_to_gemini, - openai_tools_to_gemini, parse_gemini_response, parse_gemini_sse_event, - sanitize_schema_for_google, GeminiFunctionDeclaration, GeminiGenerationConfig, - GeminiImageContent, GeminiImageRequest, GeminiImageResponse, GeminiInlineData, GeminiPart, - GeminiPredictContent, GeminiTextRequest, GeminiThinkingConfig, GeminiTool, + gemini_completion_tokens, gemini_event_to_openai_sse_chunks, gemini_prompt_tokens, + gemini_response_to_openai, openai_messages_to_gemini, openai_tools_to_gemini, + parse_gemini_response, parse_gemini_sse_event, sanitize_schema_for_google, + GeminiFunctionDeclaration, GeminiGenerationConfig, GeminiImageContent, GeminiImageRequest, + GeminiImageResponse, GeminiInlineData, GeminiPart, GeminiPredictContent, GeminiTextRequest, + GeminiThinkingConfig, GeminiTool, }, image_handler::{download_and_encode_s3_image, prepare_messages_for_api}, proxy::{ProxyBuildArgs, ProxyRequest}, @@ -685,12 +686,32 @@ impl QueryBuilder for GoogleAIQueryBuilder { stream_event_processor.send(event, &mut events_str).await?; } - let usage = gemini_usage.map(|u| { - TokenUsage::new( - u.prompt_token_count, - u.candidates_token_count, - u.total_token_count, - ) + // Through the shared helpers, which fold in the two counts Gemini reports beside + // the headline ones: tool-use prompt tokens, disjoint from `promptTokenCount` and + // present on every tool-using turn, and thinking tokens. Reading the headline + // fields alone under-reports the prompt of exactly the conversations compaction + // has to notice. + let usage = gemini_usage.and_then(|u| { + let has_prompt = + u.prompt_token_count.is_some() || u.tool_use_prompt_token_count.is_some(); + let has_completion = + u.candidates_token_count.is_some() || u.thoughts_token_count.is_some(); + if !has_prompt && !has_completion && u.total_token_count.is_none() { + // An endpoint that reported nothing has to stay `None`. A zeroed count + // reads downstream as an empty prompt, where absent means "fall back to + // estimating the conversation". + return None; + } + let prompt = has_prompt.then(|| gemini_prompt_tokens(&u)); + let completion = has_completion.then(|| gemini_completion_tokens(&u)); + Some(TokenUsage::new( + prompt, + completion, + u.total_token_count.or_else(|| match (prompt, completion) { + (Some(p), Some(c)) => Some(p.saturating_add(c)), + _ => None, + }), + )) }); Ok(ParsedResponse::Text { @@ -1014,4 +1035,22 @@ mod tests { .headers .contains(&("x-goog-api-key".to_string(), "api-key".to_string()))); } + + /// Gemini reports a tool-using turn's input across two disjoint fields. Reading only + /// the headline one hides the tool results from the agent step's usage — and from the + /// compaction trigger, which is what would notice them. + #[test] + fn gemini_agent_usage_counts_tool_use_and_thinking_tokens() { + let usage = crate::ai_google::GeminiUsageMetadata { + prompt_token_count: Some(17), + candidates_token_count: Some(17), + total_token_count: Some(146), + tool_use_prompt_token_count: Some(60), + thoughts_token_count: Some(52), + ..Default::default() + }; + + assert_eq!(gemini_prompt_tokens(&usage), 77); + assert_eq!(gemini_completion_tokens(&usage), 69); + } } diff --git a/backend/windmill-ai/src/providers/mod.rs b/backend/windmill-ai/src/providers/mod.rs index 908f007978..b5b6281264 100644 --- a/backend/windmill-ai/src/providers/mod.rs +++ b/backend/windmill-ai/src/providers/mod.rs @@ -14,6 +14,130 @@ use std::time::{Duration, Instant}; /// its `thinking` param, Gemini to a zero budget or the model's floor). pub(crate) const REASONING_OFF_SENTINEL: &str = "none"; +/// The effort to send for a model, dropping the off sentinel on a model that rejects every +/// disable: the model then reasons at its default instead of failing the request. The UI +/// never offers off on these, so this guards an agent step saved before it stopped, or an +/// effort passed in as a flow input. +pub fn effective_reasoning_effort<'a>(model: &str, effort: Option<&'a str>) -> Option<&'a str> { + match effort { + Some(effort) + if effort == REASONING_OFF_SENTINEL + && reasoning_rule(model).is_some_and(|rule| !rule.can_disable) => + { + None + } + effort => effort, + } +} + +/// Whether a request with function tools must send the off sentinel on Chat Completions: +/// the model refuses tools there while it reasons, and does accept being turned off. +pub(crate) fn completions_tools_need_reasoning_off(model: &str) -> bool { + reasoning_rule(model).is_some_and(|rule| rule.completions_tools_need_off && rule.can_disable) +} + +/// What the backend needs to know about a model's reasoning: the rows of `REASONING_RULES` +/// in the frontend's `reasoningRegistry.ts`, cut down to the two facts the wire needs. +/// `reasoningParity.json` next to that file is checked by both sides' tests. +struct ReasoningRule { + matches: fn(&str) -> bool, + /// False when the provider rejects every disable, so the off sentinel must not be sent. + can_disable: bool, + /// Chat Completions refuses function tools while the model reasons, even with the + /// effort omitted (live-verified); the Responses API has no such limit. + completions_tools_need_off: bool, +} + +/// Matched in order against the lowercased model id, first match wins. A model no row +/// matches keeps whatever effort it was given. +const REASONING_RULES: &[ReasoningRule] = &[ + // Live-verified: Fable, Mythos and the 5.x point releases reject `thinking: disabled`. + ReasoningRule { + matches: |m| m.contains("claude-fable") || m.contains("claude-mythos"), + can_disable: false, + completions_tools_need_off: false, + }, + ReasoningRule { + matches: is_claude_5_point_release, + can_disable: false, + completions_tools_need_off: false, + }, + // Live-verified: astra takes low..max only, where sol and luna also take `none`. + ReasoningRule { + matches: |m| base_id(m).starts_with("gpt-6-astra"), + can_disable: false, + completions_tools_need_off: true, + }, + ReasoningRule { + matches: |m| gpt_version(m).is_some_and(|(major, _)| major >= 6), + can_disable: true, + completions_tools_need_off: true, + }, + ReasoningRule { + matches: |m| matches!(gpt_version(m), Some((5, Some(minor))) if minor >= 5), + can_disable: true, + completions_tools_need_off: true, + }, + ReasoningRule { + matches: |m| matches!(gpt_version(m), Some((5, Some(_)))), + can_disable: true, + completions_tools_need_off: false, + }, + // gpt-5 and the o-series reject `none`. + ReasoningRule { + matches: |m| matches!(gpt_version(m), Some((5, None))), + can_disable: false, + completions_tools_need_off: false, + }, + ReasoningRule { + matches: |m| { + let base = base_id(m); + base.starts_with('o') && base[1..].starts_with(|c: char| c.is_ascii_digit()) + }, + can_disable: false, + completions_tools_need_off: false, + }, +]; + +fn reasoning_rule(model: &str) -> Option<&'static ReasoningRule> { + let model = model.to_lowercase(); + REASONING_RULES.iter().find(|rule| (rule.matches)(&model)) +} + +/// The id after a gateway's `vendor/` and before a `:variant`. +fn base_id(model: &str) -> &str { + let last = model.rsplit('/').next().unwrap_or(model); + last.split(':').next().unwrap_or(last) +} + +/// `(major, minor)` of a `gpt-` id. The major is one digit then a separator or the end, +/// since Azure names gpt-3.5 `gpt-35-turbo`. +fn gpt_version(model: &str) -> Option<(u32, Option)> { + let rest = base_id(model).strip_prefix("gpt-")?; + let mut chars = rest.chars(); + let major = chars.next()?.to_digit(10)?; + match chars.next() { + None | Some('-') => Some((major, None)), + Some('.') => { + let minor: String = chars.take_while(char::is_ascii_digit).collect(); + Some((major, minor.parse().ok())) + } + _ => None, + } +} + +/// Sonnet or Opus 5.x with x >= 1. The version match stops at one digit so a dated id +/// (`claude-sonnet-5-20260101`) stays Sonnet 5. +fn is_claude_5_point_release(model: &str) -> bool { + let model = model.replace('.', "-"); + ["claude-opus-5-", "claude-sonnet-5-"].iter().any(|prefix| { + model.split(prefix).skip(1).any(|rest| { + let mut chars = rest.chars(); + matches!(chars.next(), Some('1'..='9')) && !matches!(chars.next(), Some('0'..='9')) + }) + }) +} + /// Whether a Claude model removed the sampling params (`temperature`, `top_p`, /// `top_k`). On these, any value is a hard 400 — `temperature is deprecated for /// this model` — whatever the thinking mode, so the param has to be dropped on @@ -119,6 +243,46 @@ pub fn remember_chat_completions_only(base_url: &str, model: &str) { CHAT_COMPLETIONS_ONLY.insert((base_url.to_string(), model.to_string()), Instant::now()); } +#[cfg(test)] +mod reasoning_rule_tests { + use super::*; + + /// The frontend registry's test reads the same file. These rules see the model id + /// alone, so it holds only rows whose answer doesn't depend on the provider. + #[test] + fn agrees_with_the_frontend_registry() { + let rows: Vec = serde_json::from_str(include_str!( + "../../../../frontend/src/lib/components/copilot/reasoningParity.json" + )) + .unwrap(); + for row in rows { + let model = row["model"].as_str().unwrap(); + let can_disable = row["canDisable"].as_bool().unwrap(); + let tools_need_off = row["completionsToolsNeedOff"].as_bool().unwrap(); + let sent = effective_reasoning_effort(model, Some(REASONING_OFF_SENTINEL)); + assert_eq!(sent.is_some(), can_disable, "{model}"); + assert_eq!( + effective_reasoning_effort(model, Some("low")), + Some("low"), + "{model}" + ); + assert_eq!( + completions_tools_need_reasoning_off(model), + tools_need_off && can_disable, + "{model}" + ); + } + } + + #[test] + fn reads_ids_the_frontend_resolves_first() { + // Azure's gpt-3.5 is not major 35, and a gateway prefix is not part of the id. + assert_eq!(gpt_version("gpt-35-turbo"), None); + assert!(completions_tools_need_reasoning_off("openai/gpt-6-sol")); + assert_eq!(effective_reasoning_effort("openai/o3", Some("none")), None); + } +} + #[cfg(test)] mod chat_completions_only_tests { use super::*; diff --git a/backend/windmill-ai/src/providers/other.rs b/backend/windmill-ai/src/providers/other.rs index b6040d8d79..1ea54a7e79 100644 --- a/backend/windmill-ai/src/providers/other.rs +++ b/backend/windmill-ai/src/providers/other.rs @@ -1,3 +1,4 @@ +use super::{completions_tools_need_reasoning_off, REASONING_OFF_SENTINEL}; use crate::{ ai_providers::AIProvider, image_handler::prepare_messages_for_api, @@ -143,6 +144,15 @@ impl OtherQueryBuilder { let (reasoning_effort, thinking, temperature) = provider_reasoning_fields(&self.provider_kind, args.reasoning_effort, args.temperature); + // With tools, gpt-5.5+ only run here with reasoning off: turn it off where the model + // can, rather than failing every turn. + let reasoning_effort = if args.tools.is_some_and(|tools| !tools.is_empty()) + && completions_tools_need_reasoning_off(args.model) + { + Some(REASONING_OFF_SENTINEL) + } else { + reasoning_effort + }; // Build request with stream_options for usage tracking let request_with_usage = OpenAICompletionRequest { diff --git a/backend/windmill-ai/src/query_builder.rs b/backend/windmill-ai/src/query_builder.rs index 3281b51cd0..986c433f42 100644 --- a/backend/windmill-ai/src/query_builder.rs +++ b/backend/windmill-ai/src/query_builder.rs @@ -6,6 +6,49 @@ use crate::ai_types::OpenAIToolCall; use crate::proxy::{ProxyBuildArgs, ProxyRequest}; use crate::types::*; +/// Only explicit input-context rejections warrant dropping conversation history. +/// Rate limits, output-token limits and generic validation errors must propagate. +pub fn is_context_length_error(message: &str) -> bool { + let message = message.to_ascii_lowercase(); + message.contains("context_length_exceeded") + || message.contains("maximum context length") + || message.contains("prompt is too long") + || message.contains("exceed context limit") + || message.contains("input is too long for requested model") + || (message.contains("input token count") && message.contains("exceeds the maximum")) + || message.contains("too many input tokens") +} + +#[cfg(test)] +mod context_error_tests { + use super::is_context_length_error; + + #[test] + fn recognizes_context_rejections_without_retrying_other_provider_errors() { + for message in [ + r#"{"error":{"code":"context_length_exceeded"}}"#, + "This model's maximum context length is 128000 tokens", + "prompt is too long: 210000 tokens > 200000 maximum", + "input length and `max_tokens` exceed context limit: 199000 + 8192 > 200000", + "The input token count (10000) exceeds the maximum number of tokens allowed (8192)", + "ValidationException: Input is too long for requested model.", + "ValidationException: Too many input tokens", + ] { + assert!(is_context_length_error(message), "{message}"); + } + for message in [ + "Rate limit exceeded: tokens per minute", + "max_tokens exceeds the maximum output tokens", + "Invalid tool schema", + "Additional properties are not allowed: stream_options", + "Request body too large", + "Internal server error", + ] { + assert!(!is_context_length_error(message), "{message}"); + } + } +} + /// Arguments for building an AI request pub struct BuildRequestArgs<'a> { pub messages: &'a [OpenAIMessage], diff --git a/backend/windmill-ai/src/sse.rs b/backend/windmill-ai/src/sse.rs index 57acd66738..ada50afca5 100644 --- a/backend/windmill-ai/src/sse.rs +++ b/backend/windmill-ai/src/sse.rs @@ -304,7 +304,16 @@ pub enum AnthropicDelta { Unknown, } -/// Anthropic usage information from message_delta event +/// The `message` envelope of a `message_start` event. Only its usage is read: the +/// prompt-side counts appear here and nowhere else in the stream. +#[derive(Deserialize, Debug)] +pub struct AnthropicStreamMessage { + #[serde(default)] + pub usage: Option, +} + +/// Anthropic usage information, reported across `message_start` (prompt side) and +/// `message_delta` (completion side) #[derive(Deserialize, Debug, Clone)] pub struct AnthropicUsage { #[serde(default)] @@ -322,7 +331,10 @@ pub struct AnthropicUsage { #[serde(tag = "type")] pub enum AnthropicSSEEvent { #[serde(rename = "message_start")] - MessageStart {}, + MessageStart { + #[serde(default)] + message: Option, + }, #[serde(rename = "content_block_start")] ContentBlockStart { index: usize, content_block: AnthropicContentBlockStart }, #[serde(rename = "content_block_delta")] @@ -371,7 +383,8 @@ pub struct AnthropicSSEParser { pub annotations: Vec, /// Whether web search was used in this response pub used_websearch: bool, - /// Token usage from message_delta event + /// Token usage, merged from the `message_start` (prompt side) and `message_delta` + /// (completion side) events pub usage: Option, /// Claude thinking block accumulated from `thinking`/`signature` deltas /// (or a redacted block). Attached to the first tool call of the turn so it @@ -574,14 +587,35 @@ impl SSEParser for AnthropicSSEParser { let error_msg = message.unwrap_or_else(|| "Unknown error".to_string()); tracing::error!("Anthropic streaming error: {}", error_msg); } + AnthropicSSEEvent::MessageStart { message } => { + // The only event carrying the prompt-side counts. `message_delta` + // reports the completion, so dropping this one leaves the request + // with no input token count at all. + if let Some(usage) = message.and_then(|message| message.usage) { + self.usage = Some(usage); + } + } AnthropicSSEEvent::MessageDelta { usage } => { if let Some(u) = usage { - self.usage = Some(u); + match &mut self.usage { + // Field by field, so the prompt counts from `message_start` + // survive a delta that only reports the completion. + Some(existing) => { + existing.input_tokens = u.input_tokens.or(existing.input_tokens); + existing.output_tokens = u.output_tokens.or(existing.output_tokens); + existing.cache_read_input_tokens = u + .cache_read_input_tokens + .or(existing.cache_read_input_tokens); + existing.cache_creation_input_tokens = u + .cache_creation_input_tokens + .or(existing.cache_creation_input_tokens); + } + None => self.usage = Some(u), + } } } // Ignore other events - AnthropicSSEEvent::MessageStart {} - | AnthropicSSEEvent::MessageStop {} + AnthropicSSEEvent::MessageStop {} | AnthropicSSEEvent::Ping {} | AnthropicSSEEvent::Unknown => {} } @@ -794,6 +828,11 @@ pub enum OpenAIResponsesSSEEvent { #[serde(rename = "response.completed")] Completed { response: OpenAIResponsesResponse }, + /// Response ended by `max_output_tokens` or a filter: the same object, with the + /// tokens spent so far, so it is billed the same + #[serde(rename = "response.incomplete")] + Incomplete { response: OpenAIResponsesResponse }, + /// Response created #[serde(rename = "response.created")] Created {}, @@ -969,8 +1008,8 @@ impl SSEParser for OpenAIResponsesSSEParser { }); } - OpenAIResponsesSSEEvent::Completed { response } => { - // Extract usage from response.completed event + OpenAIResponsesSSEEvent::Completed { response } + | OpenAIResponsesSSEEvent::Incomplete { response } => { if let Some(usage) = response.usage { self.usage = Some(usage); } @@ -1041,6 +1080,43 @@ mod tests { assert_eq!(token_usage.total_tokens, Some(4820)); } + struct NoopSink; + + #[async_trait::async_trait] + impl StreamEventSink for NoopSink { + async fn send( + &self, + _event: StreamingEvent, + _events_str: &mut String, + ) -> Result<(), Error> { + Ok(()) + } + } + + /// The prompt-side counts arrive only on `message_start` and the completion total + /// only on `message_delta`; a parser that keeps just the last one reports a request + /// with no input tokens at all. + #[tokio::test] + async fn anthropic_usage_merges_message_start_and_message_delta() { + let mut parser = AnthropicSSEParser::new(Box::new(NoopSink)); + parser + .parse_event_data( + r#"{"type":"message_start","message":{"id":"msg_1","role":"assistant","usage":{"input_tokens":4821,"output_tokens":1,"cache_read_input_tokens":4096,"cache_creation_input_tokens":128}}}"#, + ) + .await + .unwrap(); + parser + .parse_event_data(r#"{"type":"message_delta","usage":{"output_tokens":312}}"#) + .await + .unwrap(); + + let usage = parser.usage.expect("usage"); + assert_eq!(usage.input_tokens, Some(4821)); + assert_eq!(usage.output_tokens, Some(312)); + assert_eq!(usage.cache_read_input_tokens, Some(4096)); + assert_eq!(usage.cache_creation_input_tokens, Some(128)); + } + #[test] fn openai_responses_usage_maps_cached_input_tokens() { // Payload shape returned by the OpenAI Responses API. diff --git a/backend/windmill-ai/src/types.rs b/backend/windmill-ai/src/types.rs index ac65f637e2..7e2de09e01 100644 --- a/backend/windmill-ai/src/types.rs +++ b/backend/windmill-ai/src/types.rs @@ -82,6 +82,14 @@ pub enum Memory { #[serde(default, deserialize_with = "deserialize_null_as_zero")] context_length: usize, }, + Compaction { + /// Overrides the window looked up from the model. Only a step the lookup cannot + /// serve — a Custom AI deployment, a model id not in the table — needs one, and + /// a value larger than the model actually serves lets the conversation overflow + /// before the summary is ever taken. + #[serde(default, deserialize_with = "deserialize_null_as_zero")] + context_window: usize, + }, /// Written before `window`. Its `memory_id` stays a fallback behind the run's memory id. Auto { #[serde(default, deserialize_with = "deserialize_null_as_zero")] @@ -286,7 +294,10 @@ impl ProviderWithResource { /// The reasoning effort to thread to the provider, treating an empty string /// (e.g. a cleared flow input) as unset. pub fn get_reasoning_effort(&self) -> Option<&str> { - self.reasoning_effort.as_deref().filter(|s| !s.is_empty()) + crate::providers::effective_reasoning_effort( + &self.model, + self.reasoning_effort.as_deref().filter(|s| !s.is_empty()), + ) } pub async fn get_base_url(&self, db: &DB) -> Result { @@ -388,13 +399,28 @@ impl TokenUsage { Self::new(input, output, total) } - /// Add cache token information + /// Records a cached prefix the provider counted *inside* `input_tokens`, which the + /// OpenAI-shaped ones do. The counts are kept only for the cost split. pub fn with_cache(mut self, read: Option, write: Option) -> Self { self.cache_read_input_tokens = read; self.cache_write_input_tokens = write; self } + /// Records a cached prefix the provider reported *beside* `input_tokens` rather than + /// inside it, which Anthropic and Bedrock do. `input_tokens` is raised to the whole + /// prompt, so `input_tokens` means the same thing whatever served the request; the + /// cache counts stay as the subsets they have become, and the total follows. + pub fn with_cache_beside_input(mut self, read: Option, write: Option) -> Self { + let beside = read.unwrap_or(0).saturating_add(write.unwrap_or(0)); + self.input_tokens = self.input_tokens.map(|input| input.saturating_add(beside)); + self.total_tokens = match (self.input_tokens, self.output_tokens) { + (Some(input), Some(output)) => Some(input.saturating_add(output)), + _ => self.total_tokens.map(|total| total.saturating_add(beside)), + }; + self.with_cache(read, write) + } + pub fn is_empty(&self) -> bool { self.input_tokens.is_none() && self.output_tokens.is_none() @@ -939,6 +965,26 @@ mod tests { use super::*; use std::collections::HashMap; + /// Whichever provider served the request, `input_tokens` has to end up meaning the + /// whole prompt. Leaving the Anthropic shape as reported under-states it by the + /// entire cached prefix, which is exactly what compaction has to notice. + #[test] + fn both_provider_cache_shapes_report_the_whole_prompt() { + // OpenAI-shaped: cached_tokens is already inside input_tokens. + let openai = TokenUsage::new(Some(1000), Some(10), Some(1010)).with_cache(Some(800), None); + assert_eq!(openai.input_tokens, Some(1000)); + assert_eq!(openai.total_tokens, Some(1010)); + + // Anthropic-shaped: the cached prefix is reported beside input_tokens, and the + // total follows the prompt it is folded into. + let anthropic = TokenUsage::from_input_output(Some(200), Some(10)) + .with_cache_beside_input(Some(5000), Some(300)); + assert_eq!(anthropic.input_tokens, Some(5500)); + assert_eq!(anthropic.total_tokens, Some(5510)); + // Kept as the subsets they have become, for the cost split. + assert_eq!(anthropic.cache_read_input_tokens, Some(5000)); + } + /// Helper to create a simple string type schema fn string_schema() -> OpenAPISchema { OpenAPISchema { diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index 29ee0fe36a..e100cf88c6 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -621,12 +621,17 @@ const FLOW_JOBS: [&'static str; 6] = [ "jobs/run_and_stream/f", ]; +/// Runs of a saved AI agent: the server builds the run from the stored agent, so the route is +/// scoped by the agent's path like a deployed runnable's, as `jobs:run:agents:`. +const AGENT_JOBS: [&'static str; 1] = ["jobs/run/agent"]; + lazy_static::lazy_static! { static ref RUN_PATH_ACTIONS: Vec<&'static str> = { let mut v = vec!["jobs/resume/", "jobs/run/batch_rerun_jobs", "jobs/run/workflow_as_code", "jobs/run/dependencies","jobs/run/flow_dependencies", "apps_u/execute_component", "apps_u/upload_s3_file"]; v.extend(SCRIPT_JOBS); v.extend(FLOW_JOBS); + v.extend(AGENT_JOBS); v }; } @@ -646,13 +651,13 @@ fn map_http_method_to_action(method: &str, route_path: &str) -> ScopeAction { } } -/// Checks the route path to determine the runnable kind (either "flows" or "scripts"). +/// Checks the route path to determine the runnable kind ("agents", "flows" or "scripts"). /// /// The order of checks is important: /// - Flow-related paths are checked first to avoid false positives, as some flow paths /// (e.g., `/run_preview_flow`) share prefixes with script paths (e.g., `/run_preview`). /// -/// Returns `"flows"` or `"scripts"` based on the match, or `None` if no match is found. +/// Returns the kind based on the match, or `None` if no match is found. fn determine_kind_from_route(route_path: &str) -> Option { if route_path.starts_with("jobs") { // Preview/bundle runs execute arbitrary code with no deployed path, so @@ -666,6 +671,9 @@ fn determine_kind_from_route(route_path: &str) -> Option { { return None; } + if AGENT_JOBS.iter().any(|path| route_path.starts_with(path)) { + return Some("agents".to_string()); + } if FLOW_JOBS.iter().any(|path| route_path.starts_with(path)) { return Some("flows".to_string()); } else if SCRIPT_JOBS.iter().any(|path| route_path.starts_with(path)) { @@ -1103,9 +1111,9 @@ fn is_global_read_open_to_job_token(route_path: &str) -> bool { | "/api/settings/global/hub_base_url" | "/api/docs/search" | "/api/docs/page" - | "/api/integrations/hub/list" | "/api/embeddings/query_hub_scripts" - ) || route_path.starts_with("/api/scripts/hub/") + ) || route_path.starts_with("/api/integrations/hub/") + || route_path.starts_with("/api/scripts/hub/") || route_path.starts_with("/api/flows/hub/") || route_path.starts_with("/api/apps/hub/") } @@ -1725,6 +1733,10 @@ mod tests { scope_for_route("POST", "/api/w/ws/jobs/run/f/u/x/y").as_deref(), Some("jobs:run:flows") ); + assert_eq!( + scope_for_route("POST", "/api/w/ws/jobs/run/agent/u/x/y").as_deref(), + Some("jobs:run:agents") + ); // Preview/bundle runs have no deployed path and their handlers require the // broad `jobs:run` scope, so the derived scope must not carry a kind. diff --git a/backend/windmill-api-embeddings/src/lib.rs b/backend/windmill-api-embeddings/src/lib.rs index 1de99fb44f..6e4d6a1c71 100644 --- a/backend/windmill-api-embeddings/src/lib.rs +++ b/backend/windmill-api-embeddings/src/lib.rs @@ -78,6 +78,8 @@ pub struct HubScriptResult { id: i64, version_id: i64, summary: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, app: String, kind: String, score: f32, @@ -163,6 +165,9 @@ struct HubScript { id: i64, version_id: i64, summary: String, + // Nearly a fifth of hub scripts carry an explicit `"description": null`; a bare + // String here fails the whole blob and takes hub search down with it. + description: Option, app: String, kind: String, embedding: Vec, @@ -360,6 +365,10 @@ impl EmbeddingsDb { let mut hm = HashMap::new(); hm.insert("ask_id".to_string(), script.ask_id.clone().to_string()); hm.insert("summary".to_string(), script.summary.clone()); + hm.insert( + "description".to_string(), + script.description.clone().unwrap_or_default(), + ); hm.insert("app".to_string(), script.app.clone()); hm.insert("kind".to_string(), script.kind.clone()); hm.insert("id".to_string(), script.id.clone().to_string()); @@ -517,6 +526,10 @@ impl EmbeddingsDb { .get("summary") .ok_or(Error::msg("no summary"))? .to_owned(), + description: metadata + .get("description") + .filter(|d| !d.is_empty()) + .map(|d| d.to_owned()), app: metadata.get("app").ok_or(Error::msg("no app"))?.to_owned(), kind: metadata .get("kind") @@ -707,6 +720,31 @@ pub fn global_service() -> Router { mod tests { use super::trim_to_top_score; + // The blob carries an explicit `"description": null` for roughly a fifth of hub + // scripts, and an older hub omits the key entirely. A bare String here fails the + // whole 155 MB array and takes hub search down with it. + #[test] + fn reads_a_hub_script_whether_or_not_it_has_a_description() { + let present = r#"{"ask_id":1,"id":2,"version_id":3,"summary":"s","description":"d","app":"a","kind":"script","embedding":[]}"#; + let null = r#"{"ask_id":1,"id":2,"version_id":3,"summary":"s","description":null,"app":"a","kind":"script","embedding":[]}"#; + let missing = r#"{"ask_id":1,"id":2,"version_id":3,"summary":"s","app":"a","kind":"script","embedding":[]}"#; + + assert_eq!( + serde_json::from_str::(present) + .unwrap() + .description, + Some("d".to_string()) + ); + for without in [null, missing] { + assert_eq!( + serde_json::from_str::(without) + .unwrap() + .description, + None + ); + } + } + #[test] fn trims_scores_more_than_5pct_below_top() { // top=1.0, cutoff at 0.95: 0.96 stays (0.04 drop), 0.93 is the first diff --git a/backend/windmill-api-flow-conversations/src/lib.rs b/backend/windmill-api-flow-conversations/src/lib.rs index 0ec295c23f..7320721ea5 100644 --- a/backend/windmill-api-flow-conversations/src/lib.rs +++ b/backend/windmill-api-flow-conversations/src/lib.rs @@ -14,7 +14,7 @@ pub use windmill_common::flow_conversations::FlowConversation; use windmill_common::{ db::{UserDB, DB}, error::{JsonResult, Result}, - flow_conversations::MessageType, + flow_conversations::{running_turns, MessageType, RunningTurn}, utils::{not_found_if_none, paginate, truncate_with_ellipsis, Pagination}, }; @@ -81,7 +81,7 @@ async fn list_conversations( Path(w_id): Path, Query(pagination): Query, Query(query): Query, -) -> JsonResult> { +) -> JsonResult> { let (per_page, offset) = paginate(pagination); let mut tx = user_db.clone().begin(&authed).await?; @@ -123,9 +123,27 @@ async fn list_conversations( let conversations = sqlx::query_as::(&sql) .fetch_all(&mut *tx) .await?; + let ids: Vec = conversations.iter().map(|c| c.id).collect(); + let mut running = running_turns(&mut *tx, &ids).await?; tx.commit().await?; - Ok(Json(conversations)) + Ok(Json( + conversations + .into_iter() + .map(|conversation| ListedConversation { + running_turn: running.remove(&conversation.id), + conversation, + }) + .collect(), + )) +} + +#[derive(Serialize)] +pub struct ListedConversation { + #[serde(flatten)] + pub conversation: FlowConversation, + /// Lets a chat that opens after a turn started follow it, and tell which chats are busy. + pub running_turn: Option, } async fn delete_conversation( diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index c7df69879e..aa86a7ff07 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -16,10 +16,12 @@ use axum::{ }; use windmill_api_auth::{ auth::{list_tokens_internal, TruncatedTokenWithEmail}, - build_scope_path_predicate, check_scopes, maybe_refresh_folders, require_owner_of_path, - ApiAuthed, + build_scope_path_predicate, check_scopes, get_scope_tags, maybe_refresh_folders, + require_owner_of_path, ApiAuthed, +}; +use windmill_common::workspaces::{ + check_deploy_rules, check_operator_can_build_flows, operator_can_build_flows, RuleCheckResult, }; -use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult}; use windmill_common::{ user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay}, utils::HTTP_CLIENT, @@ -35,7 +37,7 @@ use sqlx::{FromRow, Postgres, Transaction}; use windmill_audit::audit_oss::{audit_log, AuditAuthorable}; use windmill_audit::ActionKind; use windmill_common::assets::{clear_static_asset_usage, AssetUsageKind}; -use windmill_common::flows::FlowModule; +use windmill_common::flows::{FlowModule, FlowValue}; use windmill_common::min_version::{ MIN_VERSION_SUPPORTS_DEBOUNCING, MIN_VERSION_SUPPORTS_DEBOUNCING_V2, MIN_VERSION_SUPPORTS_NODE_DEBOUNCING, @@ -241,7 +243,7 @@ async fn list_flows( // Append the authed user's drafts at paths with no deployed flow; see scripts.rs. if lq.include_draft_only.unwrap_or(false) - && !authed.is_operator + && (!authed.is_operator || operator_can_build_flows(&db, &w_id).await?) && offset == 0 && lq.path_start.is_none() && lq.path_exact.is_none() @@ -570,7 +572,13 @@ async fn list_paths_linking_agent( Ok(Json(flows)) } -async fn validate_flow(new_flow: &NewFlow) -> error::Result<()> { +async fn validate_flow( + new_flow: &NewFlow, + authed: &ApiAuthed, + db: &DB, + user_db: &UserDB, + w_id: &str, +) -> error::Result<()> { #[cfg(not(feature = "enterprise"))] if new_flow.ws_error_handler_muted.is_some_and(|val| val) { return Err(Error::BadRequest( @@ -581,9 +589,139 @@ async fn validate_flow(new_flow: &NewFlow) -> error::Result<()> { guard_flow_from_debounce_data(new_flow).await?; + if authed.is_operator { + validate_operator_flow( + &new_flow.parse_flow_value()?, + &new_flow.tag, + new_flow.schema.as_ref().map(|s| s.0.get()), + authed, + db, + user_db, + w_id, + ) + .await?; + } + return Ok(()); } +/// What an operator with builder rights must pass to store a flow, deployed or as a draft: a +/// developer who loads a builder's draft in the editor runs its code as themselves. +pub async fn validate_operator_flow( + value: &FlowValue, + flow_tag: &Option, + schema: Option<&str>, + authed: &ApiAuthed, + db: &DB, + user_db: &UserDB, + w_id: &str, +) -> error::Result<()> { + // Dynamic dropdown code runs as whoever loads the flow's form: it is code like a step's. + if let Some(schema) = schema { + let schema: serde_json::Value = serde_json::from_str(schema)?; + if schema.get("x-windmill-dyn-select-code").is_some() { + return Err(Error::PermissionDenied( + "This flow has dynamic dropdown code, so only a developer can edit it".to_string(), + )); + } + } + validate_operator_composed_flow(value, flow_tag, authed, db, user_db, w_id).await +} + +/// Runs on every write and every preview of a flow authored by an operator with builder rights. +/// The walk in `check_flow_is_composition_only` only sees the value; what it collects is +/// authorized here against the caller's own permissions. +pub async fn validate_operator_composed_flow( + value: &FlowValue, + flow_tag: &Option, + authed: &ApiAuthed, + db: &DB, + user_db: &UserDB, + w_id: &str, +) -> error::Result<()> { + let mut refs = windmill_common::flows::check_flow_is_composition_only(value)?; + + // A tag is how a step picks the worker group it runs on: unauthorized, a builder could route + // a job onto a privileged one. + refs.tags.extend(flow_tag.clone().filter(|t| !t.is_empty())); + if !refs.tags.is_empty() { + // Job-aware: a WM_TOKEN running as a superadmin must not unlock restricted tags. + let is_super_admin = windmill_api_auth::is_super_admin_authed(db, authed).await?; + for tag in &refs.tags { + windmill_common::jobs::check_tag_available_for_workspace_internal( + db, + w_id, + tag, + None, + std::future::ready(w_id.to_string()), + is_super_admin, + get_scope_tags(authed), + ) + .await?; + } + } + + if refs.runnables.is_empty() && refs.pinned_scripts.is_empty() { + return Ok(()); + } + // A flow can step through the same script thirty times; this runs on every write, preview and + // dependency job. + refs.runnables.sort(); + refs.runnables.dedup(); + refs.pinned_scripts + .sort_by_key(|(path, hash)| (path.clone(), hash.0)); + refs.pinned_scripts + .dedup_by_key(|(path, hash)| (path.clone(), hash.0)); + // The worker resolves a step's path with the root DB handle and runs it as that runnable's + // `on_behalf_of`, so composing an unreadable path would run code the builder cannot see. RLS + // on this transaction is the check. + let mut tx = user_db.clone().begin(authed).await?; + for (is_flow, path) in &refs.runnables { + let readable = if *is_flow { + sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM flow WHERE workspace_id = $1 AND path = $2)", + w_id, + path, + ) + } else { + sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2)", + w_id, + path, + ) + } + .fetch_one(&mut *tx) + .await? + .unwrap_or(false); + if !readable { + return Err(Error::PermissionDenied(format!( + "{} {path} does not exist or is not readable by you", + if *is_flow { "Flow" } else { "Script" } + ))); + } + } + // A pinned step is dispatched by its hash alone, ignoring the path beside it, so a readable + // path paired with another script's hash would still run that other script. + for (path, hash) in &refs.pinned_scripts { + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2 AND hash = $3)", + w_id, + path, + hash.0, + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false); + if !exists { + return Err(Error::PermissionDenied(format!( + "Version {hash} is not a readable version of {path}" + ))); + } + } + tx.commit().await?; + Ok(()) +} + async fn create_flow( authed: ApiAuthed, Extension(db): Extension, @@ -592,11 +730,7 @@ async fn create_flow( Path(w_id): Path, Json(mut nf): Json, ) -> Result<(StatusCode, String)> { - if authed.is_operator { - return Err(Error::NotAuthorized( - "Operators cannot create flows for security reasons".to_string(), - )); - } + check_operator_can_build_flows(&db, &w_id, authed.is_operator, "create flows").await?; check_scopes(&authed, || format!("flows:write:{}", nf.path))?; // A `<= 0` flow timeout is "unset", not a 0-second limit that kills every run instantly. @@ -616,7 +750,7 @@ async fn create_flow( return Err(Error::PermissionDenied(msg)); } - validate_flow(&nf).await?; + validate_flow(&nf, &authed, &db, &user_db, &w_id).await?; if *CLOUD_HOSTED { let nb_flows = sqlx::query_scalar!("SELECT COUNT(*) FROM flow WHERE workspace_id = $1", &w_id) @@ -1187,11 +1321,7 @@ async fn update_flow( Path((w_id, flow_path)): Path<(String, StripPath)>, Json(ef): Json, ) -> Result { - if authed.is_operator { - return Err(Error::NotAuthorized( - "Operators cannot update flows for security reasons".to_string(), - )); - } + check_operator_can_build_flows(&db, &w_id, authed.is_operator, "update flows").await?; let flow_path = flow_path.to_path(); // The URL identifies the flow being updated; the body path is only needed to rename. let mut nf = ef.into_new_flow(flow_path); @@ -1218,7 +1348,7 @@ async fn update_flow( return Err(Error::PermissionDenied(msg)); } - validate_flow(&nf).await?; + validate_flow(&nf, &authed, &db, &user_db, &w_id).await?; let authed = maybe_refresh_folders(&flow_path, &w_id, authed, &db).await; let mut tx = user_db.clone().begin(&authed).await?; @@ -1855,11 +1985,7 @@ async fn archive_flow_by_path( Path((w_id, path)): Path<(String, StripPath)>, Json(archived): Json, ) -> Result { - if authed.is_operator { - return Err(Error::NotAuthorized( - "Operators cannot archive flows for security reasons".to_string(), - )); - } + check_operator_can_build_flows(&db, &w_id, authed.is_operator, "archive flows").await?; let path = path.to_path(); check_scopes(&authed, || format!("flows:write:{}", path))?; if let RuleCheckResult::Blocked(msg) = check_deploy_rules( @@ -2000,11 +2126,7 @@ async fn delete_flow_by_path( Path((w_id, path)): Path<(String, StripPath)>, Query(query): Query, ) -> Result { - if authed.is_operator { - return Err(Error::NotAuthorized( - "Operators cannot delete flows for security reasons".to_string(), - )); - } + check_operator_can_build_flows(&db, &w_id, authed.is_operator, "delete flows").await?; let path = path.to_path(); check_scopes(&authed, || format!("flows:write:{}", path))?; if let RuleCheckResult::Blocked(msg) = check_deploy_rules( diff --git a/backend/windmill-api-integration-tests/tests/agent_runs.rs b/backend/windmill-api-integration-tests/tests/agent_runs.rs new file mode 100644 index 0000000000..b0302187cb --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/agent_runs.rs @@ -0,0 +1,60 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; + +use windmill_test_utils::*; + +const AGENT: &str = "u/test-user-3/agent"; + +async fn run(base_url: &str, token: &str) -> anyhow::Result<(u16, String)> { + let resp = reqwest::Client::new() + .post(format!( + "{base_url}/w/test-workspace/jobs/run/agent/{AGENT}" + )) + .header("Authorization", format!("Bearer {token}")) + .json(&json!({ "user_message": "hi" })) + .send() + .await?; + Ok((resp.status().as_u16(), resp.text().await?)) +} + +/// Reading an agent is what allows running it, and the run is the caller's own: `test-user-2`, a +/// plain member, runs `test-user-3`'s agent once it may read it, lending nobody's permissions. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_agent_run_needs_read_and_runs_as_caller(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let base_url = format!("http://localhost:{}/api", server.addr.port()); + + sqlx::query( + "INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) + VALUES ('test-workspace', $1, $2, 'ai_agent', '{}', 'test-user-3')", + ) + .bind(AGENT) + .bind(json!({ "system_prompt": "hi" })) + .execute(&db) + .await?; + + let (status, body) = run(&base_url, "SECRET_TOKEN_2").await?; + assert_eq!(status, 404, "an unreadable agent: {body}"); + + sqlx::query( + "UPDATE resource SET extra_perms = '{\"u/test-user-2\": false}' + WHERE workspace_id = 'test-workspace' AND path = $1", + ) + .bind(AGENT) + .execute(&db) + .await?; + let (status, body) = run(&base_url, "SECRET_TOKEN_2").await?; + assert_eq!(status, 201, "a readable agent: {body}"); + + let (permissioned_as, created_by) = sqlx::query_as::<_, (String, String)>( + "SELECT permissioned_as, created_by FROM v2_job WHERE id = $1::uuid", + ) + .bind(&body) + .fetch_one(&db) + .await?; + assert_eq!(permissioned_as, "u/test-user-2"); + assert_eq!(created_by, "test-user-2"); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/datatable_roles.rs b/backend/windmill-api-integration-tests/tests/datatable_roles.rs index 4948a0098a..ddb94f8c2b 100644 --- a/backend/windmill-api-integration-tests/tests/datatable_roles.rs +++ b/backend/windmill-api-integration-tests/tests/datatable_roles.rs @@ -391,7 +391,11 @@ async fn concurrent_role_creations_both_survive(db: Pool) -> anyhow::R assert_eq!(a.0, 200, "{}", a.1); assert_eq!(b.0, 200, "{}", b.1); - let catalog = windmill_common::datatable_roles::read_role_catalog(&db).await?; + let catalog = windmill_common::datatable_roles::read_role_catalog( + &db, + windmill_common::datatable_roles::DatatableRoleCluster::Instance, + ) + .await?; let recorded: Vec<&str> = catalog.values().map(|r| r.name.as_str()).collect(); for name in &names { assert!( @@ -460,7 +464,11 @@ async fn a_role_delete_that_fails_part_way_leaves_the_role_disabled( let body = resp.text().await?; assert_eq!(status, 400, "{body}"); - let catalog = windmill_common::datatable_roles::read_role_catalog(&db).await?; + let catalog = windmill_common::datatable_roles::read_role_catalog( + &db, + windmill_common::datatable_roles::DatatableRoleCluster::Instance, + ) + .await?; let role = catalog .get(&id) .expect("a failed delete keeps the entry to retry"); diff --git a/backend/windmill-api-integration-tests/tests/drafts.rs b/backend/windmill-api-integration-tests/tests/drafts.rs index b3bac8d010..f45ffb3f8e 100644 --- a/backend/windmill-api-integration-tests/tests/drafts.rs +++ b/backend/windmill-api-integration-tests/tests/drafts.rs @@ -165,7 +165,7 @@ async fn test_update_draft_write_authorization(db: Pool) -> anyhow::Re .json(&json!({ "value": { "a": 1 } })) .send() .await?; - assert_eq!(r.status(), 401, "operator rejected"); + assert_eq!(r.status(), 403, "operator rejected"); Ok(()) } diff --git a/backend/windmill-api-integration-tests/tests/fork_background.rs b/backend/windmill-api-integration-tests/tests/fork_background.rs new file mode 100644 index 0000000000..24ef3468ae --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/fork_background.rs @@ -0,0 +1,136 @@ +use serde_json::{json, Value}; +use sqlx::{Pool, Postgres}; + +use windmill_test_utils::*; + +async fn wait_for_fork(client: &reqwest::Client, base_url: &str, creation_id: &str) -> Value { + for _ in 0..100 { + let status: Value = client + .get(format!("{base_url}/fork_creation_status/{creation_id}")) + .header("Authorization", "Bearer SECRET_TOKEN") + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + if status["status"] != "running" { + return status; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + panic!("fork creation {creation_id} still running"); +} + +/// A fork created in the background is reported by `fork_creation_status`, including a failure +/// that happens after the request has already returned. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_fork_created_in_background(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let base_url = format!( + "http://localhost:{}/api/w/test-workspace/workspaces", + server.addr.port() + ); + let client = reqwest::Client::new(); + let create = |id: &str| { + client + .post(format!("{base_url}/create_fork?background=true")) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ "id": id, "name": id })) + .send() + }; + + let resp = create("wm-fork-bg-ok").await?; + assert_eq!(resp.status(), 200); + let ok_creation = resp.text().await?; + let status = wait_for_fork(&client, &base_url, &ok_creation).await; + assert_eq!(status, json!({ "status": "completed" })); + // Nobody but the user who started a creation reads it. + let resp = client + .get(format!("{base_url}/fork_creation_status/{ok_creation}")) + .header("Authorization", "Bearer SECRET_TOKEN_2") + .send() + .await?; + assert_eq!(resp.status(), 404); + let exists: bool = + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM workspace WHERE id = 'wm-fork-bg-ok')") + .fetch_one(&db) + .await?; + assert!(exists); + // Under CE's workspace cap. + sqlx::query("UPDATE workspace SET deleted = true WHERE id = 'wm-fork-bg-ok'") + .execute(&db) + .await?; + + // Hold the fork's write lock so the request is validated and returns, then commit a workspace + // with the same id: the background write fails on it. + let mut blocker = db.begin().await?; + sqlx::query("SELECT pg_advisory_xact_lock(hashtext('fork:wm-fork-bg-err'))") + .execute(&mut *blocker) + .await?; + let resp = create("wm-fork-bg-err").await?; + assert_eq!(resp.status(), 200); + let err_creation = resp.text().await?; + let resp = create("wm-fork-bg-err").await?; + assert_eq!(resp.status(), 400, "a second creation while the first is in flight"); + sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ('wm-fork-bg-err', 'x', 'x')") + .execute(&mut *blocker) + .await?; + blocker.commit().await?; + let status = wait_for_fork(&client, &base_url, &err_creation).await; + assert_eq!(status["status"], "failed"); + assert!( + status["error"].as_str().unwrap().contains("workspace_pkey"), + "{status}" + ); + + // A retry of the same id is a new attempt: the failed one does not read its outcome. + sqlx::query("DELETE FROM workspace WHERE id = 'wm-fork-bg-err'") + .execute(&db) + .await?; + let resp = create("wm-fork-bg-err").await?; + assert_eq!(resp.status(), 200); + let retry_creation = resp.text().await?; + assert_eq!( + wait_for_fork(&client, &base_url, &retry_creation).await, + json!({ "status": "completed" }) + ); + let resp = client + .get(format!("{base_url}/fork_creation_status/{err_creation}")) + .header("Authorization", "Bearer SECRET_TOKEN") + .send() + .await?; + assert_eq!(resp.status(), 404); + + // An attempt whose server stopped heartbeating before its fork committed reads as failed. + let abandoned = "5c3e1b1e-0000-4000-8000-000000000000"; + sqlx::query( + "INSERT INTO workspace_fork_creation + (fork_workspace_id, parent_workspace_id, created_by, creation_id, heartbeat_at) + VALUES ('wm-fork-gone', 'test-workspace', 'test@windmill.dev', $1::uuid, + now() - interval '2 minutes')", + ) + .bind(abandoned) + .execute(&db) + .await?; + assert_eq!( + wait_for_fork(&client, &base_url, abandoned).await["status"], + "failed" + ); + + // Without the flag the fork is created before the answer, which clients asking for a + // background fork recognise a server without it by. + sqlx::query("UPDATE workspace SET deleted = true WHERE id = 'wm-fork-bg-err'") + .execute(&db) + .await?; + let resp = client + .post(format!("{base_url}/create_fork")) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ "id": "wm-fork-sync", "name": "wm-fork-sync" })) + .send() + .await?; + assert_eq!(resp.text().await?, "Created forked workspace wm-fork-sync"); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/operator_builder_flows.rs b/backend/windmill-api-integration-tests/tests/operator_builder_flows.rs new file mode 100644 index 0000000000..7be3920124 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/operator_builder_flows.rs @@ -0,0 +1,317 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_common::workspaces::invalidate_operator_rights_cache; +use windmill_test_utils::*; + +const WS: &str = "test-workspace"; + +fn operator_client() -> reqwest::Client { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::AUTHORIZATION, + reqwest::header::HeaderValue::from_str("Bearer OPERATOR_TOKEN_1").unwrap(), + ); + reqwest::ClientBuilder::new() + .default_headers(headers) + .build() + .unwrap() +} + +async fn set_builder(db: &Pool, flows: bool) -> anyhow::Result<()> { + sqlx::query( + "UPDATE workspace_settings SET operator_settings = $1::text::jsonb WHERE workspace_id = $2", + ) + .bind(format!(r#"{{"builder_flows": {flows}}}"#)) + .bind(WS) + .execute(db) + .await?; + // The right is read through a process-global 60s cache keyed by workspace id. + invalidate_operator_rights_cache(WS); + Ok(()) +} + +async fn add_script(db: &Pool, hash: i64, path: &str, owner: &str) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, + summary, description, lock, extra_perms) + VALUES ($1, $2, $3, 'x', 'bun', 'script', $4, '{}', '', '', '', '{}')", + ) + .bind(WS) + .bind(hash) + .bind(path) + .bind(owner) + .execute(db) + .await?; + Ok(()) +} + +fn composition_flow_at(path: &str, step_path: &str) -> serde_json::Value { + json!({ + "path": path, + "summary": "", + "description": "", + "schema": {}, + "value": {"modules": [{ + "id": "a", + "value": {"type": "script", "path": step_path, "input_transforms": {}} + }]} + }) +} + +fn inline_code_flow(path: &str) -> serde_json::Value { + json!({ + "path": path, + "summary": "", + "description": "", + "schema": {}, + "value": {"modules": [{ + "id": "a", + "value": { + "type": "rawscript", + "content": "export async function main() { return 1 }", + "language": "bun", + "input_transforms": {} + } + }]} + }) +} + +/// The whole boundary in one pass: the builder right lets an operator compose runnables that are +/// already deployed and nothing more, and the endpoints that author code stay shut whether or not +/// it is granted. +#[sqlx::test(migrations = "../migrations", fixtures("base", "permissions_test"))] +async fn test_operator_builder_flows_boundary(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let api = format!("http://localhost:{port}/api/w/{WS}"); + let c = operator_client(); + + // A composition-only flow references a runnable that exists and the builder can read, so the + // fixture needs one. + add_script(&db, 4241, "u/operator/some_script", "operator").await?; + + set_builder(&db, false).await?; + let resp = c + .post(format!("{api}/flows/create")) + .json(&composition_flow_at( + "u/operator/f1", + "u/operator/some_script", + )) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "an operator without the builder right must not create a flow" + ); + + set_builder(&db, true).await?; + // A payload that omits the key, like a git-sync file that predates it, keeps the right. + let resp = reqwest::Client::new() + .post(format!("{api}/workspaces/operator_settings")) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({"runs": true})) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + + let resp = c + .post(format!("{api}/flows/create")) + .json(&composition_flow_at( + "u/operator/f1", + "u/operator/some_script", + )) + .send() + .await?; + assert!( + resp.status().is_success(), + "a builder must be able to create a composition-only flow: {}", + resp.text().await? + ); + + let mut tagged = composition_flow_at("u/operator/f5", "u/operator/some_script"); + tagged["tag"] = json!("privileged_group"); + let resp = c + .post(format!("{api}/flows/create")) + .json(&tagged) + .send() + .await?; + assert_eq!( + resp.status(), + 400, + "a builder must not route a flow onto a worker tag the workspace cannot use" + ); + + let mut with_dyn_code = composition_flow_at("u/operator/f1", "u/operator/some_script"); + with_dyn_code["schema"] = + json!({"x-windmill-dyn-select-code": "x", "x-windmill-dyn-select-lang": "bun"}); + let resp = c + .post(format!("{api}/flows/update/u/operator/f1")) + .json(&with_dyn_code) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "a builder must not save a flow carrying dropdown code" + ); + let resp = c + .post(format!("{api}/jobs/run/dynamic_select")) + .json( + &json!({"entrypoint_function": "f", "runnable_ref": {"source": "inline", "code": "x"}}), + ) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "an operator's inline dropdown refusal must not be a 401" + ); + + // A dependency job rewrites bookkeeping stored under the path it names, so a builder must not + // aim one at a path it cannot write. + let resp = c + .post(format!("{api}/jobs/run/flow_dependencies")) + .json(&json!({"path": "u/alice/private_flow", "flow_value": {"modules": []}})) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "a builder must not run a dependency job on a path it cannot write" + ); + + // A legacy draft has no owner, so it lists as the builder's own, and a script draft is one + // the builder cannot write: that row must read as not writable, not fail the whole list. + sqlx::query( + "INSERT INTO draft (workspace_id, path, typ, value) VALUES ($1, 'u/operator/some_script', 'script', '{}')", + ) + .bind(WS) + .execute(&db) + .await?; + let resp = c.get(format!("{api}/drafts/list")).send().await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + let drafts: serde_json::Value = resp.json().await?; + assert_eq!(drafts[0]["can_write"], false, "{drafts}"); + + let resp = c + .post(format!("{api}/flows/create")) + .json(&inline_code_flow("u/operator/f2")) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "a builder must not deploy a flow carrying inline code" + ); + + // Draft storage strips a NUL, turning these keys into the plain ones the check reads. + let mut nul_value = inline_code_flow("u/operator/f1"); + let v = nul_value.as_object_mut().unwrap().remove("value").unwrap(); + nul_value["value\0"] = v; + let mut nul_dyn = composition_flow_at("u/operator/f1", "u/operator/some_script"); + nul_dyn["schema"] = json!({"x-windmill-dyn-select-code\0": "x"}); + for (draft, expected) in [ + ( + composition_flow_at("u/operator/f1", "u/operator/some_script"), + 200, + ), + (inline_code_flow("u/operator/f1"), 403), + (with_dyn_code.clone(), 403), + (nul_value, 403), + (nul_dyn, 403), + ] { + let resp = c + .post(format!("{api}/drafts/update/flow/u/operator/f1")) + .json(&json!({ "value": draft, "force": true })) + .send() + .await?; + let status = resp.status(); + assert_eq!( + status, + expected, + "flow draft {draft}: {}", + resp.text().await? + ); + } + + // Same for the preview path, which runs a request-supplied flow value rather than a stored one. + let resp = c + .post(format!("{api}/jobs/run/preview_flow")) + .json(&json!({"value": inline_code_flow("u/operator/f2")["value"], "args": {}})) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "a builder must not preview a flow carrying inline code" + ); + + // Authoring code directly stays shut with the right granted. + let resp = c + .post(format!("{api}/scripts/create")) + .json(&json!({ + "path": "u/operator/s1", + "summary": "", + "description": "", + "content": "export async function main() { return 1 }", + "language": "bun", + "is_template": false + })) + .send() + .await?; + assert!( + !resp.status().is_success(), + "a builder must not create a script" + ); + + // `permissions_test` gives the operator fixture no rights on `u/alice/**`. + add_script(&db, 4243, "u/alice/private", "alice").await?; + let resp = c + .post(format!("{api}/flows/create")) + .json(&composition_flow_at("u/operator/f4", "u/alice/private")) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "a builder must not compose a runnable it cannot read" + ); + + add_script(&db, 4242, "u/operator/pinned", "operator").await?; + let pinned = |hash: &str| { + json!({ + "path": "u/operator/f3", "summary": "", "description": "", "schema": {}, + "value": {"modules": [{ + "id": "a", + "value": { + "type": "script", "path": "u/operator/pinned", "hash": hash, + "input_transforms": {} + } + }]} + }) + }; + let resp = c + .post(format!("{api}/flows/create")) + .json(&pinned("0000000000000000")) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "a builder must not pin a hash that is not a version of the step's path" + ); + let resp = c + .post(format!("{api}/flows/create")) + .json(&pinned("0000000000001092")) + .send() + .await?; + assert!( + resp.status().is_success(), + "a builder must be able to pin the real version of a readable script: {}", + resp.text().await? + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/scripts.rs b/backend/windmill-api-integration-tests/tests/scripts.rs index 3a146add27..d6afc9f302 100644 --- a/backend/windmill-api-integration-tests/tests/scripts.rs +++ b/backend/windmill-api-integration-tests/tests/scripts.rs @@ -15,6 +15,17 @@ fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { builder.header("Authorization", "Bearer SECRET_TOKEN") } +async fn last_script_deletion(db: &Pool) -> windmill_common::DeletedScriptVersions { + let payload: String = sqlx::query_scalar( + "SELECT payload FROM notify_event WHERE channel = $1 ORDER BY id DESC LIMIT 1", + ) + .bind(windmill_common::SCRIPT_VERSION_DELETED_CHANNEL) + .fetch_one(db) + .await + .unwrap(); + serde_json::from_str(&payload).unwrap() +} + async fn authed_get(port: u16, endpoint: &str, path: &str) -> reqwest::Response { authed(client().get(script_url(port, endpoint, path))) .send() @@ -366,6 +377,12 @@ async fn test_script_endpoints(db: Pool) -> anyhow::Result<()> { .await .unwrap(); assert_eq!(resp.status(), 200); + // Every process evicts deleted versions from its script caches on this event. + let deleted = last_script_deletion(&db).await; + let another: windmill_common::scripts::ScriptHash = + serde_json::from_value(json!(another_hash))?; + assert_eq!(deleted.paths, ["u/test-user/another_script"]); + assert_eq!(deleted.hashes, [another.0]); // --- delete_bulk --- let resp = authed(client().delete(format!("{base}/delete_bulk"))) @@ -374,6 +391,9 @@ async fn test_script_endpoints(db: Pool) -> anyhow::Result<()> { .await .unwrap(); assert_eq!(resp.status(), 200, "delete_bulk: {}", resp.text().await?); + let deleted = last_script_deletion(&db).await; + assert_eq!(deleted.paths, ["u/test-user/test_script"]); + assert!(!deleted.hashes.is_empty(), "{deleted:?}"); let resp = authed_get(port, "exists/p", "u/test-user/test_script").await; assert_eq!(resp.json::().await?, false); diff --git a/backend/windmill-api-integration-tests/tests/users.rs b/backend/windmill-api-integration-tests/tests/users.rs index 65d44509d7..56656e54dd 100644 --- a/backend/windmill-api-integration-tests/tests/users.rs +++ b/backend/windmill-api-integration-tests/tests/users.rs @@ -993,3 +993,56 @@ async fn test_drafts_follow_their_owner_without_a_fkey(db: Pool) -> an Ok(()) } + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_impersonation_session_is_never_refreshed(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let base = format!("http://localhost:{}/api/users", server.addr.port()); + let get = |token: &'static str, path: &str| { + client() + .get(format!("{base}/{path}")) + .header("Authorization", format!("Bearer {token}")) + .send() + }; + + sqlx::query( + "INSERT INTO token (token_hash, token_prefix, email, label, expiration) + VALUES (encode(sha256('IMPERSONATION_TOKEN'::bytea), 'hex'), 'IMPERSONAT', + 'test2@windmill.dev', 'impersonation:test@windmill.dev', now() + interval '1 day')", + ) + .execute(&db) + .await?; + let refreshed = get("IMPERSONATION_TOKEN", "refresh_token") + .await? + .text() + .await?; + assert_eq!(refreshed, "this session cannot be refreshed"); + + // Once the row is gone, as when the monitor sweeps an expired one, the auth cache still + // accepts the token for a while. + sqlx::query("DELETE FROM token WHERE label LIKE 'impersonation:%'") + .execute(&db) + .await?; + assert_eq!(get("IMPERSONATION_TOKEN", "whoami").await?.status(), 200); + let refreshed = get("IMPERSONATION_TOKEN", "refresh_token") + .await? + .text() + .await?; + assert_eq!(refreshed, "this session cannot be refreshed"); + + let sessions: i64 = sqlx::query_scalar( + "SELECT count(*) FROM token WHERE email = 'test2@windmill.dev' AND label = 'session'", + ) + .fetch_one(&db) + .await?; + assert_eq!(sessions, 0); + + let refreshed = get("SECRET_TOKEN_3", "refresh_token").await?.text().await?; + assert_eq!( + refreshed, "token refreshed", + "an ordinary token still refreshes" + ); + + Ok(()) +} diff --git a/backend/windmill-api-npm-proxy/src/lib.rs b/backend/windmill-api-npm-proxy/src/lib.rs index f018ad0258..48759a6ded 100644 --- a/backend/windmill-api-npm-proxy/src/lib.rs +++ b/backend/windmill-api-npm-proxy/src/lib.rs @@ -26,7 +26,7 @@ use windmill_common::{ global_settings::{ load_value_from_global_settings, NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING, }, - utils::{parse_npmrc_registry, StripPath}, + utils::{parse_npmrc_registry, RegistryAuth, StripPath}, }; use windmill_api_auth::ApiAuthed; @@ -149,7 +149,7 @@ pub fn workspaced_service() -> Router { fn build_registry_request( url: &str, - auth_token: &Option, + auth: &Option, registry_base_url: &str, ) -> Result { let parsed_url = @@ -166,8 +166,17 @@ fn build_registry_request( } let mut req = HTTP_CLIENT.get(url); - if let Some(token) = auth_token { - req = req.bearer_auth(token); + // A packument may point a tarball at another port or plain http on the same host; + // credentials, often a real password, only go to the registry's own origin. + if parsed_url.origin() != parsed_base.origin() { + return Ok(req); + } + match auth { + Some(RegistryAuth::Bearer(token)) => req = req.bearer_auth(token), + Some(RegistryAuth::Basic(credentials)) => { + req = req.header(header::AUTHORIZATION, format!("Basic {credentials}")) + } + None => {} } Ok(req) } @@ -308,12 +317,12 @@ lazy_static::lazy_static! { async fn fetch_package_json( db: &sqlx::Pool, package: &str, -) -> Result<(Arc, String, Option)> { - let (registry_url, auth_token) = get_npm_registry(db) +) -> Result<(Arc, String, Option)> { + let (registry_url, auth) = get_npm_registry(db) .await? .ok_or_else(|| Error::BadRequest("No private npm registry configured".to_string()))?; - let document = fetch_package_json_from(®istry_url, &auth_token, package).await?; - Ok((document, registry_url, auth_token)) + let document = fetch_package_json_from(®istry_url, &auth, package).await?; + Ok((document, registry_url, auth)) } /// The same fetch against a registry the caller has already resolved. @@ -325,7 +334,7 @@ async fn fetch_package_json( /// immutable, so that survives switching back. async fn fetch_package_json_from( registry_url: &str, - auth_token: &Option, + auth: &Option, package: &str, ) -> Result> { let registry_url = registry_url.to_string(); @@ -340,7 +349,7 @@ async fn fetch_package_json_from( tracing::info!("Fetching package metadata from: {}", package_url); - let response = build_registry_request(&package_url, auth_token, ®istry_url)? + let response = build_registry_request(&package_url, auth, ®istry_url)? .header(header::ACCEPT, ABBREVIATED_PACKUMENT) .send() .await @@ -380,7 +389,7 @@ async fn tarball_response( package: &str, version: &str, registry_url: &str, - auth_token: &Option, + auth: &Option, ) -> Result { let tarball_url = package_json .get("versions") @@ -390,7 +399,7 @@ async fn tarball_response( .and_then(|t| t.as_str()) .ok_or_else(|| Error::NotFound(format!("Tarball not found for {}@{}", package, version)))?; - let response = build_registry_request(tarball_url, auth_token, registry_url)? + let response = build_registry_request(tarball_url, auth, registry_url)? .send() .await .map_err(|e| Error::InternalErr(format!("Failed to download tarball: {}", e)))?; @@ -411,9 +420,9 @@ async fn fetch_tarball( package: &str, version: &str, registry_url: &str, - auth_token: &Option, + auth: &Option, ) -> Result { - tarball_response(package_json, package, version, registry_url, auth_token) + tarball_response(package_json, package, version, registry_url, auth) .await? .bytes() .await @@ -642,10 +651,10 @@ async fn get_package_file( Some(content) => content, // Not a file a type request asks for, so it was never kept: walk the archive for it None => { - let (package_json, registry_url, auth_token) = + let (package_json, registry_url, auth) = fetch_package_json(&db, &package).await?; let archive = - fetch_tarball(&package_json, &package, &version, ®istry_url, &auth_token) + fetch_tarball(&package_json, &package, &version, ®istry_url, &auth) .await?; let path = target.clone(); blocking(move || read_one_entry(&archive, &path)) @@ -668,13 +677,13 @@ async fn get_package_tarball( Extension(db): Extension>, ) -> Result { let (package, version) = parse_package_and_version(package_version_path.to_path())?; - let (package_json, registry_url, auth_token) = fetch_package_json(&db, &package).await?; + let (package_json, registry_url, auth) = fetch_package_json(&db, &package).await?; let response = tarball_response( &package_json, &package, &version, ®istry_url, - &auth_token, + &auth, ) .await?; @@ -693,11 +702,11 @@ async fn get_package_tarball( .into_response()) } -/// Get the npm registry URL and optional auth token from global settings. +/// Get the npm registry URL and optional credentials from global settings. /// Checks the `npmrc` setting first, then falls back to `npm_config_registry`. async fn get_npm_registry( db: &sqlx::Pool, -) -> Result)>> { +) -> Result)>> { let npmrc = load_value_from_global_settings(db, NPMRC_SETTING) .await? .and_then(|v| v.as_str().map(|s| s.to_string())); @@ -716,7 +725,7 @@ async fn get_npm_registry( let (url, token) = if s.contains(":_authToken=") { let parts: Vec<&str> = s.split(":_authToken=").collect(); let url = parts[0].to_string(); - let token = parts.get(1).map(|t| t.to_string()); + let token = parts.get(1).map(|t| RegistryAuth::Bearer(t.to_string())); (url, token) } else { (s.clone(), None) @@ -941,7 +950,7 @@ async fn cached_package( ) -> Result { // Resolving the registry is a settings read; the packument behind it is a round trip, // and a version already on disk needs neither, so it is fetched only on a miss. - let (registry_url, auth_token) = get_npm_registry(db) + let (registry_url, auth) = get_npm_registry(db) .await? .ok_or_else(|| Error::BadRequest("No private npm registry configured".to_string()))?; @@ -963,9 +972,9 @@ async fn cached_package( } // The same snapshot the cache key came from, not a second read of the setting. - let package_json = fetch_package_json_from(®istry_url, &auth_token, package).await?; + let package_json = fetch_package_json_from(®istry_url, &auth, package).await?; let archive = - fetch_tarball(&package_json, package, version, ®istry_url, &auth_token).await?; + fetch_tarball(&package_json, package, version, ®istry_url, &auth).await?; let target = dir.clone(); let extracted = blocking(move || { diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs index 78cd6eb378..7a523c89fa 100644 --- a/backend/windmill-api-schedule/src/lib.rs +++ b/backend/windmill-api-schedule/src/lib.rs @@ -409,7 +409,8 @@ async fn create_schedule( paused_until, cron_version, dynamic_skip, - labels + labels, + late_run_streak "#, w_id, ns.path, @@ -621,7 +622,10 @@ async fn edit_schedule( edited_by = $25, edited_at = now(), permissioned_as = $26, - labels = COALESCE($27, labels) + labels = COALESCE($27, labels), + late_run_streak = 0, + missed_occurrences = 0, + last_missed_at = NULL WHERE path = $19 AND workspace_id = $20 RETURNING workspace_id, @@ -656,7 +660,8 @@ async fn edit_schedule( paused_until, cron_version, dynamic_skip, - labels + labels, + late_run_streak "#, es.schedule, es.timezone, @@ -1004,6 +1009,9 @@ async fn list_schedule( pub struct ScheduleWJobs { pub path: String, pub jobs: Option>, + pub late_run_streak: i32, + pub missed_occurrences: i32, + pub last_missed_at: Option>, } async fn list_schedule_with_jobs( @@ -1020,7 +1028,7 @@ async fn list_schedule_with_jobs( // - use of the `ix_v2_job_root_by_path` index; hence the `parent_job IS NULL` clause. // - both `workspace_id = $1` checks are required to hit both indexes. "SELECT - schedule.path, t.jobs FROM schedule, + schedule.path, t.jobs, schedule.late_run_streak, schedule.missed_occurrences, schedule.last_missed_at FROM schedule, LATERAL(SELECT ARRAY( SELECT json_build_object('id', id, 'success', status = 'success', 'duration_ms', duration_ms) FROM v2_job_completed c JOIN v2_job j USING (id) @@ -1157,7 +1165,10 @@ pub async fn set_enabled( r#" UPDATE schedule SET enabled = $1, - email = $2 + email = $2, + late_run_streak = 0, + missed_occurrences = 0, + last_missed_at = NULL WHERE path = $3 AND workspace_id = $4 RETURNING workspace_id, @@ -1192,7 +1203,8 @@ pub async fn set_enabled( paused_until, cron_version, dynamic_skip, - labels + labels, + late_run_streak "#, payload.enabled, authed.email, diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 42617b249b..bce67755bb 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -17,7 +17,7 @@ use windmill_common::{ utils::{BulkDeleteRequest, WithStarredInfoQuery, HTTP_CLIENT}, webhook::{WebhookMessage, WebhookShared}, workspaces::{check_deploy_rules, RuleCheckResult}, - DB, + DeletedScriptVersions, DB, }; use windmill_queue::schedule::clear_schedule; @@ -551,6 +551,7 @@ async fn create_snapshot_script( let mut handle_deployment_metadata = None; let mut moved_native_triggers = Vec::new(); let mut deployed_path = None; + let mut deployed_perpetual = false; while let Some(field) = multipart.next_field().await.unwrap() { let name = field.name().unwrap().to_string(); let data = field.bytes().await.unwrap(); @@ -559,6 +560,7 @@ async fn create_snapshot_script( let is_tar = ns.codebase.as_ref().is_some_and(|x| x.ends_with(".tar")); let use_esm = ns.codebase.as_ref().is_some_and(|x| x.contains(".esm")); deployed_path = Some(ns.path.clone()); + deployed_perpetual = ns.restart_unless_cancelled == Some(true); let (new_hash, ntx, hdm, moved) = create_script_internal( ns, w_id.clone(), @@ -616,6 +618,19 @@ async fn create_snapshot_script( } reregister_moved_native_triggers(&db, &authed, &w_id, moved_native_triggers); if let Some(hdm) = handle_deployment_metadata { + let runnable_now = matches!(hdm, PostCommitDeploy::Full { .. }); + if let Some(script_path) = deployed_path + .as_deref() + .filter(|_| runnable_now && deployed_perpetual) + { + windmill_queue::restart_perpetual_runs_on_new_version( + &db, + &w_id, + script_path, + &authed.username, + ) + .await; + } hdm.handle(&db).await?; } return Ok((StatusCode::CREATED, format!("{}", script_hash.unwrap()))); @@ -734,6 +749,9 @@ async fn deploy_script( return Err(Error::PermissionDenied(msg)); } let script_path = ns.path.clone(); + // Only a perpetual deploy can have runs to move, so every other one skips the lookups that + // would find that out. + let perpetual = ns.restart_unless_cancelled == Some(true); let email = authed.email.clone(); let username = authed.username.clone(); let authed_for_triggers = authed.clone(); @@ -757,6 +775,19 @@ async fn deploy_script( // they don't run against a version whose lock does not exist yet — and // don't run twice. let ready_to_test = matches!(hdm, PostCommitDeploy::Full { .. }); + // The version is runnable, so the perpetual runs of earlier ones move to it here, before + // anything that can fail this deploy after its commit: a version nothing moved to would + // leave those runs on the old code with nothing left to notice. A deploy that needed lock + // generation hands this to its dependency job instead. + if ready_to_test && perpetual { + windmill_queue::restart_perpetual_runs_on_new_version( + &db, + &w_id, + &script_path, + &username, + ) + .await; + } hdm.handle(&db).await?; let db2 = db.clone(); if ready_to_test { @@ -787,6 +818,16 @@ fn invalidate_script_path_caches(w_id: &str, script_path: &str) { RAW_SCRIPT_LATEST_HASH_CACHE.remove(&format!("{w_id}:{script_path}")); } +/// [`DeletedScriptVersions::evict`], plus this crate's own path -> hash cache. Run by the +/// deleting process after its commit and by every process on the deletion event. +pub fn evict_deleted_script_versions(deleted: DeletedScriptVersions) { + deleted.evict_with(|deleted| { + for path in &deleted.paths { + RAW_SCRIPT_LATEST_HASH_CACHE.remove(&format!("{}:{path}", deleted.workspace_id)); + } + }); +} + /// What a script deploy still has to do once its transaction has committed. enum PostCommitDeploy { /// Everything, for a deploy with no dependency job to hand it to. @@ -2864,6 +2905,12 @@ async fn create_script_internal<'c>( if let Some(dm) = ns.deployment_message { args.insert("deployment_message".to_string(), to_raw_value(&dm)); } + // The version becomes runnable when this job writes its lock, which is where the + // perpetual runs of earlier versions can move to it. Only a deploy someone made carries + // this, so a relock triggered by an imported script changing leaves those runs alone. + if ns.restart_unless_cancelled.is_some_and(|x| x) { + args.insert("restart_perpetual_runs".to_string(), to_raw_value(&true)); + } if let Some(ref p_path) = p_path_opt { args.insert("parent_path".to_string(), to_raw_value(&p_path)); } @@ -4124,6 +4171,8 @@ async fn delete_script_by_hash( // the script was never a pipeline member. clear_script_triggers(&mut *tx, &w_id, &script.path, AssetUsageKind::Script).await?; clear_macro_registry(&mut *tx, &w_id, &script.path).await?; + let deleted = DeletedScriptVersions::new(&w_id, [(script.path.clone(), hash.0)]); + deleted.notify(&mut *tx).await?; audit_log( &mut *tx, @@ -4136,6 +4185,7 @@ async fn delete_script_by_hash( ) .await?; tx.commit().await?; + evict_deleted_script_versions(deleted); webhook.send_message( w_id.clone(), @@ -4203,14 +4253,24 @@ async fn delete_script_by_path( .fetch_all(&mut *tx) .await?; - let script = sqlx::query_scalar!( - "DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING path", + let deleted_hashes = sqlx::query_scalar!( + "DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING hash", path, w_id ) - .fetch_one(&mut *tx) + .fetch_all(&mut *tx) .await .map_err(|e| Error::internal_err(format!("deleting script by path {w_id}: {e:#}")))?; + if deleted_hashes.is_empty() { + return Err(Error::NotFound(format!( + "script {path} not found in {w_id}" + ))); + } + let script = path.to_string(); + let deleted = DeletedScriptVersions::new( + &w_id, + deleted_hashes.into_iter().map(|h| (script.clone(), h)), + ); // After the DELETE, never before: every dbt writer locks the `script` row // first, so taking a sidecar ahead of it deadlocks one of the pair. The @@ -4250,6 +4310,7 @@ async fn delete_script_by_path( // the script was never a pipeline member. clear_script_triggers(&mut *tx, &w_id, path, AssetUsageKind::Script).await?; clear_macro_registry(&mut *tx, &w_id, path).await?; + deleted.notify(&mut *tx).await?; if !query.keep_captures.unwrap_or(false) { sqlx::query!( @@ -4280,6 +4341,7 @@ async fn delete_script_by_path( ) .await?; tx.commit().await?; + evict_deleted_script_versions(deleted); handle_deployment_metadata( &authed.email, @@ -4375,14 +4437,21 @@ async fn delete_scripts_bulk( } } - let mut deleted_paths = sqlx::query_scalar!( - "DELETE FROM script WHERE workspace_id = $1 AND path = ANY($2) RETURNING path", - w_id, - &request.paths - ) - .fetch_all(&mut *tx) - .await - .map_err(|e| Error::internal_err(format!("deleting scripts in bulk {w_id}: {e:#}")))?; + let deleted = DeletedScriptVersions::new( + &w_id, + sqlx::query!( + "DELETE FROM script WHERE workspace_id = $1 AND path = ANY($2) RETURNING path, hash", + w_id, + &request.paths + ) + .fetch_all(&mut *tx) + .await + .map_err(|e| Error::internal_err(format!("deleting scripts in bulk {w_id}: {e:#}")))? + .into_iter() + .map(|r| (r.path, r.hash)), + ); + deleted.notify(&mut *tx).await?; + let deleted_paths = deleted.paths.clone(); // Same reason as the single-path delete, over every requested path rather // than the deleted ones: a path that had no script left can still hold state. @@ -4391,10 +4460,6 @@ async fn delete_scripts_bulk( windmill_common::dbt_manifest::clear_dbt_editor_graphs(&mut tx, &w_id, p).await?; } - // remove duplicates from deleted_paths - deleted_paths.sort(); - deleted_paths.dedup(); - sqlx::query!( "DELETE FROM draft WHERE workspace_id = $1 AND path = ANY($2) AND typ = 'script'", w_id, @@ -4435,6 +4500,7 @@ async fn delete_scripts_bulk( .await?; tx.commit().await?; + evict_deleted_script_versions(deleted); try_join_all(deleted_paths.iter().map(|path| { handle_deployment_metadata( diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index ca93d3c682..71cbcc8460 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -42,7 +42,6 @@ use axum::{ routing::{get, post}, Json, Router, }; -use serde_json::json; use serde::{Deserialize, Serialize}; use windmill_ai::ai_cache::bump_instance_ai_config_revision; @@ -61,6 +60,7 @@ use windmill_common::{ ACCENT_COLOR_SETTING, AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, + EXTERNAL_INSTANCE_PG_SETTING, GITHUB_APP_WEBHOOK_BASE_URL_SETTING, HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, INSTANCE_BANNER_SETTING, MAX_RETENTION_OVERRIDE_WORKSPACES, @@ -181,6 +181,22 @@ pub fn global_service() -> Router { "/refresh_custom_instance_user_pwd", post(refresh_custom_instance_user_pwd), ) + .route( + "/external_instance_pg/status", + get(get_external_instance_pg_status), + ) + .route( + "/external_instance_pg/setup", + post(setup_external_instance_pg), + ) + .route( + "/external_instance_pg/databases", + get(list_external_instance_pg_databases), + ) + .route( + "/external_instance_pg/databases/{name}", + post(create_external_instance_pg_database).delete(drop_external_instance_pg_database), + ) .route( "/setup_custom_instance_pg_database/{name}", post(setup_custom_instance_pg_database), @@ -853,6 +869,14 @@ pub async fn set_global_setting_internal( ))); } + if key == EXTERNAL_INSTANCE_PG_SETTING { + return windmill_common::external_instance_pg::write_external_instance_pg_setting( + db, + Some(&value), + ) + .await; + } + run_setting_pre_write_hook(db, &key, &value).await?; match value { @@ -1248,7 +1272,7 @@ async fn set_instance_config( let desired_map = desired.global_settings.to_settings_map(); if !desired_map.is_empty() { let current_map = current.global_settings.to_settings_map(); - let settings_diff = + let mut settings_diff = instance_config::diff_global_settings(¤t_map, &desired_map, ApplyMode::Merge); let ai_config_changed = settings_diff .upserts @@ -1277,8 +1301,15 @@ async fn set_instance_config( } for (key, value) in &settings_diff.upserts { - run_setting_pre_write_hook(&db, key, value).await?; + if key != EXTERNAL_INSTANCE_PG_SETTING { + run_setting_pre_write_hook(&db, key, value).await?; + } } + windmill_common::external_instance_pg::write_external_instance_pg_from_diff( + &db, + &mut settings_diff, + ) + .await?; instance_config::apply_settings_diff(&db, &settings_diff) .await @@ -1643,6 +1674,8 @@ struct CustomInstanceDb { tag: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] used_by_workspaces: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_id: Option, } #[derive(Deserialize, Debug, Serialize, Default)] @@ -1685,7 +1718,40 @@ async fn list_custom_instance_pg_databases( )) })?; - if windmill_api_auth::is_super_admin_authed(&db, &authed).await? { + if !windmill_api_auth::is_super_admin_authed(&db, &authed).await? { + // A fork copy's name gives away the workspace it was reserved for, so every pending fork on + // the instance would be listed. Kept for members of that workspace, and wherever the + // caller's workspaces use it, e.g. the fork it was finalized into. + let reserved_visible: BTreeSet = sqlx::query_scalar( + r#"SELECT e.k FROM global_settings gs + CROSS JOIN LATERAL jsonb_each(gs.value->'databases') AS e(k, v) + WHERE gs.name = 'custom_instance_pg_databases' AND e.v->>'workspace_id' IS NOT NULL + AND (EXISTS (SELECT 1 FROM usr WHERE usr.email = $1 + AND usr.workspace_id = e.v->>'workspace_id') + OR EXISTS (SELECT 1 FROM usr JOIN workspace_settings ws + ON ws.workspace_id = usr.workspace_id + CROSS JOIN LATERAL jsonb_each( + CASE WHEN jsonb_typeof(ws.datatable->'datatables') = 'object' + THEN ws.datatable->'datatables' ELSE '{}'::jsonb END) dt + WHERE usr.email = $1 + AND dt.value->'database'->>'resource_type' = 'instance' + AND dt.value->'database'->>'resource_path' = e.k))"#, + ) + .bind(&authed.email) + .fetch_all(&db) + .await? + .into_iter() + .collect(); + result.retain(|dbname, entry| { + entry.workspace_id.is_none() || reserved_visible.contains(dbname) + }); + // Which workspace reserved a copy is still only for superadmins. + for entry in result.values_mut() { + entry.workspace_id = None; + } + return Ok(Json(result)); + } + { // Enrich each database with the list of workspaces referencing it through // either a ducklake catalog or a datatable database whose resource_type is // 'instance'. Not stored in DB to avoid drift. @@ -1741,6 +1807,139 @@ async fn refresh_custom_instance_user_pwd( Ok(Json(())) } +async fn get_external_instance_pg_status( + authed: ApiAuthed, + Extension(db): Extension, +) -> JsonResult { + require_super_admin(&db, &authed).await?; + Ok(Json( + windmill_common::external_instance_pg::external_instance_pg_status(&db).await?, + )) +} + +#[derive(Deserialize)] +struct SetupExternalInstancePgBody { + #[serde(default)] + rotate_passwords: bool, +} + +async fn setup_external_instance_pg( + authed: ApiAuthed, + Extension(db): Extension, + Json(body): Json, +) -> JsonResult { + require_super_admin(&db, &authed).await?; + let report = windmill_common::external_instance_pg::setup_external_instance_pg_unchecked( + &db, + body.rotate_passwords, + ) + .await?; + let rotated = body.rotate_passwords.to_string(); + let success = report.success.to_string(); + windmill_audit::audit_oss::audit_log( + &db, + &authed, + "settings.setup_external_instance_pg", + windmill_audit::ActionKind::Update, + "global", + Some(&authed.email), + Some( + [ + ("rotate_passwords", rotated.as_str()), + ("success", success.as_str()), + ] + .into(), + ), + ) + .await?; + Ok(Json(report)) +} + +#[derive(Serialize)] +struct ExternalInstancePgDatabase { + #[serde(flatten)] + status: windmill_common::instance_config::CustomInstanceDb, + used_by_workspaces: Vec, +} + +async fn list_external_instance_pg_databases( + authed: ApiAuthed, + Extension(db): Extension, +) -> JsonResult> { + require_super_admin(&db, &authed).await?; + let databases = windmill_common::external_instance_pg::external_instance_databases(&db).await?; + let mut usages = + windmill_common::external_instance_pg::external_instance_database_usages(&db).await?; + Ok(Json( + databases + .into_iter() + .map(|(name, status)| { + let used_by_workspaces = usages.remove(&name).unwrap_or_default(); + ( + name, + ExternalInstancePgDatabase { + status, + used_by_workspaces: used_by_workspaces.into_iter().collect(), + }, + ) + }) + .collect(), + )) +} + +async fn create_external_instance_pg_database( + authed: ApiAuthed, + Extension(db): Extension, + Path(dbname): Path, + Json(body): Json, +) -> JsonResult<()> { + require_super_admin(&db, &authed).await?; + let tag = body.tag.as_deref().unwrap_or("datatable"); + let mut tx = db.begin().await?; + windmill_common::external_instance_pg::create_external_instance_database_unchecked( + &db, &mut tx, &dbname, tag, None, + ) + .await?; + tx.commit().await?; + windmill_audit::audit_oss::audit_log( + &db, + &authed, + "settings.create_external_instance_pg_database", + windmill_audit::ActionKind::Create, + "global", + Some(&authed.email), + Some([("dbname", dbname.as_str()), ("tag", tag)].into()), + ) + .await?; + Ok(Json(())) +} + +async fn drop_external_instance_pg_database( + authed: ApiAuthed, + Extension(db): Extension, + Path(dbname): Path, +) -> JsonResult<()> { + require_super_admin(&db, &authed).await?; + // A data table naming a dropped database fails on every job, far from the drop that caused it. + let mut tx = db.begin().await?; + windmill_common::external_instance_pg::drop_external_instance_database_unchecked( + &mut tx, &dbname, None, + ) + .await?; + tx.commit().await?; + windmill_audit::audit_oss::audit_log( + &db, + &authed, + "settings.drop_external_instance_pg_database", + windmill_audit::ActionKind::Delete, + "global", + Some(&authed.email), + Some([("dbname", dbname.as_str())].into()), + ) + .await?; + Ok(Json(())) +} + #[derive(Deserialize)] struct SetupCustomInstanceDbBody { tag: Option, @@ -1752,18 +1951,46 @@ async fn setup_custom_instance_pg_database( Path(dbname): Path, Json(body): Json, ) -> JsonResult { + // Before anything is recorded: the status written below replaces the registry entry, and with it + // the workspace a fork copy is reserved for. + require_super_admin(&db, &authed).await?; + windmill_common::workspaces::ensure_instance_pg_available(&db).await?; + // Fork cleanup checks and drops the database and its entry under this lock. Held from before + // the setup creates the database to after its entry is written, neither lands on the other's + // half-done state: a dropped database with its entry written back, or the reverse. + let mut tx = db.begin().await?; + windmill_common::datatable_roles::lock_instance_databases_governance(&mut tx, [dbname.trim()]) + .await?; let mut logs = CustomInstanceDbLogs::default(); let result = setup_custom_instance_pg_database_inner(authed, &db, &dbname, &mut logs).await; let success = result.is_ok(); let error = result.err().map(|e| e.to_string()); - let status = - CustomInstanceDb { logs, success, error, tag: body.tag, used_by_workspaces: vec![] }; + let status = CustomInstanceDb { + logs, + success, + error, + tag: body.tag, + used_by_workspaces: vec![], + workspace_id: None, + }; let status_json = serde_json::to_value(&status).map_err(to_anyhow)?; - // Save that the database was setup successfully - sqlx::query!( - r#"UPDATE global_settings SET value = jsonb_set(value, '{databases}', (COALESCE(value->'databases', '{}'::jsonb) || to_jsonb($1::json))) WHERE name = 'custom_instance_pg_databases'"#, - json!({ dbname: status_json }) - ).execute(&db).await?; + // The fork reservation is carried over inside the write, from whatever the row holds then: a + // rename migrating it while the setup above ran would otherwise be overwritten with the value + // this request started from, stranding the copy under the archived workspace. + let saved = sqlx::query_scalar::<_, serde_json::Value>( + r#"UPDATE global_settings SET value = jsonb_set(value, '{databases}', + COALESCE(value->'databases', '{}'::jsonb) + || jsonb_build_object($1::text, $2::jsonb || jsonb_build_object( + 'workspace_id', value->'databases'->$1::text->'workspace_id'))) + WHERE name = 'custom_instance_pg_databases' + RETURNING value->'databases'->$1::text"#, + ) + .bind(&dbname) + .bind(&status_json) + .fetch_one(&mut *tx) + .await?; + tx.commit().await?; + let status: CustomInstanceDb = serde_json::from_value(saved).map_err(to_anyhow)?; Ok(Json(status)) } diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index 8d1242befa..ac9defcd53 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -2866,8 +2866,26 @@ async fn refresh_token( .to_string(), )); } + let t_hash = windmill_common::auth::hash_token(&token); + // Only a live, non-impersonation row may be exchanged for a session: an impersonation must + // end at its 24h expiry, not become a renewable service-account session billed as active. + // Read the row rather than trust `authed`, which the auth cache keeps serving for up to + // 120s after the row expired or was swept. `jwt_` tokens have no row. + if !token.starts_with("jwt_") { + let refreshable = sqlx::query_scalar!( + "SELECT NOT starts_with(COALESCE(label, ''), 'impersonation:') FROM token + WHERE token_hash = $1 AND (expiration IS NULL OR expiration > now())", + &t_hash + ) + .fetch_optional(&db) + .await? + .flatten() + .unwrap_or(false); + if !refreshable { + return Ok("this session cannot be refreshed".to_string()); + } + } if let Some(thresh_s) = query.if_expiring_in_less_than_s { - let t_hash = windmill_common::auth::hash_token(&token); let not_expired = sqlx::query_scalar!("SELECT true FROM token WHERE token_hash = $1 and expiration IS NOT NULL and expiration > now() + $2::int * '1 sec'::interval", &t_hash, thresh_s) .fetch_optional(&db) .await? diff --git a/backend/windmill-api-workspaces/Cargo.toml b/backend/windmill-api-workspaces/Cargo.toml index 7ca1f692ad..9f05a26f28 100644 --- a/backend/windmill-api-workspaces/Cargo.toml +++ b/backend/windmill-api-workspaces/Cargo.toml @@ -50,6 +50,7 @@ sha2.workspace = true sqlx.workspace = true tokio.workspace = true tokio-postgres.workspace = true +tokio-util.workspace = true tracing.workspace = true url.workspace = true uuid.workspace = true diff --git a/backend/windmill-api-workspaces/src/datatable_acl.rs b/backend/windmill-api-workspaces/src/datatable_acl.rs index 04dc3d4e80..ebab6214ce 100644 --- a/backend/windmill-api-workspaces/src/datatable_acl.rs +++ b/backend/windmill-api-workspaces/src/datatable_acl.rs @@ -6,7 +6,7 @@ * LICENSE-AGPL for a copy of the license. */ -//! Ownership and grants on the objects of an instance data table. +//! Ownership and grants on the objects of a data table on a cluster Windmill manages. //! //! [`datatable_permissions`](crate::datatable_permissions) decides who may connect as which role; //! this decides what each role may then touch. Every change is a real `GRANT`, `REVOKE`, @@ -33,7 +33,7 @@ use windmill_audit::audit_oss::audit_log; use windmill_audit::ActionKind; use windmill_common::datatable_roles::{ lock_role_catalog, quote_ident, read_role_catalog, read_role_catalog_tx, DatatableRoleCatalog, - ADMIN_DATATABLE_ROLE, CUSTOM_INSTANCE_USER, + DatatableRoleCluster, ADMIN_DATATABLE_ROLE, CUSTOM_INSTANCE_USER, }; use windmill_common::error::{pg_error_message, Error, JsonResult, Result}; use windmill_common::workspaces::{resolve_governing_datatable, DataTable, GoverningDatatable}; @@ -297,22 +297,22 @@ fn role_names(catalog: &DatatableRoleCatalog) -> Vec { names } -fn ensure_instance(governing: &GoverningDatatable) -> Result<()> { - if governing.is_instance() { - return Ok(()); - } - Err(Error::BadRequest(format!( - "Data table '{}' is backed by a Postgres resource, so its access is managed on that \ - server directly. Only a data table on the Windmill instance's own database has data \ - table roles to grant to.", - governing.name - ))) +/// The cluster whose roles the data table's grants name. +fn ensure_managed(governing: &GoverningDatatable) -> Result { + governing.role_cluster().ok_or_else(|| { + Error::BadRequest(format!( + "Data table '{}' is backed by a Postgres resource, so its access is managed on that \ + server directly. Only a data table on a database Windmill manages has data table \ + roles to grant to.", + governing.name + )) + }) } /// The data table's `admin` connection, and the notices Postgres sends on it. /// -/// Authorization: connects as `custom_instance_user` with the instance's own credentials and checks -/// nothing. Callers MUST have authorized the request first — a request about to be refused must +/// Authorization: connects as `custom_instance_user` with the cluster's stored credentials and +/// checks nothing. Callers MUST have authorized the request first — a request about to be refused must /// not get as far as this connection. async fn connect_as_admin_unchecked( db: &DB, @@ -322,21 +322,33 @@ async fn connect_as_admin_unchecked( mpsc::UnboundedReceiver, String, )> { - ensure_instance(governing)?; + let cluster = ensure_managed(governing)?; // Built from the authorized entry, never by resolving the settings again: a save in between // could point the entry at a resource on another server and back, and this connection would // then alter a database the later checks of the entry never see. - let mut pg = PgDatabase::parse_uri(&windmill_common::get_database_url().await?.as_str().await)?; - pg.dbname = governing + let dbname = governing .datatable .database .as_ref() .expect("a governing entry owns a database") .resource_path .clone(); - pg.user = Some(CUSTOM_INSTANCE_USER.to_string()); - pg.password = Some(windmill_common::utils::get_custom_pg_instance_password(db).await?); - let dbname = pg.dbname.clone(); + let pg = match cluster { + DatatableRoleCluster::Instance => { + let mut pg = + PgDatabase::parse_uri(&windmill_common::get_database_url().await?.as_str().await)?; + pg.dbname = dbname.clone(); + pg.user = Some(CUSTOM_INSTANCE_USER.to_string()); + pg.password = Some(windmill_common::utils::get_custom_pg_instance_password(db).await?); + pg + } + DatatableRoleCluster::ExternalInstance => { + windmill_common::external_instance_pg::external_instance_connection_unchecked( + db, &dbname, false, + ) + .await? + } + }; let (client, notices) = connect_with_notices(db, &pg).await?; Ok((client, notices, dbname)) } @@ -1085,12 +1097,12 @@ async fn get_datatable_acl( let target: AclTarget = query.try_into()?; let governing = resolve_governing_datatable(&db, &w_id, &datatable_name).await?; ensure_reaches_governing_datatable(&db, &w_id, &datatable_name, &governing, &authed).await?; - ensure_instance(&governing)?; + let cluster = ensure_managed(&governing)?; let editable = ensure_governs_datatable(&db, &authed, &w_id, &governing) .await .is_ok(); let roles = if editable { - role_names(&read_role_catalog(&db).await?) + role_names(&read_role_catalog(&db, cluster).await?) } else { vec![] }; @@ -1376,7 +1388,7 @@ async fn authorize_acl_change( ) -> Result { let governing = resolve_governing_datatable(db, w_id, datatable_name).await?; ensure_governs_datatable(db, authed, w_id, &governing).await?; - ensure_instance(&governing)?; + ensure_managed(&governing)?; Ok(governing) } @@ -1386,8 +1398,14 @@ static APPLY_SLOT: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(1) /// provisioned before data table roles gave `custom_instance_user` none. Adds that option to its /// database and `public` privileges, and nothing else: default privileges are left alone, since a /// schema's change of owner is planned against them. Best-effort, as a grant it fails to enable is -/// refused when it runs. -async fn ensure_grant_options(client: &tokio_postgres::Client, db: &DB, dbname: &str) { +/// refused when it runs. An external instance database was created with the options, so one +/// missing there is someone's deliberate revoke and is left alone. +async fn ensure_grant_options( + client: &tokio_postgres::Client, + db: &DB, + cluster: DatatableRoleCluster, + dbname: &str, +) { let held = client .query_one( "SELECT has_database_privilege(current_database(), 'CONNECT WITH GRANT OPTION') @@ -1399,7 +1417,7 @@ async fn ensure_grant_options(client: &tokio_postgres::Client, db: &DB, dbname: ) .await .is_ok_and(|row| row.get::<_, bool>(0)); - if held { + if held || cluster != DatatableRoleCluster::Instance { return; } if let Err(e) = grant_options_as_server(db, dbname).await { @@ -1625,7 +1643,8 @@ async fn plan_datatable_acl( ) -> JsonResult { crate::datatable_acl_oss::ensure_datatable_acl_available()?; let governing = authorize_acl_change(&db, &authed, &w_id, &datatable_name).await?; - let catalog = read_role_catalog(&db).await?; + let cluster = ensure_managed(&governing)?; + let catalog = read_role_catalog(&db, cluster).await?; let (client, _notices, dbname) = connect_as_admin_unchecked(&db, &governing).await?; Ok(Json( build_plan(&client, &dbname, &catalog, &req.target, &req.change).await?, @@ -1649,6 +1668,7 @@ async fn apply_datatable_acl( // connection could wait forever on a pool that concurrent applies, queued on the same locks, // have exhausted. let governing = authorize_acl_change(&db, &authed, &w_id, &datatable_name).await?; + let cluster = ensure_managed(&governing)?; // Applies queue on an instance-wide lock while each holds a direct connection to the instance's // Postgres; unbounded, the queue alone could exhaust its connection limit. One at a time per // server, and the ones waiting hold no connection at all. @@ -1657,7 +1677,7 @@ async fn apply_datatable_acl( .await .map_err(|e| Error::internal_err(format!("ACL apply slot closed: {e}")))?; let (mut client, mut notices, dbname) = connect_as_admin_unchecked(&db, &governing).await?; - ensure_grant_options(&client, &db, &dbname).await; + ensure_grant_options(&client, &db, cluster, &dbname).await; // Held until the change is committed: a role renamed or dropped meanwhile would change what // the plan names, and a settings save could move the entry onto another database. Taken in the @@ -1672,7 +1692,7 @@ async fn apply_datatable_acl( .fetch_optional(&mut *tx) .await? .flatten(); - let catalog = read_role_catalog_tx(&mut tx).await?; + let catalog = read_role_catalog_tx(&mut tx, cluster).await?; let plan = build_plan(&client, &dbname, &catalog, &req.target, &req.change).await?; if !entry_unchanged(&governing, entry_now) || &plan.statements != confirmed { diff --git a/backend/windmill-api-workspaces/src/datatable_clone.rs b/backend/windmill-api-workspaces/src/datatable_clone.rs index a57a2216e5..6083b7b619 100644 --- a/backend/windmill-api-workspaces/src/datatable_clone.rs +++ b/backend/windmill-api-workspaces/src/datatable_clone.rs @@ -11,8 +11,8 @@ //! The fork request makes its copies before it writes the fork: each is created, filled and — for a //! data table under roles — given the source's owners and grants. What each copy was made from //! stays in the request ([`MadeCopy`]), so the fork's transaction checks it against the source as it -//! is then, and a fork that fails drops the instance copies it made ([`drop_copies_after`]) and names -//! the others, which live on servers of the workspace's own. +//! is then, and a fork that fails drops the copies it made on a cluster Windmill manages +//! ([`drop_copies_after`]) and names the others, which live on servers of the workspace's own. use std::collections::BTreeSet; @@ -22,8 +22,8 @@ use windmill_common::error::{pg_error_message, Error, Result}; use windmill_common::utils::require_admin; use windmill_common::worker::CLOUD_HOSTED; use windmill_common::workspaces::{ - get_datatable_resource_from_db_unchecked, DataTableDatabase, DataTableForkBehavior, - GoverningDatatable, + get_datatable_resource_from_db_unchecked, DataTableCatalogResourceType, DataTableDatabase, + DataTableForkBehavior, GoverningDatatable, }; use windmill_common::{PgDatabase, DB}; @@ -312,27 +312,38 @@ pub(crate) async fn drop_copies_after(db: &DB, copies: Vec, error: Err } async fn drop_copy(db: &DB, source_database: &DataTableDatabase, dbname: &str) -> Result<()> { - if source_database.resource_type - == windmill_common::workspaces::DataTableCatalogResourceType::Instance - { - match windmill_common::drop_unused_instance_database(db, dbname).await? { - windmill_common::Cleanup::Dropped => Ok(()), - windmill_common::Cleanup::InUse(users) => Err(Error::BadRequest(format!( - "kept, since workspaces {} now use it", - users.join(", ") - ))), - windmill_common::Cleanup::Waiter(pid) => Err(Error::BadRequest(format!( - "kept, since a request (pid {pid}) is still waiting to name it" - ))), + match source_database.resource_type { + DataTableCatalogResourceType::Instance => { + match windmill_common::drop_unused_instance_database(db, dbname).await? { + windmill_common::Cleanup::Dropped => Ok(()), + windmill_common::Cleanup::InUse(users) => Err(Error::BadRequest(format!( + "kept, since workspaces {} now use it", + users.join(", ") + ))), + windmill_common::Cleanup::Waiter(pid) => Err(Error::BadRequest(format!( + "kept, since a request (pid {pid}) is still waiting to name it" + ))), + } } - } else { + // Refused, and the copy kept, when anything names it by then. + DataTableCatalogResourceType::ExternalInstance => { + let mut tx = db.begin().await?; + windmill_common::external_instance_pg::drop_external_instance_database_unchecked( + &mut tx, dbname, None, + ) + .await?; + tx.commit().await?; + Ok(()) + } + DataTableCatalogResourceType::Postgresql => { // On a server of the workspace's own, where a resource edited meanwhile can already name it // and nothing locks such an edit: dropping it could take someone's data. - Err(Error::BadRequest( - "kept on its PostgreSQL server, where it may already be in use; drop it there once it \ - is not, before forking the same data table under this id again" - .to_string(), - )) + Err(Error::BadRequest( + "kept on its PostgreSQL server, where it may already be in use; drop it there once \ + it is not, before forking the same data table under this id again" + .to_string(), + )) + } } } @@ -349,10 +360,10 @@ async fn make_copy( request.name )) })?; - let is_instance = governing.is_instance(); + let managed = source_database.resource_type.is_windmill_managed(); // Resolved from the snapshot, not read again: the connection the copy is made on is then // exactly what these rows describe, which the fork's own clone of them is checked against. - let (server, connection): (PgDatabase, Option) = if is_instance { + let (server, connection): (PgDatabase, Option) = if managed { let server = serde_json::from_value( get_datatable_resource_from_db_unchecked(db, parent_w_id, request.name).await?, ) @@ -383,22 +394,44 @@ async fn make_copy( // Dumped before the database exists, so a source that cannot be read leaves nothing behind. // Ownership never carries over: the restore runs as the target's connection user. Grants do, - // except on the instance, where the replay below is what reproduces them. + // except on a cluster Windmill manages, which plants its own and where the replay below is what + // reproduces the roles'. let dump = pg_dump_database( &server, PgDumpOptions { schema_only: request.behavior == DataTableForkBehavior::SchemaOnly, no_owner: true, - no_acl: is_instance, + no_acl: managed, ..Default::default() }, ) .await?; - if is_instance { - windmill_common::create_custom_instance_database(db, request.dbname, "datatable").await?; - } else { - create_database_on_server(db, &server, request.dbname).await?; + match source_database.resource_type { + DataTableCatalogResourceType::Instance => { + windmill_common::create_custom_instance_database( + db, + request.dbname, + "datatable", + Some(parent_w_id), + ) + .await? + } + DataTableCatalogResourceType::ExternalInstance => { + let mut tx = db.begin().await?; + windmill_common::external_instance_pg::create_external_instance_database_unchecked( + db, + &mut tx, + request.dbname, + "datatable", + Some(parent_w_id), + ) + .await?; + tx.commit().await?; + } + DataTableCatalogResourceType::Postgresql => { + create_database_on_server(db, &server, request.dbname).await? + } } let target = PgDatabase { dbname: request.dbname.to_string(), ..server.clone() }; @@ -481,11 +514,16 @@ async fn fill( let replayed = now.datatable.permissions.is_some(); if replayed { crate::datatable_replay_oss::ensure_replay()?; - let catalog = read_role_catalog_tx(&mut tx).await?; + // The copy is on the source's cluster, so its roles come from that cluster's catalog. + let cluster = governing + .role_cluster() + .ok_or_else(|| Error::internal_err("a replayed clone is on a managed database"))?; + let catalog = read_role_catalog_tx(&mut tx, cluster).await?; // The replay leaves `CONNECT` to the catalog, and creating the database only tried to set // it: a copy `PUBLIC` could still connect to would admit logins the source turns away. windmill_common::datatable_roles::converge_connect_grants_with( db, + cluster, &target.dbname, &catalog, ) diff --git a/backend/windmill-api-workspaces/src/datatable_migrations.rs b/backend/windmill-api-workspaces/src/datatable_migrations.rs index df1fac203b..f762fda175 100644 --- a/backend/windmill-api-workspaces/src/datatable_migrations.rs +++ b/backend/windmill-api-workspaces/src/datatable_migrations.rs @@ -11,7 +11,7 @@ //! to keep that file focused on core workspace configuration. use crate::workspaces::{ - is_instance_datatable, pg_dump_database, strip_unreplayable_dump_lines, ItemComparison, + managed_datatable_kind, pg_dump_database, strip_unreplayable_dump_lines, ItemComparison, PgDumpOptions, }; @@ -1669,7 +1669,9 @@ async fn generate_initial_datatable_migration( // without what a replay elsewhere cannot run: the replaying user owns none of this // database's objects, and the grants Windmill plants in an instance database (`ALTER // DEFAULT PRIVILEGES FOR ROLE ...`) fail even replaying onto the same server. - let no_acl = is_instance_datatable(&db, &w_id, &datatable_name).await?; + let no_acl = managed_datatable_kind(&db, &w_id, &datatable_name) + .await? + .is_some(); let dump_file = pg_dump_database( &pg_db, PgDumpOptions { diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 185fe17b25..81fead981b 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -176,6 +176,10 @@ pub fn workspaced_service() -> Router { .route("/leave", post(leave_workspace)) .route("/get_workspace_name", get(get_workspace_name)) .route("/create_fork", post(create_workspace_fork)) + .route( + "/fork_creation_status/{creation_id}", + get(get_fork_creation_status), + ) .route("/attach_dev_workspace", post(attach_dev_workspace)) .route("/detach_dev_workspace", post(detach_dev_workspace)) .route("/get_dev_workspace", get(get_dev_workspace)) @@ -1665,6 +1669,14 @@ async fn run_slack_message_test_job( Path(w_id): Path, Json(req): Json, ) -> JsonResult { + // Runs as the error handler identity, handed the Slack bot token. + if !(windmill_queue::is_preset_handler_path(&req.hub_script_path) + && req.hub_script_path.ends_with("-slack")) + { + return Err(Error::BadRequest( + "Only a preset Slack handler from the hub can be tested".to_string(), + )); + } let mut fake_result = HashMap::new(); fake_result.insert("error".to_string(), to_raw_value(&req.test_msg)); fake_result.insert("success_result".to_string(), to_raw_value(&req.test_msg)); @@ -1689,7 +1701,7 @@ async fn run_slack_message_test_job( Some(Utc::now()), Some(sqlx::types::Json(to_raw_value(&extra_args))), authed.email.as_str(), - false, + None, false, None, // Note: we could mark it as high priority to return result quickly to the user ) @@ -2300,7 +2312,8 @@ struct DataTableTables { schemas: TableListMap, #[serde(skip_serializing_if = "Option::is_none")] error: Option, - /// On the instance database: the only kind that can be under roles or have its access edited. + /// On a database Windmill manages, on its own cluster or the external one: the only kinds that + /// can be under roles or have their access edited. instance: bool, permissioned: bool, /// The roles this caller may connect as, by name; empty when not under roles. @@ -2557,7 +2570,7 @@ async fn list_one_datatable_tables( }; let result: Result<()> = async { let governing = resolve_governing_datatable(db, w_id, &entry.datatable_name).await?; - entry.instance = governing.is_instance(); + entry.instance = governing.role_cluster().is_some(); let usable = crate::datatable_permissions_oss::usable_datatable_roles(db, authed, w_id, &governing) .await?; @@ -3082,6 +3095,25 @@ pub(crate) async fn resolve_pg_source_checked( w_id: &str, source: &str, ) -> Result { + Ok( + resolve_pg_source_checked_with_kind(db, user_db, authed, w_id, source) + .await? + .0, + ) +} + +/// [`resolve_pg_source_checked`], also reporting the kind of Windmill-managed database behind the +/// source, `None` for a user resource. Callers that guard a managed connection MUST take the kind +/// from here rather than ask separately: between two reads a save can flip the entry, leaving the guards of one kind +/// applied to the connection of the other. +pub(crate) async fn resolve_pg_source_checked_with_kind( + db: &DB, + user_db: &UserDB, + authed: &ApiAuthed, + w_id: &str, + source: &str, +) -> Result<(PgDatabase, Option)> { + let mut managed_kind = None; let db_resource = if let Some(name) = source.strip_prefix("datatable://") { windmill_common::workspaces::ensure_datatable_admin_access( db, @@ -3090,7 +3122,13 @@ pub(crate) async fn resolve_pg_source_checked( &DatatableAccess::Authed(authed.to_authed_ref()), ) .await?; - get_datatable_resource_from_db_unchecked(db, w_id, name).await? + let (connection, kind) = + windmill_common::workspaces::get_datatable_connection_and_kind_unchecked( + db, w_id, name, + ) + .await?; + managed_kind = kind; + connection } else if let Some(path) = source.strip_prefix("$res:") { let db_with_authed = windmill_common::db::DbWithOptAuthed::from_authed( authed, @@ -3123,27 +3161,37 @@ pub(crate) async fn resolve_pg_source_checked( ))); }; - serde_json::from_value(db_resource) - .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e))) + let pg: PgDatabase = serde_json::from_value(db_resource) + .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?; + Ok((pg, managed_kind)) } -/// Whether the data table `name` is backed by the Windmill instance's own PostgreSQL -/// rather than a user resource. -pub(crate) async fn is_instance_datatable(db: &DB, w_id: &str, name: &str) -> Result { +/// The kind of the database backing the data table `name` when Windmill manages it (on its own +/// cluster or the external one), `None` when it is a user resource. +pub(crate) async fn managed_datatable_kind( + db: &DB, + w_id: &str, + name: &str, +) -> Result> { // Resolved rather than read: a pointer entry owns no database of its own, so only the entry it - // lands on can answer. A name that resolves to nothing keeps the historical `false`. + // lands on can answer. A name that resolves to nothing keeps the historical `None`. Ok(resolve_governing_datatable(db, w_id, name) .await .ok() .and_then(|g| g.datatable.database) - .is_some_and(|d| d.resource_type == DataTableCatalogResourceType::Instance)) + .map(|d| d.resource_type) + .filter(|kind| kind.is_windmill_managed())) } /// Same, for the `datatable://` / `$res:` form the import endpoints take. -async fn is_instance_datatable_source(db: &DB, w_id: &str, source: &str) -> Result { +async fn managed_datatable_source_kind( + db: &DB, + w_id: &str, + source: &str, +) -> Result> { match source.strip_prefix("datatable://") { - Some(name) => is_instance_datatable(db, w_id, name).await, - None => Ok(false), + Some(name) => managed_datatable_kind(db, w_id, name).await, + None => Ok(None), } } @@ -3242,13 +3290,7 @@ pub(crate) async fn pg_dump_database( if let Some(ref password) = pg_db.password { cmd.env("PGPASSWORD", password); } - - if let Some(ref sslmode) = pg_db.sslmode { - cmd.env("PGSSLMODE", sslmode); - } - if let Some(options) = pg_db.non_empty_options() { - cmd.env("PGOPTIONS", options); - } + let _root_cert = apply_pg_tls_env(&mut cmd, pg_db)?; let output = cmd .output() @@ -3366,7 +3408,7 @@ async fn comment_out_unsupported_settings( /// A psql invocation against `pg_db`, carrying the connection settings the CLI reads /// from the environment. -fn psql_command(pg_db: &PgDatabase) -> tokio::process::Command { +fn psql_command(pg_db: &PgDatabase) -> Result<(tokio::process::Command, Option)> { let mut cmd = tokio::process::Command::new("psql"); cmd.arg("--host") .arg(&pg_db.host) @@ -3383,13 +3425,91 @@ fn psql_command(pg_db: &PgDatabase) -> tokio::process::Command { if let Some(ref password) = pg_db.password { cmd.env("PGPASSWORD", password); } + let root_cert = apply_pg_tls_env(&mut cmd, pg_db)?; + Ok((cmd, root_cert)) +} + +/// Give libpq the TLS settings `PgDatabase::connect` applies. The returned file holds the root +/// certificate `PGSSLROOTCERT` names, so it must outlive the command. +fn apply_pg_tls_env( + cmd: &mut tokio::process::Command, + pg_db: &PgDatabase, +) -> Result> { if let Some(ref sslmode) = pg_db.sslmode { cmd.env("PGSSLMODE", sslmode); } if let Some(options) = pg_db.non_empty_options() { cmd.env("PGOPTIONS", options); } - cmd + if let Some(pem) = pg_db + .root_certificate_pem + .as_deref() + .filter(|p| !p.is_empty()) + { + let file = DumpFile::new()?; + std::fs::write(&file.path, pem) + .map_err(|e| Error::internal_err(format!("Failed to write root certificate: {e}")))?; + cmd.env("PGSSLROOTCERT", &file.path); + return Ok(Some(file)); + } + // Only a connection that asked to be verified against the system trust store. Without a file, + // libpq's own default would look for `~/.postgresql/root.crt` and refuse a verify-* mode. libpq + // takes the special `system` value with verify-full only, so verify-ca needs the bundle itself. + if pg_db.accept_invalid_certs == Some(false) { + match pg_db.sslmode.as_deref() { + Some("verify-full") => { + cmd.env("PGSSLROOTCERT", "system"); + } + Some("verify-ca") => { + if let Some(bundle) = windmill_common::system_ca_bundle() { + cmd.env("PGSSLROOTCERT", bundle); + } + } + _ => {} + } + } + Ok(None) +} + +#[cfg(test)] +mod pg_tls_env_tests { + use super::apply_pg_tls_env; + use windmill_common::PgDatabase; + + fn root_cert_env(sslmode: &str) -> Option { + let pg_db = PgDatabase { + host: "db".to_string(), + user: None, + password: None, + port: None, + sslmode: Some(sslmode.to_string()), + dbname: "d".to_string(), + root_certificate_pem: None, + accept_invalid_certs: Some(false), + use_iam_auth: None, + region: None, + options: None, + }; + let mut cmd = tokio::process::Command::new("psql"); + apply_pg_tls_env(&mut cmd, &pg_db).unwrap(); + cmd.as_std() + .get_envs() + .find(|(k, _)| *k == "PGSSLROOTCERT") + .and_then(|(_, v)| v.map(|v| v.to_os_string())) + } + + #[test] + fn system_roots_only_through_verify_full() { + assert_eq!( + root_cert_env("verify-full").as_deref(), + Some("system".as_ref()) + ); + // libpq refuses `sslrootcert=system` with verify-ca, which would fail every dump and restore. + assert_ne!( + root_cert_env("verify-ca").as_deref(), + Some("system".as_ref()) + ); + } } /// GUC names the server backing `pg_db` knows about. @@ -3399,7 +3519,8 @@ fn psql_command(pg_db: &PgDatabase) -> tokio::process::Command { /// and an unset mode, where `PgDatabase::connect` would hand a TLS-only server a /// plaintext socket and fail before the import ever starts. async fn server_setting_names(pg_db: &PgDatabase) -> Result> { - let output = psql_command(pg_db) + let (mut cmd, _root_cert) = psql_command(pg_db)?; + let output = cmd .arg("--tuples-only") .arg("--no-align") .arg("--command") @@ -3436,7 +3557,8 @@ pub(crate) async fn pg_import_dump(target_db: &PgDatabase, dump_file: &DumpFile) let supported_settings = server_setting_names(target_db).await?; comment_out_unsupported_settings(dump_file, &supported_settings).await?; - let output = psql_command(target_db) + let (mut cmd, _root_cert) = psql_command(target_db)?; + let output = cmd .arg("--set") .arg("ON_ERROR_STOP=1") .arg("--single-transaction") @@ -3494,9 +3616,39 @@ async fn create_pg_database( } } - if is_instance_datatable_source(&db, &w_id, &req.source).await? { - windmill_common::create_custom_instance_database(&db, &req.target_dbname, "datatable") + if let Some(source_kind) = managed_datatable_source_kind(&db, &w_id, &req.source).await? { + // Held until the copy is registered, as a rename migrates reservations to the new id under + // it once the old one is archived: a copy registered after that would be reserved for an + // id nothing answers on. + let mut tx = db.begin().await?; + windmill_common::workspaces::lock_fork_datatables(&mut tx, &w_id).await?; + let live = sqlx::query_scalar::<_, bool>("SELECT NOT deleted FROM workspace WHERE id = $1") + .bind(&w_id) + .fetch_optional(&mut *tx) + .await? + .unwrap_or(false); + if !live { + return Err(Error::BadRequest(format!("Workspace '{w_id}' is archived"))); + } + if source_kind == DataTableCatalogResourceType::ExternalInstance { + windmill_common::external_instance_pg::create_external_instance_database_unchecked( + &db, + &mut tx, + &req.target_dbname, + "datatable", + Some(&w_id), + ) .await?; + } else { + windmill_common::create_custom_instance_database( + &db, + &req.target_dbname, + "datatable", + Some(&w_id), + ) + .await?; + } + tx.commit().await?; } else { let source_pg = resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.source).await?; @@ -3583,12 +3735,12 @@ pub(crate) async fn ensure_datatable_is_clonable( let governing = resolve_governing_datatable(db, w_id, name).await?; // The copy has to name a database of its own. A resource-backed entry reached through a // pointer names one this workspace does not own, so there is nothing here to repoint. - let is_instance = governing + let is_managed = governing .datatable .database .as_ref() - .is_some_and(|d| d.resource_type == DataTableCatalogResourceType::Instance); - if governing.workspace_id != w_id && !is_instance { + .is_some_and(|d| d.resource_type.is_windmill_managed()); + if governing.workspace_id != w_id && !is_managed { return Err(Error::BadRequest(format!( "Data table '{name}' points at a resource-backed data table in another workspace \ and cannot be copied; fork it from the workspace that owns it." @@ -3642,9 +3794,11 @@ async fn import_pg_database( } let schema_only = req.fork_behavior == DataTableForkBehavior::SchemaOnly; - let source_pg = resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.source).await?; - let mut target_pg = - resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.target).await?; + let mut fork_lock: Option> = None; + let (source_pg, source_kind) = + resolve_pg_source_checked_with_kind(&db, &user_db, &authed, &w_id, &req.source).await?; + let (mut target_pg, target_kind) = + resolve_pg_source_checked_with_kind(&db, &user_db, &authed, &w_id, &req.target).await?; if let Some(ref override_dbname) = req.target_dbname_override { if !windmill_api_auth::is_super_admin_authed(&db, &authed).await? { @@ -3654,6 +3808,29 @@ async fn import_pg_database( .to_string(), )); } + // The kind the connection above was built from, never a second read: an entry flipped + // to a resource between the two would keep the managed connection here and lose the + // check that decides which copy it may fill. + if let Some(kind) = target_kind { + // Held until the restore is done: fork finalization takes the first, and every + // save newly naming a database, in any workspace, the second. Nothing may start + // using this database while `psql` is still filling it. + let mut tx = db.begin().await?; + windmill_common::workspaces::lock_fork_datatables(&mut tx, &w_id).await?; + windmill_common::datatable_roles::lock_instance_databases_governance( + &mut tx, + [override_dbname.as_str()], + ) + .await?; + windmill_common::ensure_fork_database_available_to( + &mut tx, + kind, + override_dbname, + &w_id, + ) + .await?; + fork_lock = Some(tx); + } } target_pg.dbname = override_dbname.clone(); } @@ -3663,8 +3840,7 @@ async fn import_pg_database( // what it creates it owns. Grants do, except around an instance data table — Windmill // plants `custom_instance_user` grants in one, which nothing else can replay. Elsewhere // the ACLs are user intent (`REVOKE ... FROM PUBLIC`) and dropping them widens access. - let no_acl = is_instance_datatable_source(&db, &w_id, &req.target).await? - || is_instance_datatable_source(&db, &w_id, &req.source).await?; + let no_acl = target_kind.is_some() || source_kind.is_some(); let dump_file = pg_dump_database( &source_pg, @@ -3672,6 +3848,9 @@ async fn import_pg_database( ) .await?; pg_import_dump(&target_pg, &dump_file).await?; + if let Some(tx) = fork_lock { + tx.commit().await?; + } Ok(format!( "Imported from '{}' into '{}'", @@ -3772,39 +3951,76 @@ async fn edit_ducklake_config( ) .await?; - let old_ducklakes = sqlx::query_scalar!( - r#" - SELECT ws.ducklake->'ducklakes' AS ducklake_name - FROM workspace_settings ws - WHERE ws.workspace_id = $1 - "#, - &w_id + // Under the row lock the save writes with, taken before the database locks below as fork + // cleanup takes the two. + let old_ducklakes = sqlx::query_scalar::<_, Option>( + "SELECT ws.ducklake->'ducklakes' FROM workspace_settings ws + WHERE ws.workspace_id = $1 FOR UPDATE", ) + .bind(&w_id) .fetch_one(&mut *tx) .await? .unwrap_or(serde_json::Value::Null); let old_ducklakes: HashMap = serde_json::from_value(old_ducklakes).unwrap_or_default(); - // Check that non-superadmins are not abusing Instance databases - if !is_superadmin { - for (name, dl) in new_config.settings.ducklakes.iter() { - if dl.catalog.resource_type == DucklakeCatalogResourceType::Instance { - let old_dl = old_ducklakes.get(name); - if old_dl.is_none() - || old_dl.unwrap().catalog.resource_type - != DucklakeCatalogResourceType::Instance - || old_dl.unwrap().catalog.resource_path != dl.catalog.resource_path - { - return Err(Error::BadRequest( - "Only superadmins can create or modify ducklakes with Instance databases" - .to_string(), - )); - } - } + // Check that non-superadmins are not abusing Instance databases. An unchanged catalog is left + // alone either way, so a downgraded instance can still save lakes that already name an + // external instance database. + for (name, dl) in new_config.settings.ducklakes.iter() { + let kind = &dl.catalog.resource_type; + if !matches!( + kind, + DucklakeCatalogResourceType::Instance | DucklakeCatalogResourceType::ExternalInstance + ) { + continue; + } + let unchanged = old_ducklakes.get(name).is_some_and(|old| { + &old.catalog.resource_type == kind + && old.catalog.resource_path == dl.catalog.resource_path + }); + if unchanged { + continue; + } + // Before the registration check, whose refusal would otherwise tell a workspace admin + // which databases exist on the cluster. + if !is_superadmin { + return Err(Error::BadRequest( + "Only superadmins can create or modify ducklakes with Instance databases" + .to_string(), + )); + } + if *kind == DucklakeCatalogResourceType::ExternalInstance { + windmill_common::external_instance_pg::ensure_external_instance_available()?; + windmill_common::external_instance_pg::ensure_external_instance_database_registered( + &mut tx, + &dl.catalog.resource_path, + ) + .await?; + } else { + windmill_common::workspaces::ensure_instance_pg_available(&mut *tx).await?; } } + // Fork cleanup decides nothing uses an instance database under this lock, so a catalog newly + // put on one must not commit between its check and its drop. + windmill_common::datatable_roles::lock_instance_databases_governance( + &mut *tx, + new_config + .settings + .ducklakes + .iter() + .filter(|(name, dl)| { + dl.catalog.resource_type == DucklakeCatalogResourceType::Instance + && old_ducklakes.get(name.as_str()).is_none_or(|old| { + old.catalog.resource_type != DucklakeCatalogResourceType::Instance + || old.catalog.resource_path != dl.catalog.resource_path + }) + }) + .map(|(_, dl)| dl.catalog.resource_path.as_str()), + ) + .await?; + let config: serde_json::Value = serde_json::to_value(&new_config.settings) .map_err(|err| Error::internal_err(err.to_string()))?; @@ -3860,6 +4076,8 @@ async fn edit_datatable_config( let is_superadmin = require_super_admin(&db, &authed).await.is_ok(); let mut tx = db.begin().await?; + // Ahead of the settings row, as fork cleanup of this workspace takes the two. + windmill_common::workspaces::lock_fork_datatables(&mut tx, &w_id).await?; windmill_common::lock_instance_databases( &mut tx, new_config.settings.datatables.values().filter_map(|dt| { @@ -3983,6 +4201,7 @@ async fn edit_datatable_config( // so these line up with the `datatable_configured` adoption counts. created_substrates.push(match dt.database.as_ref().map(|d| d.resource_type) { Some(DataTableCatalogResourceType::Instance) => "instance", + Some(DataTableCatalogResourceType::ExternalInstance) => "external_instance", Some(DataTableCatalogResourceType::Postgresql) => "postgresql", None => "reference", }); @@ -4018,18 +4237,22 @@ async fn edit_datatable_config( ))); } // Carrying the block onto a resource-backed entry would produce a data table the chokepoint - // refuses on every job — a save that succeeds and breaks everything afterwards. Refuse it - // instead: turning roles off first is one step, and it keeps discarding an access decision - // something somebody chose rather than a side effect of moving a database. + // refuses on every job — a save that succeeds and breaks everything afterwards — and onto + // the other managed cluster, one whose role ids name nothing in that cluster's catalog. + // Refuse it instead: turning roles off first is one step, and it keeps discarding an access + // decision something somebody chose rather than a side effect of moving a database. + let old_kind = old + .and_then(|old| old.database.as_ref()) + .map(|d| d.resource_type); if dt.permissions.is_some() && dt .database .as_ref() - .is_some_and(|d| d.resource_type != DataTableCatalogResourceType::Instance) + .is_some_and(|d| Some(d.resource_type) != old_kind) { return Err(Error::BadRequest(format!( - "Data table '{name}' is under roles, which only a data table on the instance \ - database can be. Turn its roles off before moving it to a PostgreSQL resource." + "Data table '{name}' is under roles, which belong to the cluster its database is \ + on. Turn its roles off before moving it to another kind of database." ))); } // A pointer names no database of its own, so the form's empty `database` is correct there. @@ -4054,35 +4277,52 @@ async fn edit_datatable_config( // Check that non-superadmins are not abusing Instance databases, which reach a database this // workspace does not own. Pointing an entry at another workspace's data table is not checked // here because it cannot be requested at all: `reference` is overwritten from the stored entry - // above, for every caller. + // above, for every caller. An unchanged entry is left alone either way, so a downgraded + // instance can still save settings that already name an external instance database. // // Compared against the entry the carried fields came from, not the one stored under the same // name: otherwise swapping two names keeps each database in place while moving a clone's // `governed_by` off the copy it governs. - if !is_superadmin { - for (name, dt) in new_config.settings.datatables.iter() { - let old_dt = old_datatables.get( + for (name, dt) in new_config.settings.datatables.iter() { + let Some(database) = dt + .database + .as_ref() + .filter(|d| d.resource_type.is_windmill_managed()) + else { + continue; + }; + let unchanged = old_datatables + .get( rename_src .get(name.as_str()) .copied() .unwrap_or(name.as_str()), - ); - if dt - .database - .as_ref() - .is_some_and(|d| d.resource_type == DataTableCatalogResourceType::Instance) - { - let unchanged = old_dt.and_then(|o| o.database.as_ref()).is_some_and(|o| { - o.resource_type == DataTableCatalogResourceType::Instance - && Some(&o.resource_path) == dt.database.as_ref().map(|d| &d.resource_path) - }); - if !unchanged { - return Err(Error::BadRequest( - "Only superadmins can create or modify data tables with Instance databases" - .to_string(), - )); - } - } + ) + .and_then(|o| o.database.as_ref()) + .is_some_and(|o| { + o.resource_type == database.resource_type + && o.resource_path == database.resource_path + }); + if unchanged { + continue; + } + // Before the registration check, whose refusal would otherwise tell a workspace admin + // which databases exist on the cluster. + if !is_superadmin { + return Err(Error::BadRequest( + "Only superadmins can create or modify data tables with Instance databases" + .to_string(), + )); + } + if database.resource_type == DataTableCatalogResourceType::ExternalInstance { + windmill_common::external_instance_pg::ensure_external_instance_available()?; + windmill_common::external_instance_pg::ensure_external_instance_database_registered( + &mut tx, + &database.resource_path, + ) + .await?; + } else { + windmill_common::workspaces::ensure_instance_pg_available(&mut *tx).await?; } } @@ -4102,7 +4342,7 @@ async fn edit_datatable_config( // entry through a declared rename alone, and a settings sync never declares one, so an entry // without roles that newly points at such a database — a name added, or an existing one // repointed — would answer everyone there as `admin`. That holds whichever workspace governs it. - let newly_pointed: Vec<(&String, &str)> = new_config + let newly_pointed: Vec<(&String, DataTableCatalogResourceType, &str)> = new_config .settings .datatables .iter() @@ -4112,7 +4352,7 @@ async fn edit_datatable_config( let db = dt .database .as_ref() - .filter(|d| d.resource_type == DataTableCatalogResourceType::Instance)?; + .filter(|d| d.resource_type.is_windmill_managed())?; let lookup = rename_src .get(name.as_str()) .copied() @@ -4124,38 +4364,72 @@ async fn edit_datatable_config( old_db.resource_type != db.resource_type || old_db.resource_path != db.resource_path }); - repointed.then_some((name, db.resource_path.as_str())) + repointed.then_some((name, db.resource_type, db.resource_path.as_str())) }) .collect(); // Another workspace turning roles on for the same database holds only its own settings row, so - // without this the scan below could read past its uncommitted write. + // without this the scan below could read past its uncommitted write. Every managed database + // this save newly names is locked, not just the ones the scan is about: fork cleanup takes the + // same lock to decide nothing uses the database it is dropping. + let newly_named: std::collections::BTreeSet<&str> = new_config + .settings + .datatables + .iter() + .filter_map(|(name, dt)| { + let db = dt + .database + .as_ref() + .filter(|d| d.resource_type == DataTableCatalogResourceType::Instance)?; + let lookup = rename_src + .get(name.as_str()) + .copied() + .unwrap_or(name.as_str()); + old_datatables + .get(lookup) + .and_then(|old| old.database.as_ref()) + .is_none_or(|old_db| { + old_db.resource_type != db.resource_type + || old_db.resource_path != db.resource_path + }) + .then_some(db.resource_path.as_str()) + }) + .collect(); windmill_common::datatable_roles::lock_instance_databases_governance( &mut *tx, - newly_pointed.iter().map(|(_, dbname)| *dbname), + newly_pointed + .iter() + .map(|(_, _, dbname)| *dbname) + .chain(newly_named.iter().copied()), ) .await?; - let governed_elsewhere: Vec = if newly_pointed.is_empty() { + let governed_elsewhere: Vec<(String, String)> = if newly_pointed.is_empty() { vec![] } else { - sqlx::query_scalar( - "SELECT DISTINCT dt.value->'database'->>'resource_path' FROM workspace_settings ws + sqlx::query_as( + "SELECT DISTINCT dt.value->'database'->>'resource_type', + dt.value->'database'->>'resource_path' + FROM workspace_settings ws CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt WHERE ws.workspace_id <> $1 AND (dt.value ? 'permissions' OR dt.value ? 'governed_by') - AND dt.value->'database'->>'resource_type' = 'instance'", + AND dt.value->'database'->>'resource_type' IN ('instance', 'external_instance')", ) .bind(&w_id) .fetch_all(&mut *tx) .await? }; - for (name, dbname) in newly_pointed { + for (name, kind, dbname) in newly_pointed { let governed_here = old_datatables.values().any(|old| { (old.permissions.is_some() || old.governed_by.is_some()) - && old.database.as_ref().is_some_and(|d| { - d.resource_type == DataTableCatalogResourceType::Instance - && d.resource_path == dbname - }) + && old + .database + .as_ref() + .is_some_and(|d| d.resource_type == kind && d.resource_path == dbname) }); - if governed_here || governed_elsewhere.iter().any(|g| g == dbname) { + if governed_here + || governed_elsewhere + .iter() + .any(|(k, p)| k == kind.as_ref() && p == dbname) + { return Err(Error::BadRequest(format!( "Data table '{name}' would point at database '{dbname}', which a data table under \ roles uses, without carrying those roles: everyone reaching '{name}' would connect \ @@ -6663,7 +6937,9 @@ async fn clone_workspace_data( source_workspace_id: &str, target_workspace_id: &str, authed: &ApiAuthed, + progress: &ForkProgress, ) -> Result<()> { + progress.step("Copying settings, folders and groups"); // Clone workspace settings (merge with existing basic settings) update_workspace_settings(tx, source_workspace_id, target_workspace_id).await?; @@ -6685,6 +6961,7 @@ async fn clone_workspace_data( // Clone groups clone_groups(tx, source_workspace_id, target_workspace_id).await?; + progress.step("Copying resources and variables"); // Clone resource types clone_resource_types(tx, source_workspace_id, target_workspace_id).await?; @@ -6694,6 +6971,7 @@ async fn clone_workspace_data( // Clone variables (including external secret backend replication) clone_variables(tx, db, source_workspace_id, target_workspace_id).await?; + progress.step("Copying scripts"); // Clone scripts with new hashes clone_scripts(tx, source_workspace_id, target_workspace_id).await?; @@ -6711,18 +6989,21 @@ async fn clone_workspace_data( clone_metric_catalog(tx, source_workspace_id, target_workspace_id).await?; clone_asset_usages_and_triggers(tx, source_workspace_id, target_workspace_id).await?; + progress.step("Copying flows"); // Clone flows with new versions clone_flows(tx, source_workspace_id, target_workspace_id).await?; // Clone flow nodes clone_flow_nodes(tx, source_workspace_id, target_workspace_id).await?; + progress.step("Copying apps"); // Clone apps with new IDs and app scripts let _app_id_mapping = clone_apps(tx, source_workspace_id, target_workspace_id, authed).await?; // Clone raw apps clone_raw_apps(tx, source_workspace_id, target_workspace_id).await?; + progress.step("Copying drafts and dependencies"); // Clone the forker's own per-user drafts (plus the legacy NULL-email // workspace draft, if any) so they keep their pending edits in the // fork. Other users' drafts are intentionally NOT cloned — they don't @@ -8429,13 +8710,14 @@ async fn point_kept_datatables_at_parent( if dt.reference.is_some() { continue; } - // Only instance databases. A resource-backed data table names a resource, and the settings - // clone gave the fork its own copy of that resource in its own workspace — pointing at the - // parent's entry would silently move the fork onto the parent's resource instead. + // Only instance databases, on either cluster. A resource-backed data table names a + // resource, and the settings clone gave the fork its own copy of that resource in its own + // workspace — pointing at the parent's entry would silently move the fork onto the + // parent's resource instead. if dt .database .as_ref() - .is_none_or(|d| d.resource_type != DataTableCatalogResourceType::Instance) + .is_none_or(|d| !d.resource_type.is_windmill_managed()) { continue; } @@ -8609,11 +8891,39 @@ async fn apply_forked_datatable( })?, }; - if database.resource_type == DataTableCatalogResourceType::Instance { + if database.resource_type == DataTableCatalogResourceType::ExternalInstance { + windmill_common::external_instance_pg::ensure_external_instance_database_registered( + tx, + &fdt.new_dbname, + ) + .await?; + } + if database.resource_type.is_windmill_managed() { + // Held until the fork commits, as every save newly naming a database takes it: none may + // claim the copy between the check below and this fork's entry landing on it. + windmill_common::datatable_roles::lock_instance_databases_governance( + &mut **tx, + [fdt.new_dbname.as_str()], + ) + .await?; + } + if database.resource_type.is_windmill_managed() + && !windmill_api_auth::is_super_admin_authed(db, authed).await? + { + windmill_common::ensure_fork_database_available_to( + &mut **tx, + database.resource_type, + &fdt.new_dbname, + parent_w_id, + ) + .await?; + } + if database.resource_type.is_windmill_managed() { // The whole `database` object, not just its `resource_path`: a pointer entry has none to - // patch. `reference` goes with it — exactly one of the two may be set. + // patch. `reference` goes with it — exactly one of the two may be set. The copy was created + // on the same cluster as its source, so it keeps the source's kind. let new_database = serde_json::json!({ - "resource_type": "instance", + "resource_type": database.resource_type, "resource_path": &fdt.new_dbname, }); sqlx::query( @@ -8919,6 +9229,7 @@ async fn create_workspace_fork( authed: ApiAuthed, Extension(db): Extension, Path(parent_workspace_id): Path, + Query(q): Query, Json(nw): Json, ) -> Result { enforce_fork_depth(&db, &parent_workspace_id, 0).await?; @@ -9024,63 +9335,279 @@ async fn create_workspace_fork( // Refused here, before any database exists; `make_copies` checks again under the locks. validate_forked_datatables(&db, &authed, &parent_workspace_id, &nw).await?; - // Detached from the request: a client that goes away while the copies are made must still end - // with the fork created or no copy left behind, and dropping the handler's future would skip - // that cleanup. - tokio::spawn(async move { - let fork_id = nw.id.clone(); - let copies = crate::datatable_clone::make_copies( - &db, - &authed, - &parent_workspace_id, - ©_requests(&nw.forked_datatables), - ) - .await?; - let replayed: Vec = copies - .iter() - .filter(|c| c.replayed) - .map(|c| c.behavior) - .collect(); - match write_workspace_fork( + if !q.background.unwrap_or(false) { + // Detached from the request: a client that goes away while the copies are made must still + // end with the fork created or no copy left behind, and dropping the handler's future + // would skip that cleanup. + return tokio::spawn(make_workspace_fork( + db, + authed, + parent_workspace_id, + nw, + dev_workspace_label, + ForkProgress::default(), + )) + .await + .map_err(|e| { + Error::internal_err(format!("Creating the fork stopped unexpectedly: {e}")) + })?; + } + + let fork_id = nw.id.clone(); + sqlx::query( + "DELETE FROM workspace_fork_creation + WHERE COALESCE(finished_at, heartbeat_at) < now() - interval '7 days'", + ) + .execute(&db) + .await?; + let claimed = sqlx::query( + "INSERT INTO workspace_fork_creation + (fork_workspace_id, parent_workspace_id, created_by, creation_id) + VALUES ($1, $2, $3, $5) + ON CONFLICT (fork_workspace_id) DO UPDATE SET + parent_workspace_id = EXCLUDED.parent_workspace_id, + created_by = EXCLUDED.created_by, + creation_id = EXCLUDED.creation_id, + started_at = now(), heartbeat_at = now(), step = NULL, finished_at = NULL, + error = NULL + WHERE workspace_fork_creation.finished_at IS NOT NULL + OR workspace_fork_creation.heartbeat_at < now() - $4 * interval '1 second' + RETURNING creation_id", + ) + .bind(&fork_id) + .bind(&parent_workspace_id) + .bind(&authed.email) + .bind(FORK_HEARTBEAT_STALE_SECS as f64) + .bind(Uuid::new_v4()) + .fetch_optional(&db) + .await?; + // Every write of this attempt is scoped to its `creation_id`: one taken over as abandoned must + // not report over the attempt that took its place. + let Some(creation_id) = claimed.map(|r| r.get::("creation_id")) else { + return Err(Error::BadRequest(format!( + "workspace '{fork_id}' is already being created" + ))); + }; + + BACKGROUND_FORKS.spawn(async move { + let (progress_tx, mut progress_rx) = tokio::sync::watch::channel(""); + // The one writer of the run's row while it copies: it records a new step as soon as the + // copy reports it, and refreshes the heartbeat in between. + let heartbeat = { + let (db, fork_id) = (db.clone(), fork_id.clone()); + tokio::spawn(async move { + let mut interval = + tokio::time::interval(std::time::Duration::from_secs(FORK_HEARTBEAT_SECS)); + let mut reporting = true; + loop { + tokio::select! { + _ = interval.tick() => {} + changed = progress_rx.changed(), if reporting => reporting = changed.is_ok(), + } + let step = *progress_rx.borrow_and_update(); + if let Err(e) = sqlx::query( + "UPDATE workspace_fork_creation SET heartbeat_at = now(), step = $3 + WHERE fork_workspace_id = $1 AND creation_id = $2", + ) + .bind(&fork_id) + .bind(creation_id) + .bind((!step.is_empty()).then_some(step)) + .execute(&db) + .await + { + tracing::warn!("fork '{fork_id}': could not record progress: {e}"); + } + } + }) + }; + let made = tokio::spawn(make_workspace_fork( db.clone(), authed, parent_workspace_id, nw, dev_workspace_label, - &copies, + ForkProgress { creation_id: Some(creation_id), steps: Some(progress_tx) }, + )) + .await + .map_err(|e| Error::internal_err(format!("Creating the fork stopped unexpectedly: {e}"))) + .and_then(|r| r); + heartbeat.abort(); + let error = made.err().map(|e| { + tracing::error!("Creating fork '{fork_id}' failed: {e}"); + e.to_string() + }); + // A fork that committed is already recorded complete, even when its commit's + // acknowledgement was lost and the attempt reads as an error here. + if let Err(e) = sqlx::query( + "UPDATE workspace_fork_creation SET finished_at = now(), error = $3 + WHERE fork_workspace_id = $1 AND creation_id = $2 AND finished_at IS NULL", ) + .bind(&fork_id) + .bind(creation_id) + .bind(error) + .execute(&db) .await { - Ok(message) => { - for behavior in replayed { - windmill_common::feature_usage::log_feature_usage( - "datatable", - "clone_replayed", - match behavior { - DataTableForkBehavior::SchemaOnly => "schema_only", - _ => "schema_and_data", - }, - ); - } - Ok(message) - } - // A commit whose acknowledgement was lost can still have committed, and a concurrent - // request for the same id can have: once the write has settled, the cleanup keeps - // whichever copies a committed fork names, and drops the rest. - Err(e) => match wait_for_fork_write(&db, &fork_id).await { - Ok(()) => Err(crate::datatable_clone::drop_copies_after(&db, copies, e).await), - Err(settle) => { - tracing::error!( - "Could not tell whether fork '{fork_id}' was created, so its copies were \ - kept: {settle}" - ); - Err(e) - } - }, + tracing::error!("fork '{fork_id}': could not record how its creation ended: {e}"); } - }) + }); + Ok(creation_id.to_string()) +} + +/// How often a fork created in the background records that its copy is still running, and how long +/// without that record its creation counts as abandoned. +const FORK_HEARTBEAT_SECS: u64 = 10; +const FORK_HEARTBEAT_STALE_SECS: u64 = 60; + +/// Forks being created in the background. The server waits for them on shutdown: a copy cut short +/// never drops the data table databases it already made, and a retry under the same id is refused +/// while they exist. +pub static BACKGROUND_FORKS: std::sync::LazyLock = + std::sync::LazyLock::new(tokio_util::task::TaskTracker::new); + +#[derive(Deserialize)] +struct CreateWorkspaceForkQuery { + /// Return once the request is validated and create the fork in the background, answering with + /// the id `fork_creation_status` reads this attempt by. Without it the answer is + /// `Created forked workspace `, which clients take as a server without background forks. + background: Option, +} + +/// The attempt a fork created in the background belongs to, and where its copy is, for its status +/// to report. +#[derive(Default)] +struct ForkProgress { + creation_id: Option, + steps: Option>, +} + +impl ForkProgress { + fn step(&self, step: &'static str) { + if let Some(tx) = &self.steps { + tx.send_replace(step); + } + } +} + +#[derive(Serialize)] +struct ForkCreationStatus { + /// `running`, `completed` or `failed`. + status: &'static str, + /// The part of the copy a running fork is in. + #[serde(skip_serializing_if = "Option::is_none")] + step: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +async fn get_fork_creation_status( + authed: ApiAuthed, + Extension(db): Extension, + Path((parent_workspace_id, creation_id)): Path<(String, Uuid)>, +) -> JsonResult { + let row = sqlx::query( + "SELECT c.created_by, c.finished_at IS NOT NULL AS finished, c.error, c.step, + c.heartbeat_at < now() - $3 * interval '1 second' AS stale + FROM workspace_fork_creation c + WHERE c.creation_id = $1 AND c.parent_workspace_id = $2", + ) + .bind(creation_id) + .bind(&parent_workspace_id) + .bind(FORK_HEARTBEAT_STALE_SECS as f64) + .fetch_optional(&db) + .await?; + // Only the user who started it reads an attempt: its id is not handed to anyone else. + let Some(row) = row.filter(|r| r.get::("created_by") == authed.email) else { + return Err(Error::NotFound(format!( + "no fork creation {creation_id} from '{parent_workspace_id}'" + ))); + }; + let error: Option = row.get("error"); + let status = if row.get::("finished") { + match error { + Some(error) => ForkCreationStatus { status: "failed", step: None, error: Some(error) }, + None => ForkCreationStatus { status: "completed", step: None, error: None }, + } + } else if row.get::("stale") { + ForkCreationStatus { + status: "failed", + step: None, + error: Some( + "the server creating the fork stopped before it finished; check the server logs \ + and whether the fork exists before retrying" + .to_string(), + ), + } + } else { + ForkCreationStatus { status: "running", step: row.get("step"), error: None } + }; + Ok(Json(status)) +} + +/// Copy the data tables the fork gets, then write the fork itself, dropping the copies again if +/// that write fails. +async fn make_workspace_fork( + db: DB, + authed: ApiAuthed, + parent_workspace_id: String, + nw: CreateWorkspaceFork, + dev_workspace_label: Option, + progress: ForkProgress, +) -> Result { + let fork_id = nw.id.clone(); + if !nw.forked_datatables.is_empty() { + progress.step("Copying data tables"); + } + let copies = crate::datatable_clone::make_copies( + &db, + &authed, + &parent_workspace_id, + ©_requests(&nw.forked_datatables), + ) + .await?; + let replayed: Vec = copies + .iter() + .filter(|c| c.replayed) + .map(|c| c.behavior) + .collect(); + match write_workspace_fork( + db.clone(), + authed, + parent_workspace_id, + nw, + dev_workspace_label, + &copies, + &progress, + ) .await - .map_err(|e| Error::internal_err(format!("Creating the fork stopped unexpectedly: {e}")))? + { + Ok(message) => { + for behavior in replayed { + windmill_common::feature_usage::log_feature_usage( + "datatable", + "clone_replayed", + match behavior { + DataTableForkBehavior::SchemaOnly => "schema_only", + _ => "schema_and_data", + }, + ); + } + Ok(message) + } + // A commit whose acknowledgement was lost can still have committed, and a concurrent + // request for the same id can have: once the write has settled, the cleanup keeps + // whichever copies a committed fork names, and drops the rest. + Err(e) => match wait_for_fork_write(&db, &fork_id).await { + Ok(()) => Err(crate::datatable_clone::drop_copies_after(&db, copies, e).await), + Err(settle) => { + tracing::error!( + "Could not tell whether fork '{fork_id}' was created, so its copies were \ + kept: {settle}" + ); + Err(e) + } + }, + } } /// The database a data table of the fork or dev workspace `fork_id` is copied into, as the wizard @@ -9176,6 +9703,7 @@ async fn write_workspace_fork( nw: CreateWorkspaceFork, dev_workspace_label: Option, copies: &[crate::datatable_clone::MadeCopy], + progress: &ForkProgress, ) -> Result { let mut tx: Transaction<'_, Postgres> = db.begin().await?; // Held until this transaction ends: after an error, the copies' cleanup waits on it, so it reads @@ -9184,6 +9712,11 @@ async fn write_workspace_fork( .bind(&nw.id) .execute(&mut *tx) .await?; + // Before the settings clone reads the parent's data tables: a pointer this fork ends up with + // must not be written after cleanup of the parent decided that nothing points at its copies. + // Also before the external cluster's lifecycle lock, which finalizing an external copy takes: + // fork cleanup takes the two in this order. + windmill_common::workspaces::lock_fork_datatables(&mut tx, &parent_workspace_id).await?; if nw.is_dev_workspace { // The checks above ran outside a transaction, so the parent's eligibility and the chain's @@ -9273,8 +9806,15 @@ async fn write_workspace_fork( .await?; // Clone all data from the parent workspace using Rust implementation - if let Err(e) = - clone_workspace_data(&mut tx, &db, &parent_workspace_id, &forked_id, &authed).await + if let Err(e) = clone_workspace_data( + &mut tx, + &db, + &parent_workspace_id, + &forked_id, + &authed, + progress, + ) + .await { // A genuine `\u0000` in a source `json` value (`app_version.value` / // `flow_version.schema`) aborts the clone when it is re-encoded to jsonb: @@ -9305,6 +9845,7 @@ async fn write_workspace_fork( // enabled=false. Disabled rows have no side effects (no listener // attaches, no cron fires) so this is safe by construction. The user // re-enables in the fork, with parent-conflict warnings on enable. + progress.step("Copying triggers and schedules"); clone_triggers_and_schedules(&mut tx, &parent_workspace_id, &forked_id).await?; repoint_unresolvable_cloned_identities(&mut tx, &forked_id, &authed).await?; @@ -9328,6 +9869,32 @@ async fn write_workspace_fork( ))); } } + // Saves name an external database under the external cluster's lifecycle lock instead. + let external_copies: Vec<&str> = copies + .iter() + .filter(|c| { + c.source_database.resource_type == DataTableCatalogResourceType::ExternalInstance + }) + .map(|c| c.dbname.as_str()) + .collect(); + if !external_copies.is_empty() { + windmill_common::external_instance_pg::lock_external_instance_pg_state(&mut tx).await?; + } + for dbname in &external_copies { + let uses = windmill_common::workspaces::managed_database_uses( + &mut tx, + DataTableCatalogResourceType::ExternalInstance, + dbname, + None, + ) + .await?; + if !uses.is_empty() { + return Err(Error::BadRequest(format!( + "Database '{dbname}' copied for this fork is already used by {}; fork again", + uses.join(", ") + ))); + } + } // Update forked datatable settings to point to new databases for fdt in &nw.forked_datatables { @@ -9426,6 +9993,16 @@ async fn write_workspace_fork( (!copied.is_empty()).then(|| [("copied_datatables", copied.as_str())].into()), ) .await?; + // Committed with the fork, so its attempt reads complete exactly when the fork exists. + if let Some(creation_id) = progress.creation_id { + sqlx::query( + "UPDATE workspace_fork_creation SET finished_at = now(), error = NULL + WHERE creation_id = $1", + ) + .bind(creation_id) + .execute(&mut *tx) + .await?; + } tx.commit().await?; // A pre-creation lookup could have cached an EMPTY ancestor chain for this id, which @@ -9439,6 +10016,7 @@ async fn write_workspace_fork( windmill_common::workspaces::invalidate_protection_rules_cache(&parent_workspace_id); } + // Clients that ask for a background fork recognise a server without it by this answer. Ok(format!("Created forked workspace {}", &forked_id)) } @@ -10294,8 +10872,12 @@ async fn invite_user( nu.email = nu.email.to_lowercase(); #[cfg(feature = "enterprise")] - if let Some(msg) = - windmill_common::ee_oss::check_seat_cap_for_new_user(&db, &nu.email, nu.operator).await? + if let Some(msg) = windmill_common::ee_oss::check_seat_cap_for_new_user( + &db, + &nu.email, + windmill_common::workspaces::consumes_operator_seat(&db, &w_id, nu.operator).await?, + ) + .await? { return Err(Error::BadRequest(msg)); } @@ -10448,8 +11030,12 @@ async fn add_user( }; #[cfg(feature = "enterprise")] - if let Some(msg) = - windmill_common::ee_oss::check_seat_cap_for_new_user(&db, &nu.email, nu.operator).await? + if let Some(msg) = windmill_common::ee_oss::check_seat_cap_for_new_user( + &db, + &nu.email, + windmill_common::workspaces::consumes_operator_seat(&db, &w_id, nu.operator).await?, + ) + .await? { return Err(Error::BadRequest(msg)); } @@ -11012,10 +11598,15 @@ struct ChangeOperatorSettings { folders: bool, #[serde(default)] workers: bool, - /// Writes operators may perform unless withdrawn, so `None` (key absent) must mean "leave as - /// stored" rather than a value: the row is merged, not overwritten, and this endpoint takes - /// whole-object payloads from git-sync files that predate the key. Defaulting either way here - /// would make an older file silently withdraw or restore the right on every pull. + /// Write rights, so `None` (key absent) must mean "leave as stored" rather than a value: the + /// row is merged, not overwritten, and this endpoint takes whole-object payloads from git-sync + /// files that predate the key. Defaulting either way here would make an older file silently + /// withdraw or grant the right on every push. + /// + /// `builder_flows` lets every operator of this workspace compose flows out of already-deployed + /// runnables, and makes each of them consume a full author seat instead of half of one. + #[serde(default, skip_serializing_if = "Option::is_none")] + builder_flows: Option, #[serde(default, skip_serializing_if = "Option::is_none")] manage_schedules: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -11030,6 +11621,17 @@ async fn update_operator_settings( ) -> Result { require_admin(authed.is_admin, &authed.username)?; + // Every operator of the workspace turns into a full seat, which an offline license may not + // cover. It is a no-op delta when the right is already on. + #[cfg(feature = "enterprise")] + if settings.builder_flows == Some(true) { + if let Some(msg) = + windmill_common::ee_oss::check_seat_cap_for_operator_builder(&db, &w_id).await? + { + return Err(Error::BadRequest(msg)); + } + } + let mut tx = db.begin().await?; let settings_json = serde_json::json!(settings); @@ -13765,18 +14367,27 @@ async fn prune_versions( let pruned = match req.resource_type.as_str() { "scripts" => { - let result = sqlx::query( - "DELETE FROM script - WHERE workspace_id = $1 AND hash NOT IN ( - SELECT DISTINCT ON (path) hash FROM script - WHERE workspace_id = $1 AND deleted = false - ORDER BY path, created_at DESC - )", - ) - .bind(&w_id) - .execute(&db) - .await?; - result.rows_affected() + let mut tx = db.begin().await?; + let deleted = windmill_common::DeletedScriptVersions::new( + &w_id, + sqlx::query_as::<_, (String, i64)>( + "DELETE FROM script + WHERE workspace_id = $1 AND hash NOT IN ( + SELECT DISTINCT ON (path) hash FROM script + WHERE workspace_id = $1 AND deleted = false + ORDER BY path, created_at DESC + ) + RETURNING path, hash", + ) + .bind(&w_id) + .fetch_all(&mut *tx) + .await?, + ); + deleted.notify(&mut *tx).await?; + tx.commit().await?; + let pruned = deleted.hashes.len() as u64; + deleted.evict(); + pruned } "flows" => { let deleted = sqlx::query( diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 9039bda2ef..8ca98f66d1 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -56,6 +56,11 @@ pub(crate) async fn change_workspace_id( let mut tx = db.begin().await?; + // The settings copy below carries every data table entry to the new id, which fork cleanup of + // the old id cannot see until this commits: without the lock it could drop a copy the renamed + // workspace goes on using. Before the pairing lock, as forking takes the two in that order. + windmill_common::workspaces::lock_fork_datatables(&mut tx, &old_id).await?; + // A rename rewrites the workspace's dev flag and reparents its children, so it decides on the // same state the pairing handlers do: without this lock a concurrent create/attach could commit // an active dev workspace under the shell this rename is about to archive. Both ids, since the @@ -869,6 +874,10 @@ pub(crate) async fn change_workspace_id( } } + // After every workspace_settings write above: fork cleanup locks a settings row before the + // registry, so taking the registry first here would deadlock with it. + migrate_fork_reservations(&mut tx, &old_id, &rw.new_id).await?; + // Audit log in the same transaction as the workspace changes audit_log( &mut *tx, @@ -937,6 +946,14 @@ pub(crate) async fn change_workspace_id( let (_schedules_count, canceled_count, _deleted_tokens_count) = archive_workspace_impl(&db, &old_id, &authed.username, None).await?; + // The old id stays live between the commit above and the archive, and a fork copy created for + // it in that window registers under it. Creation checks the workspace is live under the fork + // lock, so once this has run under it, no copy can be reserved for the old id any more. + let mut tx = db.begin().await?; + windmill_common::workspaces::lock_fork_datatables(&mut tx, &old_id).await?; + migrate_fork_reservations(&mut tx, &old_id, &rw.new_id).await?; + tx.commit().await?; + info!( "Workspace id change completed: moved {} to {}, archived old workspace", old_id, rw.new_id @@ -948,6 +965,50 @@ pub(crate) async fn change_workspace_id( )) } +/// A fork copy reserved for the old id would otherwise be unreachable: its creator cannot import +/// into it or finish its fork under the new id, and nothing else would ever drop it. +async fn migrate_fork_reservations( + tx: &mut Transaction<'_, Postgres>, + old_id: &str, + new_id: &str, +) -> Result<()> { + const MIGRATE: &str = r#"UPDATE global_settings SET value = jsonb_set(value, '{databases}', ( + SELECT COALESCE(jsonb_object_agg(k, CASE WHEN v->>'workspace_id' = $1 + THEN jsonb_set(v, '{workspace_id}', to_jsonb($2::text)) ELSE v END), '{}'::jsonb) + FROM jsonb_each(COALESCE(value->'databases', '{}'::jsonb)) AS e(k, v) + )) + WHERE name = $3"#; + sqlx::query(MIGRATE) + .bind(old_id) + .bind(new_id) + .bind("custom_instance_pg_databases") + .execute(&mut **tx) + .await?; + // External copies are registered in the cluster state, which its writers rewrite whole under + // the lifecycle lock. Only taken when there is something to move, as setup can hold it for as + // long as the cluster takes to answer. + let state = windmill_common::global_settings::EXTERNAL_INSTANCE_PG_STATE_SETTING; + let reserved = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS (SELECT 1 FROM global_settings, jsonb_each(value->'databases') AS e(k, v) + WHERE name = $1 AND jsonb_typeof(value->'databases') = 'object' + AND v->>'workspace_id' = $2)", + ) + .bind(state) + .bind(old_id) + .fetch_one(&mut **tx) + .await?; + if reserved { + windmill_common::external_instance_pg::lock_external_instance_pg_state(tx).await?; + sqlx::query(MIGRATE) + .bind(old_id) + .bind(new_id) + .bind(state) + .execute(&mut **tx) + .await?; + } + Ok(()) +} + #[derive(Deserialize)] pub(crate) struct DeleteWorkspaceQuery { pub(crate) only_delete_forks: Option, @@ -1430,9 +1491,7 @@ pub async fn drop_forked_datatable_databases( _ => continue, }; - if database.resource_type - == windmill_common::workspaces::DataTableCatalogResourceType::Instance - { + if database.resource_type.is_windmill_managed() { let db_to_drop = &database.resource_path; if !db_to_drop.starts_with("wm_fork_") { errors.push(format!( @@ -1441,7 +1500,112 @@ pub async fn drop_forked_datatable_databases( )); continue; } - if let Err(e) = windmill_common::drop_custom_instance_database(&db, db_to_drop).await { + // The fork's own entry is what is going away; anything else still reaching the copy, + // a child fork's pointer at this entry included, keeps it. The lock keeps a child fork + // from gaining such a pointer before the drop. + // A task of its own, so a client going away cannot stop it between dropping the + // database and committing the entry's removal. + let dropped = tokio::spawn({ + let (db, w_id, dt_name, db_to_drop) = ( + db.clone(), + w_id.clone(), + dt_name.clone(), + db_to_drop.clone(), + ); + let resource_type = database.resource_type; + async move { + let mut tx = db.begin().await?; + // The three locks a settings save takes, in its order: this workspace's data + // tables, its settings row, and the database itself. Without them a save could + // rename this entry, or point another one here, either side of the check below. + windmill_common::workspaces::lock_fork_datatables(&mut tx, &w_id).await?; + // The snapshot above was read unlocked: a save committing since could have + // repointed this entry, and the entry is removed below whatever it names by then. + let current = sqlx::query_scalar::<_, Option>( + "SELECT datatable->'datatables'->$2 FROM workspace_settings + WHERE workspace_id = $1 FOR UPDATE", + ) + .bind(&w_id) + .bind(&dt_name) + .fetch_optional(&mut *tx) + .await? + .flatten() + .and_then(|v| serde_json::from_value::(v).ok()); + if !current.is_some_and(|dt| { + dt.forked_from.is_some() + && dt.database.is_some_and(|d| { + d.resource_type == resource_type && d.resource_path == db_to_drop + }) + }) { + return Err(Error::BadRequest( + "the data table changed while it was being cleaned up".to_string(), + )); + } + let external = resource_type + == windmill_common::workspaces::DataTableCatalogResourceType::ExternalInstance; + if external { + // Before the governance lock, in the order settings saves and fork + // finalization take the two. + windmill_common::external_instance_pg::lock_external_instance_pg_state( + &mut tx, + ) + .await?; + } + windmill_common::datatable_roles::lock_instance_databases_governance( + &mut tx, + [db_to_drop.as_str()], + ) + .await?; + // Before the entry is removed: child fork pointers are found through it. + let uses = windmill_common::workspaces::managed_database_uses( + &mut tx, + resource_type, + &db_to_drop, + Some((w_id.as_str(), dt_name.as_str())), + ) + .await?; + if !uses.is_empty() { + return Err(Error::BadRequest(format!( + "it is still used by {}", + uses.join(", ") + ))); + } + // The entry goes with the database: a fork this one is cloned into afterwards + // must not inherit a pointer at a data table whose database is gone. + sqlx::query( + "UPDATE workspace_settings SET datatable = datatable #- ARRAY['datatables', $2] + WHERE workspace_id = $1", + ) + .bind(&w_id) + .bind(&dt_name) + .execute(&mut *tx) + .await?; + if external { + // Checks the uses of the database itself, and unregisters it. + windmill_common::external_instance_pg::drop_external_instance_database_unchecked( + &mut tx, + &db_to_drop, + Some((w_id.as_str(), dt_name.as_str())), + ) + .await?; + } else { + windmill_common::drop_custom_instance_database_keep_entry(&db, &db_to_drop) + .await?; + sqlx::query( + "UPDATE global_settings SET value = value #- ARRAY['databases', $1] + WHERE name = 'custom_instance_pg_databases'", + ) + .bind(&db_to_drop) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok::<_, Error>(()) + } + }) + .await + .unwrap_or_else(|e| Err(Error::internal_err(format!("cleanup task failed: {e}")))); + if let Err(e) = dropped { errors.push(format!( "Could not drop instance database '{}' for datatable://{}: {}", db_to_drop, dt_name, e @@ -1808,7 +1972,17 @@ async fn resolve_fork_catalog_pg( "ducklake://{ducklake_name}: malformed registry catalog identity `{catalog}`" )) })?; - let catalog_resource = if resource_type == "instance" { + let catalog_resource = if resource_type == "external_instance" { + serde_json::to_value( + windmill_common::external_instance_pg::external_instance_connection_unchecked( + db, + resource_path, + false, + ) + .await?, + ) + .map_err(|e| Error::internal_err(format!("serializing pg creds: {e}")))? + } else if resource_type == "instance" { let mut pg_creds = windmill_common::PgDatabase::parse_uri( &windmill_common::get_database_url().await?.as_str().await, )?; diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 075ced45a9..d8755f8995 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.819.0 + version: 1.820.0 title: Windmill API contact: @@ -1398,6 +1398,14 @@ paths: - workspace parameters: - $ref: "#/components/parameters/WorkspaceId" + - name: background + description: | + return once the request is validated and create the fork in the background, answering + with the creation id getForkCreationStatus reads + in: query + required: false + schema: + type: boolean requestBody: description: new forked workspace required: true @@ -1407,12 +1415,45 @@ paths: $ref: "#/components/schemas/CreateWorkspaceFork" responses: "201": - description: forked workspace created + description: forked workspace created, or with background set, the id of its creation content: text/plain: schema: type: string + /w/{workspace}/workspaces/fork_creation_status/{creation_id}: + get: + summary: get the status of a fork created in the background + operationId: getForkCreationStatus + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: creation_id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: fork creation status + content: + application/json: + schema: + type: object + properties: + status: + type: string + enum: [running, completed, failed] + step: + type: string + description: the part of the copy a running fork is in + error: + type: string + required: + - status + /w/{workspace}/workspaces/attach_dev_workspace: post: summary: attach an existing workspace as the dev workspace of this (prod) workspace @@ -1572,6 +1613,104 @@ paths: schema: type: object + /settings/external_instance_pg/status: + get: + summary: Returns whether the external instance cluster is configured and how its last setup went + operationId: getExternalInstancePgStatus + tags: + - setting + responses: + "200": + description: external instance cluster status + content: + application/json: + schema: + $ref: "#/components/schemas/ExternalInstancePgStatus" + + /settings/external_instance_pg/setup: + post: + summary: Sets up the external instance cluster with its saved admin login, optionally rotating the passwords Windmill manages on it (enterprise edition only) + operationId: setupExternalInstancePg + tags: + - setting + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + rotate_passwords: + type: boolean + responses: + "200": + description: the setup report, also stored as the last setup + content: + application/json: + schema: + $ref: "#/components/schemas/ExternalInstancePgSetupReport" + + /settings/external_instance_pg/databases: + get: + summary: Lists the databases Windmill created on the external instance cluster, with the workspaces whose data tables, Ducklake catalogs or pending fork cleanups use each + operationId: listExternalInstancePgDatabases + tags: + - setting + responses: + "200": + description: databases by name + content: + application/json: + schema: + type: object + additionalProperties: + $ref: "#/components/schemas/CustomInstanceDb" + + /settings/external_instance_pg/databases/{name}: + post: + summary: Creates a database on the external instance cluster (enterprise edition only) + operationId: createExternalInstancePgDatabase + tags: + - setting + parameters: + - name: name + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + tag: + $ref: "#/components/schemas/CustomInstanceDbTag" + responses: + "200": + description: database created + content: + application/json: + schema: {} + delete: + summary: Drops a database Windmill created on the external instance cluster, refused while a data table, Ducklake catalog or pending fork cleanup uses it + operationId: dropExternalInstancePgDatabase + tags: + - setting + parameters: + - name: name + in: path + required: true + schema: + type: string + responses: + "200": + description: database dropped + content: + application/json: + schema: {} + /settings/list_custom_instance_pg_databases: post: summary: Returns the set-up statuses of custom instance pg databases @@ -1590,13 +1729,19 @@ paths: /settings/datatable_roles: get: - summary: list the instance's data table roles + summary: list the data table roles of one Windmill-managed Postgres cluster operationId: listInstanceDatatableRoles tags: - setting + parameters: + - in: query + name: cluster + required: false + schema: + $ref: "#/components/schemas/DatatableRoleCluster" responses: "200": - description: the instance role catalog + description: the cluster's role catalog content: application/json: schema: @@ -1604,7 +1749,7 @@ paths: items: $ref: "#/components/schemas/InstanceDatatableRole" post: - summary: create a data table role on the instance's Postgres cluster + summary: create a data table role on a Windmill-managed Postgres cluster operationId: createInstanceDatatableRole tags: - setting @@ -1618,6 +1763,8 @@ paths: properties: name: type: string + cluster: + $ref: "#/components/schemas/DatatableRoleCluster" responses: "200": description: the created role @@ -5351,7 +5498,7 @@ paths: type: string resource_type: type: string - enum: [postgres, instance] + enum: [postgres, instance, external_instance] resource_path: type: string governing_workspace_id: @@ -9762,6 +9909,13 @@ paths: properties: name: type: string + documented: + description: >- + whether the integration carries provider knowledge checked + against the live API, on top of the resource type and example + scripts /integrations/hub/{app}/meta returns for any + integration. Absent on a hub predating the flag. + type: boolean picks: description: how often the integration has been picked, absent on a hub that does not count picks type: integer @@ -9772,6 +9926,162 @@ paths: required: - name + /integrations/hub/{app}/meta: + get: + summary: get hub integration metadata + operationId: getHubIntegrationMeta + tags: + - integration + parameters: + - name: app + description: integration slug + in: path + required: true + schema: + type: string + responses: + "200": + description: integration metadata + content: + application/json: + schema: + type: object + properties: + app: + type: string + display_name: + type: string + description: + type: string + nullable: true + docs_url: + type: string + nullable: true + curated: + description: >- + whether the script set was pruned to idiomatic actions rather + than generated one per endpoint; null when nobody has assessed + it, which is distinct from a stated false + type: boolean + nullable: true + metadata_source: + description: >- + whether the provider knowledge was authored (curated) or + inferred from the shipped scripts (derived) + type: string + enum: + - curated + - derived + meta: + description: >- + the integration's authored meta.json verbatim; the content + repo owns its schema, so it is passed through unvalidated + type: object + nullable: true + additionalProperties: true + meta_updated_at: + type: string + nullable: true + derived: + description: facts computed from the integration's shipped scripts + type: object + properties: + api_hosts: + type: array + items: + type: object + properties: + host: + type: string + count: + type: number + required: + - host + - count + style: + type: string + enum: + - fetch + - sdk + - mixed + - unknown + languages: + type: object + additionalProperties: + type: number + script_counts: + type: object + properties: + total: + type: number + by_kind: + type: object + additionalProperties: + type: number + required: + - total + - by_kind + top_scripts: + type: array + items: + type: object + properties: + path: + type: string + ask_id: + type: number + version_id: + type: number + summary: + type: string + description: + type: string + nullable: true + kind: + type: string + language: + type: string + nullable: true + views: + type: number + votes: + type: number + required: + - path + - summary + - kind + required: + - api_hosts + - style + - languages + - script_counts + - top_scripts + resource_types: + type: array + items: + type: object + properties: + id: + type: number + name: + type: string + description: + type: string + nullable: true + schema: + description: >- + the resource type's JSON schema; the hub stores it as text + and returns it as a JSON string, so a client must be ready + for either that or an object + required: + - name + - schema + # `derived` and `resource_types` are omitted by a hub older than the + # client, which reads every section as optional. + required: + - app + - display_name + /flows/hub/list: get: summary: list all hub flows @@ -10100,6 +10410,9 @@ paths: type: number summary: type: string + description: + type: string + nullable: true app: type: string version_id: @@ -10169,6 +10482,8 @@ paths: type: number summary: type: string + description: + type: string app: type: string kind: @@ -11791,6 +12106,11 @@ paths: content: application/json: schema: {} + "409": + description: >- + Chat-enabled flow only: the conversation named by `memory_id` is still answering + a message. The body is JSON: `{ "error": string, "running_turn": { "job_id", + "user_seq" } }`. /w/{workspace}/jobs/run_wait_result/fv/{version}: post: @@ -11831,6 +12151,11 @@ paths: content: application/json: schema: {} + "409": + description: >- + Chat-enabled flow only: the conversation named by `memory_id` is still answering + a message. The body is JSON: `{ "error": string, "running_turn": { "job_id", + "user_seq" } }`. get: summary: run flow by version with GET and wait until completion @@ -11863,6 +12188,11 @@ paths: content: application/json: schema: {} + "409": + description: >- + Chat-enabled flow only: the conversation named by `memory_id` is still answering + a message. The body is JSON: `{ "error": string, "running_turn": { "job_id", + "user_seq" } }`. /w/{workspace}/jobs/run_and_stream/f/{path}: post: @@ -11904,6 +12234,11 @@ paths: text/event-stream: schema: type: string + "409": + description: >- + Chat-enabled flow only: the conversation named by `memory_id` is still answering + a message. The body is JSON: `{ "error": string, "running_turn": { "job_id", + "user_seq" } }`. get: summary: run flow by path with GET and stream updates via SSE @@ -11937,6 +12272,11 @@ paths: text/event-stream: schema: type: string + "409": + description: >- + Chat-enabled flow only: the conversation named by `memory_id` is still answering + a message. The body is JSON: `{ "error": string, "running_turn": { "job_id", + "user_seq" } }`. /w/{workspace}/jobs/run_and_stream/fv/{version}: post: @@ -11984,6 +12324,11 @@ paths: text/event-stream: schema: type: string + "409": + description: >- + Chat-enabled flow only: the conversation named by `memory_id` is still answering + a message. The body is JSON: `{ "error": string, "running_turn": { "job_id", + "user_seq" } }`. get: summary: run flow by version with GET and stream updates via SSE @@ -12023,6 +12368,11 @@ paths: text/event-stream: schema: type: string + "409": + description: >- + Chat-enabled flow only: the conversation named by `memory_id` is still answering + a message. The body is JSON: `{ "error": string, "running_turn": { "job_id", + "user_seq" } }`. /w/{workspace}/jobs/run_and_stream/p/{path}: post: @@ -15432,6 +15782,11 @@ paths: schema: type: string format: uuid + "409": + description: >- + Chat-enabled flow only: the conversation named by `memory_id` is still answering + a message. The body is JSON: `{ "error": string, "running_turn": { "job_id", + "user_seq" } }`. /w/{workspace}/jobs/run/fv/{version}: post: @@ -15489,6 +15844,11 @@ paths: schema: type: string format: uuid + "409": + description: >- + Chat-enabled flow only: the conversation named by `memory_id` is still answering + a message. The body is JSON: `{ "error": string, "running_turn": { "job_id", + "user_seq" } }`. /w/{workspace}/jobs/run/batch_rerun_jobs: post: @@ -15982,6 +16342,52 @@ paths: schema: type: string format: uuid + "409": + description: >- + Chat-enabled flow only: the conversation named by `memory_id` is still answering + a message. The body is JSON: `{ "error": string, "running_turn": { "job_id", + "user_seq" } }`. + + /w/{workspace}/jobs/run/agent/{path}: + post: + summary: run a saved AI agent + description: | + Runs the `ai_agent` resource at `path` as stored, for any caller who can read it + (operators included), as the caller, who needs access to the agent's AI resource and to + whatever its tools use. With `memory_id`, the run is a turn + of the caller's conversation with the agent; without, a single run. + operationId: runAgent + tags: + - job + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/Path" + - name: memory_id + description: The conversation this turn belongs to. A uuid is used as is; any other string is hashed within the workspace and agent. + in: query + schema: + type: string + requestBody: + description: the run's inputs + required: true + content: + application/json: + schema: + type: object + properties: + user_message: + type: string + user_attachments: + type: array + items: {} + responses: + "201": + description: job created + content: + text/plain: + schema: + type: string + format: uuid /w/{workspace}/jobs/run_wait_result/preview_flow: post: @@ -16011,6 +16417,11 @@ paths: content: application/json: schema: {} + "409": + description: >- + Chat-enabled flow only: the conversation named by `memory_id` is still answering + a message. The body is JSON: `{ "error": string, "running_turn": { "job_id", + "user_seq" } }`. /w/{workspace}/jobs/run/dynamic_select: post: @@ -28902,6 +29313,23 @@ components: is_test: type: boolean description: Started from the flow editor's test panel rather than a deployed run + running_turn: + type: object + nullable: true + description: >- + The turn the conversation is still answering, set by the list endpoint: its + newest user message, while the flow run it started is queued or running. A + run into this conversation is refused with 409 until the turn ends. + required: [job_id, user_seq] + properties: + job_id: + type: string + format: uuid + description: The flow run of the turn + user_seq: + type: integer + format: int64 + description: created_seq of the user message that started the turn FlowConversationMessage: type: object @@ -31772,6 +32200,17 @@ components: (over a deployed row or a synthesized draft-only row). Frontend appends a `*` to the displayed name. type: boolean + agent_memory: + description: | + For an `ai_agent` resource only, its `memory` setting: the one part + of the value a listing returns, since it decides whether the agent + keeps a conversation. + draft_path: + type: string + description: | + On a draft-only row, the path its editor has staged when it + differs from the storage path (e.g. a never-deployed item parked at + `u/{user}/draft_{uuid}`). required: - path - resource_type @@ -32035,6 +32474,16 @@ components: - id - success - duration_ms + late_run_streak: + type: integer + description: runs in a row, up to the latest, that started or finished after the next occurrence was due, so that occurrence was missed + missed_occurrences: + type: integer + description: occurrences missed by the latest streak of late runs, kept after it ends; a lower bound once a single late run misses 1000 + last_missed_at: + type: string + format: date-time + description: latest missed occurrence, kept after the schedule runs on time again; past 1000 misses in one late run, when the miss was detected, at most one period later ErrorHandler: type: string @@ -33966,9 +34415,55 @@ components: - ducklake - datatable + ExternalInstancePgSetupStep: + type: object + required: [name, status, message] + properties: + name: + type: string + status: + type: string + enum: [ok, warning, error] + message: + type: string + + ExternalInstancePgSetupReport: + type: object + required: [success, finished_at, steps] + properties: + success: + type: boolean + description: no step failed; warnings leave it true + finished_at: + type: string + format: date-time + steps: + type: array + items: + $ref: "#/components/schemas/ExternalInstancePgSetupStep" + + ExternalInstancePgStatus: + type: object + required: [configured, database_count] + properties: + configured: + type: boolean + database_count: + type: integer + last_setup: + $ref: "#/components/schemas/ExternalInstancePgSetupReport" + + DatatableRoleCluster: + type: string + description: >- + The Windmill-managed Postgres cluster a data table role is a login on: Windmill's own + (behind `instance` data tables) or the external instance cluster (behind + `external_instance` ones). Defaults to `instance`. + enum: [instance, external_instance] + InstanceDatatableRole: type: object - required: [id, name, enabled] + required: [id, name, enabled, cluster] properties: id: type: string @@ -33976,6 +34471,8 @@ components: type: string enabled: type: boolean + cluster: + $ref: "#/components/schemas/DatatableRoleCluster" DatatableRoleTenants: type: object @@ -33997,8 +34494,10 @@ components: supported: type: boolean description: >- - Whether this data table can be put under roles at all. Only one backed by the - instance database can: a role is a login on that cluster. + Whether this data table can be put under roles at all. Only one on a database Windmill + manages can: a role is a login on that database's cluster. + cluster: + $ref: "#/components/schemas/DatatableRoleCluster" permissioned: type: boolean default_role: @@ -34310,7 +34809,10 @@ components: type: array items: type: string - description: Workspaces that reference this database via a ducklake catalog or datatable database with resource_type 'instance'. Computed at request time, not persisted. + description: Workspaces that reference this database through a ducklake catalog or a datatable database of the kind being listed — 'instance' for the instance databases endpoint, 'external_instance' for the external cluster one. Computed at request time, not persisted, and only returned to superadmins. + workspace_id: + type: string + description: The workspace a member created this database for as a fork copy. Only that workspace can import into it or point a fork at it. NewSqsTrigger: type: object @@ -35901,6 +36403,9 @@ components: True when the authed user has a draft for this app — either no deployed row exists at this path (draft-only) or the user has saved a per-user draft on top of the deployed row. + draft_only: + type: boolean + description: No deployed counterpart exists at this path — the draft is the whole item. draft_path: type: string description: | @@ -36318,6 +36823,7 @@ components: - postgresql - mysql - instance + - external_instance resource_path: type: string required: @@ -36381,6 +36887,7 @@ components: enum: - postgresql - instance + - external_instance resource_path: type: string required: @@ -37262,6 +37769,9 @@ components: workers: type: boolean description: Whether operators can view workers page + builder_flows: + type: boolean + description: Whether operators can compose flows out of existing runnables (consumes a full seat). Omitting the field leaves the stored value unchanged. manage_schedules: type: boolean description: Whether operators can create, edit and delete schedules. Granted unless withdrawn; omitting the field leaves the stored value unchanged. diff --git a/backend/windmill-api/src/agent_runs.rs b/backend/windmill-api/src/agent_runs.rs new file mode 100644 index 0000000000..d2e76dcca0 --- /dev/null +++ b/backend/windmill-api/src/agent_runs.rs @@ -0,0 +1,184 @@ +//! Running a saved AI agent from its own page, and the one-step flow its runs and evals share. +//! +//! The run is built here from the stored agent rather than sent by the client, which is what lets +//! anyone who can read the agent run it, operators included, the way a deployed flow runs by path: +//! a preview takes arbitrary code, this takes only the run's inputs. It runs as the caller, who +//! needs access to the agent's AI resource and to whatever its tools use. + +use std::collections::HashMap; + +use axum::{ + extract::{Path, Query}, + Extension, Json, +}; +use hyper::StatusCode; +use serde_json::value::RawValue; +use windmill_api_auth::check_scopes; +use windmill_common::{ + db::UserDB, + error::{Error, Result}, + jobs::JobPayload, + users::username_to_permissioned_as, + utils::{not_found_if_none, StripPath}, +}; +use windmill_queue::{push, PushArgs, PushIsolationLevel}; + +use crate::ai_evals::run::config_to_draft; +use crate::ai_evals::subject::AgentDraft; +use crate::db::{ApiAuthed, DB}; +use crate::jobs::{handle_chat_conversation_messages, set_flow_memory_id, RunJobQuery}; + +/// The id the agent's step carries in the run, as the editor's chat names it. A token scoped to +/// `jobs:run:agents:` reads its runs back by it (`require_job_within_run_scope`). +const AGENT_NODE_ID: &str = "__wm_agent_root"; + +/// A turn of the agent's chat when `memory_id` names a conversation, a single run otherwise. +/// Returns the job id. +pub(crate) async fn run_agent( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((w_id, path)): Path<(String, StripPath)>, + Query(run_query): Query, + Json(args): Json>>, +) -> Result<(StatusCode, String)> { + #[cfg(feature = "enterprise")] + crate::jobs::check_license_key_valid().await?; + + let path = path.to_path(); + check_scopes(&authed, || format!("jobs:run:agents:{path}"))?; + + // Read through the caller's own permissions: reading the agent is what allows running it. + let mut tx = user_db.clone().begin(&authed).await?; + let value = sqlx::query_scalar!( + "SELECT value AS \"value: sqlx::types::Json\" + FROM resource WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent'", + w_id, + path + ) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + let Some(sqlx::types::Json(value)) = not_found_if_none(value, "Agent", path)? else { + return Err(Error::BadRequest(format!( + "Agent {path} has no configuration" + ))); + }; + let config = config_to_draft(value)?; + + // A chat turn is filed where the agent's chat lists its conversations, which a flow cannot + // name (flow paths carry no `.`), so the two never share a conversation list. + let chat = run_query.memory_id.is_some(); + let run_path = if chat { + format!("{path}.chat") + } else { + path.to_string() + }; + // A step memory id would replace the conversation's, and the agent would forget the turns + // before; history comes from the conversation alone. + let flow_value = agent_step_flow( + &config, + AGENT_NODE_ID, + chat, + &["memory_id", "previous_messages"], + )?; + + let push_authed = authed.clone().into(); + let (uuid, mut tx) = push( + &db, + PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()), + &w_id, + JobPayload::RawFlow { + value: flow_value, + path: Some(run_path.clone()), + restarted_from: None, + }, + PushArgs::from(&args), + authed.display_username(), + &authed.email, + username_to_permissioned_as(&authed.username), + authed.token_prefix.as_deref(), + authed.username_override.as_deref(), + None, + None, + None, + None, + None, + None, + false, + false, + None, + true, + None, + None, + None, + None, + Some(&push_authed), + false, + None, + authed.trigger_or_fallback(None), + None, + ) + .await?; + + if let Some(memory_id) = run_query.memory_key(&w_id, &run_path) { + set_flow_memory_id(&mut tx, uuid, memory_id).await?; + } + if chat { + // A real conversation, not a test one: this is the agent as deployed, not a draft. + handle_chat_conversation_messages( + &mut tx, + &authed, + &w_id, + &run_path, + &run_query, + args.get("user_message"), + uuid, + false, + &args, + ) + .await?; + } + tx.commit().await?; + + Ok((StatusCode::CREATED, uuid.to_string())) +} + +/// The agent as a one-step flow, validated by deserializing through `FlowValue` rather than +/// trusted as raw JSON. Its own transforms are the step's, minus `without`, and the run's inputs +/// supply the message and the attachments over the top. Always inlined, never a link to the +/// resource: a linked step would resolve the agent when it runs, not as it was read here. +pub(crate) fn agent_step_flow( + config: &AgentDraft, + id: &str, + chat: bool, + without: &[&str], +) -> Result { + let mut input_transforms = match &config.input_transforms { + serde_json::Value::Object(map) => map.clone(), + _ => serde_json::Map::new(), + }; + for key in without { + input_transforms.remove(*key); + } + for key in ["user_message", "user_attachments"] { + input_transforms.insert( + key.to_string(), + serde_json::json!({ "type": "javascript", "expr": format!("flow_input.{}", key) }), + ); + } + let mut flow = serde_json::json!({ + "modules": [{ + "id": id, + "value": { + "type": "aiagent", + "tools": config.tools, + "input_transforms": serde_json::Value::Object(input_transforms), + } + }] + }); + if chat { + flow["chat_input_enabled"] = serde_json::json!(true); + } + Ok(serde_json::from_value(flow)?) +} diff --git a/backend/windmill-api/src/ai_evals/run.rs b/backend/windmill-api/src/ai_evals/run.rs index 5d282c7e5b..42c2fcb2f2 100644 --- a/backend/windmill-api/src/ai_evals/run.rs +++ b/backend/windmill-api/src/ai_evals/run.rs @@ -248,7 +248,7 @@ fn build_run_flow( /// The agent step, reading its case from the iteration rather than from the flow's arguments. fn agent_module(config: &AgentDraft) -> Result { - let flow = build_case_flow(config)?; + let flow = crate::agent_runs::agent_step_flow(config, AGENT_NODE_ID, false, &[])?; let mut value = serde_json::to_value(&flow.modules[0].value)?; if let Some(map) = value.as_object_mut() { let transforms = map @@ -269,36 +269,6 @@ fn agent_module(config: &AgentDraft) -> Result { Ok(serde_json::json!({ "id": AGENT_NODE_ID, "value": value })) } -/// The agent step as a one-module flow, so the module shape is validated by deserializing -/// through `FlowValue` rather than trusted as raw JSON. -fn build_case_flow(config: &AgentDraft) -> Result { - // The configuration runs exactly as authored: its own brain transforms are the module's, and - // the case supplies the message and the attachments over the top. - let mut input_transforms = match &config.input_transforms { - serde_json::Value::Object(map) => map.clone(), - _ => serde_json::Map::new(), - }; - for key in ["user_message", "user_attachments"] { - input_transforms.insert( - key.to_string(), - serde_json::json!({ "type": "javascript", "expr": format!("flow_input.{}", key) }), - ); - } - - // Always inlined, never a link to the resource: a linked step would resolve the agent when - // each case runs, which is the one thing a run of a named version must not do. - let mut agent_value = serde_json::Map::new(); - agent_value.insert("type".to_string(), serde_json::json!("aiagent")); - agent_value.insert("tools".to_string(), serde_json::json!(config.tools)); - agent_value.insert( - "input_transforms".to_string(), - serde_json::Value::Object(input_transforms), - ); - Ok(serde_json::from_value(serde_json::json!({ - "modules": [{ "id": AGENT_NODE_ID, "value": serde_json::Value::Object(agent_value) }] - }))?) -} - /// How many times the agent has been saved, not the identity of the row holding that value: runs /// are named by it and compared by it, so it has to be the resource's own count rather than a /// sequence the whole instance shares. @@ -348,7 +318,7 @@ pub(crate) async fn require_agent( /// transforms, its tools the module's tools. The same conversion for a draft and for what is /// deployed, so the two hash comparably — which is what lets a draft run be recognised as the /// version it became. -fn config_to_draft(value: serde_json::Value) -> Result { +pub(crate) fn config_to_draft(value: serde_json::Value) -> Result { let mut config = match value { serde_json::Value::Object(map) => map, _ => return Err(Error::BadRequest("The agent is not an object".to_string())), diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index ef48927676..f01275c953 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -4362,12 +4362,10 @@ async fn execute_component( let resolved_delete_secs = resolve_delete_after_secs(None, policy_triggerables.delete_after_secs); - // `_MODULES` and `_TEMP_SCRIPT_REFS` are server-injected control keys (into - // `extra`) that the worker reads back for a `Preview` job — which an inline run - // is. A caller supplying them in `args` would inject module content/locks or - // redirect relative-import resolution, unpinned, as the app identity. Drop them; - // legitimate values ride in `extra`, never the request `args`. - payload.args.remove("_MODULES"); + // `_TEMP_SCRIPT_REFS` is a server-injected control key (into `extra`) that the + // worker reads back for a `Preview` job, which an inline run is. A caller + // supplying it in `args` would redirect relative-import resolution, unpinned, as + // the app identity. Drop it; the legitimate value rides in `extra`. payload.args.remove("_TEMP_SCRIPT_REFS"); let (mut args, job_id) = build_args( diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index 8bcbb56384..7b2268e38f 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -14,13 +14,16 @@ use axum::{ Json, Router, }; use serde::{Deserialize, Serialize}; +use windmill_api_flows::flows::validate_operator_flow; use windmill_common::{ db::UserDB, error::{Error, Result}, + flows::FlowValue, user_drafts::{DraftUserRef, UserDraftItemKind, ENCRYPTED_DRAFT_PREFIX}, users::resolve_username_to_email, utils::{check_proper_path, strip_json_nul}, variables::{build_crypt, encrypt}, + workspaces::operator_can_build_flows, }; pub fn workspaced_service() -> Router { @@ -101,10 +104,10 @@ async fn list_drafts( Path(w_id): Path, Query(query): Query, ) -> Result>> { - // Operators have no drafts of their own (they can't write any, see - // `require_can_write_path`), so this list is always empty for them. They - // can still READ some collaborators' drafts via `/drafts/get`. - if authed.is_operator { + // Without builder rights an operator has no drafts of their own (they can't write any, see + // `require_can_write_path`), so this list is always empty for them. They can still READ some + // collaborators' drafts via `/drafts/get`. + if authed.is_operator && !operator_can_build_flows(&db, &w_id).await? { return Ok(Json(vec![])); } let all_users = query.all_users.unwrap_or(false); @@ -154,10 +157,13 @@ async fn list_drafts( { Ok(()) => true, // A stored draft can sit at an unwritable path — unauthorized, - // or malformed (`BadRequest`; the `draft` table has no path - // constraint). Either way it's not writable, and one bad row - // must not 400 the whole listing. - Err(Error::NotAuthorized(_)) | Err(Error::BadRequest(_)) => false, + // refused to an operator (`PermissionDenied`), or malformed + // (`BadRequest`; the `draft` table has no path constraint). + // Either way it's not writable, and one bad row must not fail + // the whole listing. + Err(Error::NotAuthorized(_)) + | Err(Error::PermissionDenied(_)) + | Err(Error::BadRequest(_)) => false, Err(e) => return Err(e), }; out.push(row); @@ -464,6 +470,32 @@ async fn update_draft( } } + if authed.is_operator && kind == UserDraftItemKind::Flow { + if let Some(value) = &req.value { + #[derive(Deserialize)] + struct FlowDraft { + #[serde(default)] + value: FlowValue, + schema: Option>, + tag: Option, + } + // The text stored below, NULs stripped: a stripped NUL can rename a key into one + // this check reads. + let draft: FlowDraft = serde_json::from_str(&strip_json_nul(value.0.get())) + .map_err(|e| Error::BadRequest(format!("Invalid flow draft: {e}")))?; + validate_operator_flow( + &draft.value, + &draft.tag, + draft.schema.as_deref().map(|s| s.get()), + &authed, + &db, + &user_db, + &w_id, + ) + .await?; + } + } + let applied = if let Some(value) = &req.value { // Secret variable values must never sit in `draft.value` in plaintext // (see `encrypt_secret_variable_value`). @@ -1111,13 +1143,14 @@ fn table_for_kind(kind: UserDraftItemKind) -> Option<&'static str> { } /// Resolves to `Ok(())` if `authed` may SAVE a draft at `path`. Operators are -/// rejected outright. Two layers: +/// rejected, except for flow drafts in a workspace that granted them builder +/// rights. Two layers: /// 1. Claim-based namespace rules (admin, own `u/`, member `g/`, writable /// `f/`) — mirror what RLS reads from the same JWT claims, and are the /// ENTIRE check for draft-only paths (no deployed row for RLS to use). /// 2. An RLS write-probe on the deployed row (`SELECT ... FOR UPDATE`) for /// what the path can't answer, above all item-level extra_perms grants. -async fn require_can_write_path( +pub(crate) async fn require_can_write_path( authed: &ApiAuthed, db: &DB, user_db: &UserDB, @@ -1128,14 +1161,19 @@ async fn require_can_write_path( if authed.is_admin { return Ok(()); } - // Operators are read-only and never WRITE drafts. Read access is - // deliberately asymmetric: `require_can_read_path` has no operator block, - // so an operator can still READ a draft they can read via `/drafts/get`, - // mirroring their read access to deployed content. Intended. + // Operators are read-only and never WRITE drafts, except a flow draft where the workspace + // granted the builder right: the kind has to be checked, or the right would open drafts of + // kinds it says nothing about. Read access is deliberately asymmetric: + // `require_can_read_path` has no operator block, so an operator can still READ a draft they + // can read via `/drafts/get`, mirroring their read access to deployed content. Intended. if authed.is_operator { - return Err(Error::NotAuthorized( - "operators cannot save drafts".to_string(), - )); + let granted = + matches!(kind, UserDraftItemKind::Flow) && operator_can_build_flows(db, w_id).await?; + if !granted { + return Err(Error::PermissionDenied( + "operators cannot save drafts".to_string(), + )); + } } // Cheap claim-based namespace checks first: they evaluate the same JWT // claims RLS reads, so the outcome matches the policies while sparing the diff --git a/backend/windmill-api/src/integration.rs b/backend/windmill-api/src/integration.rs index 2def3e6ed0..10bd1fc24c 100644 --- a/backend/windmill-api/src/integration.rs +++ b/backend/windmill-api/src/integration.rs @@ -1,9 +1,16 @@ use crate::{db::DB, HTTP_CLIENT}; -use axum::{extract::Query, response::IntoResponse, routing::get, Extension, Router}; +use axum::{ + extract::{Path, Query}, + response::IntoResponse, + routing::get, + Extension, Router, +}; use windmill_common::{error::Error, utils::query_elems_from_hub, HUB_BASE_URL}; pub fn global_service() -> Router { - Router::new().route("/hub/list", get(list_hub_integrations)) + Router::new() + .route("/hub/list", get(list_hub_integrations)) + .route("/hub/{app}/meta", get(get_hub_integration_meta)) } #[derive(serde::Deserialize)] @@ -29,3 +36,60 @@ async fn list_hub_integrations( .await?; Ok::<_, Error>((status_code, headers, response)) } + +/// Axum percent-decodes a path parameter, so an interpolated slug carrying `..`, `?` +/// or `#` re-targets the proxied GET at another path on the hub origin — with the +/// instance's hub credentials attached. Slugs are `[A-Za-z0-9_-]`; reject the rest. +fn is_hub_integration_slug(app: &str) -> bool { + !app.is_empty() + && app.len() <= 64 + && app + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + +/// Everything a caller needs to write code against one integration: its resource +/// types, the provider knowledge the content repo authored, and facts derived from +/// the shipped scripts. A hub older than the endpoint answers 404, which passes +/// through as-is. +async fn get_hub_integration_meta( + Path(app): Path, + Extension(db): Extension, +) -> impl IntoResponse { + if !is_hub_integration_slug(&app) { + return Err(Error::BadRequest(format!( + "Not a valid integration name: {app}" + ))); + } + let (status_code, headers, response) = query_elems_from_hub( + &HTTP_CLIENT, + &format!("{}/integrations/{}/meta", **HUB_BASE_URL.load(), app), + None, + &db, + ) + .await?; + Ok::<_, Error>((status_code, headers, response)) +} + +#[cfg(test)] +mod tests { + use super::is_hub_integration_slug; + + #[test] + fn rejects_slugs_that_would_re_target_the_proxied_request() { + assert!(is_hub_integration_slug("confluence")); + assert!(is_hub_integration_slug("aws-ses")); + assert!(is_hub_integration_slug("bamboo_hr")); + assert!(is_hub_integration_slug("RSS")); + + for escape in [ + "../../scripts/top", + "confluence?foo=bar", + "confluence#frag", + "confluence/meta", + "", + ] { + assert!(!is_hub_integration_slug(escape), "accepted {escape}"); + } + } +} diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 27e34b6ef2..785c6ff935 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -26,6 +26,7 @@ use std::time::Instant; use tokio::io::AsyncReadExt; use tower::ServiceBuilder; use url::Url; +use windmill_api_flows::flows::validate_operator_composed_flow; use windmill_common::assets::AssetUsageAccessType; use windmill_common::auth::TOKEN_PREFIX_LEN; #[cfg(feature = "run_inline")] @@ -53,7 +54,9 @@ use windmill_common::worker::{Connection, CLOUD_HOSTED, WINDMILL_DIR}; use windmill_common::workspace_dependencies::{ RawWorkspaceDependencies, MIN_VERSION_WORKSPACE_DEPENDENCIES, }; -use windmill_common::workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}; +use windmill_common::workspaces::{ + check_operator_can_build_flows, check_user_against_rule, ProtectionRuleKind, RuleCheckResult, +}; use windmill_common::DYNAMIC_INPUT_CACHE; #[cfg(all(feature = "enterprise", feature = "instance_smtp"))] use windmill_common::{email_oss::send_email_html, server::load_smtp_config}; @@ -282,6 +285,7 @@ pub fn workspaced_service() -> Router { ) .route("/add_batch_jobs/{n}", post(add_batch_jobs)) .route("/run/preview_flow", post(run_preview_flow_job)) + .route("/run/agent/{*path}", post(crate::agent_runs::run_agent)) .route( "/run_wait_result/preview_flow", post(run_wait_result_preview_flow), @@ -1894,7 +1898,10 @@ async fn require_job_within_run_scope( // NULL for a job no such scope reaches directly (previews, dependency jobs, // flow-inlined scripts) — those are still readable as a step of a matching flow, // through their ancestors. A `singlestepflow` wraps either a script or a flow, so it - // projects onto the wrapped runnable the same way the batch-rerun query does. + // projects onto the wrapped runnable the same way the batch-rerun query does. An agent + // run is a preview of the one-step flow `agent_runs::agent_step_flow` builds, filed under the + // agent's path (`.chat` for a chat turn); the editor's own runs of it look the same, + // and are runs of that agent too. let chain = sqlx::query!( r#"WITH RECURSIVE chain(id, parent_job) AS ( SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2 @@ -1902,8 +1909,11 @@ async fn require_job_within_run_scope( SELECT j.id, j.parent_job FROM v2_job j JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2 ) - SELECT j.runnable_path, + SELECT + CASE WHEN a.agent THEN regexp_replace(j.runnable_path, '\.chat$', '') + ELSE j.runnable_path END AS runnable_path, CASE + WHEN a.agent THEN 'agents' WHEN j.kind IN ('script', 'script_hub', 'unassigned_script') THEN 'scripts' WHEN j.kind IN ('flow', 'unassigned_flow') THEN 'flows' WHEN j.kind IN ('singlestepflow', 'unassigned_singlestepflow') THEN @@ -1916,7 +1926,10 @@ async fn require_job_within_run_scope( ) = 'flow' THEN 'flows' ELSE 'scripts' END END AS scope_kind, CASE WHEN j.trigger_kind = 'app' THEN j.trigger END AS launched_by_app - FROM v2_job j JOIN chain c ON c.id = j.id + FROM v2_job j JOIN chain c ON c.id = j.id, + LATERAL (SELECT j.kind = 'flowpreview' + AND j.raw_flow->'modules'->1 IS NULL + AND j.raw_flow->'modules'->0->>'id' = '__wm_agent_root' AS agent) a WHERE j.workspace_id = $2"#, job_id, w_id, @@ -2772,15 +2785,16 @@ async fn send_email_with_instance_smtp( return Err(anyhow::anyhow!("Feature not supported in cloud hosted windmill").into()); } - // Any code pushed as a workspace or schedule error handler, custom ones included, runs as - // one of these identities: this keeps out ad-hoc job tokens, not who authors handler code. + // Workspace error handlers (admin-configured, custom ones included) and the preset hub + // handlers of a schedule run as one of these identities. A custom schedule handler runs as + // the schedule and is set by any schedule writer, so it must stay out of this list. let is_handler_job = authed.email == EMAIL_ERROR_HANDLER_USER_EMAIL || authed.email == ERROR_HANDLER_USER_EMAIL || authed.email == SCHEDULE_ERROR_HANDLER_USER_EMAIL; if !is_handler_job && !windmill_api_auth::is_super_admin_authed(&db, &authed).await? { return Err(Error::NotAuthorized( - "Only super admin or a workspace/schedule error handler job can send emails with the instance SMTP" + "Only super admin, a workspace error handler or a preset schedule handler can send emails with the instance SMTP" .to_string(), )); } @@ -4227,6 +4241,7 @@ async fn get_started_at_by_ids( struct ListableQueuedJob { pub id: Uuid, pub running: bool, + pub canceled: bool, pub created_by: String, pub created_at: chrono::DateTime, pub started_at: Option>, @@ -4272,6 +4287,9 @@ async fn list_queue_jobs( &[ "v2_job.id", "v2_job_queue.running", + // A canceled row stays in the queue until a worker picks it up and completes it, and + // the `QueuedJob` schema this answers with declares the field either way. + "v2_job_queue.canceled_by IS NOT NULL as canceled", "v2_job.created_by", "v2_job.created_at", "v2_job_queue.started_at", @@ -4770,15 +4788,7 @@ pub async fn resume_suspended_flow_as_owner( .await?; if is_wac { - // WAC: directly decrement suspend counter - if flow.suspend > 0 { - sqlx::query!( - "UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1", - flow.id, - ) - .execute(&mut *tx) - .await?; - } + decrement_suspend(flow.id, &mut tx).await?; } else { resume_immediately_if_relevant(flow, job_id, &mut tx).await?; } @@ -4929,12 +4939,7 @@ async fn resume_suspended( .execute(&mut *tx) .await?; } else if is_wac { - if flow.suspend > 0 { - sqlx::query("UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1") - .bind(&flow.id) - .execute(&mut *tx) - .await?; - } + decrement_suspend(flow.id, &mut tx).await?; } else { resume_immediately_if_relevant(flow, resume_job_id, &mut tx).await?; } @@ -5412,8 +5417,13 @@ async fn resume_suspended_job_internal( let value = value.unwrap_or(serde_json::Value::Null); verify_suspended_secret(&w_id, &db, job_id, resume_id, &approver, secret).await?; - // Get flow info - works for step-level, flow-level, and WAC approval - let (flow_info, is_flow_level, is_wac) = get_flow_info_for_resume(job_id, &w_id, &db).await?; + let mut tx: Transaction<'_, Postgres> = db.begin().await?; + + // Get flow info - works for step-level, flow-level, and WAC approval. This locks the + // queue row until commit, so resumes serialize with each other and with the worker + // entering the suspend. + let (flow_info, is_flow_level, is_wac) = + get_flow_info_for_resume(job_id, &w_id, &mut tx).await?; // HMAC secret = full capability. Skip approval_conditions checks: possession of the full // resume URL is the authorization (it is only disclosed to intended approvers, e.g. when a @@ -5426,7 +5436,7 @@ async fn resume_suspended_job_internal( "#, Uuid::from_u128(job_id.as_u128() ^ resume_id as u128), ) - .fetch_one(&db) + .fetch_one(&mut *tx) .await? .unwrap_or(false); @@ -5444,7 +5454,6 @@ async fn resume_suspended_job_internal( } else { authed.as_ref().map(|x| x.username.clone()) }; - let mut tx: Transaction<'_, Postgres> = db.begin().await?; // Inside the transaction that inserts the row and moves the suspend counter: // validating earlier would let the workflow resolve this step and suspend on the @@ -5473,20 +5482,11 @@ async fn resume_suspended_job_internal( .await?; } else if is_wac { // WAC approval: decrement suspend counter directly on the WAC parent job. - // `flow_info.suspend` was read before this transaction took the queue-row - // lock, so gating on it would skip the decrement for a workflow that - // suspended in between and leave the approval parked until timeout. - sqlx::query!( - "UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) \ - WHERE id = $1 AND suspend > 0", - flow_info.id, - ) - .execute(&mut *tx) - .await?; + decrement_suspend(flow_info.id, &mut tx).await?; } else if is_flow_level { - // For flow-level resumes, decrement the suspend counter if the flow is currently suspended - // The approval will be matched when the worker checks for resumes (both step-level and flow-level) - resume_immediately_for_flow_level(&flow_info, &mut tx).await?; + // Flow-level resumes don't match a step: the approval is picked up when the worker + // checks for resumes (both step-level and flow-level). + decrement_suspend(flow_info.id, &mut tx).await?; } else { // For step-level resumes, try to resume immediately if the step is waiting resume_immediately_if_relevant(flow_info, job_id, &mut tx).await?; @@ -5564,42 +5564,27 @@ async fn resume_immediately_if_relevant<'c>( job_id: Uuid, tx: &mut Transaction<'c, Postgres>, ) -> error::Result<()> { - Ok( - if let Some(suspend) = (0 < flow.suspend).then(|| flow.suspend - 1) { - let status = - serde_json::from_value::(flow.flow_status.context("no flow status")?) - .context("deserialize flow status")?; - if matches!(status.current_step(), Some(FlowStatusModule::WaitingForEvents { job, .. }) if job == &job_id) - { - sqlx::query!( - "UPDATE v2_job_queue SET suspend = $1 WHERE id = $2", - suspend, - flow.id, - ) - .execute(&mut **tx) - .await?; - } - }, - ) + Ok(if 0 < flow.suspend { + let status = + serde_json::from_value::(flow.flow_status.context("no flow status")?) + .context("deserialize flow status")?; + if matches!(status.current_step(), Some(FlowStatusModule::WaitingForEvents { job, .. }) if job == &job_id) + { + decrement_suspend(flow.id, tx).await?; + } + }) } -/// For flow-level resumes, decrement the suspend counter if the flow is currently suspended. -/// Unlike step-level resumes, we don't check if the job_id matches - we just need the flow -/// to be in a suspended state. -async fn resume_immediately_for_flow_level<'c>( - flow: &FlowInfo, - tx: &mut Transaction<'c, Postgres>, -) -> error::Result<()> { - if flow.suspend > 0 { - let new_suspend = flow.suspend - 1; - sqlx::query!( - "UPDATE v2_job_queue SET suspend = $1 WHERE id = $2", - new_suspend, - flow.id, - ) - .execute(&mut **tx) - .await?; - } +/// Relative rather than `suspend = - 1`, so concurrent approvals each remove one +/// event from the count instead of overwriting each other's decrement. +async fn decrement_suspend(flow_id: Uuid, tx: &mut Transaction<'_, Postgres>) -> error::Result<()> { + sqlx::query!( + "UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) \ + WHERE id = $1 AND suspend > 0", + flow_id, + ) + .execute(&mut **tx) + .await?; Ok(()) } @@ -5648,10 +5633,10 @@ struct FlowInfo { async fn get_flow_info_for_resume( job_id: Uuid, w_id: &str, - db: &DB, + tx: &mut Transaction<'_, Postgres>, ) -> error::Result<(FlowInfo, bool, bool)> { // Single query that determines if job_id is a flow, step, or WAC job, - // and fetches the appropriate suspended job info. + // and locks the appropriate suspended job's queue row. // For WAC jobs (no parent, not a flow), the job itself is the suspended target. let result = sqlx::query!( r#" @@ -5662,7 +5647,6 @@ async fn get_flow_info_for_resume( ) SELECT q.id AS "id!", - s.flow_status, q.suspend AS "suspend!", j.runnable_path AS script_path, j.permissioned_as_email AS email, @@ -5674,13 +5658,12 @@ async fn get_flow_info_for_resume( ELSE COALESCE(ji.parent_job, ji.id) END JOIN v2_job j ON j.id = q.id - LEFT JOIN v2_job_status s ON s.id = q.id FOR UPDATE OF q "#, job_id, w_id, ) - .fetch_optional(db) + .fetch_optional(&mut **tx) .await? .ok_or_else(|| { Error::NotFound(format!( @@ -5688,9 +5671,18 @@ async fn get_flow_info_for_resume( )) })?; + // Read in its own statement: a statement that waited on the queue-row lock above re-reads + // only the locked row, so a status joined into it could predate the worker's commit. + let flow_status: Option = + sqlx::query_scalar("SELECT flow_status FROM v2_job_status WHERE id = $1") + .bind(result.id) + .fetch_optional(&mut **tx) + .await? + .flatten(); + let flow_info = FlowInfo { id: result.id, - flow_status: result.flow_status, + flow_status, suspend: result.suspend, script_path: result.script_path, email: Some(result.email), @@ -5703,6 +5695,13 @@ async fn get_suspended_flow_info<'c>( job_id: Uuid, tx: &mut Transaction<'c, Postgres>, ) -> error::Result<(FlowInfo, Uuid, bool)> { + // Lock before reading: a single statement that waited on this lock would re-read only + // the queue row, pairing a fresh suspend counter with a stale flow status. + sqlx::query("SELECT 1 FROM v2_job_queue WHERE id = $1 FOR UPDATE") + .bind(job_id) + .execute(&mut **tx) + .await?; + let flow = sqlx::query_as!( FlowInfo, r#" @@ -7147,7 +7146,7 @@ pub async fn restart_flow( let completed_job = sqlx::query!( "SELECT j.runnable_path as script_path, j.args AS \"args: sqlx::types::Json>>\", - j.tag AS \"tag!\", j.priority + j.tag AS \"tag!\", j.priority, j.kind AS \"kind!: JobKind\" FROM v2_job j WHERE j.id = $1 and j.workspace_id = $2", job_id, @@ -7164,6 +7163,13 @@ pub async fn restart_flow( check_scopes(&authed, || format!("jobs:run:flows:{flow_path}"))?; let mut run_query = run_query; drop_unclaimable_run_lineage(&db, &w_id, &mut run_query, &authed).await?; + // A restarted flow preview reruns the value its request supplied, while a flow preview + // under a parent is read (by job provenance) as that parent's own definition. + let (parent_job, root_job) = if completed_job.kind == JobKind::FlowPreview { + (None, None) + } else { + (run_query.parent_job, run_query.root_job) + }; let ehm = HashMap::new(); let push_args = completed_job @@ -7207,9 +7213,9 @@ pub async fn restart_flow( authed.username_override.as_deref(), scheduled_for, None, - run_query.parent_job, + parent_job, None, - run_query.root_job, + root_job, run_query.job_id, false, false, @@ -7409,6 +7415,20 @@ pub async fn run_workflow_as_code( ) .await?; + // A task re-runs a preview with the preview's modules: the ones `push` stored for it, never + // the task's args. Read on their own, as `fetch_queued` swaps oversized args for a placeholder. + let preview_modules = if job.job_kind == JobKind::Preview { + sqlx::query_scalar::<_, Option>>>( + "SELECT args->'_MODULES' FROM v2_job WHERE id = $1", + ) + .bind(job.id) + .fetch_one(&db) + .await? + .map(|modules| modules.0) + } else { + None + }; + let (job_payload, tag, _delete_after_use, _delete_after_secs, timeout, on_behalf_of) = match job.job_kind { JobKind::Preview => ( @@ -7432,7 +7452,7 @@ pub async fn run_workflow_as_code( dedicated_worker: None, // TODO(debouncing): enable for this mode debouncing_settings: DebouncingSettings::default(), - modules: None, + modules: preview_modules, tag: None, }), Some(job.tag.clone()), @@ -8574,7 +8594,9 @@ fn operator_preview_refusal(job_id: Option) -> error::Error { } else { "Operators cannot run preview jobs for security reasons" }; - error::Error::NotAuthorized(reason.to_string()) + // 403, not 401: the frontend reads a 401 as a dead session and logs the user out, and the + // flow editor builders open reaches this route. + error::Error::PermissionDenied(reason.to_string()) } async fn run_preview_script( @@ -8618,9 +8640,6 @@ async fn run_preview_script( if let Some(fp) = &preview.flow_path { extra.insert("_FLOW_PATH".to_string(), to_raw_value(fp)); } - if let Some(ref modules) = preview.modules { - extra.insert("_MODULES".to_string(), to_raw_value(modules)); - } if let Some(ref temp_script_refs) = preview.temp_script_refs { extra.insert( "_TEMP_SCRIPT_REFS".to_string(), @@ -9259,14 +9278,32 @@ pub struct RunFlowDependenciesResponse { async fn push_flow_dependencies_job( authed: &ApiAuthed, db: &DB, + user_db: &UserDB, w_id: &str, req: RunFlowDependenciesRequest, ) -> error::Result { check_scopes(authed, || format!("jobs:run"))?; + check_operator_can_build_flows(db, w_id, authed.is_operator, "run dependencies jobs").await?; + // The dependency job locks whatever inline code this request carries, on a worker. A + // composition-only flow has none, so validating here costs a builder nothing and keeps the + // lock step from becoming the way to run code the write path refuses. if authed.is_operator { - return Err(error::Error::NotAuthorized( - "Operators cannot run dependencies jobs for security reasons".to_string(), - )); + validate_operator_composed_flow(&req.flow_value, &None, authed, db, user_db, w_id).await?; + // The job rewrites bookkeeping stored under `req.path` (dependency map, asset usages, + // lock error) even with `skip_flow_update`, so a builder must be able to write there. + crate::drafts::require_can_write_path( + authed, + db, + user_db, + w_id, + windmill_common::user_drafts::UserDraftItemKind::Flow, + &req.path, + ) + .await + .map_err(|e| match e { + error::Error::NotAuthorized(msg) => error::Error::PermissionDenied(msg), + e => e, + })?; } if req.raw_deps.is_some() { @@ -9332,20 +9369,22 @@ async fn push_flow_dependencies_job( async fn run_flow_dependencies_job( authed: ApiAuthed, Extension(db): Extension, + Extension(user_db): Extension, Path(w_id): Path, Json(req): Json, ) -> error::Result { - let uuid = push_flow_dependencies_job(&authed, &db, &w_id, req).await?; + let uuid = push_flow_dependencies_job(&authed, &db, &user_db, &w_id, req).await?; run_wait_result(&db, uuid, &w_id, None, false, &authed.username).await } async fn run_flow_dependencies_job_async( authed: ApiAuthed, Extension(db): Extension, + Extension(user_db): Extension, Path(w_id): Path, Json(req): Json, ) -> error::Result<(StatusCode, String)> { - let uuid = push_flow_dependencies_job(&authed, &db, &w_id, req).await?; + let uuid = push_flow_dependencies_job(&authed, &db, &user_db, &w_id, req).await?; Ok((StatusCode::CREATED, uuid.to_string())) } @@ -9646,15 +9685,31 @@ async fn run_preview_flow_job( Query(run_query): Query, Json(raw_flow): Json, ) -> error::Result<(StatusCode, String)> { - if authed.is_operator { - return Err(error::Error::NotAuthorized( - "Operators cannot run preview jobs for security reasons".to_string(), - )); - } + check_operator_can_build_flows(&db, &w_id, authed.is_operator, "run preview jobs").await?; // Flow preview runs an arbitrary, request-supplied flow definition; require the broad // jobs:run scope so a narrowly-scoped token cannot escape its scope. See run_preview_script. check_scopes(&authed, || format!("jobs:run"))?; require_path_read_access_for_preview(&authed, &raw_flow.path)?; + // A builder must be able to test what it composes, but the submitted value is not the stored + // one: without this the preview is a way to run inline code the write path refuses. + if authed.is_operator { + validate_operator_composed_flow( + &raw_flow.value, + &raw_flow.tag, + &authed, + &db, + &user_db, + &w_id, + ) + .await?; + } + // Restarting copies the source runs' step results into the new run, and the queue resolves + // them with the service pool, so every run the request names must be readable as the caller. + let mut level = raw_flow.restarted_from.as_ref(); + while let Some(r) = level { + require_job_update_read_access(&db, &user_db, &authed, &w_id, &r.flow_job_id, None).await?; + level = r.nested.as_deref(); + } let scheduled_for = run_query.get_scheduled_for(&db).await?; let tag = run_query.tag.clone().or(raw_flow.tag.clone()); let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()); @@ -9781,9 +9836,7 @@ async fn run_dynamic_select( DynamicSelectRunnableRef::Inline { .. } ) && authed.is_operator { - return Err(error::Error::NotAuthorized( - "Operators cannot run preview jobs for security reasons".to_string(), - )); + return Err(operator_preview_refusal(None)); } if !is_valid_entrypoint_name(&request.entrypoint_function) { diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 156c6ca898..1cd2b4101c 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -124,6 +124,7 @@ pub mod storage_list_ee; mod storage_list_oss; mod workspace_dependencies; +mod agent_runs; mod ai_evals; mod approvals; #[cfg(all(feature = "enterprise", feature = "private"))] @@ -1279,6 +1280,15 @@ pub async fn run_server( }); server.await?; + let forks = &windmill_api_workspaces::workspaces::BACKGROUND_FORKS; + forks.close(); + if !forks.is_empty() { + tracing::info!( + "server off. waiting for {} fork(s) being created", + forks.len() + ); + forks.wait().await; + } #[cfg(feature = "agent_worker_server")] for (i, bg_processor) in agent_workers_bg_processor.into_iter().enumerate() { tracing::info!("server off. shutting down agent worker bg processor {i}"); diff --git a/backend/windmill-api/src/runnables.rs b/backend/windmill-api/src/runnables.rs index 43ef23aff2..2cee0efe33 100644 --- a/backend/windmill-api/src/runnables.rs +++ b/backend/windmill-api/src/runnables.rs @@ -34,6 +34,7 @@ use std::collections::HashMap; use windmill_common::{ db::UserDB, error::{Error, JsonResult}, + workspaces::operator_can_build_flows, }; use windmill_types::scripts::ScriptHash; use windmill_types::user_drafts::DraftUserRef; @@ -397,7 +398,7 @@ fn draft_branch_sql(kind: &str) -> String { async fn list_runnables( authed: ApiAuthed, Extension(user_db): Extension, - Extension(_db): Extension, + Extension(db): Extension, Path(w_id): Path, Query(q): Query, ) -> JsonResult { @@ -571,9 +572,9 @@ async fn list_runnables( // Draft-only rows are the caller's own work in progress: never archived, so they // have no place in the archived view, and carrying no labels of their own they are // out of scope of a label filter (as in the per-kind endpoints). Operators don't - // see other people's drafts and have none of their own to see. + // see other people's drafts and have none of their own, except a builder's flows. let include_drafts = q.include_draft_only.unwrap_or(false) - && !authed.is_operator + && (!authed.is_operator || operator_can_build_flows(&db, &w_id).await?) && !show_archived && q.label.as_ref().filter(|s| !s.is_empty()).is_none(); let draft_extras_for = |kind: &str| -> Vec { @@ -659,7 +660,7 @@ async fn list_runnables( keyset: Option<&str>, limit: Option| -> Option { - if !include_drafts || !kinds.contains(&kind) { + if !include_drafts || !kinds.contains(&kind) || (authed.is_operator && kind != "flow") { return None; } // `fav` is ignored: with no favorite join there is nothing to filter on, and the @@ -1004,9 +1005,17 @@ async fn add_draft_counts( q: &CountRunnablesQuery, counts: &mut HashMap, ) -> Result<(), Error> { - if !q.include_draft_only.unwrap_or(false) || authed.is_operator { + if !q.include_draft_only.unwrap_or(false) { return Ok(()); } + // An operator has no drafts of their own, except a builder's flows. + let kinds: &[&str] = if !authed.is_operator { + kinds + } else if kinds.contains(&"flow") && operator_can_build_flows(db, w_id).await? { + &["flow"] + } else { + return Ok(()); + }; // $1 = workspace, $2 = the caller's email. let mut binds: Vec = vec![]; let branches: Vec = kinds diff --git a/backend/windmill-api/src/static_assets.rs b/backend/windmill-api/src/static_assets.rs index 22645a2ac4..66dc4da9df 100644 --- a/backend/windmill-api/src/static_assets.rs +++ b/backend/windmill-api/src/static_assets.rs @@ -51,30 +51,17 @@ const TWO_HUNDRED: &str = "200.html"; /// Check if the original path requires cross-origin isolation headers. /// /// CANONICAL COEP RATIONALE (the dev-server mirror in `frontend/vite.config.js` -/// and the navigation guards in `frontend/src/routes/(root)/(logged)/+layout.svelte` -/// point here): the headers are needed for SharedArrayBuffer and TypeScript -/// workers (raw app editor at `/apps_raw/edit|add`, in-browser bundler at -/// `/ui_builder/`). The raw app *viewer* (`/apps_raw/get/`) must NOT get them: -/// COEP `require-corp` blocks the viewed app's cross-origin subresources -/// (external images, embeds) that lack CORP — and since headers stick to the -/// document, apps would break on a page reload while working when reached via -/// client-side navigation. -/// -/// Public apps (`/public/` and custom paths `/a/`) opt in via the `wm_coep` -/// query param: a public (raw) app must set COEP to be embeddable as an iframe -/// inside a cross-origin-isolated page (which requires the embedded document to -/// also set COEP). It is opt-in rather than always-on because cross-origin -/// isolation also blocks subresources without CORP (e.g. external image URLs -/// or embeds used by classic apps), so we only enable it when the embedder -/// explicitly requests it. +/// points here). Only public apps (`/public/` and custom paths `/a/`) ever get +/// them, and only when they opt in via the `wm_coep` query param: a public (raw) +/// app must set COEP to be embeddable as an iframe inside a cross-origin-isolated +/// page (which requires the embedded document to also set COEP). It is opt-in +/// rather than always-on because COEP `require-corp` blocks subresources without +/// CORP (external image URLs, embeds), and since headers stick to the document, +/// isolating an SPA route leaks into every page reached from it client-side. #[cfg(feature = "static_frontend")] fn needs_cross_origin_isolation(original_path: &str, query: Option<&str>) -> bool { - // no trailing slash on edit/add: matches the +layout.svelte guards - original_path.starts_with("/apps_raw/edit") - || original_path.starts_with("/apps_raw/add") - || original_path.starts_with("/ui_builder/") - || ((original_path.starts_with("/public/") || original_path.starts_with("/a/")) - && query_has_flag(query, "wm_coep")) + (original_path.starts_with("/public/") || original_path.starts_with("/a/")) + && query_has_flag(query, "wm_coep") } /// Returns true if `query` contains the given flag key (with or without a @@ -98,8 +85,6 @@ fn serve_path(path: &str, original_path: &str, query: Option<&str>) -> Response< .header(header::CONTENT_TYPE, mime.as_ref()) .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*"); - // Add cross-origin isolation headers only for paths that need them - // (apps_raw editor needs SharedArrayBuffer for TypeScript workers) if needs_cross_origin_isolation(original_path, query) { res = res .header("Cross-Origin-Opener-Policy", "same-origin") @@ -167,12 +152,13 @@ mod tests { #[test] fn test_needs_cross_origin_isolation() { - // editor + bundler are always isolated, regardless of query - assert!(needs_cross_origin_isolation("/apps_raw/edit/foo", None)); - assert!(needs_cross_origin_isolation("/apps_raw/add", None)); - assert!(needs_cross_origin_isolation("/ui_builder/index.html", None)); - - // the raw app viewer must NOT be isolated + // the raw app editor, its UI builder frames and the viewer are never isolated + assert!(!needs_cross_origin_isolation("/apps_raw/edit/foo", None)); + assert!(!needs_cross_origin_isolation("/apps_raw/add", None)); + assert!(!needs_cross_origin_isolation( + "/ui_builder/index.html", + None + )); assert!(!needs_cross_origin_isolation( "/apps_raw/get/u/foo/bar", None diff --git a/backend/windmill-api/src/tracing_init.rs b/backend/windmill-api/src/tracing_init.rs index d99a7e5907..2fb668a39c 100644 --- a/backend/windmill-api/src/tracing_init.rs +++ b/backend/windmill-api/src/tracing_init.rs @@ -48,7 +48,9 @@ impl OnResponse for MyOnResponse { let status = response.status().as_u16(); if response.status().is_success() || response.status().is_redirection() { tracing::info!(latency = latency, status = status, "response") - } else if response.status().as_u16() == 404 { + } else if status == 404 || status == 409 { + // A refused turn is as expected as a miss: the flow chat takes the turn that + // refused it and sends its message after it. tracing::warn!(latency = latency, status = status, "response") } else { tracing::error!(latency = latency, status = status, "response") diff --git a/backend/windmill-common/Cargo.toml b/backend/windmill-common/Cargo.toml index e95fadfe66..96fa7ceb63 100644 --- a/backend/windmill-common/Cargo.toml +++ b/backend/windmill-common/Cargo.toml @@ -76,6 +76,7 @@ bitflags.workspace = true once_cell.workspace = true phf.workspace = true tokio-postgres.workspace = true +postgres-protocol.workspace = true postgres-native-tls.workspace = true native-tls.workspace = true diff --git a/backend/windmill-common/src/cache.rs b/backend/windmill-common/src/cache.rs index 3c385da79f..1518e8e8e3 100644 --- a/backend/windmill-common/src/cache.rs +++ b/backend/windmill-common/src/cache.rs @@ -687,8 +687,9 @@ pub mod script { schema_validation AS \"schema_validation: bool\", \ codebase LIKE '%.tar' as use_tar, \ codebase LIKE '%.esm%' as is_esm, \ - modules AS \"modules: serde_json::Value\" \ - FROM script WHERE hash = $1 LIMIT 1", + modules AS \"modules: serde_json::Value\", \ + deleted \ + FROM script WHERE hash = $1 ORDER BY deleted LIMIT 1", hash.0 ) .fetch_optional(db) @@ -696,6 +697,14 @@ pub mod script { .map_err(Into::into) .and_then(unwrap_or_error(&loc, "Script", hash)) .and_then(|r| { + // The hash leaves out the workspace, so forks and clones share it, and a deleted + // copy keeps its row with the content wiped. Any live copy holds the same code; + // only when every copy is deleted is there nothing left to run. + if r.deleted { + return Err(error::Error::NotFound(format!( + "Script version {hash} was deleted" + ))); + } Ok(RawScript { content: r.content, lock: r.lock, diff --git a/backend/windmill-common/src/datatable_roles.rs b/backend/windmill-common/src/datatable_roles.rs index a9195055ac..f355e733cb 100644 --- a/backend/windmill-common/src/datatable_roles.rs +++ b/backend/windmill-common/src/datatable_roles.rs @@ -6,22 +6,67 @@ * LICENSE-AGPL for a copy of the license. */ -//! The instance's data table role catalog. +//! The instance's data table role catalogs. //! -//! A data table role is a real Postgres login role on the Windmill cluster, named exactly as the -//! user named it, shared by every instance database. Windmill decides who may ask for a role (the -//! per-data-table tenant lists in [`crate::workspaces`]); Postgres decides what the role may then -//! touch. The catalog here is only the first half's vocabulary plus the cluster provisioning. +//! A data table role is a real Postgres login role on one cluster — Windmill's own, or the external +//! instance cluster — named exactly as the user named it, shared by every database Windmill manages +//! on that cluster. Each cluster has its own catalog: a role exists where it was created and nowhere +//! else. Windmill decides who may ask for a role (the per-data-table tenant lists in +//! [`crate::workspaces`]); Postgres decides what the role may then touch. The catalog here is only +//! the first half's vocabulary plus the cluster provisioning. //! //! Entries are keyed by a generated id so a rename moves nothing else: tenants name the id. use std::collections::BTreeMap; +use serde::{Deserialize, Serialize}; + use crate::{ error::{Error, Result}, + workspaces::DataTableCatalogResourceType, DB, }; +/// The cluster a role catalog belongs to. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DatatableRoleCluster { + /// Windmill's own Postgres, behind `instance` data tables. + #[default] + Instance, + /// The external instance cluster, behind `external_instance` data tables. + ExternalInstance, +} + +impl DatatableRoleCluster { + pub fn as_str(self) -> &'static str { + match self { + Self::Instance => "instance", + Self::ExternalInstance => "external_instance", + } + } + + pub fn parse(value: &str) -> Result { + match value { + "instance" => Ok(Self::Instance), + "external_instance" => Ok(Self::ExternalInstance), + other => Err(Error::BadRequest(format!( + "Unknown data table role cluster '{other}': expected instance or external_instance" + ))), + } + } + + /// The cluster whose roles a data table on `kind` can use. `None` for a resource-backed one, + /// which is never under roles. + pub fn of(kind: DataTableCatalogResourceType) -> Option { + match kind { + DataTableCatalogResourceType::Instance => Some(Self::Instance), + DataTableCatalogResourceType::ExternalInstance => Some(Self::ExternalInstance), + DataTableCatalogResourceType::Postgresql => None, + } + } +} + /// The connection every data table resolved to before roles existed (`custom_instance_user`). It /// owns every pre-existing object, so it is a reserved name rather than a catalog entry: never /// created, renamed or dropped. @@ -164,28 +209,42 @@ pub async fn lock_instance_databases_governance<'a>( /// need the names — but callers MUST NOT let `pwd` reach a response, a log line, an audit record /// or an export. Nothing about who may call it: the credential is the whole risk, and `Debug` is /// hand-written to redact it for the same reason. -pub async fn read_role_catalog(db: &DB) -> Result { - crate::datatable_roles_oss::read_role_catalog(db).await +pub async fn read_role_catalog( + db: &DB, + cluster: DatatableRoleCluster, +) -> Result { + crate::datatable_roles_oss::read_role_catalog(db, cluster).await } /// As [`read_role_catalog`], reading inside the caller's transaction so the value is the one /// [`lock_role_catalog`] is protecting. Same disclosure contract. pub async fn read_role_catalog_tx( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + cluster: DatatableRoleCluster, ) -> Result { - crate::datatable_roles_oss::read_role_catalog_tx(tx).await + crate::datatable_roles_oss::read_role_catalog_tx(tx, cluster).await } -/// Record a role, in the caller's transaction so it commits with the `CREATE ROLE` it describes. +/// The cluster a role belongs to, or `None` if no role has this id. +pub async fn role_cluster( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + id: &str, +) -> Result> { + crate::datatable_roles_oss::role_cluster(tx, id).await +} + +/// Record a role, in the caller's transaction. On Windmill's own cluster that commits it with the +/// `CREATE ROLE` it describes; on the external cluster the role already exists by then. /// /// Authorization: writes a generated Postgres credential. Callers MUST restrict this to superadmin /// paths and MUST hold [`lock_role_catalog`] on `tx`. pub async fn insert_role_catalog_entry( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, id: &str, + cluster: DatatableRoleCluster, role: &InstanceDatatableRole, ) -> Result<()> { - crate::datatable_roles_oss::insert_role_catalog_entry(tx, id, role).await + crate::datatable_roles_oss::insert_role_catalog_entry(tx, id, cluster, role).await } /// Update a role's recorded name, login flag and password. Same contract as @@ -215,7 +274,7 @@ pub fn role_id_by_name<'a>(catalog: &'a DatatableRoleCatalog, name: &str) -> Res .find(|(_, role)| role.name == name) .ok_or_else(|| { Error::NotFound(format!( - "'{name}' is not a data table role of this instance. Defined roles: {}.", + "'{name}' is not a data table role of this database's cluster. Defined roles: {}.", catalog .values() .map(|r| r.name.as_str()) @@ -231,70 +290,90 @@ pub fn role_id_by_name<'a>(catalog: &'a DatatableRoleCatalog, name: &str) -> Res Ok(entry.0.as_str()) } -/// Every instance database the registry knows about. Role provisioning has to reach all of them: -/// a role that cannot `CONNECT` to a database is refused by Postgres before any grant matters. +/// Every database Windmill manages on `cluster`. Role provisioning has to reach all of them: a role +/// that cannot `CONNECT` to a database is refused by Postgres before any grant matters. /// -/// Authorization: checks nothing, and names every instance database across all workspaces. Callers +/// Authorization: checks nothing, and names every managed database across all workspaces. Callers /// MUST be superadmin-gated or keep the names server-side; never return them to a workspace caller. -pub async fn registered_instance_databases(db: &DB) -> Result> { - crate::datatable_roles_oss::registered_instance_databases(db).await +pub async fn registered_instance_databases( + db: &DB, + cluster: DatatableRoleCluster, +) -> Result> { + crate::datatable_roles_oss::registered_instance_databases(db, cluster).await } -/// `CONNECT` on `dbname` for every enabled role, and none for `PUBLIC`. Run at role creation, at -/// database creation, and lazily whenever an instance data table is administered, so a database -/// provisioned before a role existed is repaired rather than left silently unreachable. +/// `CONNECT` on `dbname` for every enabled role of `cluster`, and none for `PUBLIC`. Run at role +/// creation, at database creation, and lazily whenever a managed data table is administered, so a +/// database provisioned before a role existed is repaired rather than left silently unreachable. /// /// Authorization: rewrites a database's ACL with the server's own credentials and checks nothing. /// Callers MUST have authorized administration of `dbname` — superadmin, or an admin of the /// workspace governing a data table on it. -pub async fn converge_connect_grants(db: &DB, dbname: &str) -> Result<()> { - crate::datatable_roles_oss::converge_connect_grants(db, dbname).await +pub async fn converge_connect_grants( + db: &DB, + cluster: DatatableRoleCluster, + dbname: &str, +) -> Result<()> { + crate::datatable_roles_oss::converge_connect_grants(db, cluster, dbname).await } -/// As [`converge_connect_grants`], with a catalog the caller already read. Same contract. +/// As [`converge_connect_grants`], with the catalog of `cluster` the caller already read. Same +/// contract. pub async fn converge_connect_grants_with( db: &DB, + cluster: DatatableRoleCluster, dbname: &str, catalog: &DatatableRoleCatalog, ) -> Result<()> { - crate::datatable_roles_oss::converge_connect_grants_with(db, dbname, catalog).await + crate::datatable_roles_oss::converge_connect_grants_with(db, cluster, dbname, catalog).await } -/// `CREATE ROLE LOGIN PASSWORD ...; GRANT TO custom_instance_user`, and `CONNECT` on -/// every registered database. No privileges beyond that — an admin grants them through SQL or the -/// ACL editor. +/// `CREATE ROLE LOGIN PASSWORD ...; GRANT TO custom_instance_user` on `cluster`. No +/// privileges beyond that — an admin grants them through SQL or the ACL editor. +/// +/// On Windmill's own cluster the DDL runs on `tx`, so it commits with the catalog row. The external +/// cluster is another server: the role is created there before `tx` commits, and callers MUST drop +/// it again ([`drop_datatable_role`]) if `tx` then fails to commit. /// /// Authorization: creates a cluster-wide Postgres login. Callers MUST restrict this to superadmin -/// paths, and MUST hold [`lock_role_catalog`] on the same transaction. -pub async fn create_instance_role( +/// paths, and MUST hold [`lock_role_catalog`] on `tx`. +pub async fn create_datatable_role( + db: &DB, tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + cluster: DatatableRoleCluster, name: &str, password: &str, ) -> Result<()> { - crate::datatable_roles_oss::create_instance_role(tx, name, password).await + crate::datatable_roles_oss::create_datatable_role(db, tx, cluster, name, password).await } /// Authorization: alters a cluster-wide Postgres login. Callers MUST restrict this to superadmin -/// paths, and MUST hold [`lock_role_catalog`] on the same transaction. -pub async fn set_instance_role_login( +/// paths, and MUST hold [`lock_role_catalog`] on `tx`. +pub async fn set_datatable_role_login( + db: &DB, tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + cluster: DatatableRoleCluster, name: &str, enabled: bool, ) -> Result<()> { - crate::datatable_roles_oss::set_instance_role_login(tx, name, enabled).await + crate::datatable_roles_oss::set_datatable_role_login(db, tx, cluster, name, enabled).await } -/// A rename discards an md5-hashed password, so the caller has to hand over a fresh one. +/// A rename discards an md5-hashed password, so the caller has to hand over a fresh one. On the +/// external cluster the rename lands before `tx` commits, and callers MUST rename it back if `tx` +/// then fails to commit. /// /// Authorization: renames a cluster-wide Postgres login. Callers MUST restrict this to superadmin -/// paths, and MUST hold [`lock_role_catalog`] on the same transaction. -pub async fn rename_instance_role( +/// paths, and MUST hold [`lock_role_catalog`] on `tx`. +pub async fn rename_datatable_role( + db: &DB, tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + cluster: DatatableRoleCluster, from: &str, to: &str, password: &str, ) -> Result<()> { - crate::datatable_roles_oss::rename_instance_role(tx, from, to, password).await + crate::datatable_roles_oss::rename_datatable_role(db, tx, cluster, from, to, password).await } /// A role owning anything in any database blocks its own `DROP ROLE`, and both its objects and the @@ -302,8 +381,9 @@ pub async fn rename_instance_role( /// registry. An unreachable database aborts the whole delete: dropping the role while one database /// still holds objects owned by it leaves those objects owned by a numeric OID nobody can name. /// -/// Each pass runs as the instance's own Postgres user rather than `custom_instance_user`, which -/// owns the databases and can therefore revoke a grant whoever made it. `custom_instance_user` +/// Each pass runs as the cluster's administrator rather than `custom_instance_user`: on Windmill's +/// own cluster the instance's Postgres user, on the external one its configured admin login. Both +/// own the databases and can therefore revoke a grant whoever made it. `custom_instance_user` /// could only undo what it granted itself, so a privilege planted by an operator in psql — the /// ordinary way privileges reach a role — would survive and block the drop. /// @@ -311,16 +391,19 @@ pub async fn rename_instance_role( /// MUST restrict this to superadmin paths, and MUST hold [`lock_role_catalog`] on `tx`. /// /// The per-database passes open their own connections and cannot join `tx`; the lock is what keeps -/// a concurrent mutation out while they run. Only the final `DROP ROLE` is on `tx`, so it commits -/// or rolls back with the catalog write that forgets the role. Those passes commit as they go, so -/// callers MUST have disabled the role in an earlier committed transaction: a failure part-way -/// then leaves a disabled role to retry, not an enabled one already stripped in some databases. -pub async fn drop_instance_role( +/// a concurrent mutation out while they run. On Windmill's own cluster only the final `DROP ROLE` +/// is on `tx`, so it commits or rolls back with the catalog write that forgets the role; on the +/// external cluster it runs there, and tolerates a role already gone so a retry after a failed +/// commit can finish. The passes commit as they go, so callers MUST have disabled the role in an +/// earlier committed transaction: a failure part-way then leaves a disabled role to retry, not an +/// enabled one already stripped in some databases. +pub async fn drop_datatable_role( db: &DB, tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + cluster: DatatableRoleCluster, name: &str, ) -> Result<()> { - crate::datatable_roles_oss::drop_instance_role(db, tx, name).await + crate::datatable_roles_oss::drop_datatable_role(db, tx, cluster, name).await } #[cfg(test)] diff --git a/backend/windmill-common/src/datatable_roles_oss.rs b/backend/windmill-common/src/datatable_roles_oss.rs index 2a3f9dda48..a97a936827 100644 --- a/backend/windmill-common/src/datatable_roles_oss.rs +++ b/backend/windmill-common/src/datatable_roles_oss.rs @@ -24,12 +24,12 @@ pub fn datatable_roles_unavailable() -> Error { #[cfg(all(feature = "private", feature = "enterprise"))] pub(crate) use crate::datatable_roles_ee::{ can_use_datatable_role, can_use_datatable_role_in_governing_workspace, converge_connect_grants, - converge_connect_grants_with, create_instance_role, delete_role_catalog_entry, - drop_instance_role, ensure_can_use_datatable_role, ensure_datatable_admin_access, + converge_connect_grants_with, create_datatable_role, delete_role_catalog_entry, + drop_datatable_role, ensure_can_use_datatable_role, ensure_datatable_admin_access, ensure_instance_db_grant_options_unchecked, forget_datatable_role_everywhere, insert_role_catalog_entry, read_role_catalog, read_role_catalog_tx, - registered_instance_databases, rename_instance_role, resolve_datatable_role_connection, - set_instance_role_login, update_role_catalog_entry, + registered_instance_databases, rename_datatable_role, resolve_datatable_role_connection, + role_cluster, set_datatable_role_login, update_role_catalog_entry, }; #[cfg(not(all(feature = "private", feature = "enterprise")))] @@ -39,7 +39,7 @@ pub(crate) use ce::*; mod ce { use super::datatable_roles_unavailable as unavailable; use crate::{ - datatable_roles::{DatatableRoleCatalog, InstanceDatatableRole}, + datatable_roles::{DatatableRoleCatalog, DatatableRoleCluster, InstanceDatatableRole}, db::AuthedRef, error::Result, workspaces::{ @@ -50,17 +50,31 @@ mod ce { type Tx<'a> = sqlx::Transaction<'a, sqlx::Postgres>; - pub(crate) async fn read_role_catalog(_db: &DB) -> Result { + pub(crate) async fn read_role_catalog( + _db: &DB, + _cluster: DatatableRoleCluster, + ) -> Result { Err(unavailable()) } - pub(crate) async fn read_role_catalog_tx(_tx: &mut Tx<'_>) -> Result { + pub(crate) async fn read_role_catalog_tx( + _tx: &mut Tx<'_>, + _cluster: DatatableRoleCluster, + ) -> Result { + Err(unavailable()) + } + + pub(crate) async fn role_cluster( + _tx: &mut Tx<'_>, + _id: &str, + ) -> Result> { Err(unavailable()) } pub(crate) async fn insert_role_catalog_entry( _tx: &mut Tx<'_>, _id: &str, + _cluster: DatatableRoleCluster, _role: &InstanceDatatableRole, ) -> Result<()> { Err(unavailable()) @@ -78,43 +92,57 @@ mod ce { Err(unavailable()) } - pub(crate) async fn registered_instance_databases(_db: &DB) -> Result> { + pub(crate) async fn registered_instance_databases( + _db: &DB, + _cluster: DatatableRoleCluster, + ) -> Result> { Err(unavailable()) } - /// Nothing to converge: with no roles to admit, an instance database keeps the `CONNECT` - /// grants it was created with, `PUBLIC`'s included, as it did before roles existed. - pub(crate) async fn converge_connect_grants(_db: &DB, _dbname: &str) -> Result<()> { + /// Nothing to converge: with no roles to admit, a managed database keeps the `CONNECT` grants + /// it was created with, as it did before roles existed. + pub(crate) async fn converge_connect_grants( + _db: &DB, + _cluster: DatatableRoleCluster, + _dbname: &str, + ) -> Result<()> { Ok(()) } /// As [`converge_connect_grants`]. pub(crate) async fn converge_connect_grants_with( _db: &DB, + _cluster: DatatableRoleCluster, _dbname: &str, _catalog: &DatatableRoleCatalog, ) -> Result<()> { Ok(()) } - pub(crate) async fn create_instance_role( + pub(crate) async fn create_datatable_role( + _db: &DB, _tx: &mut Tx<'_>, + _cluster: DatatableRoleCluster, _name: &str, _password: &str, ) -> Result<()> { Err(unavailable()) } - pub(crate) async fn set_instance_role_login( + pub(crate) async fn set_datatable_role_login( + _db: &DB, _tx: &mut Tx<'_>, + _cluster: DatatableRoleCluster, _name: &str, _enabled: bool, ) -> Result<()> { Err(unavailable()) } - pub(crate) async fn rename_instance_role( + pub(crate) async fn rename_datatable_role( + _db: &DB, _tx: &mut Tx<'_>, + _cluster: DatatableRoleCluster, _from: &str, _to: &str, _password: &str, @@ -122,12 +150,18 @@ mod ce { Err(unavailable()) } - pub(crate) async fn drop_instance_role(_db: &DB, _tx: &mut Tx<'_>, _name: &str) -> Result<()> { + pub(crate) async fn drop_datatable_role( + _db: &DB, + _tx: &mut Tx<'_>, + _cluster: DatatableRoleCluster, + _name: &str, + ) -> Result<()> { Err(unavailable()) } pub(crate) async fn ensure_instance_db_grant_options_unchecked( _db: &DB, + _cluster: DatatableRoleCluster, _dbname: &str, ) -> Result<()> { Err(unavailable()) diff --git a/backend/windmill-common/src/ee_oss.rs b/backend/windmill-common/src/ee_oss.rs index 2510df0960..82f99c54fb 100644 --- a/backend/windmill-common/src/ee_oss.rs +++ b/backend/windmill-common/src/ee_oss.rs @@ -71,6 +71,14 @@ pub async fn check_seat_cap_for_reactivation( Ok(None) } +#[cfg(all(feature = "enterprise", not(feature = "private")))] +pub async fn check_seat_cap_for_operator_builder( + _db: &DB, + _w_id: &str, +) -> anyhow::Result> { + Ok(None) +} + #[cfg(all(feature = "enterprise", not(feature = "private")))] pub async fn compute_instance_hash(_db: &DB) -> anyhow::Result> { // Implementation is not open source diff --git a/backend/windmill-common/src/error.rs b/backend/windmill-common/src/error.rs index da9f5f3df2..c06defaab5 100644 --- a/backend/windmill-common/src/error.rs +++ b/backend/windmill-common/src/error.rs @@ -303,7 +303,11 @@ impl IntoResponse for Error { let e = &self; - if matches!(status, axum::http::StatusCode::NOT_FOUND) { + // A refused turn is as expected as a miss: the chat follows the turn that refused it. + if matches!( + status, + axum::http::StatusCode::NOT_FOUND | axum::http::StatusCode::CONFLICT + ) { tracing::warn!(message = e.to_string()); } else { tracing::error!(message = e.to_string(), error = ?e); diff --git a/backend/windmill-common/src/external_instance_pg.rs b/backend/windmill-common/src/external_instance_pg.rs new file mode 100644 index 0000000000..c0b6203e40 --- /dev/null +++ b/backend/windmill-common/src/external_instance_pg.rs @@ -0,0 +1,456 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2022 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +//! The external Postgres cluster behind `external_instance` data tables and Ducklake catalogs. +//! +//! Windmill administers that cluster itself, logged in as the user in +//! [`EXTERNAL_INSTANCE_PG_SETTING`]. It creates `custom_instance_user` and +//! `custom_instance_replication_user` there, with passwords it generates and keeps in +//! [`EXTERNAL_INSTANCE_PG_STATE_SETTING`]. They share their names with the roles on Windmill's own +//! cluster, but they are different roles with different passwords. +//! +//! The cluster may hold data Windmill did not create. Two Windmill instances sharing one is not +//! supported: each would keep resetting the passwords the other depends on. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; + +use crate::{ + error::{Error, Result}, + global_settings::{EXTERNAL_INSTANCE_PG_SETTING, EXTERNAL_INSTANCE_PG_STATE_SETTING}, + instance_config::{CustomInstanceDb, ExternalInstancePg}, + DB, +}; + +/// What Windmill keeps about the external cluster. Server-managed and hidden: never part of the +/// instance config, never readable by an agent worker. No `Debug`: it carries live passwords. +#[derive(Serialize, Deserialize, Clone, Default)] +pub struct ExternalInstancePgState { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_pwd: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub replication_pwd: Option, + /// The databases Windmill created on the cluster. It only ever drops one of these. + #[serde(default)] + pub databases: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_setup: Option, + /// The cluster ([`external_instance_pg_address`]) the last successful setup converged. Databases + /// are only created on a cluster setup succeeded on: the passwords above exist as soon as setup + /// first runs, whether or not the cluster accepted them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub set_up_for: Option, +} + +/// What identifies the cluster a configuration points at. Other fields (admin login, sslmode) can +/// change without it becoming another cluster. +pub fn external_instance_pg_address(config: &ExternalInstancePg) -> String { + format!( + "{}:{}", + config.host.trim().to_lowercase(), + config.port.unwrap_or(5432) + ) +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ExternalInstancePgSetupReport { + /// No step failed. Warnings leave it true. + pub success: bool, + pub finished_at: chrono::DateTime, + pub steps: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ExternalInstancePgSetupStep { + pub name: String, + pub status: SetupStepStatus, + pub message: String, +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum SetupStepStatus { + Ok, + Warning, + Error, +} + +/// The status the settings page shows without running anything. +#[derive(Serialize, Debug)] +pub struct ExternalInstancePgStatus { + pub configured: bool, + pub database_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_setup: Option, +} + +/// Authorization: returns the cluster's admin password and checks nothing. Callers MUST be +/// superadmin or an internal server path. +pub(crate) async fn read_external_instance_pg_config<'c>( + executor: impl sqlx::PgExecutor<'c>, +) -> Result> { + let value = sqlx::query_scalar!( + "SELECT value FROM global_settings WHERE name = $1", + EXTERNAL_INSTANCE_PG_SETTING + ) + .fetch_optional(executor) + .await?; + value + .map(|v| { + serde_json::from_value(v).map_err(|e| { + Error::internal_err(format!("reading {EXTERNAL_INSTANCE_PG_SETTING}: {e}")) + }) + }) + .transpose() +} + +/// Authorization: returns the passwords Windmill generated on the cluster and checks nothing. +/// Callers MUST be superadmin or an internal server path. +pub(crate) async fn read_external_instance_pg_state<'c>( + executor: impl sqlx::PgExecutor<'c>, +) -> Result { + let value = sqlx::query_scalar!( + "SELECT value FROM global_settings WHERE name = $1", + EXTERNAL_INSTANCE_PG_STATE_SETTING + ) + .fetch_optional(executor) + .await?; + match value { + None => Ok(ExternalInstancePgState::default()), + Some(v) => serde_json::from_value(v).map_err(|e| { + Error::internal_err(format!("reading {EXTERNAL_INSTANCE_PG_STATE_SETTING}: {e}")) + }), + } +} + +/// Authorization: reads the hidden cluster state and checks nothing. Callers MUST be superadmin. +pub async fn external_instance_pg_status(db: &DB) -> Result { + let configured = read_external_instance_pg_config(db).await?.is_some(); + let state = read_external_instance_pg_state(db).await?; + Ok(ExternalInstancePgStatus { + configured, + database_count: state.databases.len(), + last_setup: state.last_setup, + }) +} + +/// The databases Windmill created on the external cluster, without the passwords kept beside them. +/// +/// Authorization: names every database across all workspaces, and the workspace each fork copy is +/// reserved for, and checks nothing. Callers MUST be superadmin or an internal authorization or +/// lifecycle path that does not return the names to a workspace caller. +pub async fn external_instance_databases(db: &DB) -> Result> { + Ok(read_external_instance_pg_state(db).await?.databases) +} + +/// The workspaces whose data tables or Ducklake catalogs name each database on the external cluster, +/// and the forks whose Ducklake metadata schemas there are still waiting to be dropped: those rows +/// outlive a settings change, and cleanup cannot drop a schema in a database that is gone. A row +/// whose schema is already dropped only waits on object storage, which needs no database. +/// +/// Authorization: reads every workspace's settings and checks nothing. Callers MUST be superadmin +/// or an internal lifecycle path. +pub async fn external_instance_database_usages<'c>( + db: impl sqlx::PgExecutor<'c>, +) -> Result>> { + let rows = sqlx::query_as::<_, (String, String)>( + "SELECT ws.workspace_id, entry->'database'->>'resource_path' + FROM workspace_settings ws + CROSS JOIN LATERAL jsonb_each( + CASE WHEN jsonb_typeof(ws.datatable->'datatables') = 'object' + THEN ws.datatable->'datatables' + ELSE '{}'::jsonb END + ) AS dt(k, entry) + WHERE entry->'database'->>'resource_type' = 'external_instance' + AND entry->'database'->>'resource_path' IS NOT NULL + UNION ALL + SELECT ws.workspace_id, entry->'catalog'->>'resource_path' + FROM workspace_settings ws + CROSS JOIN LATERAL jsonb_each( + CASE WHEN jsonb_typeof(ws.ducklake->'ducklakes') = 'object' + THEN ws.ducklake->'ducklakes' + ELSE '{}'::jsonb END + ) AS dl(k, entry) + WHERE entry->'catalog'->>'resource_type' = 'external_instance' + AND entry->'catalog'->>'resource_path' IS NOT NULL + UNION ALL + SELECT workspace_id, substring(catalog FROM length('external_instance:') + 1) + FROM fork_ducklake_namespace + WHERE catalog LIKE 'external\\_instance:%' AND NOT schema_dropped", + ) + .fetch_all(db) + .await?; + let mut usages: BTreeMap> = BTreeMap::new(); + for (workspace_id, dbname) in rows { + usages.entry(dbname).or_default().insert(workspace_id); + } + Ok(usages) +} + +/// Refuse to unset the cluster while Windmill still has databases or data table roles on it, or a +/// workspace still points at one: every data table there would stop resolving, and every role +/// would be a login nothing can drop any more. Allowed on every edition, so a +/// downgraded instance can still clear a setting it no longer uses. +async fn ensure_external_instance_pg_removable(conn: &mut sqlx::PgConnection) -> Result<()> { + ensure_external_instance_pg_unused(conn, &format!("removing {EXTERNAL_INSTANCE_PG_SETTING}")).await +} + +/// Refuse while Windmill has databases or data table roles on the cluster, or a workspace points +/// at one of its databases. `before` finishes the sentence saying what to do first. +async fn ensure_external_instance_pg_unused( + conn: &mut sqlx::PgConnection, + before: &str, +) -> Result<()> { + let state = read_external_instance_pg_state(&mut *conn).await?; + let usages = external_instance_database_usages(&mut *conn).await?; + let roles = sqlx::query_scalar::<_, String>( + "SELECT name FROM datatable_role WHERE cluster = 'external_instance' ORDER BY name", + ) + .fetch_all(&mut *conn) + .await?; + if state.databases.is_empty() && usages.is_empty() && roles.is_empty() { + return Ok(()); + } + let mut held = vec![]; + if !(state.databases.is_empty() && usages.is_empty()) { + let names = state + .databases + .keys() + .chain(usages.keys()) + .collect::>() + .into_iter() + .cloned() + .collect::>() + .join(", "); + held.push(format!("databases in use ({names})")); + } + if !roles.is_empty() { + held.push(format!("data table roles ({})", roles.join(", "))); + } + Err(Error::BadRequest(format!( + "The external instance cluster still holds {}. Drop them and repoint the data tables and \ + Ducklake catalogs using them before {before}.", + held.join(" and ") + ))) +} + +/// Refuse a workspace setting that newly names an `external_instance` database on an edition +/// without them. +pub fn ensure_external_instance_available() -> Result<()> { + crate::external_instance_pg_oss::ensure_external_instance_available() +} + +/// The connection an `external_instance` database resolves to: `custom_instance_user`, or the +/// replication user, on the external cluster. +/// +/// Authorization: returns live credentials and checks nothing. Callers MUST have authorized access +/// to the data table that names `dbname`. +pub async fn external_instance_connection_unchecked( + db: &DB, + dbname: &str, + replication: bool, +) -> Result { + crate::external_instance_pg_oss::external_instance_connection_unchecked(db, dbname, replication) + .await +} + +/// Create `dbname` on the external cluster and register it. Refuses a name already taken there, +/// whoever took it. +/// +/// Runs on `tx`, which it takes [`lock_external_instance_pg_state`] on: the registration lands when +/// the caller commits. Take that lock before any database governance lock, as saves do. +/// +/// Authorization: checks nothing. Callers MUST be superadmin, or be cloning a data table they may +/// fork into a `wm_fork_` database. +pub async fn create_external_instance_database_unchecked( + db: &DB, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + dbname: &str, + tag: &str, + for_workspace: Option<&str>, +) -> Result<()> { + crate::external_instance_pg_oss::create_external_instance_database_unchecked( + db, + tx, + dbname, + tag, + for_workspace, + ) + .await +} + +/// Drop `dbname` from the external cluster: only a database Windmill registered creating, and still +/// carries the mark it set there. Refused while anything uses it +/// ([`crate::workspaces::managed_database_uses`]), except the `exempt` data table entry: the fork +/// copy being cleaned up. +/// +/// Runs on `tx`, like [`create_external_instance_database_unchecked`]: the database is gone at +/// once, its registry entry when the caller commits. +/// +/// Authorization: checks nothing. Callers MUST be superadmin, or be deleting the fork that owns +/// this `wm_fork_` database. +pub async fn drop_external_instance_database_unchecked( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + dbname: &str, + exempt: Option<(&str, &str)>, +) -> Result<()> { + crate::external_instance_pg_oss::drop_external_instance_database_unchecked(tx, dbname, exempt) + .await +} + +/// Serializes everything that changes which databases exist on the external cluster, or which data +/// tables name them: setup, creates, drops, and data table saves. Held until `tx` ends. +pub async fn lock_external_instance_pg_state( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> Result<()> { + sqlx::query("SELECT pg_advisory_xact_lock(hashtext($1))") + .bind(EXTERNAL_INSTANCE_PG_STATE_SETTING) + .execute(&mut **tx) + .await?; + Ok(()) +} + +/// Refuse a data table naming `dbname` unless Windmill created it on the external cluster. Takes +/// the lock drops take, so none can remove the database before `tx`, which saves the data table, +/// commits. +/// +/// Authorization: its refusal says whether Windmill created a database of that name, which is +/// instance-wide knowledge. Callers MUST have authorized the caller as superadmin first. +pub async fn ensure_external_instance_database_registered( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + dbname: &str, +) -> Result<()> { + lock_external_instance_pg_state(tx).await?; + if read_external_instance_pg_state(&mut **tx) + .await? + .databases + .contains_key(dbname) + { + return Ok(()); + } + Err(Error::BadRequest(format!( + "Windmill did not create a database named '{dbname}' on the external instance cluster. \ + Create it from the instance settings first." + ))) +} + +/// Write [`EXTERNAL_INSTANCE_PG_SETTING`]: `None`, null or an empty string unsets it. Every writer +/// of global settings goes through this for that key — the per-key and bulk endpoints as well as +/// the declarative sync — instead of writing the row itself. +/// +/// The checks and the write share one transaction holding [`lock_external_instance_pg_state`]. A +/// check taken outside it could pass while a database create still reads the old cluster, which +/// would then register a database there after the setting names another one. +/// +/// Authorization: checks nothing. Callers MUST be superadmin, or the declarative instance config +/// sync, which applies what the operator deployed. +pub async fn write_external_instance_pg_setting( + db: &DB, + value: Option<&serde_json::Value>, +) -> Result<()> { + let value = match value { + None | Some(serde_json::Value::Null) => None, + Some(serde_json::Value::String(s)) if s.trim().is_empty() => None, + Some(value) => Some(value), + }; + let mut tx = db.begin().await?; + lock_external_instance_pg_state(&mut tx).await?; + // Every check runs on this transaction's own connection: it holds the advisory lock, and + // taking a second connection from the pool while other writers queue on that lock is how a + // small pool deadlocks. + match value { + None => { + ensure_external_instance_pg_removable(&mut tx).await?; + sqlx::query("DELETE FROM global_settings WHERE name = $1") + .bind(EXTERNAL_INSTANCE_PG_SETTING) + .execute(&mut *tx) + .await?; + } + Some(value) => { + crate::external_instance_pg_oss::validate_external_instance_pg_setting(value)?; + ensure_external_instance_pg_not_repointed(&mut tx, value).await?; + sqlx::query( + "INSERT INTO global_settings (name, value) VALUES ($1, $2) + ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value, updated_at = now()", + ) + .bind(EXTERNAL_INSTANCE_PG_SETTING) + .bind(value) + .execute(&mut *tx) + .await?; + } + } + tx.commit().await?; + tracing::info!( + "{} global setting {EXTERNAL_INSTANCE_PG_SETTING}", + if value.is_some() { "Set" } else { "Unset" } + ); + Ok(()) +} + +/// [`write_external_instance_pg_setting`] for a settings diff: writes the key if the diff touches +/// it, and takes it out of the diff so the generic apply does not write it again. +/// +/// Authorization: checks nothing. Callers MUST be superadmin, or the declarative instance config +/// sync, which applies what the operator deployed. +pub async fn write_external_instance_pg_from_diff( + db: &DB, + diff: &mut crate::instance_config::SettingsDiff, +) -> Result<()> { + if let Some(value) = diff.upserts.remove(EXTERNAL_INSTANCE_PG_SETTING) { + write_external_instance_pg_setting(db, Some(&value)).await?; + } + if let Some(i) = diff + .deletes + .iter() + .position(|k| k == EXTERNAL_INSTANCE_PG_SETTING) + { + diff.deletes.remove(i); + write_external_instance_pg_setting(db, None).await?; + } + Ok(()) +} + +/// Refuse pointing the setting at another host or port while databases or data table roles live on +/// the current one. Data tables name databases, and the role catalog names logins, not clusters, so +/// both would silently resolve to whatever the new cluster holds under the same names. Other fields +/// (admin login, sslmode) may change freely. +async fn ensure_external_instance_pg_not_repointed( + conn: &mut sqlx::PgConnection, + value: &serde_json::Value, +) -> Result<()> { + let Some(current) = read_external_instance_pg_config(&mut *conn).await? else { + return Ok(()); + }; + let Ok(desired) = serde_json::from_value::(value.clone()) else { + return Ok(()); + }; + if external_instance_pg_address(¤t) == external_instance_pg_address(&desired) { + return Ok(()); + } + ensure_external_instance_pg_unused( + conn, + &format!("pointing {EXTERNAL_INSTANCE_PG_SETTING} at another cluster"), + ) + .await +} + +/// Converge the external cluster on the configured login: check what it can do, create or update +/// Windmill's two roles with the stored passwords, and report anything that would get in the way. +/// With `rotate_passwords`, generate new passwords first. Safe to run again; running it again is +/// how a failed rotation is repaired. +/// +/// Authorization: administers the external cluster with its admin credentials and checks nothing. +/// Callers MUST be superadmin. +pub async fn setup_external_instance_pg_unchecked( + db: &DB, + rotate_passwords: bool, +) -> Result { + crate::external_instance_pg_oss::setup_external_instance_pg_unchecked(db, rotate_passwords) + .await +} diff --git a/backend/windmill-common/src/external_instance_pg_oss.rs b/backend/windmill-common/src/external_instance_pg_oss.rs new file mode 100644 index 0000000000..93b6fc63dd --- /dev/null +++ b/backend/windmill-common/src/external_instance_pg_oss.rs @@ -0,0 +1,82 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2022 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +//! Where the external instance cluster comes from: the enterprise implementation, or a refusal. +//! `private` alone is not that edition: community builds carry it. + +use crate::error::Error; + +pub fn external_instance_pg_unavailable() -> Error { + Error::BadRequest( + "External instance databases are a Windmill Enterprise Edition feature".to_string(), + ) +} + +#[cfg(all(feature = "private", feature = "enterprise"))] +pub(crate) use crate::external_instance_pg_ee::{ + create_external_instance_database_unchecked, drop_external_instance_database_unchecked, + external_instance_connection_unchecked, setup_external_instance_pg_unchecked, + validate_external_instance_pg_setting, +}; + +#[cfg(all(feature = "private", feature = "enterprise"))] +pub(crate) fn ensure_external_instance_available() -> crate::error::Result<()> { + Ok(()) +} + +#[cfg(not(all(feature = "private", feature = "enterprise")))] +pub(crate) use ce::*; + +#[cfg(not(all(feature = "private", feature = "enterprise")))] +mod ce { + use super::external_instance_pg_unavailable as unavailable; + use crate::{ + error::Result, external_instance_pg::ExternalInstancePgSetupReport, PgDatabase, DB, + }; + + pub(crate) fn validate_external_instance_pg_setting(_value: &serde_json::Value) -> Result<()> { + Err(unavailable()) + } + + pub(crate) fn ensure_external_instance_available() -> Result<()> { + Err(unavailable()) + } + + pub(crate) async fn setup_external_instance_pg_unchecked( + _db: &DB, + _rotate_passwords: bool, + ) -> Result { + Err(unavailable()) + } + + pub(crate) async fn external_instance_connection_unchecked( + _db: &DB, + _dbname: &str, + _replication: bool, + ) -> Result { + Err(unavailable()) + } + + pub(crate) async fn create_external_instance_database_unchecked( + _db: &DB, + _tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + _dbname: &str, + _tag: &str, + _for_workspace: Option<&str>, + ) -> Result<()> { + Err(unavailable()) + } + + pub(crate) async fn drop_external_instance_database_unchecked( + _tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + _dbname: &str, + _exempt: Option<(&str, &str)>, + ) -> Result<()> { + Err(unavailable()) + } +} diff --git a/backend/windmill-common/src/flow_conversations.rs b/backend/windmill-common/src/flow_conversations.rs index 91bfa4a39f..5ee99d0681 100644 --- a/backend/windmill-common/src/flow_conversations.rs +++ b/backend/windmill-common/src/flow_conversations.rs @@ -69,7 +69,9 @@ pub async fn get_or_create_conversation_with_id( is_test: bool, ) -> Result { if let Some(existing) = lock_conversation(tx, w_id, conversation_id).await? { - return same_kind(existing, is_test); + let existing = same_kind(existing, is_test)?; + refuse_running_turn(tx, conversation_id).await?; + return Ok(existing); } // Truncate title to 25 characters max @@ -104,7 +106,74 @@ pub async fn get_or_create_conversation_with_id( "conversation {conversation_id} belongs to another workspace" )) })?; - same_kind(existing, is_test) + let existing = same_kind(existing, is_test)?; + refuse_running_turn(tx, conversation_id).await?; + Ok(existing) +} + +/// The turn a conversation is still answering: its newest user message, while the flow run +/// that message started is still queued or running. +#[derive(Serialize, Debug, Clone, Copy)] +pub struct RunningTurn { + pub job_id: Uuid, + /// `created_seq` of the user message that started the turn. + pub user_seq: i64, +} + +/// One running turn per conversation holds its agent memory; a second run would write the +/// same memory concurrently. Checked under the conversation's row lock, so two runs sent at +/// once cannot both pass: the second waits, then sees the first's message and queued job. +async fn refuse_running_turn( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + conversation_id: Uuid, +) -> Result<()> { + let Some(turn) = running_turns(&mut **tx, &[conversation_id]) + .await? + .remove(&conversation_id) + else { + return Ok(()); + }; + // A JSON body, so a chat client can follow the running turn instead of failing. + Err(crate::error::Error::Generic( + axum::http::StatusCode::CONFLICT, + serde_json::json!({ + "error": "this conversation is still answering a message; wait for it to finish or stop it before sending another", + "running_turn": turn, + }) + .to_string(), + )) +} + +/// The running turn of each of `conversation_ids` that has one. +/// +/// It answers for whatever ids it is given and checks no permission of its own, so the +/// executor must be one the caller is entitled to read those conversations through: a +/// `user_db` transaction under RLS, or a transaction holding ids the caller has already +/// authorized. Handed a raw pool and ids from a request, it would report other users' jobs. +pub async fn running_turns<'e, E: sqlx::PgExecutor<'e>>( + executor: E, + conversation_ids: &[Uuid], +) -> Result> { + let rows = sqlx::query!( + r#"SELECT c.id AS "id!", u.job_id AS "job_id!", u.created_seq AS "user_seq!" + FROM unnest($1::uuid[]) AS c(id) + CROSS JOIN LATERAL ( + SELECT job_id, created_seq + FROM flow_conversation_message + WHERE conversation_id = c.id AND message_type = 'user' + ORDER BY created_seq DESC + LIMIT 1 + ) u + WHERE u.job_id IS NOT NULL + AND EXISTS (SELECT 1 FROM v2_job_queue q WHERE q.id = u.job_id)"#, + conversation_ids + ) + .fetch_all(executor) + .await?; + Ok(rows + .into_iter() + .map(|r| (r.id, RunningTurn { job_id: r.job_id, user_seq: r.user_seq })) + .collect()) } /// `memory_id` is the caller's to choose, so a preview run could name a deployed diff --git a/backend/windmill-common/src/flows.rs b/backend/windmill-common/src/flows.rs index 11ffd5da76..b392dda89a 100644 --- a/backend/windmill-common/src/flows.rs +++ b/backend/windmill-common/src/flows.rs @@ -17,6 +17,7 @@ use crate::{ cache::{self, FlowExtras}, db::DB, error::{to_anyhow, Error}, + scripts::ScriptHash, utils::{http_get_from_hub, StripPath}, worker::{to_raw_value, Connection}, DEFAULT_HUB_BASE_URL, HUB_BASE_URL, PRIVATE_HUB_MIN_VERSION, @@ -246,6 +247,156 @@ pub async fn resolve_modules( Ok(()) } +/// Checks a flow value contains nothing but composition of runnables that already exist, which is +/// all an operator with builder rights may author. Walks the modules, the preprocessor and failure +/// modules, every branch, and the `tools` of an AI agent step. +/// +/// Returns what the caller still has to authorize against its own permissions, which this +/// value-only walk cannot: every runnable the steps reference, the worker tags they pin, and the +/// `(path, hash)` pairs of version-pinned steps. See [`ComposedFlowRefs`] for why each one is not +/// already settled by the walk. +pub fn check_flow_is_composition_only(value: &FlowValue) -> Result { + let mut refs = ComposedFlowRefs::default(); + for module in value + .modules + .iter() + .chain(value.preprocessor_module.as_deref()) + .chain(value.failure_module.as_deref()) + { + check_module_is_composition_only(module, &mut refs)?; + } + Ok(refs) +} + +/// What [`check_flow_is_composition_only`] collects for the caller to authorize. +#[derive(Default)] +pub struct ComposedFlowRefs { + pub tags: Vec, + /// Every workspace runnable a step references, as `(is_flow, path)`. + pub runnables: Vec<(bool, String)>, + /// Version-pinned script steps, as `(path, hash)`. + pub pinned_scripts: Vec<(String, ScriptHash)>, +} + +fn check_module_is_composition_only( + module: &FlowModule, + refs: &mut ComposedFlowRefs, +) -> Result<(), Error> { + let value = module + .get_value() + .map_err(|e| Error::BadRequest(format!("Step {} could not be read: {e}", module.id)))?; + check_module_value_is_composition_only(&value, &module.id, refs) +} + +fn check_module_value_is_composition_only( + value: &FlowModuleValue, + id: &str, + refs: &mut ComposedFlowRefs, +) -> Result<(), Error> { + let refuse = |what: &str| Err(refused(id, what)); + // A node id points at code in a `flow_node` row, which only the dependency job produces by + // hoisting a step's code, so an authored value carrying one names another flow's code and slips + // past this walk. Editor payloads come from the un-hoisted `flow_version.value`, so refusing + // them costs nothing legitimate. + let refuse_node = |node: &Option| match node { + Some(_) => refuse("references code stored outside the flow"), + None => Ok(()), + }; + let mut push_tag = |tag: &Option| { + if let Some(tag) = tag.as_deref().filter(|t| !t.is_empty()) { + refs.tags.push(tag.to_string()); + } + }; + + match value { + FlowModuleValue::RawScript { .. } => return refuse("has inline code"), + FlowModuleValue::FlowScript { .. } => { + return refuse("references code stored outside the flow") + } + FlowModuleValue::Identity => {} + FlowModuleValue::Script { path, hash, tag_override, .. } => { + check_composable_path(path, id)?; + push_tag(tag_override); + refs.runnables.push((false, path.clone())); + if let Some(hash) = hash { + refs.pinned_scripts.push((path.clone(), *hash)); + } + } + FlowModuleValue::Flow { path, .. } => { + check_composable_path(path, id)?; + refs.runnables.push((true, path.clone())); + } + FlowModuleValue::ForloopFlow { modules, modules_node, .. } + | FlowModuleValue::WhileloopFlow { modules, modules_node, .. } => { + refuse_node(modules_node)?; + for module in modules { + check_module_is_composition_only(module, refs)?; + } + } + FlowModuleValue::BranchOne { branches, default, default_node } => { + refuse_node(default_node)?; + for module in default { + check_module_is_composition_only(module, refs)?; + } + check_branches_are_composition_only(branches, id, refs)?; + } + FlowModuleValue::BranchAll { branches, .. } => { + check_branches_are_composition_only(branches, id, refs)? + } + FlowModuleValue::AIAgent { tools, tag, agent, .. } => { + // A linked agent resolves its tools from an `ai_agent` resource at run time, and + // operators may write resources, so those tools are outside this check: the list can + // be swapped for a raw script after the flow is deployed. + if agent.is_some() { + return refuse("links an AI agent resource, whose tools live outside the flow"); + } + push_tag(tag); + for tool in tools { + if let ToolValue::FlowModule(value) = &tool.value { + check_module_value_is_composition_only(value, &tool.id, refs)?; + } + } + } + } + Ok(()) +} + +fn check_branches_are_composition_only( + branches: &[Branch], + id: &str, + refs: &mut ComposedFlowRefs, +) -> Result<(), Error> { + for branch in branches { + if branch.modules_node.is_some() { + return Err(refused( + id, + "has a branch that references code stored outside the flow", + )); + } + for module in &branch.modules { + check_module_is_composition_only(module, refs)?; + } + } + Ok(()) +} + +fn refused(id: &str, what: &str) -> Error { + Error::PermissionDenied(format!( + "Step {id} {what}, so only a developer can edit this flow. Builders can compose scripts \ + and flows that are already deployed." + )) +} + +fn check_composable_path(path: &str, id: &str) -> Result<(), Error> { + if path.starts_with("hub/") { + return Err(Error::PermissionDenied(format!( + "Step {id}: hub runnables are not available to operators with builder rights. Deploy \ + it to the workspace first." + ))); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -281,4 +432,93 @@ mod tests { let err = extract_hub_flow_id_from_path("hub/flows/0").unwrap_err(); assert!(matches!(err, Error::BadRequest(_))); } + + fn flow(value: serde_json::Value) -> FlowValue { + serde_json::from_value(value).unwrap() + } + + #[test] + fn composition_check_accepts_a_composed_flow_and_collects_its_tags() { + let refs = check_flow_is_composition_only(&flow(serde_json::json!({"modules": [{ + "id": "a", + "value": {"type": "forloopflow", "iterator": {"type": "static", "value": []}, + "parallel": false, "modules": [ + {"id": "b", "value": {"type": "script", "path": "f/x/s", "tag_override": "gpu"}}, + {"id": "c", "value": {"type": "flow", "path": "f/x/f"}}, + {"id": "d", "value": {"type": "aiagent", "input_transforms": {}, "tag": "ai", + "tools": [{"id": "t", "value": {"tool_type": "flowmodule", + "type": "script", "path": "f/x/tool"}}]}} + ]} + }]}))) + .unwrap(); + assert_eq!(refs.tags, vec!["gpu".to_string(), "ai".to_string()]); + } + + /// The walk covers `modules`, so a node reference is a way past it: it names code hoisted + /// into a `flow_node` row, possibly another flow's. + #[test] + fn composition_check_rejects_node_references() { + for value in [ + serde_json::json!({"modules": [{"id": "a", "value": {"type": "forloopflow", + "iterator": {"type": "static", "value": []}, "parallel": false, + "modules": [], "modules_node": 7}}]}), + serde_json::json!({"modules": [{"id": "a", "value": {"type": "branchone", + "branches": [], "default": [], "default_node": 7}}]}), + serde_json::json!({"modules": [{"id": "a", "value": {"type": "branchall", + "branches": [{"expr": "true", "modules": [], "modules_node": 7}]}}]}), + serde_json::json!({"modules": [{"id": "a", "value": {"type": "flowscript", + "id": 7, "language": "bun"}}]}), + ] { + assert!(check_flow_is_composition_only(&flow(value)).is_err()); + } + } + + #[test] + fn composition_check_rejects_code_reachable_through_an_ai_agent() { + for value in [ + serde_json::json!({"modules": [{"id": "a", "value": {"type": "aiagent", + "input_transforms": {}, "tools": [{"id": "t", "value": {"tool_type": "flowmodule", + "type": "rawscript", "content": "x", "language": "bun"}}]}}]}), + serde_json::json!({"modules": [{"id": "a", "value": {"type": "aiagent", + "input_transforms": {}, "tools": [], "agent": "$res:f/x/agent"}}]}), + ] { + assert!(check_flow_is_composition_only(&flow(value)).is_err()); + } + } + + #[test] + fn composition_check_rejects_code_in_every_module_slot() { + let inline = serde_json::json!({"type": "rawscript", "content": "x", "language": "bun"}); + for value in [ + serde_json::json!({"modules": [{"id": "a", "value": inline}]}), + serde_json::json!({"modules": [], "failure_module": {"id": "f", "value": inline}}), + serde_json::json!({"modules": [], "preprocessor_module": {"id": "p", "value": inline}}), + ] { + assert!(check_flow_is_composition_only(&flow(value)).is_err()); + } + } + + /// A step carrying a `hash` dispatches on that hash alone: the caller must verify the pair + /// exists and is readable, so the walk has to surface it rather than pass it through. + #[test] + fn composition_check_reports_version_pinned_steps() { + let refs = check_flow_is_composition_only(&flow(serde_json::json!({"modules": [ + {"id": "a", "value": {"type": "script", "path": "f/x/s", "hash": "000000000000007b"}}, + {"id": "b", "value": {"type": "script", "path": "f/x/t"}} + ]}))) + .unwrap(); + assert_eq!( + refs.pinned_scripts, + vec![("f/x/s".to_string(), ScriptHash(123))] + ); + } + + #[test] + fn composition_check_rejects_hub_runnables() { + for kind in ["script", "flow"] { + let value = serde_json::json!({"modules": [{"id": "a", + "value": {"type": kind, "path": "hub/1234/thing"}}]}); + assert!(check_flow_is_composition_only(&flow(value)).is_err()); + } + } } diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 7306c5c59a..9137dc364e 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -57,6 +57,11 @@ pub const SAML_METADATA_SETTING: &str = "saml_metadata"; pub const SMTP_SETTING: &str = "smtp_settings"; pub const TEAMS_SETTING: &str = "teams"; pub const INDEXER_SETTING: &str = "indexer_settings"; +pub const EXTERNAL_INSTANCE_PG_SETTING: &str = "external_instance_pg"; +/// Turns off Windmill's own Postgres as a data table and Ducklake substrate. Absent means on, +/// which is what every instance that predates the setting expects. +pub const INSTANCE_PG_DISABLED_SETTING: &str = "instance_pg_disabled"; +pub const EXTERNAL_INSTANCE_PG_STATE_SETTING: &str = "external_instance_pg_state"; pub const TIMEOUT_WAIT_RESULT_SETTING: &str = "timeout_wait_result"; pub const UNIQUE_ID_SETTING: &str = "uid"; @@ -448,6 +453,9 @@ pub const AGENT_WORKER_BLOCKED_SETTINGS: &[&str] = &[ // resolve datatable connections through the dedicated datatable endpoints, never these. "custom_instance_pg_databases", "custom_instance_replication_pwd", + // The external cluster's admin login, and the passwords Windmill generated on it. + EXTERNAL_INSTANCE_PG_SETTING, + EXTERNAL_INSTANCE_PG_STATE_SETTING, ]; /// Whether an agent worker may read the given global setting over HTTP. diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index 264916d522..fd790547b6 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -354,6 +354,8 @@ pub struct GlobalSettings { pub ducklake_settings: Option, #[serde(skip_serializing_if = "Option::is_none")] pub custom_instance_pg_databases: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub external_instance_pg: Option, // Opaque settings (EE-private structs or no clear schema) #[serde(skip_serializing_if = "Option::is_none")] @@ -815,6 +817,9 @@ pub struct CustomInstanceDb { pub error: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tag: Option, + /// The workspace a member created this fork copy for. Absent when a superadmin created it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, } /// Setup log entries for a custom instance database. @@ -841,6 +846,36 @@ pub struct CustomInstanceDbLogs { pub user_connect: String, } +// --------------------------------------------------------------------------- +// External instance PG cluster +// --------------------------------------------------------------------------- + +/// The external Postgres cluster Windmill manages for `external_instance` data tables and Ducklake +/// catalogs. `user` logs in as the cluster's administrator: it needs `CREATEDB` and `CREATEROLE`. +/// `dbname` is only where that login connects to run cluster-wide statements. +/// +/// Every field defaults rather than being required: this deserializes as part of the whole +/// instance config, and one malformed row must not make every other setting unreadable. The +/// write path and every use reject an incomplete value instead. +#[derive(Deserialize, Serialize, Clone, Debug, Default)] +#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))] +pub struct ExternalInstancePg { + #[serde(default)] + pub host: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub port: Option, + #[serde(default)] + pub user: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub password: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dbname: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sslmode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub root_certificate_pem: Option, +} + // --------------------------------------------------------------------------- // Autoscaling (worker config) // --------------------------------------------------------------------------- @@ -977,6 +1012,7 @@ pub const PROTECTED_SETTINGS: &[&str] = &[ "ducklake_settings", "custom_instance_pg_databases", "custom_instance_replication_pwd", + "external_instance_pg_state", "uid", "rsa_keys", "jwt_secret", @@ -1002,6 +1038,8 @@ pub const HIDDEN_SETTINGS: &[&str] = &[ // Server-only (written by setup/refresh via direct SQL), never operator-authored — // hidden so the config machinery can't read, rewrite, or drop it. "custom_instance_replication_pwd", + // Same for the passwords and database registry Windmill keeps for the external cluster. + "external_instance_pg_state", ]; /// Top-level settings whose entire value is sensitive and must be fully redacted in logs. @@ -1013,6 +1051,7 @@ const SENSITIVE_SETTINGS: &[&str] = &[ "license_key", "ducklake_user_pg_pwd", "custom_instance_replication_pwd", + "external_instance_pg_state", "pip_index_url", "pip_extra_index_url", "npm_config_registry", @@ -1038,6 +1077,7 @@ const NESTED_SENSITIVE_FIELDS: &[(&str, &[&str])] = &[ &["secret_key", "serviceAccountKey", "accessKey"], ), ("custom_instance_pg_databases", &["user_pwd"]), + ("external_instance_pg", &["password"]), ("github_enterprise_app", &["private_key"]), ]; @@ -1379,7 +1419,8 @@ pub async fn sync_global_settings_declarative( crate::global_settings::parse_max_token_expiration_days(desired.get(max_expiration_key)) .map_err(|e| anyhow::anyhow!("{max_expiration_key}: {e}"))?; - let diff = diff_global_settings(current, desired, ApplyMode::Replace); + let mut diff = diff_global_settings(current, desired, ApplyMode::Replace); + crate::external_instance_pg::write_external_instance_pg_from_diff(db, &mut diff).await?; apply_settings_diff(db, &diff).await?; Ok(()) @@ -1512,6 +1553,10 @@ pub fn resolve_env_refs(settings: &mut GlobalSettings) -> Result<(), String> { resolve_env_option(&mut pg.user_pwd)?; } + if let Some(pg) = &mut settings.external_instance_pg { + resolve_env_option(&mut pg.password)?; + } + Ok(()) } @@ -2495,39 +2540,33 @@ mod tests { } #[test] - fn custom_instance_replication_pwd_is_isolated_from_config() { - // The replication-role password is server-only: written by setup/refresh via direct - // SQL, never operator-authored. It must stay out of the declarative config surface - // (hidden on read) and be undeletable, so config sync can't read, rewrite, or drop it. - assert!(HIDDEN_SETTINGS.contains(&"custom_instance_replication_pwd")); - assert!(PROTECTED_SETTINGS.contains(&"custom_instance_replication_pwd")); - assert!(SENSITIVE_SETTINGS.contains(&"custom_instance_replication_pwd")); + fn server_generated_db_passwords_are_isolated_from_config() { + // These hold passwords the server generates: written by setup/refresh via direct SQL, + // never operator-authored. They must stay out of the declarative config surface + // (hidden on read) and be undeletable, so config sync can't read, rewrite, or drop them. + for key in [ + "custom_instance_replication_pwd", + "external_instance_pg_state", + ] { + assert!(HIDDEN_SETTINGS.contains(&key), "{key}"); + assert!(PROTECTED_SETTINGS.contains(&key), "{key}"); + assert!(SENSITIVE_SETTINGS.contains(&key), "{key}"); - // A stray desired value (e.g. flattened into `extra`) is ignored, not upserted. - let mut desired = BTreeMap::new(); - desired.insert( - "custom_instance_replication_pwd".to_string(), - serde_json::json!("attacker-set"), - ); - let diff = diff_global_settings(&BTreeMap::new(), &desired, ApplyMode::Merge); - assert!( - diff.upserts.is_empty(), - "hidden setting must not be upserted" - ); + // A stray desired value (e.g. flattened into `extra`) is ignored, not upserted. + let mut desired = BTreeMap::new(); + desired.insert(key.to_string(), serde_json::json!("attacker-set")); + let diff = diff_global_settings(&BTreeMap::new(), &desired, ApplyMode::Merge); + assert!(diff.upserts.is_empty(), "{key} must not be upserted"); - // A current value is never deleted by a Replace that omits it. - let mut current = BTreeMap::new(); - current.insert( - "custom_instance_replication_pwd".to_string(), - serde_json::json!("live"), - ); - let diff = diff_global_settings(¤t, &BTreeMap::new(), ApplyMode::Replace); - assert!( - !diff - .deletes - .contains(&"custom_instance_replication_pwd".to_string()), - "hidden setting must not be deleted" - ); + // A current value is never deleted by a Replace that omits it. + let mut current = BTreeMap::new(); + current.insert(key.to_string(), serde_json::json!("live")); + let diff = diff_global_settings(¤t, &BTreeMap::new(), ApplyMode::Replace); + assert!( + !diff.deletes.contains(&key.to_string()), + "{key} must not be deleted" + ); + } } #[test] diff --git a/backend/windmill-common/src/job_provenance.rs b/backend/windmill-common/src/job_provenance.rs index 1e4130de8c..63075b0c37 100644 --- a/backend/windmill-common/src/job_provenance.rs +++ b/backend/windmill-common/src/job_provenance.rs @@ -1,17 +1,54 @@ use uuid::Uuid; -use crate::{db::DB, error::Result, jobs::JobKind}; +use crate::{db::DB, error::Result, jobs::JobKind, scripts::ScriptHash}; /// Where a job's code comes from, as far as its chain of parents can prove it. pub struct JobProvenance { /// True only when the job and every ancestor run code at a path taken from a /// deployed version, never from the request that created the job. pub deployed: bool, + /// Deployed, and every script and flow in the chain runs the version its path + /// currently resolves to. Any past version still counts as deployed, and a run started + /// before a redeploy stops being latest once the redeploy lands. App runs never count: + /// app scripts are not tied to an app version. + pub latest: bool, + /// The job's own script hash (hex) or flow version id; `None` for other kinds. + pub version: Option, + /// The chain reached a parent it cannot read (other workspace, depth cap), so + /// nothing above that point is known. + pub unproven_ancestry: bool, + /// The item paths the chain took from a request, top-most first: for the job to act as + /// them, its identity must be one that could have deployed there. A job whose code + /// and path both derive from a claimed parent adds no claim of its own. + pub claimed_paths: Vec, + /// The identity the job runs as. + pub permissioned_as: String, pub parent_path: Option, pub root_id: Uuid, pub root_path: Option, pub root_kind: JobKind, pub root_trigger_kind: Option, + pub root_version: Option, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct ClaimedPath { + /// The deployable item the claim stands for: an app script's claim is its app. + pub path: String, + pub item: ClaimedItem, + /// The identity of the job that made the claim: a deployed child can run as another + /// identity (`on_behalf_of`) than the preview it runs under. + pub permissioned_as: String, +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum ClaimedItem { + Script, + /// A preview without a parent at `/`: deploying the app runs its + /// inline script at that path with the same identity, so writing the app suffices. + ScriptOrAppComponent, + Flow, + App, } struct LineageJob { @@ -19,8 +56,16 @@ struct LineageJob { parent_job: Option, kind: JobKind, runnable_path: Option, + runnable_id: Option, trigger_kind: Option, + permissioned_as: String, origin_verified: bool, + current_version: bool, + restarted: bool, + /// The job a restart rebuilt this one from. + restarted_from: Option, + app_stamped: bool, + args_modules: bool, } /// Reads any job of `w_id` regardless of the caller: authorize access to `job_id` first. @@ -29,29 +74,54 @@ pub async fn job_provenance(db: &DB, job_id: &Uuid, w_id: &str) -> Result

` block. +/// Falls back to the trimmed raw text when the model did not use the tags, so a +/// well-formed-but-untagged summary is still usable, and to the empty string — which +/// the caller counts as a failure — when the response stopped inside the scratchpad. +fn format_compact_summary(raw: &str) -> String { + // Strip the analysis scratchpad first: it precedes the summary and may itself + // mention / tokens that would otherwise be mistaken for the real + // summary boundary. + let without_analysis = ANALYSIS_BLOCK_REGEX.replace_all(raw, ""); + + let summary = if let Some(captures) = SUMMARY_BLOCK_REGEX.captures(&without_analysis) { + captures + .get(1) + .map(|m| m.as_str()) + .unwrap_or_default() + .to_string() + } else if let Some(opener) = SUMMARY_OPENER_REGEX.find(&without_analysis) { + // A truncated response or a weaker model sometimes opens without + // closing it. The text after the opener is still the summary. + without_analysis[opener.end()..].to_string() + } else if ANALYSIS_OPENER_REGEX.is_match(&without_analysis) { + // An unclosed means the response ran out before it reached the + // summary. Its draft notes are not one, and handing them over would put the + // model's own scratchpad into the conversation as fact. + return String::new(); + } else { + without_analysis.to_string() + }; + + // An orphaned opener or closer left by either branch must never reach the model. + let summary = STRAY_TAG_REGEX.replace_all(&summary, ""); + // Collapse the blank-line runs left behind by stripping the analysis block. + BLANK_LINE_RUN_REGEX + .replace_all(&summary, "\n\n") + .trim() + .to_string() +} + +lazy_static::lazy_static! { + static ref ANALYSIS_BLOCK_REGEX: regex::Regex = + regex::Regex::new(r"(?is).*?").unwrap(); + static ref ANALYSIS_OPENER_REGEX: regex::Regex = + regex::Regex::new(r"(?i)").unwrap(); + // Greedy to the last closer: the summary describes the instruction that asked for + // it, tags included, and stopping at a quoted `` cuts it off mid-sentence. + static ref SUMMARY_BLOCK_REGEX: regex::Regex = + regex::Regex::new(r"(?is)(.*)").unwrap(); + static ref SUMMARY_OPENER_REGEX: regex::Regex = + regex::Regex::new(r"(?i)").unwrap(); + static ref STRAY_TAG_REGEX: regex::Regex = + regex::Regex::new(r"(?i)").unwrap(); + static ref BLANK_LINE_RUN_REGEX: regex::Regex = regex::Regex::new(r"\n{3,}").unwrap(); +} + +/// Wraps a formatted summary as the user message that replaces the summarized prefix. +fn build_summary_message(formatted_summary: &str) -> OpenAIMessage { + OpenAIMessage { + role: "user".to_string(), + content: Some(OpenAIContent::Text(format!( + "{SUMMARY_MESSAGE_OPENING} \ + Messages after these notes are verbatim.\n\n{formatted_summary}\n\nTreat these notes as \ + your own memory of the conversation and answer from them directly. Never mention \ + notes, a summary, a context limit or an earlier portion to the user." + ))), + ..Default::default() + } +} + +/// Re-summarizing a lone summary spends a model call and loses fidelity without +/// freeing useful space. Only a prefix with additional context merits a new summary. +fn is_compaction_summary(message: &OpenAIMessage) -> bool { + message.role == "user" + && matches!( + message.content.as_ref(), + Some(OpenAIContent::Text(text)) if text.starts_with(SUMMARY_MESSAGE_OPENING) + ) +} + +/// Everything the summarization call needs that the agent loop already has in hand. +pub(crate) struct CompactionRequest<'a> { + pub query_builder: &'a dyn QueryBuilder, + pub credentials: &'a ProviderCredentials, + pub model: &'a str, + pub timeout: std::time::Duration, + pub client: &'a AuthedClient, + pub workspace_id: &'a str, + #[cfg_attr(not(feature = "bedrock"), allow(dead_code))] + pub job_id: &'a uuid::Uuid, + /// Whether the endpoint accepts the usage-tracking request shape. The agent loop + /// learns this from a rejection; sending it again here would make every + /// summarization fail on an endpoint the agent itself runs fine against. + pub include_usage: bool, +} + +fn lowest_reasoning_effort(provider: &AIProvider, model: &str) -> Option<&'static str> { + match provider { + AIProvider::GoogleAI => Some("none"), + // The pro variants each accept their own floor and reject anything below it + // (`gpt-5-pro` takes only `high`, `gpt-5.2-pro` starts at `medium`), so they + // are left to their default rather than sent an effort that fails the call. + _ if is_openai_reasoning_model(model) && !model.contains("-pro") => Some("low"), + _ => None, + } +} + +/// The prefix with every tool exchange rendered as text. The summarization request +/// carries no tool definitions, and Bedrock rejects `toolUse`/`toolResult` blocks that +/// arrive without them; the summary only needs what was called and what came back. +fn tool_exchanges_as_text(prefix: &[OpenAIMessage]) -> Vec { + let mut tool_names = std::collections::HashMap::new(); + prefix + .iter() + .map(|message| { + let mut message = message.clone(); + if let Some(calls) = message.tool_calls.take() { + let mut text = match message.content.take() { + Some(OpenAIContent::Text(text)) => text, + _ => String::new(), + }; + for call in calls { + tool_names.insert(call.id, call.function.name.clone()); + if !text.is_empty() { + text.push_str("\n\n"); + } + text.push_str(&format!( + "[Called tool `{}` with arguments: {}]", + call.function.name, call.function.arguments + )); + } + message.content = Some(OpenAIContent::Text(text)); + } + if message.role == "tool" { + let name = message + .tool_call_id + .take() + .and_then(|id| tool_names.get(&id).cloned()) + .unwrap_or_else(|| "unknown".to_string()); + let result = match message.content.take() { + Some(OpenAIContent::Text(text)) => text, + Some(parts) => serde_json::to_string(&parts).unwrap_or_default(), + None => String::new(), + }; + message.role = "user".to_string(); + message.content = Some(OpenAIContent::Text(format!( + "[Result of tool `{name}`: {result}]" + ))); + } + message + }) + .collect() +} + +// Keeping the instructions outside the transcript prevents the handoff from treating +// the compaction request as a new user task. Media parts remain available to the model. +fn summary_messages(prefix: &[OpenAIMessage]) -> Vec { + let mut transcript = vec![ContentPart::Text { text: "".to_string() }]; + for message in tool_exchanges_as_text(prefix) { + transcript.push(ContentPart::Text { text: format!("\n[{}]\n", message.role) }); + match message.content { + Some(OpenAIContent::Text(text)) => transcript.push(ContentPart::Text { text }), + Some(OpenAIContent::Parts(parts)) => transcript.extend(parts), + None => {} + } + } + transcript.push(ContentPart::Text { text: "\n".to_string() }); + vec![ + OpenAIMessage { + role: "system".to_string(), + content: Some(OpenAIContent::Text(SUMMARY_PROMPT.to_string())), + ..Default::default() + }, + OpenAIMessage { + role: "user".to_string(), + content: Some(OpenAIContent::Parts(transcript)), + ..Default::default() + }, + ] +} + +async fn summarize_prefix( + prefix: &[OpenAIMessage], + reserve_tokens: usize, + request: &CompactionRequest<'_>, +) -> Result<(String, Option), Error> { + let summary_messages = summary_messages(prefix); + + let build_args = BuildRequestArgs { + messages: &summary_messages, + tools: None, + model: request.model, + // The step's temperature is not carried over: the summary imposes its own + // thinking mode (below), and OpenAI's reasoning models reject `temperature` + // alongside any effort but their own default. A structured extraction does not + // need a set temperature, so the internal call omits it rather than track which + // model forbids which pairing. + temperature: None, + // Thinking shares the output budget with the handoff; minimize it so the + // summary can finish even when the step itself requests extensive reasoning. + reasoning_effort: lowest_reasoning_effort(&request.credentials.provider, request.model), + // Nothing streams this call's reasoning, and a summary of it would be billed. + reasoning_summary: false, + // Exactly the room the split set aside. Asking for more lets a summary land the + // conversation back over the trigger; leaving it to the provider default gives + // 64000 on Anthropic (over several Claude models' output ceiling) and the model's + // own small default on Bedrock, short enough to truncate the response. + max_tokens: Some(reserve_tokens as u32), + output_schema: None, + output_type: &OutputType::Text, + system_prompt: None, + user_message: "", + attachments: None, + has_websearch: false, + // The summarization request carries no tools, so its prefix does not match the + // agent's own requests: routing it onto the step's cache key would only evict. + prompt_cache_key: None, + }; + + let parsed = if request.credentials.provider == AIProvider::AWSBedrock { + #[cfg(feature = "bedrock")] + { + // A resource that authenticates through an OIDC role carries no keys of its + // own. Summaries are rare enough that assuming the role afresh here costs + // less than threading the agent loop's cached credentials through. + let mut assumed_role = None; + let assumed = windmill_ai::ai_bedrock::refresh_bedrock_oidc_credentials( + request.credentials, + &mut assumed_role, + request.client, + request.job_id, + ) + .await?; + let (access_key_id, secret_access_key, session_token) = match assumed { + Some(assumed) => ( + Some(assumed.access_key_id.as_str()), + Some(assumed.secret_access_key.as_str()), + Some(assumed.session_token.as_str()), + ), + None => ( + request.credentials.aws_access_key_id.as_deref(), + request.credentials.aws_secret_access_key.as_deref(), + request.credentials.aws_session_token.as_deref(), + ), + }; + windmill_ai::providers::bedrock::BedrockQueryBuilder::default() + .execute_request( + &summary_messages, + None, + request.model, + // No temperature, as on the HTTP path above. + None, + build_args.reasoning_effort, + build_args.max_tokens, + request.credentials.api_key.as_deref().unwrap_or(""), + request + .credentials + .region + .as_deref() + .unwrap_or(windmill_ai::ai_providers::USE_ENV_REGION), + Some(StreamEventProcessor::new_silent().boxed_sink()), + request.client, + request.workspace_id, + None, + access_key_id, + secret_access_key, + session_token, + ) + .await? + } + #[cfg(not(feature = "bedrock"))] + { + return Err(Error::internal_err( + "AWS Bedrock support is not enabled. Build with 'bedrock' feature.".to_string(), + )); + } + } else { + let base_url = &request.credentials.base_url; + let api_key = request.credentials.api_key.as_deref().unwrap_or(""); + let body = if request.include_usage { + request + .query_builder + .build_request(&build_args, request.client, request.workspace_id) + .await? + } else { + request + .query_builder + .build_request_without_usage(&build_args, request.client, request.workspace_id) + .await? + }; + let endpoint = + request + .query_builder + .get_endpoint(base_url, request.model, &OutputType::Text); + let auth_headers = retain_effective_credentials( + request.credentials, + request + .query_builder + .get_auth_headers(api_key, base_url, &OutputType::Text), + ); + + // As in the agent loop, `endpoint` derives from the user-controlled provider + // base_url, so DNS is pinned to the SSRF-validated address. + let mut http_request = pinned_ai_client_for(base_url) + .await? + .post(&endpoint) + .timeout(request.timeout) + .header("Content-Type", "application/json"); + for (header_name, header_value) in &auth_headers { + http_request = http_request.header(*header_name, header_value.clone()); + } + for (header_name, header_value) in common_outbound_headers(request.credentials) { + http_request = http_request.header(header_name.as_str(), header_value.as_str()); + } + + let resp = http_request + .body(body) + .send() + .await + .map_err(|e| Error::internal_err(format!("Failed to call API: {e}")))?; + + if let Err(e) = resp.error_for_status_ref() { + let text = resp + .text() + .await + .unwrap_or_else(|_| "".to_string()); + return Err(Error::internal_err(format!( + "API error calling {endpoint}: {e} - {text}" + ))); + } + + request + .query_builder + .parse_streaming_response(resp, StreamEventProcessor::new_silent().boxed_sink()) + .await? + }; + + match parsed { + ParsedResponse::Text { content, usage, .. } => Ok((content.unwrap_or_default(), usage)), + ParsedResponse::Image { .. } => Err(Error::internal_err( + "Compaction summary came back as an image".to_string(), + )), + } +} + +#[cfg(test)] +mod tests { + #[test] + fn unusable_summaries_leave_context_and_usage_untouched() { + let mut history = AgentHistory::new(vec![ + message("user", "old context"), + message("assistant", "answer"), + message("user", "new question"), + ]); + let compactor = Compactor::new(10000, 0, None); + history.record_usage(Some(9000), 3); + for summary in ["", "unfinished", &"x".repeat(40000)] { + assert!(!compactor.install_summary(&mut history, 2, summary)); + assert_eq!(history.context().len(), 3); + assert_eq!(history.last_request, Some((9000, 3))); + } + assert!(compactor.install_summary(&mut history, 2, "The user requested a report.")); + assert_eq!(history.context().len(), 2); + assert_eq!(history.result().len(), 3); + assert_eq!(history.last_request, None); + } + + #[test] + fn output_reserve_and_forced_recovery_do_not_depend_on_usage() { + let history = AgentHistory::new(vec![ + message("user", "old context"), + message("assistant", "answer"), + message("user", "more context"), + message("assistant", "answer"), + message("user", "newest question"), + ]); + let compactor = Compactor::new(10000, 0, Some(4000)); + assert_eq!(compactor.input_budget, 6000); + assert!(!compactor.needs_compaction(&history)); + assert_eq!( + compactor.plan(&history, &CompactionTrigger::ContextRejected), + Some(4) + ); + } + + #[test] + fn planning_skips_summaries_when_retained_context_cannot_fit() { + let history = AgentHistory::new(vec![ + message("system", &"s".repeat(400)), + message("user", "old question"), + message("assistant", "old answer"), + message("user", &"x".repeat(8000)), + ]); + for compactor in [ + Compactor::new(8000, 0, Some(8192)), + Compactor::new(8000, 0, Some(7000)), + Compactor::new(2000, 0, None), + Compactor::new(10000, 7000, None), + ] { + assert_eq!( + compactor.plan(&history, &CompactionTrigger::Threshold), + None + ); + assert_eq!( + compactor.plan(&history, &CompactionTrigger::ContextRejected), + None + ); + } + assert!(Compactor::new(10000, 0, None) + .plan(&history, &CompactionTrigger::ContextRejected) + .is_some()); + } + + use super::*; + + fn message(role: &str, content: &str) -> OpenAIMessage { + OpenAIMessage { + role: role.to_string(), + content: Some(OpenAIContent::Text(content.to_string())), + ..Default::default() + } + } + + #[test] + fn the_summarizer_is_sent_tool_exchanges_as_text() { + use windmill_ai::ai_types::{OpenAIFunction, OpenAIToolCall}; + let prefix = vec![ + message("user", "weather?"), + OpenAIMessage { + role: "assistant".to_string(), + tool_calls: Some(vec![OpenAIToolCall { + id: "call_1".to_string(), + r#type: "function".to_string(), + function: OpenAIFunction { + name: "get_weather".to_string(), + arguments: r#"{"city":"Paris"}"#.to_string(), + }, + extra_content: None, + }]), + ..Default::default() + }, + OpenAIMessage { + role: "tool".to_string(), + tool_call_id: Some("call_1".to_string()), + content: Some(OpenAIContent::Text("sunny".to_string())), + ..Default::default() + }, + message("assistant", "It is sunny."), + ]; + + let sent = tool_exchanges_as_text(&prefix); + + assert!(sent + .iter() + .all(|m| m.tool_calls.is_none() && m.tool_call_id.is_none() && m.role != "tool")); + let text = |i: usize| match &sent[i].content { + Some(OpenAIContent::Text(t)) => t.clone(), + other => panic!("expected text, got {other:?}"), + }; + assert!(text(1).contains("get_weather") && text(1).contains("Paris")); + assert_eq!(sent[2].role, "user"); + assert!(text(2).contains("get_weather") && text(2).contains("sunny")); + } + + #[test] + fn the_summarizer_asks_default_thinkers_for_the_least() { + assert_eq!( + lowest_reasoning_effort(&AIProvider::GoogleAI, "gemini-2.5-flash"), + Some("none") + ); + assert_eq!( + lowest_reasoning_effort(&AIProvider::OpenAI, "gpt-5-mini"), + Some("low") + ); + assert_eq!( + lowest_reasoning_effort(&AIProvider::OpenRouter, "openai/o3-mini"), + Some("low") + ); + // Sending an effort to a model that takes none is a rejected request, and so is + // `low` to a pro model, whose floor is its own. + assert_eq!( + lowest_reasoning_effort(&AIProvider::OpenAI, "gpt-4.1-mini"), + None + ); + assert_eq!( + lowest_reasoning_effort(&AIProvider::OpenAI, "gpt-5.2-pro"), + None + ); + assert_eq!( + lowest_reasoning_effort(&AIProvider::Mistral, "open-mistral-nemo"), + None + ); + assert_eq!( + lowest_reasoning_effort(&AIProvider::Anthropic, "claude-haiku-4-5"), + None + ); + } + + #[test] + fn format_compact_summary_drops_the_analysis_scratchpad() { + let raw = "mentions as a token\n\nthe real summary"; + assert_eq!(format_compact_summary(raw), "the real summary"); + } + + #[test] + fn format_compact_summary_survives_a_summary_that_quotes_its_own_tags() { + let raw = "notes\n1. The user asked for an block \ + then a block.\n2. Work continued."; + assert_eq!( + format_compact_summary(raw), + "1. The user asked for an block then a block.\n2. Work continued." + ); + } + + #[test] + fn format_compact_summary_keeps_an_unclosed_summary() { + let raw = "xcut off mid"; + assert_eq!(format_compact_summary(raw), "cut off mid"); + } + + /// Draft notes are not a summary: passing them on would insert the model's own + /// scratchpad into the conversation as the record of what happened. + #[test] + fn format_compact_summary_rejects_a_response_cut_off_inside_the_analysis() { + assert_eq!(format_compact_summary("ran out of tokens"), ""); + } + + /// A tail opening on a tool message loses the `tool_calls` that produced it, which + /// every provider rejects. + + fn tool_round(id: &str, content: &str) -> Vec { + use windmill_ai::ai_types::{OpenAIFunction, OpenAIToolCall}; + vec![ + OpenAIMessage { + role: "assistant".into(), + tool_calls: Some(vec![OpenAIToolCall { + id: id.into(), + r#type: "function".into(), + function: OpenAIFunction { name: "lookup".into(), arguments: "{}".into() }, + extra_content: None, + }]), + ..Default::default() + }, + OpenAIMessage { + role: "tool".into(), + tool_call_id: Some(id.into()), + content: Some(OpenAIContent::Text(content.into())), + agent_action: Some(windmill_common::flow_status::AgentAction::McpToolCall { + call_id: uuid::Uuid::from_u128(id.bytes().map(u128::from).sum()), + function_name: "lookup".into(), + arguments: None, + resource_path: "u/admin/mcp".into(), + }), + ..Default::default() + }, + ] + } + + #[test] + fn compaction_preserves_the_execution_record_and_invalidates_usage() { + let mut history = AgentHistory::new(vec![message("user", "do the task")]); + history.extend(tool_round("first", &"old result ".repeat(2000))); + history.extend(tool_round("second", "new result")); + history.record_usage(Some(9000), history.context().len()); + let compactor = Compactor::new(10000, 0, None); + let split = compactor + .plan(&history, &CompactionTrigger::Threshold) + .unwrap(); + assert_eq!(split, 3); + let before = serde_json::to_value( + history + .result() + .iter() + .map(|m| windmill_ai::types::Message { + message: m, + agent_action: m.agent_action.as_ref(), + }) + .collect::>(), + ) + .unwrap(); + + history.replace_prefix(split, build_summary_message("The first lookup completed.")); + + let after = serde_json::to_value( + history + .result() + .iter() + .map(|m| windmill_ai::types::Message { + message: m, + agent_action: m.agent_action.as_ref(), + }) + .collect::>(), + ) + .unwrap(); + assert_eq!(before, after); + assert_eq!(after[2]["tool_call_id"], "first"); + assert_eq!(after[4]["tool_call_id"], "second"); + assert_ne!( + after[2]["agent_action"]["call_id"], + after[4]["agent_action"]["call_id"] + ); + assert!(history.last_request.is_none()); + assert!(history.projected_tokens(0) < 1000); + assert_eq!(history.context().len(), 3); + } + + #[test] + fn exchange_boundaries_keep_parallel_calls_and_results_together() { + let mut messages = vec![message("user", "lookup")]; + let mut first = tool_round("one", "result one"); + let mut second = tool_round("two", "result two"); + first[0] + .tool_calls + .as_mut() + .unwrap() + .extend(second[0].tool_calls.take().unwrap()); + messages.extend(first); + messages.push(second.pop().unwrap()); + messages.extend(tool_round("three", "result three")); + assert_eq!(exchange_starts(&messages), vec![0, 4]); + messages.truncate(3); + assert_eq!(exchange_starts(&messages), vec![0]); + } + + #[test] + fn storage_size_excludes_system_messages_and_does_not_change_model_budget() { + let history = AgentHistory::new(vec![ + message("system", &"s".repeat(100000)), + message("user", "hi"), + ]); + assert!(persisted_bytes(history.context()) < 1000); + let compactor = Compactor::new(128000, 50000, None); + assert!(!compactor.needs_compaction(&history)); + } + + #[test] + fn storage_fallback_keeps_the_largest_complete_suffix_without_changing_history() { + let mut history = AgentHistory::new(vec![ + message("system", &"s".repeat(100000)), + message("user", &"old ".repeat(30000)), + ]); + history.extend(tool_round("old", "old result")); + history.push(message("user", "recent question")); + history.extend(tool_round("recent", "recent result")); + history.push(message("assistant", "final answer")); + let before = serde_json::to_value(history.context()).unwrap(); + let expected = &history.context()[4..]; + let limit = persisted_bytes(expected); + let saved = memory_within_capacity(history.context(), limit).unwrap(); + assert_eq!( + serde_json::to_value(saved).unwrap(), + serde_json::to_value(expected).unwrap() + ); + assert_eq!(saved[1].tool_calls.as_ref().unwrap()[0].id, "recent"); + assert_eq!(saved[2].tool_call_id.as_deref(), Some("recent")); + assert_eq!(serde_json::to_value(history.context()).unwrap(), before); + assert_eq!(serde_json::to_value(history.result()).unwrap(), before); + assert_eq!( + memory_within_capacity(history.context(), usize::MAX) + .unwrap() + .len(), + history.context().len() + ); + } + + #[test] + fn storage_fallback_does_not_split_parallel_results_or_an_oversized_newest_exchange() { + let mut messages = vec![ + message("user", "old"), + message("assistant", "answer"), + message("user", "new"), + ]; + let mut round = tool_round("one", &"x".repeat(100000)); + let second = tool_round("two", "small result"); + round[0] + .tool_calls + .as_mut() + .unwrap() + .extend(second[0].tool_calls.clone().unwrap()); + messages.extend(round); + messages.push(second[1].clone()); + assert!(memory_within_capacity(&messages, 100000).is_none()); + messages.extend(tool_round("three", "small result")); + messages.push(message("assistant", "final answer")); + assert!(memory_within_capacity(&messages, 100000).is_none()); + let messages = vec![ + message("user", "new"), + message("assistant", &"x".repeat(100000)), + ]; + assert!(memory_within_capacity(&messages, 100000).is_none()); + } + + #[test] + fn planning_keeps_complete_recent_exchanges_within_the_tail_budget() { + let mut history = AgentHistory::new(vec![message("user", "task")]); + for id in ["one", "two", "three", "four", "five"] { + history.extend(tool_round(id, &"x".repeat(8000))); + } + let compactor = Compactor::new(20000, 100, None); + history.record_usage(Some(16500), history.context().len()); + let split = compactor + .plan(&history, &CompactionTrigger::Threshold) + .unwrap(); + assert!(exchange_starts(history.context()).contains(&split)); + assert!(estimate_tokens(&history.context()[split..]) <= 8000); + assert!(split <= history.context().len() - 2); + } + + #[test] + fn storage_compaction_keeps_the_latest_turn_verbatim() { + let mut history = AgentHistory::new(vec![message("system", "prompt")]); + for turn in ["one", "two", "three", "four", "latest"] { + history.push(message("user", &format!("question {turn}"))); + history.extend(tool_round(turn, &"x".repeat(30000))); + history.push(message("assistant", "answer")); + } + let compactor = Compactor::new(1_000_000, 0, None); + let trigger = CompactionTrigger::Storage { capacity_bytes: 100_000 }; + let split = compactor.plan(&history, &trigger).unwrap(); + let tail = &history.context()[split..]; + assert!(persisted_bytes(tail) <= 50_000); + assert!(tail.iter().any(|m| matches!( + &m.content, + Some(OpenAIContent::Text(text)) if text == "question latest" + ))); + } + + #[test] + fn a_single_oversized_exchange_is_not_split() { + let mut history = AgentHistory::new(vec![message("user", "task")]); + history.extend(tool_round("one", &"large ".repeat(10000))); + let compactor = Compactor::new(10000, 0, None); + assert!(compactor.needs_compaction(&history)); + assert!(compactor + .plan(&history, &CompactionTrigger::Threshold) + .is_none()); + } + #[test] + fn summarization_instructions_are_separate_from_the_transcript_and_media() { + let prefix = vec![ + message("system", "Answer the user's report request."), + message("user", "Fetch the report, then reply only REPORT RECEIVED."), + OpenAIMessage { + role: "user".into(), + content: Some(OpenAIContent::Parts(vec![ContentPart::S3Object { + s3_object: windmill_types::s3::S3Object { + s3: "report.pdf".into(), + ..Default::default() + }, + }])), + ..Default::default() + }, + ]; + let request = summary_messages(&prefix); + assert_eq!(request.len(), 2); + assert_eq!(request[0].role, "system"); + assert!( + matches!(&request[0].content, Some(OpenAIContent::Text(text)) if text == SUMMARY_PROMPT) + ); + assert_eq!(request[1].role, "user"); + let Some(OpenAIContent::Parts(parts)) = &request[1].content else { + panic!("expected a labelled transcript with media"); + }; + assert!(parts.iter().any( + |p| matches!(p, ContentPart::S3Object { s3_object } if s3_object.s3 == "report.pdf") + )); + let transcript = serde_json::to_string(parts).unwrap(); + assert!(transcript.contains("REPORT RECEIVED")); + assert!(!transcript.contains(SUMMARY_PROMPT)); + assert!(request.iter().all(|message| message.tool_calls.is_none())); + } +} diff --git a/backend/windmill-worker/src/ai/mod.rs b/backend/windmill-worker/src/ai/mod.rs index ad0986bbea..6b99c12b50 100644 --- a/backend/windmill-worker/src/ai/mod.rs +++ b/backend/windmill-worker/src/ai/mod.rs @@ -1,6 +1,7 @@ // AI executor module structure // This module will contain all AI-related execution logic +pub mod compaction; pub mod stream_event_processor; pub mod tools; pub mod utils; diff --git a/backend/windmill-worker/src/ai/tools.rs b/backend/windmill-worker/src/ai/tools.rs index 682714292e..930a329be4 100644 --- a/backend/windmill-worker/src/ai/tools.rs +++ b/backend/windmill-worker/src/ai/tools.rs @@ -7,16 +7,15 @@ use crate::ai::utils::{ use crate::common::OccupancyMetrics; use crate::result_processor::handle_non_flow_job_error; use crate::worker_flow::{ - evaluate_input_transform, raw_script_to_payload, script_to_payload, JobPayloadWithTag, -}; -use crate::{ - create_job_dir, handle_queued_job, JobCompletedReceiver, JobCompletedSender, SendResult, - SendResultPayload, + evaluate_input_transform, raw_script_to_payload, resolve_flow_step_tag, script_to_payload, + JobPayloadWithTag, }; +use crate::{create_job_dir, handle_queued_job, JobCompletedSender}; use anyhow::Context; use mappable_rc::Marc; use serde_json::value::RawValue; -use std::{collections::HashMap, sync::Arc}; +use sqlx::types::Json; +use std::{collections::HashMap, sync::Arc, time::Duration}; use uuid::Uuid; use windmill_ai::{ai_types::OpenAIToolCall, query_builder::StreamEventSink, types::*}; use windmill_common::jobs::JobPayload; @@ -36,11 +35,12 @@ use windmill_common::{ flow_conversations::{MessageExtras, MessageType}, flow_status::AgentAction, flows::FlowModuleValue, - worker::{to_raw_value, Connection}, + worker::{make_tool_job_pull_query, to_raw_value, Connection, WORKER_CONFIG}, }; use windmill_queue::{ - add_completed_job, add_completed_job_error, check_tag_available_for_push, get_mini_pulled_job, - push, resolve_push_tag, MiniCompletedJob, MiniPulledJob, PushArgs, PushIsolationLevel, + append_logs, check_tag_available_for_push, get_mini_pulled_job, pull, push, resolve_push_tag, + tag_reads_flow_expr, try_admit_owned_job, MiniCompletedJob, MiniPulledJob, PushArgs, + PushIsolationLevel, }; /// Shared collection of abort handles for spawned tool tasks. @@ -74,6 +74,8 @@ pub struct ToolExecutionContext<'a> { pub stream_event_processor: Option<&'a StreamEventProcessor>, pub flow_context: &'a mut FlowContext, pub omit_output_from_conversation: bool, + /// The agent's flow `preserve_step_tags`: its tools pick their tag as that flow's steps do. + pub preserve_step_tags: bool, /// The thinking that led to this round's calls, stored on the first tool row written. /// None when the round wrote text, whose row carries it. pub reasoning: Option, @@ -82,6 +84,7 @@ pub struct ToolExecutionContext<'a> { // Abort handles for spawned tool tasks (used for force-cancel cleanup) pub tool_abort_handles: ToolAbortHandles, + pub job_completed_tx: JobCompletedSender, } /// Execute all tool calls from an AI response @@ -98,7 +101,8 @@ pub async fn execute_tool_calls( let mut used_structured_output_tool = false; let mut final_content = None; - for tool_call in tool_calls.iter() { + let mut calls = tool_calls.iter().peekable(); + while let Some(tool_call) = calls.next() { // Stream tool call progress if let Some(stream_event_processor) = ctx.stream_event_processor { let event = StreamingEvent::ToolExecution { @@ -150,15 +154,24 @@ pub async fn execute_tool_calls( ) .await?; } else if tool.module.is_some() { - execute_windmill_tool( - &mut ctx, - tool_call, - tool, - actions, - &mut messages, - final_events_str, - ) - .await?; + let mut batch = vec![(tool_call, tool)]; + while let Some(next_call) = calls.peek() { + if structured_output_tool_name.as_deref() + == Some(next_call.function.name.as_str()) + { + break; + } + let Some(next_tool) = tools.iter().find(|t| { + t.def.function.name == next_call.function.name + && t.mcp_source.is_none() + && t.module.is_some() + }) else { + break; + }; + batch.push((calls.next().unwrap(), next_tool)); + } + execute_windmill_tools(&mut ctx, &batch, actions, &mut messages, final_events_str) + .await?; } else { return Err(Error::internal_err(format!( "Tool type not supported: {}", @@ -313,14 +326,13 @@ async fn execute_mcp_tool_call( } /// Execute a Windmill tool (script or flow) -async fn execute_windmill_tool( - ctx: &mut ToolExecutionContext<'_>, +async fn enqueue_windmill_tool( + ctx: &ToolExecutionContext<'_>, tool_call: &OpenAIToolCall, tool: &Tool, actions: &mut Vec, - messages: &mut Vec, - final_events_str: &mut String, -) -> Result<(), Error> { + reserved: bool, +) -> Result<(Uuid, bool), Error> { // Regular Windmill tools must have a module let tool_module = tool.module.as_ref().ok_or_else(|| { Error::internal_err(format!("Tool {} has no module", tool_call.function.name)) @@ -402,8 +414,6 @@ async fn execute_windmill_tool( tool_call_args.insert(key.clone(), result); } - let is_ai_agent_tool = matches!(tool_value, FlowModuleValue::AIAgent { .. }); - let job_payload = match tool_value { FlowModuleValue::Script { path: script_path, hash: script_hash, tag_override, .. } => { script_to_payload( @@ -473,7 +483,6 @@ async fn execute_windmill_tool( )); } let path = format!("{}/tools/{}", ctx.job.runnable_path(), tool_module.id); - // a nested agent always runs inline on this worker, under this agent's tag JobPayloadWithTag { payload: JobPayload::AIAgent { path }, tag: None, @@ -502,18 +511,34 @@ async fn execute_windmill_tool( let push_args = PushArgs { args: &tool_call_args, extra: None }; - // A tool with a tag of its own naming another queue than this agent's must run on a worker - // that tag selects, and like any tag the caller chose it must pass the workspace's custom tag - // restrictions. It runs inline when this worker serves that queue: the agent holds this - // worker while it waits, so dispatching to a queue it serves could wait on itself. - let mut resolved_tool_tag = None; - if let Some(tag) = job_payload.tag.as_deref() { - resolved_tool_tag = resolve_push_tag(tag, &push_args, &ctx.job.workspace_id, ctx.db) - .await - .filter(|resolved| *resolved != ctx.job.tag); + // The agent's tag stands in for the flow's, as a sub-flow's does for its own steps. + let tool_tag = resolve_flow_step_tag( + false, + &ctx.job.tag, + &ctx.job.workspace_id, + ctx.preserve_step_tags, + job_payload.tag.as_deref(), + ); + if tool_tag.as_deref().is_some_and(tag_reads_flow_expr) { + append_logs( + &ctx.job.id, + &ctx.job.workspace_id, + format!( + "Tool '{}' is tagged with `$flow_expr[...]`, which AI agent tools do not resolve, so it runs on its default tag.\n", + tool_call.function.name + ), + ctx.conn, + ) + .await; } - let tool_tag = match (job_payload.tag.as_deref(), resolved_tool_tag.as_ref()) { - (Some(tag), Some(_)) => { + + // A tool's own tag routes its job, so like any tag the caller chose it must pass the + // workspace's custom tag restrictions. The agent's tag was already checked when it was pushed. + if let Some(tag) = tool_tag.as_deref() { + if resolve_push_tag(tag, &push_args, &ctx.job.workspace_id, ctx.db) + .await + .is_some_and(|resolved| resolved != ctx.job.tag) + { let is_super_admin = windmill_common::auth::is_super_admin_email(ctx.db, email).await?; check_tag_available_for_push( ctx.db, @@ -531,16 +556,8 @@ async fn execute_windmill_tool( )), e => e, })?; - Some(tag.to_string()) } - _ => None, - }; - let run_inline = resolved_tool_tag.is_none_or(|resolved| { - windmill_common::worker::WORKER_CONFIG - .load() - .worker_tags - .contains(&resolved) - }); + } let mut tx = ctx.db.begin().await?; @@ -573,77 +590,71 @@ async fn execute_windmill_tool( false, None, ctx.job.visible_to_owner, - Some(tool_tag.unwrap_or_else(|| ctx.job.tag.clone())), + tool_tag, job_payload.timeout, None, job_priority, job_perms.as_ref(), - run_inline, + false, None, None, None, ) .await?; - tx.commit().await?; - - if !run_inline { - // Like an inline tool's failure, a lost wait reaches the model rather than failing the - // agent. - let (result, success) = - match wait_for_dispatched_tool_job(ctx.db, &uuid, &ctx.job.workspace_id).await { - Ok(outcome) => outcome, - Err(e) => ( - to_raw_value(&serde_json::json!({ "error": { "message": e.to_string() } })), - false, - ), - }; - return report_tool_result( - ctx, - tool_call, - tool_module, - job_id, - &result, - success, - is_ai_agent_tool && success, - messages, - final_events_str, + let mut tx = tx; + let reserved = if reserved { + let tags = local_tool_tags(); + // Reserve a supported first tool before commit so other workers cannot race it. + let claimed = sqlx::query!( + "UPDATE v2_job_queue SET worker = $1, running = true, started_at = now() + WHERE id = $2 AND tag = ANY($3)", + ctx.worker_name, + uuid, + &tags, ) - .await; - } - - let tool_job = get_mini_pulled_job(ctx.db, &uuid).await?; - - let Some(tool_job) = tool_job else { - return Err(Error::internal_err("Tool job not found".to_string())); + .execute(&mut *tx) + .await? + .rows_affected() + == 1; + if claimed { + sqlx::query!("UPDATE v2_job_runtime SET ping = now() WHERE id = $1", uuid) + .execute(&mut *tx) + .await?; + } + claimed + } else { + false }; + tx.commit().await?; + Ok((uuid, reserved)) +} - // The tool gets a token of its own, as it would on any worker: the agent's token would - // identify the tool as the agent job (its OIDC path and job id). - let tool_client = AuthedClient::new( - ctx.base_internal_url.to_string(), - tool_job.workspace_id.clone(), - windmill_queue::create_token(ctx.db, &tool_job, None).await, - None, - ); - let tool_job = Arc::new(tool_job); +fn local_tool_tags() -> Vec { + WORKER_CONFIG + .load() + .priority_tags_sorted + .iter() + .flat_map(|group| group.tags.iter().cloned()) + .collect() +} - let (inner_job_completed_tx, inner_job_completed_rx) = JobCompletedSender::new(ctx.conn, 1); - - let inner_job_completed_rx = inner_job_completed_rx.expect( - "inner_job_completed_tx should be set as agent jobs are not supported on agent workers", - ); +fn spawn_local_tool( + ctx: &ToolExecutionContext<'_>, + tool_job: MiniPulledJob, + reserved: bool, +) -> tokio::task::JoinHandle> { + let mut tool_job = Arc::new(tool_job); // Spawn handle_queued_job on separate task to prevent tokio stack overflow // Clone everything needed for the spawned task - let tool_job_spawn = tool_job.clone(); + let db = ctx.db.clone(); let conn_spawn = ctx.conn.clone(); - let client_spawn = tool_client; let hostname_spawn = ctx.hostname.to_string(); let worker_name_spawn = ctx.worker_name.to_string(); let worker_dir_spawn = ctx.worker_dir.to_string(); let base_internal_url_spawn = ctx.base_internal_url.to_string(); - let inner_job_completed_tx_spawn = inner_job_completed_tx.clone(); + let job_completed_tx = ctx.job_completed_tx.clone(); let mut occupancy_metrics_spawn = ctx.occupancy_metrics.clone(); let mut killpill_rx_spawn = ctx.killpill_rx.resubscribe(); @@ -652,147 +663,270 @@ async fn execute_windmill_tool( #[cfg(feature = "benchmark")] let mut bench_spawn = windmill_common::bench::BenchmarkIter::new(); - let job_dir = create_job_dir(&worker_dir_spawn, tool_job_spawn.id).await; + let result = async { + if reserved { + loop { + let queued = get_mini_pulled_job(&db, &tool_job.id) + .await? + .ok_or_else(|| Error::AlreadyCompleted("Tool job already completed".to_string()))?; + tool_job = Arc::new(queued); + sqlx::query!( + "UPDATE v2_job_runtime SET ping = now() WHERE id = $1", + tool_job.id, + ) + .execute(&db) + .await?; + if try_admit_owned_job(&db, &tool_job).await? { + let started_at = sqlx::query_scalar!( + "UPDATE v2_job_queue SET started_at = now() WHERE id = $1 RETURNING started_at", + tool_job.id, + ) + .fetch_optional(&db) + .await? + .flatten(); + Arc::make_mut(&mut tool_job).started_at = started_at; + break; + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + let perms = + windmill_common::auth::get_job_perms(&db, &tool_job.id, &tool_job.workspace_id).await?; + let token = windmill_queue::create_token(&db, &tool_job, perms).await; + let client_spawn = AuthedClient::new( + base_internal_url_spawn.clone(), + tool_job.workspace_id.clone(), + token, + None, + ); + let job_dir = create_job_dir(&worker_dir_spawn, tool_job.id).await; - let result = handle_queued_job( - tool_job_spawn, - None, - None, - None, - None, - &conn_spawn, - &client_spawn, - &hostname_spawn, - &worker_name_spawn, - &worker_dir_spawn, - &job_dir, - None, - &base_internal_url_spawn, - inner_job_completed_tx_spawn, - &mut occupancy_metrics_spawn, - &mut killpill_rx_spawn, - None, - None, - #[cfg(feature = "benchmark")] - &mut bench_spawn, - ) + handle_queued_job( + tool_job.clone(), + None, + None, + None, + None, + &conn_spawn, + &client_spawn, + &hostname_spawn, + &worker_name_spawn, + &worker_dir_spawn, + &job_dir, + None, + &base_internal_url_spawn, + job_completed_tx, + &mut occupancy_metrics_spawn, + &mut killpill_rx_spawn, + None, + None, + #[cfg(feature = "benchmark")] + &mut bench_spawn, + ) + .await + } .await; - // Return both result and updated metrics - (result, occupancy_metrics_spawn) + match result { + Err(err) => { + let err_string = format!("{}: {}", err.name(), err); + handle_non_flow_job_error( + &db, + &MiniCompletedJob::from(tool_job), + 0, + None, + err_string, + windmill_common::worker::error_to_value(&err), + &worker_name_spawn, + ) + .await?; + } + Ok(_) => {} + } + Ok(occupancy_metrics_spawn) }); // Register abort handle so the task can be killed on force-cancel let abort_handle = join_handle.abort_handle(); // unwrap safe: lock is only held briefly for push/drain, no panic possible inside ctx.tool_abort_handles.lock().unwrap().push(abort_handle); - - // Await the spawned task - let (handle_result, updated_occupancy) = join_handle.await.map_err(|e| { - if e.is_cancelled() { - Error::ExecutionErr("Tool execution task was cancelled".to_string()) - } else { - Error::internal_err(format!("Tool execution task failed: {}", e)) - } - })?; - - // Merge occupancy metrics back - ctx.occupancy_metrics.total_duration_of_running_jobs = - updated_occupancy.total_duration_of_running_jobs; - - match handle_result { - Err(err) => { - handle_tool_execution_error( - ctx, - tool_call, - tool_module, - &MiniCompletedJob::from(tool_job), - job_id, - err, - messages, - final_events_str, - ) - .await?; - } - Ok(outcome) => { - handle_tool_execution_success( - ctx, - tool_call, - tool_module, - job_id, - outcome.is_success(), - is_ai_agent_tool, - inner_job_completed_rx, - messages, - final_events_str, - ) - .await?; - } - } - - Ok(()) + join_handle } -/// Handle tool execution error -async fn handle_tool_execution_error( +async fn execute_windmill_tools( ctx: &mut ToolExecutionContext<'_>, - tool_call: &OpenAIToolCall, - tool_module: &windmill_common::flows::FlowModule, - tool_job: &MiniCompletedJob, - job_id: Uuid, - err: Error, + batch: &[(&OpenAIToolCall, &Tool)], + actions: &mut Vec, messages: &mut Vec, final_events_str: &mut String, ) -> Result<(), Error> { - let err_string = format!("{}: {}", err.name(), err.to_string()); - let err_json = windmill_common::worker::error_to_value(&err); - let _ = handle_non_flow_job_error( - ctx.db, - tool_job, - 0, - None, - err_string.clone(), - err_json, - ctx.worker_name, - ) - .await; - - let error_message = format!("Error running tool: {}", err_string); - messages.push(OpenAIMessage { - role: "tool".to_string(), - content: Some(OpenAIContent::Text(error_message.clone())), - tool_call_id: Some(tool_call.id.clone()), - agent_action: Some(AgentAction::ToolCall { - job_id, - function_name: tool_call.function.name.clone(), - module_id: tool_module.id.clone(), - }), - ..Default::default() - }); - - // Stream tool result (error case) - if let Some(stream_event_processor) = ctx.stream_event_processor { - let tool_result_event = StreamingEvent::ToolResult { - call_id: tool_call.id.clone(), - function_name: tool_call.function.name.clone(), - result: error_message.clone(), - success: false, - }; - stream_event_processor - .send(tool_result_event, final_events_str) - .await?; + let mut job_ids = Vec::with_capacity(batch.len()); + let mut local = None; + for (index, (call, tool)) in batch.iter().enumerate() { + if index > 0 { + if let Some(processor) = ctx.stream_event_processor { + processor + .send( + StreamingEvent::ToolExecution { + call_id: call.id.clone(), + function_name: call.function.name.clone(), + }, + final_events_str, + ) + .await?; + } + } + let (id, reserved) = enqueue_windmill_tool(ctx, call, tool, actions, index == 0).await?; + job_ids.push(id); + if reserved { + let job = get_mini_pulled_job(ctx.db, &id) + .await? + .ok_or_else(|| Error::internal_err("Reserved tool job not found".to_string()))?; + local = Some(spawn_local_tool(ctx, job, true)); + } } - if let Some(parent_job) = ctx.parent_job { - update_flow_status_module_with_actions_success(ctx.db, parent_job, false).await?; + let mut results: HashMap = HashMap::new(); + let mut next_result = 0; + let mut poll = tokio::time::interval(Duration::from_millis(100)); + poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + while next_result < batch.len() || local.is_some() { + tokio::select! { + result = async { local.as_mut().unwrap().await }, if local.is_some() => { + local = None; + let metrics = result.map_err(|e| Error::internal_err(format!("Tool task failed: {e}")))??; + ctx.occupancy_metrics.total_duration_of_running_jobs = metrics.total_duration_of_running_jobs; + ctx.tool_abort_handles.lock().unwrap().retain(|handle| !handle.is_finished()); + } + _ = poll.tick() => {} + } + + let pending: Vec = job_ids[next_result..] + .iter() + .filter(|id| !results.contains_key(id)) + .copied() + .collect(); + if !pending.is_empty() { + let completed = sqlx::query!( + "SELECT id, status = 'success' AS \"success!\", result AS \"result: Json>\" + FROM v2_job_completed WHERE workspace_id = $1 AND id = ANY($2)", + ctx.job.workspace_id, + &pending, + ).fetch_all(ctx.db).await?; + for completed in completed { + let index = job_ids + .iter() + .position(|id| *id == completed.id) + .ok_or_else(|| Error::internal_err("Unexpected tool completion".to_string()))?; + let (call, tool) = batch[index]; + let result = completed + .result + .map(|value| value.0) + .unwrap_or_else(|| to_raw_value(&serde_json::Value::Null)); + let is_agent = tool.module.as_ref().is_some_and(|module| { + matches!(module.get_value(), Ok(FlowModuleValue::AIAgent { .. })) + }); + let content = if is_agent && completed.success { + extract_ai_agent_output(&result).unwrap_or_else(|| result.get().to_string()) + } else { + result.get().to_string() + }; + if let Some(processor) = ctx.stream_event_processor { + processor + .send( + StreamingEvent::ToolResult { + call_id: call.id.clone(), + function_name: call.function.name.clone(), + result: content.clone(), + success: completed.success, + }, + final_events_str, + ) + .await?; + } + results.insert(completed.id, (completed.success, content)); + } + } + + // Transcript rows, model messages, and positional action statuses share call order. + while next_result < batch.len() { + let job_id = job_ids[next_result]; + let Some((success, content)) = results.remove(&job_id) else { + break; + }; + let (call, tool) = batch[next_result]; + let module = tool + .module + .as_ref() + .ok_or_else(|| Error::internal_err("Windmill tool has no module".to_string()))?; + messages.push(OpenAIMessage { + role: "tool".to_string(), + content: Some(OpenAIContent::Text(content.clone())), + tool_call_id: Some(call.id.clone()), + agent_action: Some(AgentAction::ToolCall { + job_id, + function_name: call.function.name.clone(), + module_id: module.id.clone(), + }), + ..Default::default() + }); + if let Some(parent) = ctx.parent_job { + update_flow_status_module_with_actions_success(ctx.db, parent, success).await?; + } + let (content, extras) = windmill_tool_row(call, success, &content); + add_tool_message_to_chat(ctx, Some(job_id), &content, success, Some(extras)).await; + next_result += 1; + } + + if local.is_none() && next_result < batch.len() { + let pending: Vec = job_ids[next_result..] + .iter() + .filter(|id| !results.contains_key(id)) + .copied() + .collect(); + if !pending.is_empty() { + local = claim_local_tool(ctx, &pending).await?; + } + } } - - let (content, extras) = windmill_tool_row(tool_call, false, &error_message); - add_tool_message_to_chat(ctx, Some(job_id), &content, false, Some(extras)).await; - Ok(()) } +async fn claim_local_tool( + ctx: &ToolExecutionContext<'_>, + pending: &[Uuid], +) -> Result>>, Error> { + let query = ( + String::new(), + make_tool_job_pull_query(pending, &local_tool_tags()), + ); + #[cfg(feature = "benchmark")] + let mut bench = windmill_common::bench::BenchmarkIter::new(); + let mut pulled = pull( + ctx.db, + false, + ctx.worker_name, + Some(&query), + #[cfg(feature = "benchmark")] + &mut bench, + ) + .await?; + if let Err(err) = pulled.maybe_apply_debouncing(ctx.db).await { + pulled.error_while_preprocessing = Some(err.to_string()); + } + match pulled.to_pulled_job() { + Ok(job) => Ok(job.map(|job| spawn_local_tool(ctx, job.job, false))), + Err( + windmill_queue::PulledJobResultToJobErr::MissingConcurrencyKey(job) + | windmill_queue::PulledJobResultToJobErr::ErrorWhilePreprocessing(job), + ) => { + ctx.job_completed_tx.send_job(job, true).await?; + Ok(None) + } + } +} + /// Extract the `output` field of an `AIAgentResult` envelope, serialized back to JSON. /// Returns `None` if the result is not a JSON object carrying an `output` field. fn extract_ai_agent_output(result: &RawValue) -> Option { @@ -802,200 +936,6 @@ fn extract_ai_agent_output(result: &RawValue) -> Option { .map(|output| output.get().to_string()) } -/// Handle tool execution success -async fn handle_tool_execution_success( - ctx: &mut ToolExecutionContext<'_>, - tool_call: &OpenAIToolCall, - tool_module: &windmill_common::flows::FlowModule, - job_id: Uuid, - success: bool, - is_ai_agent_tool: bool, - inner_job_completed_rx: JobCompletedReceiver, - messages: &mut Vec, - final_events_str: &mut String, -) -> Result<(), Error> { - let send_result = inner_job_completed_rx.bounded_rx.try_recv().ok(); - - let (result, job_success) = if let Some(SendResult { - result: SendResultPayload::JobCompleted(ref jc), - .. - }) = send_result - { - let result = jc.result.clone(); - // Write tool completion to the DB inline instead of forwarding through - // the parent channel. Forwarding would deadlock for nested agents: the - // sub-tool result would fill the parent's bounded(1) channel, leaving - // no room for the agent's own completion from process_result. - if jc.success { - add_completed_job( - ctx.db, - &jc.job, - true, - false, - sqlx::types::Json(&*jc.result), - jc.result_columns.clone(), - jc.mem_peak, - jc.canceled_by.clone(), - false, - jc.duration, - jc.from_cache.unwrap_or(false), - ) - .await - .map_err(|e| Error::internal_err(format!("Failed to add completed job: {e}")))?; - } else { - let error_value: serde_json::Value = - serde_json::from_str(jc.result.get()).unwrap_or_else(|_| { - serde_json::json!({ "message": format!("Non serializable error: {}", jc.result.get()) }) - }); - add_completed_job_error( - ctx.db, - &jc.job, - jc.mem_peak, - jc.canceled_by.clone(), - error_value, - ctx.worker_name, - false, - jc.duration, - ) - .await - .map_err(|e| Error::internal_err(format!("Failed to add completed job error: {e}")))?; - } - (result, jc.success) - } else { - return Err(Error::internal_err( - "Tool job completed but no result".to_string(), - )); - }; - - report_tool_result( - ctx, - tool_call, - tool_module, - job_id, - &result, - success, - is_ai_agent_tool && job_success, - messages, - final_events_str, - ) - .await -} - -/// Wait for a tool job another worker runs; returns its result and whether it succeeded. -/// Canceling the agent cancels this job along with it, as a child of the agent's job. -async fn wait_for_dispatched_tool_job( - db: &DB, - id: &Uuid, - w_id: &str, -) -> Result<(Box, bool), Error> { - const MAX_CONSECUTIVE_POLL_ERRORS: u32 = 10; - let mut interval = std::time::Duration::from_millis(50); - let mut poll_errors = 0; - loop { - // One statement reads both tables from one snapshot, so a job completing between two - // reads cannot look like one that vanished. - let state = match sqlx::query!( - "SELECT c.id IS NOT NULL AS \"completed!\", - c.result AS \"result: sqlx::types::Json>\", - c.status = 'success' AS \"success\", - EXISTS(SELECT 1 FROM v2_job_queue WHERE id = $1) AS \"queued!\" - FROM (SELECT 1) one - LEFT JOIN v2_job_completed c ON c.id = $1 AND c.workspace_id = $2", - id, - w_id - ) - .fetch_one(db) - .await - { - Ok(state) => { - poll_errors = 0; - state - } - Err(e) if poll_errors < MAX_CONSECUTIVE_POLL_ERRORS => { - poll_errors += 1; - tracing::warn!("polling tool job {id} failed, retrying: {e}"); - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - continue; - } - Err(e) => return Err(e.into()), - }; - if state.completed { - let result = state - .result - .map(|r| r.0) - .unwrap_or_else(|| to_raw_value(&serde_json::Value::Null)); - return Ok((result, state.success.unwrap_or(false))); - } - if !state.queued { - return Err(Error::internal_err(format!( - "tool job {id} is neither queued nor completed" - ))); - } - tokio::time::sleep(interval).await; - interval = std::cmp::min(interval * 2, std::time::Duration::from_secs(1)); - } -} - -/// Hand a finished tool job's result to the model, the stream, the flow status and the chat. -async fn report_tool_result( - ctx: &mut ToolExecutionContext<'_>, - tool_call: &OpenAIToolCall, - tool_module: &windmill_common::flows::FlowModule, - job_id: Uuid, - result: &RawValue, - success: bool, - is_ai_agent_output: bool, - messages: &mut Vec, - final_events_str: &mut String, -) -> Result<(), Error> { - // A nested agent returns the whole `AIAgentResult` envelope: on top of `output` it carries - // the child's entire message history, stream log and token usage. Feeding that back would - // grow the caller's context by the child's full transcript on every call, so the caller only - // sees `output`. The envelope stays intact in the tool job's completed row. - let tool_result = if is_ai_agent_output { - extract_ai_agent_output(result).unwrap_or_else(|| result.get().to_string()) - } else { - result.get().to_string() - }; - - messages.push(OpenAIMessage { - role: "tool".to_string(), - content: Some(OpenAIContent::Text(tool_result.clone())), - tool_call_id: Some(tool_call.id.clone()), - agent_action: Some(AgentAction::ToolCall { - job_id, - function_name: tool_call.function.name.clone(), - module_id: tool_module.id.clone(), - }), - ..Default::default() - }); - - let (content, extras) = windmill_tool_row(tool_call, success, &tool_result); - - // The job ran; whether it ran successfully is `success`, and the row stored below is - // worded from it. The stream has to carry the same value, or the card the reader watches - // and the row that replaces it describe the same call differently. - if let Some(stream_event_processor) = ctx.stream_event_processor { - let tool_result_event = StreamingEvent::ToolResult { - call_id: tool_call.id.clone(), - function_name: tool_call.function.name.clone(), - result: tool_result, - success, - }; - stream_event_processor - .send(tool_result_event, final_events_str) - .await?; - } - - if let Some(parent_job) = ctx.parent_job { - update_flow_status_module_with_actions_success(ctx.db, parent_job, success).await?; - } - - add_tool_message_to_chat(ctx, Some(job_id), &content, success, Some(extras)).await; - - Ok(()) -} - /// A Windmill tool's conversation row: worded from the tool, carrying the model's call and /// the exact text the model got back, the same text agent memory keeps for that tool /// message, so a card needs no job fetch. The call is the model's arguments, not the job's @@ -1060,9 +1000,7 @@ async fn add_tool_message_to_chat( .or(ctx.job.flow_step_id.as_deref()); let step_name = get_step_name_from_flow(ctx.summary.as_deref(), effective_step_id); - // Awaited, not spawned: `created_seq` is the transcript's order, so a round's rows - // must commit in the order of its calls. Calls run one after another; running them - // in parallel would need their rows written in call order all the same. + // created_seq defines transcript order, so these writes must stay sequential. if let Err(e) = add_message_to_conversation( ctx.db, &memory_id, diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index 3d7fce4d07..958caeaefc 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -1,3 +1,7 @@ +use crate::ai::compaction::{ + memory_within_capacity, persisted_bytes, AgentHistory, CompactionRequest, CompactionTrigger, + Compactor, +}; use crate::ai::tools::{execute_tool_calls, ToolAbortHandles, ToolExecutionContext}; use crate::ai::utils::{ add_message_to_conversation, any_tool_needs_previous_result, cleanup_mcp_clients, @@ -6,7 +10,8 @@ use crate::ai::utils::{ parse_raw_script_schema, update_flow_status_module_with_actions, update_flow_status_module_with_actions_success, }; -use crate::memory_oss::{read_from_memory, write_to_memory}; +use crate::memory_common::MAX_MEMORY_SIZE_BYTES; +use crate::memory_oss::{memory_storage_capacity_bytes, read_from_memory, write_to_memory}; use crate::worker_flow::{get_previous_job_result, get_transform_context}; use async_recursion::async_recursion; use regex::Regex; @@ -24,6 +29,7 @@ use crate::ai::tools::McpClientStub as McpClient; use windmill_ai::{ ai_providers::AIProvider, image_handler::upload_image_to_s3, + model_context::model_context_window, providers::{ create_chat_completions_query_builder, create_query_builder, is_chat_completions_only, openai::{ @@ -79,12 +85,10 @@ lazy_static::lazy_static! { const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10; const HARD_MAX_AGENT_ITERATIONS: usize = 1000; -/// What a run stopped by `max_iterations` reports back. `Message` rather than -/// `OpenAIMessage` is load-bearing: `agent_action` is `skip_serializing` on the -/// latter and reaches JSON only through this wrapper, so serializing these raw -/// drops every tool name and job id and leaves the partial run unreadable. +/// Partial failures retain action metadata through `Message`: serializing raw +/// `OpenAIMessage` values would strip the tool names and IDs the viewer needs. #[derive(serde::Serialize)] -struct MaxIterPartialResult<'a> { +struct AgentPartialResult<'a> { messages: Vec>, } @@ -120,6 +124,63 @@ fn prepare_auto_memory_messages_for_persistence( non_system_messages[start_idx..].to_vec() } +/// Provider request settings must follow any endpoint fallback learned during the run. +struct CompactionContext<'a> { + compactor: Option<&'a mut Compactor>, + timeout: Option, + credentials: &'a windmill_ai::credentials::ProviderCredentials, + args: &'a AIAgentArgs, + client: &'a AuthedClient, + job: &'a MiniPulledJob, + conn: &'a Connection, +} + +/// Bills and logs successful checkpoints. Request and persistence policy stay with their callers. +async fn compact_if_needed( + ctx: CompactionContext<'_>, + query_builder: &dyn windmill_ai::query_builder::QueryBuilder, + include_usage: bool, + messages: &mut AgentHistory, + trigger: CompactionTrigger, + final_usage: &mut Option, +) -> bool { + let (Some(compactor), Some(timeout)) = (ctx.compactor, ctx.timeout) else { + return false; + }; + let pass = compactor + .compact( + messages, + trigger, + &CompactionRequest { + query_builder, + credentials: ctx.credentials, + model: ctx.args.provider.get_model(), + timeout, + client: ctx.client, + workspace_id: &ctx.job.workspace_id, + job_id: &ctx.job.id, + include_usage, + }, + ) + .await; + if let Some(usage) = pass.usage { + match final_usage { + Some(existing) => existing.accumulate(&usage), + None => *final_usage = Some(usage), + } + } + if pass.changed { + append_logs( + &ctx.job.id, + &ctx.job.workspace_id, + "AI agent memory summarized; the run's action results are preserved.\n".to_string(), + ctx.conn, + ) + .await; + } + pass.changed +} + /// The inputs a linked step supplies for itself; the resource holds the rest of the brain. const FLOW_LOCAL_AGENT_KEYS: [&str; 5] = [ "user_message", @@ -137,13 +198,34 @@ const STEP_HISTORY_KEYS: [&str; 2] = ["memory_id", "previous_messages"]; enum HistorySource<'a> { /// Supplied by the flow and replayed as is: memory is neither read nor written. Messages(&'a [OpenAIMessage]), - Window { + Managed { memory_id: Uuid, - context_length: usize, + bound: MemoryBound, }, Stateless, } +/// What keeps a managed conversation inside the model's context. +#[derive(Debug, Clone, Copy)] +enum MemoryBound { + /// Only the most recent messages are sent and kept. + LastMessages(usize), + /// Everything is sent and kept; compaction summarizes what no longer fits. + Compaction, +} + +impl MemoryBound { + /// How many of the memory's messages a run reads and writes back. Compaction keeps all of + /// them: the summary it writes is what bounds the conversation, so truncating on top of it + /// would drop the tail that summary was written to precede. + fn messages_to_keep(self) -> usize { + match self { + MemoryBound::LastMessages(context_length) => context_length, + MemoryBound::Compaction => usize::MAX, + } + } +} + /// A step's memory id counts only as the step authored it. A static empty value is a form /// placeholder, so it reads as unset rather than as an expression that evaluated to nothing, which /// runs without memory; an AI-filled value would let the model choose which memory the agent reads. @@ -169,7 +251,12 @@ fn resolve_history_source<'a>( flow_path: &str, ) -> (HistorySource<'a>, Vec<&'static str>) { let mut notes = Vec::new(); - let no_memory_id = "No memory id was passed to this run, so the agent runs without memory."; + let managed = |bound: MemoryBound, notes: &mut Vec<&'static str>| { + managed_memory_id(args, run_memory_id, workspace_id, flow_path, notes) + .map_or(HistorySource::Stateless, |memory_id| { + HistorySource::Managed { memory_id, bound } + }) + }; match &args.memory { // The step's own history inputs came after these, so a step that still holds one reads it // alone: what it did before the editor offered them is what it keeps doing. @@ -182,43 +269,25 @@ fn resolve_history_source<'a>( // An id baked in at save time only ever applied when the run carried none. match run_memory_id.or(*memory_id) { Some(memory_id) => ( - HistorySource::Window { memory_id, context_length: *context_length }, + HistorySource::Managed { + memory_id, + bound: MemoryBound::LastMessages(*context_length), + }, notes, ), None => { - notes.push(no_memory_id); + notes.push(NO_RUN_MEMORY_ID); (HistorySource::Stateless, notes) } } } Some(Memory::Window { context_length }) => { - if args - .previous_messages - .as_ref() - .is_some_and(|messages| !messages.is_empty()) - { - notes.push("Managed memory is on, so this step's previous messages are ignored."); - } - let memory_id = match args.memory_id.as_deref() { - Some("") => { - notes.push( - "This step's memory id evaluated to an empty value, so the agent runs without memory.", - ); - return (HistorySource::Stateless, notes); - } - Some(step_memory_id) => memory_key(workspace_id, flow_path, step_memory_id), - None => match run_memory_id { - Some(memory_id) => memory_id, - None => { - notes.push(no_memory_id); - return (HistorySource::Stateless, notes); - } - }, - }; - ( - HistorySource::Window { memory_id, context_length: *context_length }, - notes, - ) + let source = managed(MemoryBound::LastMessages(*context_length), &mut notes); + (source, notes) + } + Some(Memory::Compaction { .. }) => { + let source = managed(MemoryBound::Compaction, &mut notes); + (source, notes) } Some(Memory::Off) | None => { if args.memory_id.as_deref().is_some_and(|id| !id.is_empty()) { @@ -232,6 +301,40 @@ fn resolve_history_source<'a>( } } +const NO_RUN_MEMORY_ID: &str = + "No memory id was passed to this run, so the agent runs without memory."; + +/// The memory a step under a current setting reads and writes: its own id where it set one, +/// otherwise the run's. `None` is a step that ends up stateless, with the reason noted. +fn managed_memory_id( + args: &AIAgentArgs, + run_memory_id: Option, + workspace_id: &str, + flow_path: &str, + notes: &mut Vec<&'static str>, +) -> Option { + if args + .previous_messages + .as_ref() + .is_some_and(|messages| !messages.is_empty()) + { + notes.push("Managed memory is on, so this step's previous messages are ignored."); + } + match args.memory_id.as_deref() { + Some("") => { + notes.push( + "This step's memory id evaluated to an empty value, so the agent runs without memory.", + ); + None + } + Some(step_memory_id) => Some(memory_key(workspace_id, flow_path, step_memory_id)), + None => run_memory_id.or_else(|| { + notes.push(NO_RUN_MEMORY_ID); + None + }), + } +} + /// An older memory setting reads neither history input, which is only visible in the job log: the /// editor offers them on a step that has been moved to the current settings. fn note_unread_step_inputs(notes: &mut Vec<&'static str>, args: &AIAgentArgs) { @@ -504,6 +607,7 @@ pub async fn handle_ai_agent_job( hostname: &str, killpill_rx: &mut tokio::sync::broadcast::Receiver<()>, has_stream: &mut bool, + job_completed_tx: crate::JobCompletedSender, ) -> Result, Error> { // build_args_map returns None if no $res:/$var: transforms needed, in which case use original args let local_args = match build_args_map(job, client, conn).await? { @@ -583,6 +687,7 @@ pub async fn handle_ai_agent_job( }; let value = flow_data.value(); + let preserve_step_tags = value.preserve_step_tags; let module = if direct_parent_job_kind == JobKind::AIAgent { let parent_agent_step_id = direct_parent_job_flow_step_id.as_deref().ok_or_else(|| { @@ -1008,8 +1113,10 @@ pub async fn handle_ai_agent_job( has_stream, has_websearch, omit_output_from_conversation, + preserve_step_tags, cancel_rx, tool_abort_handles.clone(), + job_completed_tx, ); let mut occupancy_opt = Some(occupancy_metrics); @@ -1028,13 +1135,10 @@ pub async fn handle_ai_agent_job( cancel_tx, CANCEL_GRACE_PERIOD, ) - .await? + .await }; // agent_fut and update_job are now dropped — borrows on mcp_clients and canceled_by released - // Cleanup MCP clients - cleanup_mcp_clients(mcp_clients).await; - let format_cancel_info = |cb: &Option| { cb.as_ref() .map_or(("unknown".to_string(), "unknown".to_string()), |x| { @@ -1045,43 +1149,42 @@ pub async fn handle_ai_agent_job( }) }; - match outcome { - GracefulPollOutcome::Ok(result) => Ok(result), - GracefulPollOutcome::Timeout(ms) => { - tracing::error!("AI agent timeout after {}s", ms / 1000); - // A tool dispatched to another worker group would otherwise still run later, after - // the agent that wanted its result is gone. - let reason = format!("parent AI agent {} timed out", job.id); - let cb = CanceledBy { username: None, reason: Some(reason) }; - cleanup_orphaned_tool_jobs(db, &job.id, &job.workspace_id, Some(cb)).await; - Err(Error::ExecutionErr(format!( - "AI agent timeout after (>{}s)", - ms / 1000 - ))) - } - GracefulPollOutcome::Cancelled { canceled_by: cb } => { - let (by, reason) = format_cancel_info(&cb); - Err(Error::ExecutionErr(format!( - "Job cancelled by {by} (reason: {reason})" - ))) - } - GracefulPollOutcome::CancelledTimeout { canceled_by: cb } => { - let (by, reason) = format_cancel_info(&cb); - // Abort any still-running spawned tool tasks - // unwrap safe: lock is only held briefly for push/drain, no panic possible inside - for handle in tool_abort_handles.lock().unwrap().drain(..) { - handle.abort(); + let result = match outcome { + Err(error) => Err(error), + Ok(outcome) => match outcome { + GracefulPollOutcome::Ok(result) => Ok(result), + GracefulPollOutcome::Timeout(ms) => { + tracing::error!("AI agent timeout after {}s", ms / 1000); + Err(Error::ExecutionErr(format!( + "AI agent timeout after (>{}s)", + ms / 1000 + ))) } - // Hard timeout: clean up orphaned jobs still stuck in v2_job_queue - cleanup_orphaned_tool_jobs(db, &job.id, &job.workspace_id, cb).await; - Err(Error::ExecutionErr(format!( - "Job cancelled by {by} (reason: {reason}, timed out waiting for tool calls)" - ))) - } - GracefulPollOutcome::AlreadyCompleted => { - Err(Error::AlreadyCompleted("Job already completed".to_string())) + GracefulPollOutcome::Cancelled { canceled_by: cb } => { + let (by, reason) = format_cancel_info(&cb); + Err(Error::ExecutionErr(format!( + "Job cancelled by {by} (reason: {reason})" + ))) + } + GracefulPollOutcome::CancelledTimeout { canceled_by: cb } => { + let (by, reason) = format_cancel_info(&cb); + Err(Error::ExecutionErr(format!( + "Job cancelled by {by} (reason: {reason}, timed out waiting for tool calls)" + ))) + } + GracefulPollOutcome::AlreadyCompleted => { + Err(Error::AlreadyCompleted("Job already completed".to_string())) + } + }, + }; + if result.is_err() { + for handle in tool_abort_handles.lock().unwrap().drain(..) { + handle.abort(); } + cleanup_orphaned_tool_jobs(db, &job.id, &job.workspace_id, canceled_by.clone()).await; } + cleanup_mcp_clients(mcp_clients).await; + result } /// OpenAI rejects a `prompt_cache_key` over 64 characters @@ -1128,12 +1231,14 @@ pub async fn run_agent( has_stream: &mut bool, has_websearch: bool, omit_output_from_conversation: bool, + preserve_step_tags: bool, // cancellation signal from parent cancel_rx: tokio::sync::watch::Receiver, // abort handles for spawned tool tasks tool_abort_handles: ToolAbortHandles, + job_completed_tx: crate::JobCompletedSender, ) -> error::Result> { let output_type = args.output_type.as_ref().unwrap_or(&OutputType::Text); let credentials = args.provider.to_provider_credentials(db).await?; @@ -1229,7 +1334,7 @@ pub async fn run_agent( "'user_message' must be provided for image output" } else if matches!( args.memory, - Some(Memory::Window { .. } | Memory::Auto { .. }) + Some(Memory::Window { .. } | Memory::Compaction { .. } | Memory::Auto { .. }) ) { "'user_message' must be provided while managed memory is on" } else { @@ -1241,13 +1346,13 @@ pub async fn run_agent( if matches!(output_type, OutputType::Text) { match &history { HistorySource::Messages(provided) => messages.extend(provided.iter().cloned()), - HistorySource::Window { memory_id, context_length } => { + HistorySource::Managed { memory_id, bound } => { if let Some(step_id) = effective_flow_step_id { match read_from_memory(db, &job.workspace_id, *memory_id, step_id).await { Ok(Some(loaded_messages)) => { let messages_to_load = prepare_auto_memory_messages_for_request( &loaded_messages, - *context_length, + bound.messages_to_keep(), ); messages.extend(messages_to_load); } @@ -1339,6 +1444,7 @@ pub async fn run_agent( } } + let mut messages = AgentHistory::new(messages); let mut actions = vec![]; let mut content = None; let mut final_usage: Option = None; @@ -1424,6 +1530,44 @@ pub async fn run_agent( .map(|m| m.clamp(1, HARD_MAX_AGENT_ITERATIONS)) .unwrap_or(DEFAULT_MAX_AGENT_ITERATIONS); + let mut compactor = match &args.memory { + Some(Memory::Compaction { context_window }) if is_text_output => { + let tool_schema_tokens = tool_defs + .as_ref() + .and_then(|defs| serde_json::to_string(defs).ok()) + .map(|schemas| schemas.len() / 4) + .unwrap_or(0); + // An unset window — the usual case — is looked up from the model. The field + // is the override for what the lookup cannot serve: a Custom AI deployment, + // or an id the table does not list. + let context_window = match context_window { + 0 => model_context_window(args.provider.get_model()), + declared => *declared, + }; + Some(Compactor::new( + context_window, + tool_schema_tokens, + args.max_completion_tokens, + )) + } + _ => None, + }; + // The store otherwise evicts oldest messages, including the summary. Bound only + // persisted context here; the running agent can still use the full model window. + let persist_capacity = match (&compactor, &history, effective_flow_step_id) { + (Some(_), HistorySource::Managed { .. }, Some(_)) => memory_storage_capacity_bytes().await, + _ => None, + }; + // The summarization call runs under the agent's own request timeout, which resolves + // from the job alone and so is the same for every iteration. + let compaction_timeout = match compactor { + Some(_) => Some( + resolve_job_timeout(conn, &job.workspace_id, job.id, job.timeout) + .await + .0, + ), + None => None, + }; // Held across iterations so an OIDC role is assumed once for the job and // re-assumed only near expiry, rather than on every model turn. #[cfg(feature = "bedrock")] @@ -1440,253 +1584,328 @@ pub async fn run_agent( break; } - // Handle AWS Bedrock provider specially using the official SDK - let parsed = if credentials.provider == AIProvider::AWSBedrock { - #[cfg(feature = "bedrock")] - { - let region = credentials - .region - .as_deref() - .unwrap_or(windmill_ai::ai_providers::USE_ENV_REGION); - // An OIDC role resolves to ordinary IAM keys, so from here the - // call is identical to the explicit-keys mode. - let assumed = windmill_ai::ai_bedrock::refresh_bedrock_oidc_credentials( - &credentials, - &mut bedrock_assumed_role, - client, - &job.id, - ) - .await?; - let (access_key_id, secret_access_key, session_token) = match assumed { - Some(assumed) => ( - Some(assumed.access_key_id.as_str()), - Some(assumed.secret_access_key.as_str()), - Some(assumed.session_token.as_str()), - ), - None => ( - credentials.aws_access_key_id.as_deref(), - credentials.aws_secret_access_key.as_deref(), - credentials.aws_session_token.as_deref(), - ), - }; - // Use Bedrock SDK via dedicated query builder - windmill_ai::providers::bedrock::BedrockQueryBuilder::default() - .execute_request( - &messages, - tool_defs.as_deref(), - args.provider.get_model(), - args.temperature, - args.provider.get_reasoning_effort(), - args.max_completion_tokens, - api_key, - region, - stream_event_processor.as_ref().map(|p| p.boxed_sink()), - client, - &job.workspace_id, - structured_output_tool_name.as_deref(), - access_key_id, - secret_access_key, - session_token, - ) - .await? - } - #[cfg(not(feature = "bedrock"))] - { - return Err(Error::internal_err( - "AWS Bedrock support is not enabled. Build with 'bedrock' feature.".to_string(), - )); - } - } else { - // For all other providers, use the HTTP client approach - let mut build_args = BuildRequestArgs { - messages: &messages, - tools: tool_defs.as_deref(), - model: args.provider.get_model(), - temperature: args.temperature, - reasoning_effort: args.provider.get_reasoning_effort(), - max_tokens: args.max_completion_tokens, - output_schema: args.output_schema.as_ref(), - output_type, - system_prompt: args.system_prompt.as_deref(), - user_message: args.user_message.as_deref().unwrap_or(""), - attachments: args.user_attachments.as_deref(), - has_websearch, - prompt_cache_key: include_prompt_cache_key.then_some(prompt_cache_key.as_str()), - reasoning_summary: !is_reasoning_summary_unavailable( - &credentials, - args.provider.get_model(), - ), - }; + compact_if_needed( + CompactionContext { + compactor: compactor.as_mut(), + timeout: compaction_timeout, + credentials: &credentials, + args, + client, + job, + conn, + }, + query_builder.as_ref(), + include_usage, + &mut messages, + CompactionTrigger::Threshold, + &mut final_usage, + ) + .await; - // A worker cannot run the client credentials exchange, so an OAuth resource - // has no token here: the request would carry an empty credential and come - // back 401. - if needs_unavailable_oauth_exchange( - &credentials, - args.provider.resource.token_url.as_deref(), - &query_builder.get_auth_headers(api_key, base_url, output_type), - ) { - return Err(Error::ExecutionErr(format!( - "The {:?} resource authenticates with OAuth, which AI agent steps do not \ - support. Set an API key on the resource, or carry the provider's credential \ - header in its `headers`.", - credentials.provider - ))); - } + let mut retried_context = false; + let (parsed, request_message_count) = loop { + // How many messages this request carries, so the provider's prompt count can + // later be told apart from what the response and its tool results add. + let request_message_count = messages.context().len(); - let timeout = resolve_job_timeout(conn, &job.workspace_id, job.id, job.timeout) - .await - .0; - - let trailing_headers = common_outbound_headers(&credentials).collect::>(); - - // `endpoint` derives from the user-controlled provider base_url, so pin - // DNS to the SSRF-validated address: the connect must not rebind to an - // internal IP between the check and the request (TOCTOU). - let pinned_ai_client = pinned_ai_client_for(base_url).await?; - - // Helper to build HTTP request with headers - let build_http_request = - |endpoint: &str, auth_headers: &[(&'static str, String)], body: String| { - let mut req = pinned_ai_client - .post(endpoint) - .timeout(timeout) - .header("Content-Type", "application/json"); - - for (header_name, header_value) in auth_headers { - req = req.header(*header_name, header_value.clone()); - } - - for (header_name, header_value) in &trailing_headers { - req = req.header(header_name.as_str(), header_value.as_str()); - } - - req.body(body) - }; - - // An endpoint can reject the request shape rather than the model: - // `stream_options` and `prompt_cache_key`, which not every OpenAI-compatible - // gateway accepts, a reasoning summary, which OpenAI refuses to unverified - // organizations, and the route itself, when an Azure resource is outside - // the Responses API's model/region matrix. Each is retried once with that - // part dropped. - // Set where the route is found to be absent, and read once the fallback has - // answered: a rejection it did not resolve says nothing about the deployment. - let mut rerouted_by_a_route_rejection = false; - let resp = loop { - let request_body = if include_usage { - query_builder - .build_request(&build_args, client, &job.workspace_id) - .await? - } else { - query_builder - .build_request_without_usage(&build_args, client, &job.workspace_id) - .await? - }; - let endpoint = - query_builder.get_endpoint(base_url, args.provider.get_model(), output_type); - let auth_headers = retain_effective_credentials( - &credentials, - query_builder.get_auth_headers(api_key, base_url, output_type), - ); - - let resp = build_http_request(&endpoint, &auth_headers, request_body) - .send() - .await - .map_err(|e| Error::internal_err(format!("Failed to call API: {}", e)))?; - - match resp.error_for_status_ref() { - Ok(_) => { - if rerouted_by_a_route_rejection { - remember_chat_completions_only(base_url, args.provider.get_model()); - } - break resp; - } - Err(e) => { - let status = resp.status(); - let text = resp - .text() - .await - .unwrap_or_else(|_| "".to_string()); - - // Common error patterns: 400 Bad Request with mentions of stream_options or include_usage - let rejects_usage_tracking = include_usage - && query_builder.supports_retry_without_usage() - && status.as_u16() == 400 - && (text.contains("stream_options") - || text.contains("include_usage") - || text.contains("Additional properties are not allowed")); - - // An OpenAI-compatible gateway that validates the body strictly - // names the offending field, whether it calls it an unrecognized - // argument or an unexpected additional property. - let rejects_prompt_cache_key = build_args.prompt_cache_key.is_some() - && status.as_u16() == 400 - && text.contains("prompt_cache_key"); - - let summary_refused = build_args.reasoning_summary - && rejects_reasoning_summary(status.as_u16(), &text); - - // Only the first call of the step may re-route: an endpoint that - // does not serve this API rejects that one already, whereas a - // rejection once the conversation is under way is about the - // conversation (context length, content filter, tool schema). - let route_unserved = i == 0 - && query_builder.supports_chat_completions_fallback(base_url) - && matches!(status.as_u16(), 400 | 404) - && *output_type == OutputType::Text; - - if rejects_usage_tracking { - tracing::info!( - "Retrying request without stream_options due to provider incompatibility" - ); - include_usage = false; - } else if rejects_prompt_cache_key { - // Checked before the route fallback: the endpoint serves this - // route, it just refuses one optional field, and re-routing - // the whole step over that would give up far more. - tracing::info!( - "Retrying request without prompt_cache_key due to provider incompatibility" - ); - include_prompt_cache_key = false; - build_args.prompt_cache_key = None; - } else if summary_refused { - tracing::info!( - "Retrying request without the reasoning summary the endpoint refused" - ); - remember_reasoning_summary_unavailable( - &credentials, + // Handle AWS Bedrock provider specially using the official SDK + let attempt: error::Result<_> = async { + let parsed = if credentials.provider == AIProvider::AWSBedrock { + #[cfg(feature = "bedrock")] + { + let region = credentials + .region + .as_deref() + .unwrap_or(windmill_ai::ai_providers::USE_ENV_REGION); + // An OIDC role resolves to ordinary IAM keys, so from here the + // call is identical to the explicit-keys mode. + let assumed = windmill_ai::ai_bedrock::refresh_bedrock_oidc_credentials( + &credentials, + &mut bedrock_assumed_role, + client, + &job.id, + ) + .await?; + let (access_key_id, secret_access_key, session_token) = match assumed { + Some(assumed) => ( + Some(assumed.access_key_id.as_str()), + Some(assumed.secret_access_key.as_str()), + Some(assumed.session_token.as_str()), + ), + None => ( + credentials.aws_access_key_id.as_deref(), + credentials.aws_secret_access_key.as_deref(), + credentials.aws_session_token.as_deref(), + ), + }; + // Use Bedrock SDK via dedicated query builder + windmill_ai::providers::bedrock::BedrockQueryBuilder::default() + .execute_request( + messages.context(), + tool_defs.as_deref(), args.provider.get_model(), - ); - build_args.reasoning_summary = false; - } else if route_unserved { - tracing::info!( - "Endpoint rejected the request ({}), falling back to chat/completions", - status - ); - // Only a 404 says the route is absent. A 400 is ambiguous — - // a deployment that does serve the route rejects tool - // schemas, blocked hosted tools and filtered content the - // same way — so it re-routes this step and nothing more. - rerouted_by_a_route_rejection = status.as_u16() == 404; - query_builder = create_chat_completions_query_builder(&credentials); - include_usage = true; + args.temperature, + args.provider.get_reasoning_effort(), + args.max_completion_tokens, + api_key, + region, + stream_event_processor.as_ref().map(|p| p.boxed_sink()), + client, + &job.workspace_id, + structured_output_tool_name.as_deref(), + access_key_id, + secret_access_key, + session_token, + ) + .await? + } + #[cfg(not(feature = "bedrock"))] + { + return Err(Error::internal_err( + "AWS Bedrock support is not enabled. Build with 'bedrock' feature.".to_string(), + )); + } + } else { + // For all other providers, use the HTTP client approach + let mut build_args = BuildRequestArgs { + messages: messages.context(), + tools: tool_defs.as_deref(), + model: args.provider.get_model(), + temperature: args.temperature, + reasoning_effort: args.provider.get_reasoning_effort(), + max_tokens: args.max_completion_tokens, + output_schema: args.output_schema.as_ref(), + output_type, + system_prompt: args.system_prompt.as_deref(), + user_message: args.user_message.as_deref().unwrap_or(""), + attachments: args.user_attachments.as_deref(), + has_websearch, + prompt_cache_key: include_prompt_cache_key.then_some(prompt_cache_key.as_str()), + reasoning_summary: !is_reasoning_summary_unavailable( + &credentials, + args.provider.get_model(), + ), + }; + + // A worker cannot run the client credentials exchange, so an OAuth resource + // has no token here: the request would carry an empty credential and come + // back 401. + if needs_unavailable_oauth_exchange( + &credentials, + args.provider.resource.token_url.as_deref(), + &query_builder.get_auth_headers(api_key, base_url, output_type), + ) { + return Err(Error::ExecutionErr(format!( + "The {:?} resource authenticates with OAuth, which AI agent steps do not \ + support. Set an API key on the resource, or carry the provider's credential \ + header in its `headers`.", + credentials.provider + ))); + } + + let timeout = resolve_job_timeout(conn, &job.workspace_id, job.id, job.timeout) + .await + .0; + + let trailing_headers = common_outbound_headers(&credentials).collect::>(); + + // `endpoint` derives from the user-controlled provider base_url, so pin + // DNS to the SSRF-validated address: the connect must not rebind to an + // internal IP between the check and the request (TOCTOU). + let pinned_ai_client = pinned_ai_client_for(base_url).await?; + + // Helper to build HTTP request with headers + let build_http_request = + |endpoint: &str, auth_headers: &[(&'static str, String)], body: String| { + let mut req = pinned_ai_client + .post(endpoint) + .timeout(timeout) + .header("Content-Type", "application/json"); + + for (header_name, header_value) in auth_headers { + req = req.header(*header_name, header_value.clone()); + } + + for (header_name, header_value) in &trailing_headers { + req = req.header(header_name.as_str(), header_value.as_str()); + } + + req.body(body) + }; + + // An endpoint can reject the request shape rather than the model: + // `stream_options` and `prompt_cache_key`, which not every OpenAI-compatible + // gateway accepts, a reasoning summary, which OpenAI refuses to unverified + // organizations, and the route itself, when an Azure resource is outside + // the Responses API's model/region matrix. Each is retried once with that + // part dropped. + // Set where the route is found to be absent, and read once the fallback has + // answered: a rejection it did not resolve says nothing about the deployment. + let mut rerouted_by_a_route_rejection = false; + let resp = loop { + let request_body = if include_usage { + query_builder + .build_request(&build_args, client, &job.workspace_id) + .await? } else { - return Err(Error::internal_err(format!( - "API error calling {}: {} - {}", - endpoint, e, text - ))); + query_builder + .build_request_without_usage(&build_args, client, &job.workspace_id) + .await? + }; + let endpoint = + query_builder.get_endpoint(base_url, args.provider.get_model(), output_type); + let auth_headers = retain_effective_credentials( + &credentials, + query_builder.get_auth_headers(api_key, base_url, output_type), + ); + + let resp = build_http_request(&endpoint, &auth_headers, request_body) + .send() + .await + .map_err(|e| Error::internal_err(format!("Failed to call API: {}", e)))?; + + match resp.error_for_status_ref() { + Ok(_) => { + if rerouted_by_a_route_rejection { + remember_chat_completions_only(base_url, args.provider.get_model()); + } + break resp; + } + Err(e) => { + let status = resp.status(); + let text = resp + .text() + .await + .unwrap_or_else(|_| "".to_string()); + + // Common error patterns: 400 Bad Request with mentions of stream_options or include_usage + let rejects_usage_tracking = include_usage + && query_builder.supports_retry_without_usage() + && status.as_u16() == 400 + && (text.contains("stream_options") + || text.contains("include_usage") + || text.contains("Additional properties are not allowed")); + + // An OpenAI-compatible gateway that validates the body strictly + // names the offending field, whether it calls it an unrecognized + // argument or an unexpected additional property. + let rejects_prompt_cache_key = build_args.prompt_cache_key.is_some() + && status.as_u16() == 400 + && text.contains("prompt_cache_key"); + + let summary_refused = build_args.reasoning_summary + && rejects_reasoning_summary(status.as_u16(), &text); + + // Only the first call of the step may re-route: an endpoint that + // does not serve this API rejects that one already, whereas a + // rejection once the conversation is under way is about the + // conversation (context length, content filter, tool schema). + let route_unserved = i == 0 + && !windmill_ai::query_builder::is_context_length_error(&text) + && query_builder.supports_chat_completions_fallback(base_url) + && matches!(status.as_u16(), 400 | 404) + && *output_type == OutputType::Text; + + if rejects_usage_tracking { + tracing::info!( + "Retrying request without stream_options due to provider incompatibility" + ); + include_usage = false; + } else if rejects_prompt_cache_key { + // Checked before the route fallback: the endpoint serves this + // route, it just refuses one optional field, and re-routing + // the whole step over that would give up far more. + tracing::info!( + "Retrying request without prompt_cache_key due to provider incompatibility" + ); + include_prompt_cache_key = false; + build_args.prompt_cache_key = None; + } else if summary_refused { + tracing::info!( + "Retrying request without the reasoning summary the endpoint refused" + ); + remember_reasoning_summary_unavailable( + &credentials, + args.provider.get_model(), + ); + build_args.reasoning_summary = false; + } else if route_unserved { + tracing::info!( + "Endpoint rejected the request ({}), falling back to chat/completions", + status + ); + // Only a 404 says the route is absent. A 400 is ambiguous — + // a deployment that does serve the route rejects tool + // schemas, blocked hosted tools and filtered content the + // same way — so it re-routes this step and nothing more. + rerouted_by_a_route_rejection = status.as_u16() == 404; + query_builder = create_chat_completions_query_builder(&credentials); + include_usage = true; + } else { + return Err(Error::internal_err(format!( + "API error calling {}: {} - {}", + endpoint, e, text + ))); + } + } } + }; + + if let Some(ref stream_event_processor) = stream_event_processor { + query_builder + .parse_streaming_response(resp, stream_event_processor.boxed_sink()) + .await? + } else { + query_builder.parse_image_response(resp).await? + } + }; + Ok(parsed) + }.await; + + match attempt { + Ok(parsed) => break (parsed, request_message_count), + Err(error) + if !retried_context + && compactor.is_some() + && windmill_ai::query_builder::is_context_length_error( + &error.to_string(), + ) => + { + retried_context = true; + append_logs( + &job.id, + &job.workspace_id, + "Provider rejected the context size; attempting compaction before one retry.\n".to_string(), + conn, + ).await; + let changed = compact_if_needed( + CompactionContext { + compactor: compactor.as_mut(), + timeout: compaction_timeout, + credentials: &credentials, + args, + client, + job, + conn, + }, + query_builder.as_ref(), + include_usage, + &mut messages, + CompactionTrigger::ContextRejected, + &mut final_usage, + ) + .await; + if !changed { + append_logs( + &job.id, + &job.workspace_id, + "Context compaction could not produce a smaller usable request; returning the provider error without retry.\n".to_string(), + conn, + ).await; + return Err(error); } } - }; - - if let Some(ref stream_event_processor) = stream_event_processor { - query_builder - .parse_streaming_response(resp, stream_event_processor.boxed_sink()) - .await? - } else { - query_builder.parse_image_response(resp).await? + Err(error) => return Err(error), } }; @@ -1700,6 +1919,11 @@ pub async fn run_agent( used_websearch, usage, } => { + messages.record_usage( + usage.as_ref().and_then(|u| u.input_tokens), + request_message_count, + ); + // Accumulate usage from this iteration if let Some(u) = usage { match &mut final_usage { @@ -1823,7 +2047,7 @@ pub async fn run_agent( name: &'static str, #[serde(skip_serializing_if = "Option::is_none")] step_id: Option<&'a str>, - result: MaxIterPartialResult<'a>, + result: AgentPartialResult<'a>, } return Err(Error::ExecutionRawError( serde_json::value::to_raw_value(&MaxIterError { @@ -1833,8 +2057,9 @@ pub async fn run_agent( ), name: "ExecutionErr", step_id: effective_flow_step_id, - result: MaxIterPartialResult { + result: AgentPartialResult { messages: messages + .result() .iter() .map(|m| Message { message: m, @@ -1882,6 +2107,7 @@ pub async fn run_agent( stream_event_processor: stream_event_processor.as_ref(), flow_context: &mut flow_context, omit_output_from_conversation, + preserve_step_tags, reasoning: if structured_output_first { None } else { @@ -1890,6 +2116,7 @@ pub async fn run_agent( previous_result: &previous_result, id_context: &id_context, tool_abort_handles: tool_abort_handles.clone(), + job_completed_tx: job_completed_tx.clone(), }; let (tool_messages, tool_content, tool_used_structured_output) = @@ -1997,8 +2224,42 @@ pub async fn run_agent( } } + if let Some(capacity_bytes) = + persist_capacity.filter(|limit| persisted_bytes(messages.context()) > *limit) + { + compact_if_needed( + CompactionContext { + compactor: compactor.as_mut(), + timeout: compaction_timeout, + credentials: &credentials, + args, + client, + job, + conn, + }, + query_builder.as_ref(), + include_usage, + &mut messages, + CompactionTrigger::Storage { capacity_bytes }, + &mut final_usage, + ) + .await; + } + let memory_to_save = match persist_capacity { + Some(limit) => memory_within_capacity(messages.context(), limit), + None => Some(messages.context()), + }; + if memory_to_save.is_some_and(|tail| tail.len() < messages.context().len()) { + append_logs(&job.id, &job.workspace_id, + "AI agent memory exceeds storage capacity after compaction; saving only the newest complete exchanges. Older memory was dropped; the answer and run action results are preserved.\n".to_string(), conn).await; + } else if memory_to_save.is_none() { + append_logs(&job.id, &job.workspace_id, + "AI agent memory has no complete user-starting conversation that fits storage capacity and memory was not saved. The answer is preserved; the next run will load the previous saved memory.\n".to_string(), conn).await; + } + // Return the final result let final_messages: Vec = messages + .result() .iter() .map(|m| Message { message: m, agent_action: m.agent_action.as_ref() }) .collect(); @@ -2040,34 +2301,56 @@ pub async fn run_agent( } } - // final_messages holds the complete history: what was loaded plus this run's messages - if matches!(output_type, OutputType::Text) { - if let HistorySource::Window { memory_id, context_length } = &history { + // Storage limits affect only the saved suffix, never the returned execution history. + if let Some(memory_to_save) = memory_to_save.filter(|_| matches!(output_type, OutputType::Text)) + { + if let HistorySource::Managed { memory_id, bound } = &history { if let Some(step_id) = effective_flow_step_id { - let all_messages: Vec = - final_messages.iter().map(|m| m.message.clone()).collect(); - - if !all_messages.is_empty() { + if !memory_to_save.is_empty() { let messages_to_persist = prepare_auto_memory_messages_for_persistence( - &all_messages, - *context_length, + memory_to_save, + bound.messages_to_keep(), ); - if let Err(e) = write_to_memory( + match write_to_memory( db, &job.workspace_id, *memory_id, step_id, &messages_to_persist, + matches!(bound, MemoryBound::LastMessages(_)), ) .await { - tracing::error!( - "Failed to persist {} messages to memory for step {}: {}", - messages_to_persist.len(), - step_id, - e - ); + // The conversation outgrew what the database holds and was cut + // from its oldest message. Only the worker's own log says so + // otherwise, so from the flow's side the agent simply starts the + // next run having forgotten how this one began. + Ok(dropped) if dropped > 0 => { + append_logs( + &job.id, + &job.workspace_id, + format!( + "Memory does not fit the {}KB the database holds, so its \ + {dropped} oldest messages were dropped. Configure instance \ + object storage to keep the whole conversation.\n", + MAX_MEMORY_SIZE_BYTES / 1000 + ), + conn, + ) + .await; + } + Ok(_) => {} + Err(e) => { + tracing::error!( + "Failed to persist {} messages to memory for step {}: {}", + messages_to_persist.len(), + step_id, + e + ); + append_logs(&job.id, &job.workspace_id, + "Agent memory could not be saved. The answer is preserved; the next run may load older memory.\n".to_string(), conn).await; + } } } } @@ -2131,6 +2414,7 @@ mod tests { enum Resolved { Messages(usize), Window(Uuid, usize), + Compaction(Uuid, usize), Stateless { noted: bool }, } @@ -2215,6 +2499,18 @@ mod tests { None, Resolved::Stateless { noted: true }, ), + ( + "compaction keeps the run's memory", + json!({ "memory": { "kind": "compaction", "context_window": 32000 } }), + Some(run), + Resolved::Compaction(run, 32000), + ), + ( + "a cleared context window is left for the model lookup to fill", + json!({ "memory": { "kind": "compaction", "context_window": null } }), + Some(run), + Resolved::Compaction(run, 0), + ), ( "a step memory id overrides the run's", json!({ "memory": window, "memory_id": "cust_1" }), @@ -2278,8 +2574,20 @@ mod tests { let args: AIAgentArgs = serde_json::from_value(raw).unwrap(); let resolved = match resolve_history_source(&args, run_memory_id, "ws", "f/flow") { (HistorySource::Messages(m), _) => Resolved::Messages(m.len()), - (HistorySource::Window { memory_id, context_length }, _) => { - Resolved::Window(memory_id, context_length) + ( + HistorySource::Managed { + memory_id, + bound: MemoryBound::LastMessages(context_length), + }, + _, + ) => Resolved::Window(memory_id, context_length), + (HistorySource::Managed { memory_id, bound: MemoryBound::Compaction }, _) => { + match args.memory { + Some(Memory::Compaction { context_window }) => { + Resolved::Compaction(memory_id, context_window) + } + _ => panic!("expected compaction settings"), + } } (HistorySource::Stateless, notes) => { Resolved::Stateless { noted: !notes.is_empty() } @@ -2380,7 +2688,7 @@ mod tests { ..Default::default() }]; - let partial = MaxIterPartialResult { + let partial = AgentPartialResult { messages: messages .iter() .map(|m| Message { message: m, agent_action: m.agent_action.as_ref() }) @@ -2789,11 +3097,13 @@ async fn cleanup_orphaned_tool_jobs( ) }); - // Find direct child jobs still in v2_job_queue (agent tool jobs are always direct children) let orphaned_ids: Vec = match sqlx::query_scalar!( - r#"SELECT j.id FROM v2_job j - JOIN v2_job_queue q ON q.id = j.id - WHERE j.parent_job = $1 AND j.workspace_id = $2"#, + r#"WITH RECURSIVE descendants AS ( + SELECT id FROM v2_job WHERE parent_job = $1 AND workspace_id = $2 + UNION ALL + SELECT j.id FROM v2_job j JOIN descendants d ON j.parent_job = d.id + WHERE j.workspace_id = $2 + ) SELECT d.id AS "id!" FROM descendants d JOIN v2_job_queue q ON q.id = d.id"#, parent_job_id, w_id, ) diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 841ea8861a..c4a792c6a0 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -2362,7 +2362,11 @@ try {{ #[cfg(feature = "deno_core")] { - let env_code = build_nativets_env_code(base_internal_url, &reserved_variables); + let env_code = build_nativets_env_code( + base_internal_url, + &reserved_variables, + annotation.no_network, + ); let js_code = read_file_content(&format!("{job_dir}/main.js")).await?; let started_at = Instant::now(); let args = crate::common::build_args_map(job, client, conn) @@ -2940,7 +2944,9 @@ pub async fn handle_wac_v2_output( dedicated_worker: None, concurrency_settings: ConcurrencySettingsWithCustom::default(), debouncing_settings: DebouncingSettings::default(), - modules: None, + // `push` drops the `_MODULES` the children inherit with the + // parent's args, so the preview's own modules are handed over here. + modules: modules.clone(), tag: None, })) } @@ -3805,11 +3811,15 @@ pub async fn get_common_bun_proc_envs(base_internal_url: Option<&str>) -> HashMa pub fn build_nativets_env_code( base_internal_url: &str, reserved_variables: &HashMap, + no_network: bool, ) -> String { format!( "const process = {{ env: {{}} }};\nconst BASE_URL = '{base_internal_url}';\nconst BASE_INTERNAL_URL = '{base_internal_url}';\nprocess.env['BASE_URL'] = BASE_URL;process.env['BASE_INTERNAL_URL'] = BASE_INTERNAL_URL;\n{}", reserved_variables .iter() + // Useless without network, and a `//no_network` script could still + // hand it out through its result to whoever authored it. + .filter(|(k, _)| !(no_network && k.as_str() == "WM_TOKEN")) .map(|(k, v)| { // The key is attacker-controllable (custom workspace env vars), so // escape it as a string literal too, not just the value. @@ -4204,7 +4214,11 @@ pub async fn start_worker( .iter() .map(|x| (x.name.clone(), x.value.clone())) .collect(); - let env_code = build_nativets_env_code(base_internal_url, &reserved_variables); + let env_code = build_nativets_env_code( + base_internal_url, + &reserved_variables, + annotation.no_network, + ); return handle_dedicated_bunnative( inner_content, diff --git a/backend/windmill-worker/src/duckdb_executor.rs b/backend/windmill-worker/src/duckdb_executor.rs index 943703472f..b8c850a2f4 100644 --- a/backend/windmill-worker/src/duckdb_executor.rs +++ b/backend/windmill-worker/src/duckdb_executor.rs @@ -1475,6 +1475,7 @@ pub async fn do_duckdb( &job.id, client, &mut hidden_passwords, + job_dir, ) .await?, ); @@ -1490,13 +1491,19 @@ pub async fn do_duckdb( &mut hidden_passwords, &job.workspace_id, materialize.as_ref().map(|(_, m)| m.asset_path.as_str()), + job_dir, ) .await? { probe_blocks.extend(q); - } else if let Some(q) = - transform_attach_datatable(&query_block, conn, &mut hidden_passwords, job) - .await? + } else if let Some(q) = transform_attach_datatable( + &query_block, + conn, + &mut hidden_passwords, + job, + job_dir, + ) + .await? { probe_blocks.extend(q); } else { @@ -1552,6 +1559,7 @@ pub async fn do_duckdb( &job.id, client, &mut hidden_passwords, + job_dir, ) .await?, ); @@ -1567,13 +1575,19 @@ pub async fn do_duckdb( &mut hidden_passwords, &job.workspace_id, materialize.as_ref().map(|(_, m)| m.asset_path.as_str()), + job_dir, ) .await? { v.extend(ducklake_query); - } else if let Some(datatable_query) = - transform_attach_datatable(&query_block, conn, &mut hidden_passwords, job) - .await? + } else if let Some(datatable_query) = transform_attach_datatable( + &query_block, + conn, + &mut hidden_passwords, + job, + job_dir, + ) + .await? { v.extend(datatable_query); } else { @@ -2240,11 +2254,80 @@ fn parse_attach_db_resource<'a>(query: &'a str) -> Option Result { +/// The verification a DuckDB postgres attach keeps, as its libpq `sslmode` and `sslrootcert`. +/// +/// Attaches have always turned verify-ca and verify-full into `require`, which resources rely on. +/// A connection that explicitly refuses invalid certificates — the external instance cluster's — +/// keeps its mode instead: under `require` its shared password would go to whichever server +/// answers. DuckDB's libpq takes one root file, so it gets the system bundle plus the configured +/// certificate, written in the job directory: a resource's certificate is workspace-controlled, so +/// a file per distinct one has to go with the job rather than pile up on the worker. +fn pg_attach_verification<'a>( + res: &'a PgDatabase, + job_dir: &str, +) -> Result> { + let mode = match res.sslmode.as_deref() { + // The rule every other Postgres connection uses, so a resource that verifies elsewhere is + // not quietly downgraded here. + Some(mode @ ("verify-ca" | "verify-full")) if !res.verify_mode_skips_verification() => mode, + _ => return Ok(None), + }; + let bundle = windmill_common::system_ca_bundle() + .map(std::fs::read_to_string) + .transpose() + .map_err(|e| Error::ExecutionErr(format!("Failed to read the system CA bundle: {e}")))? + .unwrap_or_default(); + let pem = res.root_certificate_pem.as_deref().unwrap_or_default(); + if bundle.is_empty() && pem.is_empty() { + return Err(Error::ExecutionErr(format!( + "sslmode {mode} needs a root certificate, and this worker has no system CA bundle" + ))); + } + let roots = format!("{bundle}\n{pem}\n"); + use sha2::Digest; + let path = std::path::Path::new(job_dir).join(format!( + "pg_roots_{}.pem", + hex::encode(&sha2::Sha256::digest(roots.as_bytes())[..8]) + )); + // The job's modules are written in this directory first, so whatever already sits at this path + // may be caller-supplied: trusting it would let the caller pick the CA. Replace it, and + // `create_new` refuses to write through anything recreated there. + let write_roots = || -> std::io::Result<()> { + match std::fs::remove_file(&path) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e), + _ => {} + } + use std::io::Write; + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path)? + .write_all(roots.as_bytes()) + }; + write_roots() + .map_err(|e| Error::ExecutionErr(format!("Failed to write root certificates: {e}")))?; + Ok(Some((mode, path))) +} + +fn pg_attach_uri(res: &PgDatabase, job_dir: &str) -> Result { + let uri = res.to_uri(); + let Some((mode, roots)) = pg_attach_verification(res, job_dir)? else { + return Ok(uri); + }; + let base = uri.strip_suffix("?sslmode=require").ok_or_else(|| { + Error::internal_err("unexpected sslmode in a postgres connection URI".to_string()) + })?; + Ok(format!( + "{base}?sslmode={mode}&sslrootcert={}", + urlencoding::encode(&roots.to_string_lossy()) + )) +} + +fn format_attach_db_conn_str(db_resource: Value, db_type: &str, job_dir: &str) -> Result { let s = match db_type.to_lowercase().as_str() { "postgres" | "postgresql" => { let res: PgDatabase = serde_json::from_value(db_resource)?; - res.to_uri() + pg_attach_uri(&res, job_dir)? } #[cfg(feature = "mysql")] "mysql" => { @@ -2316,6 +2399,7 @@ async fn transform_attach_db_resource_query( job_id: &Uuid, client: &AuthedClient, hidden_passwords: &mut Arc>>, + job_dir: &str, ) -> Result> { let db_resource: Value = client .get_resource_value_interpolated(parsed.resource_path, Some(job_id.to_string())) @@ -2323,8 +2407,14 @@ async fn transform_attach_db_resource_query( if let Some(pwd) = db_resource.get("password").and_then(|p| p.as_str()) { hidden_passwords.lock().unwrap().push(pwd.to_string()); } - db_resource_to_attach_statements(db_resource, parsed.name, parsed.db_type, parsed.extra_args) - .await + db_resource_to_attach_statements( + db_resource, + parsed.name, + parsed.db_type, + parsed.extra_args, + job_dir, + ) + .await } async fn db_resource_to_attach_statements( @@ -2332,11 +2422,12 @@ async fn db_resource_to_attach_statements( ident_name: &str, db_type: &str, extra_args: Option<&str>, + job_dir: &str, ) -> Result> { // Escape single quotes: the connection string is built from resource fields // (host/db/user/password) and embedded in a single-quoted DuckDB literal, so an // unescaped quote in any field would otherwise break out of the ATTACH statement. - let conn_str = format_attach_db_conn_str(db_resource, db_type)?.replace('\'', "''"); + let conn_str = format_attach_db_conn_str(db_resource, db_type, job_dir)?.replace('\'', "''"); let attach_str = format!( "ATTACH '{}' as {} (TYPE {}{});", conn_str, @@ -2359,6 +2450,7 @@ async fn transform_attach_ducklake( hidden_passwords: &mut Arc>>, w_id: &str, materialize_target: Option<&str>, + job_dir: &str, ) -> Result>> { lazy_static::lazy_static! { static ref RE: regex::Regex = regex::Regex::new(r"(?i)ATTACH\s*'ducklake(://[^':]+)?'\s*AS\s+([^ ;]+)\s*(\([^)]*\))?").unwrap(); @@ -2391,7 +2483,9 @@ async fn transform_attach_ducklake( format!(", {}", user_extra_args) }; let db_type = match ducklake.catalog.resource_type { - DucklakeCatalogResourceType::Instance => "postgres", + DucklakeCatalogResourceType::Instance | DucklakeCatalogResourceType::ExternalInstance => { + "postgres" + } _ => ducklake.catalog.resource_type.as_ref(), }; @@ -2407,7 +2501,7 @@ async fn transform_attach_ducklake( // single-quoted DuckDB literals below, so an unescaped quote in a resource // field would break out of the ATTACH statement. let db_conn_str = - format_attach_db_conn_str(ducklake.catalog_resource, db_type)?.replace('\'', "''"); + format_attach_db_conn_str(ducklake.catalog_resource, db_type, job_dir)?.replace('\'', "''"); let storage = ducklake .storage .storage @@ -2462,6 +2556,7 @@ async fn transform_attach_ducklake( defer, materialize_target, hidden_passwords, + job_dir, )?); } Ok(Some(statements)) @@ -2495,6 +2590,7 @@ fn fork_defer_statements( defer: &windmill_common::workspaces::DucklakeForkDefer, materialize_target: Option<&str>, hidden_passwords: &mut Arc>>, + job_dir: &str, ) -> Result> { let mut stmts = vec![]; if defer.ancestors.is_empty() { @@ -2507,12 +2603,13 @@ fn fork_defer_statements( hidden_passwords.lock().unwrap().push(pwd.to_string()); } let db_type = match a.catalog.resource_type { - DucklakeCatalogResourceType::Instance => "postgres", + DucklakeCatalogResourceType::Instance + | DucklakeCatalogResourceType::ExternalInstance => "postgres", _ => a.catalog.resource_type.as_ref(), }; stmts.push(get_attach_db_install_str(db_type)?.to_string()); - let conn_str = - format_attach_db_conn_str(a.catalog_resource.clone(), db_type)?.replace('\'', "''"); + let conn_str = format_attach_db_conn_str(a.catalog_resource.clone(), db_type, job_dir)? + .replace('\'', "''"); let storage = a .storage .storage @@ -2631,6 +2728,7 @@ async fn transform_attach_datatable( conn: &Connection, hidden_passwords: &mut Arc>>, job: &MiniPulledJob, + job_dir: &str, ) -> Result>> { let Some(attached) = parse_attach_datatable(query) else { return Ok(None); @@ -2673,6 +2771,7 @@ async fn transform_attach_datatable( Ok(Some(pg_secret_attach_statements( db_resource, attached.alias, + job_dir, )?)) } @@ -2693,17 +2792,32 @@ fn datatable_secret_name(alias: &str) -> String { /// ATTACH a datatable's postgres database through a DuckDB TEMPORARY SECRET holding /// the connection parameters; only sslmode and options ride in the ATTACH string. -fn pg_secret_attach_statements(db_resource: Value, alias_name: &str) -> Result> { +fn pg_secret_attach_statements( + db_resource: Value, + alias_name: &str, + job_dir: &str, +) -> Result> { let res: PgDatabase = serde_json::from_value(db_resource)?; // Escape single quotes: each field is embedded in a single-quoted DuckDB literal, // so an unescaped quote would break out of the CREATE SECRET statement. let esc = |s: &str| s.replace('\'', "''"); // The postgres secret type has no sslmode parameter, so it goes in the ATTACH // string; only the libpq values PgDatabase::to_uri collapses to are forwarded. - let sslmode = match res.sslmode.as_deref() { - Some("disable") => "disable", - Some("require") | Some("verify-ca") | Some("verify-full") => "require", - _ => "prefer", + let sslmode = match pg_attach_verification(&res, job_dir)? { + // A libpq keyword/value string: the path is quoted for libpq, then for the DuckDB literal. + Some((mode, roots)) => format!( + "{mode} sslrootcert=''{}''", + roots + .to_string_lossy() + .replace('\\', "\\\\") + .replace('\'', "\\''") + ), + None => match res.sslmode.as_deref() { + Some("disable") => "disable", + Some("require") | Some("verify-ca") | Some("verify-full") => "require", + _ => "prefer", + } + .to_string(), }; // Nor an options parameter. The value is quoted for libpq's keyword/value syntax first, // then escaped for the DuckDB literal around it. @@ -2805,6 +2919,76 @@ pub struct Arg { mod tests { use super::*; + #[test] + fn pg_attach_keeps_verification_only_when_required() { + let job_dir = std::env::temp_dir().join(format!("wm-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&job_dir).unwrap(); + let job_dir = job_dir.to_string_lossy().to_string(); + let pg = |sslmode: &str, accept_invalid_certs: Option| PgDatabase { + host: "db.internal".to_string(), + user: Some("custom_instance_user".to_string()), + password: Some("pw".to_string()), + port: None, + sslmode: Some(sslmode.to_string()), + dbname: "dt".to_string(), + root_certificate_pem: Some("-----BEGIN CERTIFICATE-----test".to_string()), + accept_invalid_certs, + use_iam_auth: None, + region: None, + options: None, + }; + let uri = pg_attach_uri(&pg("verify-full", Some(false)), &job_dir).unwrap(); + assert!(uri.contains("?sslmode=verify-full&sslrootcert="), "{uri}"); + let root = urlencoding::decode(uri.split("sslrootcert=").nth(1).unwrap()).unwrap(); + assert!(std::fs::read_to_string(root.as_ref()) + .unwrap() + .contains("-----BEGIN CERTIFICATE-----test")); + // A file a job module planted under the same name is not trusted. + std::fs::write(root.as_ref(), "-----BEGIN CERTIFICATE-----planted").unwrap(); + assert_eq!( + pg_attach_uri(&pg("verify-full", Some(false)), &job_dir).unwrap(), + uri + ); + assert!(!std::fs::read_to_string(root.as_ref()) + .unwrap() + .contains("planted")); + // Every certificate a job attaches keeps its own file: one attach must not evict another's. + for i in 0..40 { + let mut other = pg("verify-full", Some(false)); + other.root_certificate_pem = Some(format!("-----BEGIN CERTIFICATE-----{i}")); + let other = pg_attach_uri(&other, &job_dir).unwrap(); + let path = urlencoding::decode(other.split("sslrootcert=").nth(1).unwrap()).unwrap(); + assert!(std::path::Path::new(path.as_ref()).is_file(), "{path}"); + } + assert!( + std::path::Path::new(root.as_ref()).is_file(), + "the first file is still there" + ); + let external = serde_json::to_value(pg("verify-full", Some(false))).unwrap(); + let attach = &pg_secret_attach_statements(external, "dt", &job_dir).unwrap()[3]; + assert!( + attach.starts_with(&format!( + "ATTACH 'sslmode=verify-full sslrootcert=''{}''", + root.replace('\\', "\\\\") + )), + "{attach}" + ); + // A resource carrying a root certificate verifies without opting in, as it does on every + // other Postgres path; one carrying neither keeps the historical downgrade. + assert!(pg_attach_uri(&pg("verify-full", None), &job_dir) + .unwrap() + .contains("?sslmode=verify-full&sslrootcert=")); + let mut bare = pg("verify-full", None); + bare.root_certificate_pem = None; + assert!(pg_attach_uri(&bare, &job_dir) + .unwrap() + .ends_with("?sslmode=require")); + assert!(pg_attach_uri(&pg("require", Some(false)), &job_dir) + .unwrap() + .ends_with("?sslmode=require")); + std::fs::remove_dir_all(&job_dir).unwrap(); + } + #[test] fn attach_datatable_parses_name_and_role() { let reference_of = |q: &str| parse_attach_datatable(q).unwrap().reference; @@ -2956,7 +3140,7 @@ mod tests { let mut defer = test_fork_defer(vec![("orders", false)], vec![]); defer.ancestors[0].extra_args = Some("ENCRYPTED true".to_string()); let mut hp = Arc::new(Mutex::new(vec![])); - let stmts = fork_defer_statements("lake", "dl", &defer, None, &mut hp).unwrap(); + let stmts = fork_defer_statements("lake", "dl", &defer, None, &mut hp, "/tmp").unwrap(); let attach = stmts .iter() .find(|s| s.starts_with("ATTACH IF NOT EXISTS")) @@ -2980,7 +3164,7 @@ mod tests { vec![], ); let mut hp = Arc::new(Mutex::new(vec![])); - let stmts = fork_defer_statements("lake", "dl", &defer, None, &mut hp).unwrap(); + let stmts = fork_defer_statements("lake", "dl", &defer, None, &mut hp, "/tmp").unwrap(); let joined = stmts.join("\n"); assert!( joined.contains( @@ -3012,7 +3196,7 @@ mod tests { fn test_fork_defer_statements_shape() { let defer = test_fork_defer(vec![("orders", false), ("dim", true)], vec![]); let mut hp = Arc::new(Mutex::new(vec![])); - let stmts = fork_defer_statements("lake", "dl", &defer, None, &mut hp).unwrap(); + let stmts = fork_defer_statements("lake", "dl", &defer, None, &mut hp, "/tmp").unwrap(); let joined = stmts.join("\n"); // Ancestor attach: read-only, idempotent, never auto-migrating or auto-creating. assert!(joined.contains("ATTACH IF NOT EXISTS"), "{joined}"); @@ -3038,9 +3222,15 @@ mod tests { // Target currently a defer view → skip its CREATE, drop the view (+ companion). let defer = test_fork_defer(vec![("orders", false)], vec!["orders", "orders_current"]); let mut hp = Arc::new(Mutex::new(vec![])); - let stmts = - fork_defer_statements("lake", "_wm_target", &defer, Some("lake/orders"), &mut hp) - .unwrap(); + let stmts = fork_defer_statements( + "lake", + "_wm_target", + &defer, + Some("lake/orders"), + &mut hp, + "/tmp", + ) + .unwrap(); let joined = stmts.join("\n"); assert!(!joined.contains("CREATE VIEW"), "{joined}"); assert!( @@ -3055,15 +3245,22 @@ mod tests { // Target already a real table (NOT in fork_views, e.g. after a failed re-run whose // status can't be trusted) → no DROP VIEW, or the job would wedge on a type mismatch. let defer = test_fork_defer(vec![("orders", false)], vec![]); - let stmts = - fork_defer_statements("lake", "_wm_target", &defer, Some("lake/orders"), &mut hp) - .unwrap(); + let stmts = fork_defer_statements( + "lake", + "_wm_target", + &defer, + Some("lake/orders"), + &mut hp, + "/tmp", + ) + .unwrap(); assert!(!stmts.join("\n").contains("DROP VIEW"), "{stmts:?}"); // Target in a different lake → this lake's defer views are untouched. let defer = test_fork_defer(vec![("orders", false)], vec!["orders"]); let stmts = - fork_defer_statements("lake", "dl", &defer, Some("other/orders"), &mut hp).unwrap(); + fork_defer_statements("lake", "dl", &defer, Some("other/orders"), &mut hp, "/tmp") + .unwrap(); let joined = stmts.join("\n"); assert!( joined.contains("CREATE VIEW IF NOT EXISTS dl.\"orders\""), @@ -3076,7 +3273,7 @@ mod tests { fn test_fork_defer_statements_schema_qualified() { let defer = test_fork_defer(vec![("staging.raw", false)], vec![]); let mut hp = Arc::new(Mutex::new(vec![])); - let stmts = fork_defer_statements("lake", "dl", &defer, None, &mut hp).unwrap(); + let stmts = fork_defer_statements("lake", "dl", &defer, None, &mut hp, "/tmp").unwrap(); let joined = stmts.join("\n"); assert!( joined.contains("CREATE SCHEMA IF NOT EXISTS dl.\"staging\";"), @@ -3902,7 +4099,7 @@ mod tests { "dbname": "mydb", "sslmode": "require" }); - let result = format_attach_db_conn_str(db_resource, "postgres").unwrap(); + let result = format_attach_db_conn_str(db_resource, "postgres", "/tmp").unwrap(); // Should be in URI format: postgres://user:password@host:port/dbname?sslmode=require assert!(result.starts_with("postgres://")); assert!(result.contains("admin:secret123@localhost:5432/mydb")); @@ -3915,7 +4112,7 @@ mod tests { "host": "db.example.com", "dbname": "production" }); - let result = format_attach_db_conn_str(db_resource, "postgres").unwrap(); + let result = format_attach_db_conn_str(db_resource, "postgres", "/tmp").unwrap(); // Should be in URI format with defaults: postgres://postgres:@host:5432/dbname?sslmode=prefer assert!(result.starts_with("postgres://")); assert!(result.contains("@db.example.com:5432/production")); @@ -3928,7 +4125,7 @@ mod tests { "host": "localhost", "dbname": "test" }); - let result = format_attach_db_conn_str(db_resource, "postgresql").unwrap(); + let result = format_attach_db_conn_str(db_resource, "postgresql", "/tmp").unwrap(); // Should be in URI format (postgresql is treated the same as postgres) assert!(result.starts_with("postgres://")); assert!(result.contains("@localhost:5432/test")); @@ -3945,7 +4142,7 @@ mod tests { "dbname": "wm_datatables", "sslmode": "require" }); - let stmts = pg_secret_attach_statements(db_resource, "dt").unwrap(); + let stmts = pg_secret_attach_statements(db_resource, "dt", "/tmp").unwrap(); assert_eq!(stmts[0], "INSTALL postgres;"); assert_eq!(stmts[1], "LOAD postgres;"); let secret_name = datatable_secret_name("dt"); @@ -3976,7 +4173,7 @@ mod tests { if let Some(s) = input { db_resource["sslmode"] = json!(s); } - let stmts = pg_secret_attach_statements(db_resource, "dt").unwrap(); + let stmts = pg_secret_attach_statements(db_resource, "dt", "/tmp").unwrap(); assert!( stmts[3].starts_with(&format!("ATTACH 'sslmode={expected}'")), "sslmode {input:?} → {}", @@ -3988,7 +4185,7 @@ mod tests { #[test] fn test_pg_secret_attach_statements_options() { let db_resource = json!({ "host": "h", "dbname": "d", "options": r"-c search_path='a\b'" }); - let stmts = pg_secret_attach_statements(db_resource, "dt").unwrap(); + let stmts = pg_secret_attach_statements(db_resource, "dt", "/tmp").unwrap(); let secret_name = datatable_secret_name("dt"); assert_eq!( stmts[3], @@ -4012,7 +4209,7 @@ mod tests { let db_resource = json!({ "project_id": "my-gcp-project" }); - let result = format_attach_db_conn_str(db_resource, "bigquery").unwrap(); + let result = format_attach_db_conn_str(db_resource, "bigquery", "/tmp").unwrap(); assert_eq!(result, "project=my-gcp-project"); } @@ -4021,7 +4218,7 @@ mod tests { let db_resource = json!({ "other_field": "value" }); - let result = format_attach_db_conn_str(db_resource, "bigquery"); + let result = format_attach_db_conn_str(db_resource, "bigquery", "/tmp"); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("project_id")); } @@ -4029,7 +4226,7 @@ mod tests { #[test] fn test_format_attach_db_conn_str_unsupported_type() { let db_resource = json!({}); - let result = format_attach_db_conn_str(db_resource, "oracle"); + let result = format_attach_db_conn_str(db_resource, "oracle", "/tmp"); assert!(result.is_err()); assert!(result .unwrap_err() @@ -4043,7 +4240,7 @@ mod tests { "host": "localhost", "dbname": "test" }); - let result = format_attach_db_conn_str(db_resource, "POSTGRES").unwrap(); + let result = format_attach_db_conn_str(db_resource, "POSTGRES", "/tmp").unwrap(); // Should be in URI format assert!(result.starts_with("postgres://")); assert!(result.contains("@localhost:5432/test")); @@ -4060,7 +4257,7 @@ mod tests { "database": "app_db", "ssl": true }); - let result = format_attach_db_conn_str(db_resource, "mysql").unwrap(); + let result = format_attach_db_conn_str(db_resource, "mysql", "/tmp").unwrap(); assert!(result.contains("database=app_db")); assert!(result.contains("host=mysql.example.com")); assert!(result.contains("ssl_mode=required")); @@ -4077,7 +4274,7 @@ mod tests { "database": "test", "ssl": false }); - let result = format_attach_db_conn_str(db_resource, "mysql").unwrap(); + let result = format_attach_db_conn_str(db_resource, "mysql", "/tmp").unwrap(); assert!(result.contains("ssl_mode=disabled")); } diff --git a/backend/windmill-worker/src/memory_common.rs b/backend/windmill-worker/src/memory_common.rs index e024acea69..f9ab168050 100644 --- a/backend/windmill-worker/src/memory_common.rs +++ b/backend/windmill-worker/src/memory_common.rs @@ -30,16 +30,21 @@ pub async fn read_from_db( } } -/// Write AI agent memory to database with size checking and truncation +/// Write AI agent memory to database with size checking and truncation. +/// +/// Returns how many of the oldest messages were dropped to fit, so the step can say so +/// on the run: from the flow's side a truncation is invisible, the agent simply having +/// forgotten the start of its conversation by the next one. pub async fn write_to_db( db: &DB, workspace_id: &str, conversation_id: Uuid, step_id: &str, messages: &[OpenAIMessage], -) -> Result<(), Error> { + allow_truncation: bool, +) -> Result { if messages.is_empty() { - return Ok(()); + return Ok(0); } // Serialize messages and check size @@ -49,6 +54,13 @@ pub async fn write_to_db( // Truncate if necessary if size_bytes > MAX_MEMORY_SIZE_BYTES { + // Compaction owns its exchange boundaries, including after an object-store failure. + if !allow_truncation { + return Err(Error::ExecutionErr(format!( + "Memory exceeds database capacity ({} > {} bytes); existing memory was not updated", + size_bytes, MAX_MEMORY_SIZE_BYTES + ))); + } tracing::warn!( "Memory size ({} bytes) exceeds limit ({} bytes) for workspace={} conversation={} step={}. Truncating messages. Use S3 storage in workspace settings to store full conversation history.", size_bytes, @@ -78,7 +90,7 @@ pub async fn write_to_db( .execute(db) .await?; - Ok(()) + Ok(messages.len() - messages_to_store.len()) } /// Delete all memory for a conversation from database @@ -121,3 +133,23 @@ fn truncate_messages( Ok(result) } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn oversized_compaction_memory_is_rejected_before_touching_the_database() { + let db = sqlx::postgres::PgPoolOptions::new() + .connect_lazy_with(sqlx::postgres::PgConnectOptions::new()); + db.close().await; + let message: OpenAIMessage = serde_json::from_value(serde_json::json!({ + "role": "user", "content": "x".repeat(MAX_MEMORY_SIZE_BYTES) + })) + .unwrap(); + let error = write_to_db(&db, "test", Uuid::nil(), "a", &[message], false) + .await + .unwrap_err(); + assert!(error.to_string().contains("Memory exceeds database capacity")); + } +} diff --git a/backend/windmill-worker/src/memory_oss.rs b/backend/windmill-worker/src/memory_oss.rs index dcdab14e2a..64d6c2a0e5 100644 --- a/backend/windmill-worker/src/memory_oss.rs +++ b/backend/windmill-worker/src/memory_oss.rs @@ -30,14 +30,29 @@ pub async fn write_to_memory( conversation_id: Uuid, step_id: &str, messages: &[OpenAIMessage], -) -> anyhow::Result<()> { + allow_truncation: bool, +) -> anyhow::Result { if messages.is_empty() { - return Ok(()); + return Ok(0); } - memory_common::write_to_db(db, workspace_id, conversation_id, step_id, messages) - .await - .map_err(|e| anyhow::anyhow!("Database write failed: {e:?}")) + memory_common::write_to_db( + db, + workspace_id, + conversation_id, + step_id, + messages, + allow_truncation, + ) + .await + .map_err(|e| anyhow::anyhow!("Database write failed: {e:?}")) +} + +/// How many bytes a persisted memory can hold, `None` where nothing bounds it. +/// In OSS: always the database limit +#[cfg(not(all(feature = "private", feature = "enterprise")))] +pub async fn memory_storage_capacity_bytes() -> Option { + Some(memory_common::MAX_MEMORY_SIZE_BYTES) } /// Delete all memory for a conversation from storage diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 5f5ca170b4..6b24b93e77 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -828,7 +828,7 @@ pub async fn handle_python_job( for k, v in list(pre_args.items()): if v == '': del pre_args[k] - kwargs = inner_script.preprocessor(**pre_args) + kwargs = _await(inner_script.preprocessor(**pre_args)) kwrags_json = res_to_json(kwargs, type(kwargs)) with open("args.json", 'w', encoding="utf-8") as f: f.write(kwrags_json)"# @@ -969,6 +969,8 @@ result_json = os.path.join(os.path.abspath(os.path.dirname(__file__)), "result.j def res_to_json(res, typ): {res_to_json_body} +{PY_AWAIT_HELPER} + try: {preprocessor} {spread} @@ -977,7 +979,7 @@ try: del args[k] if inner_script.{main_override} is None or not callable(inner_script.{main_override}): raise ValueError("{main_override} function is missing") - res = inner_script.{main_override}(**args) + res = _await(inner_script.{main_override}(**args)) typ = type(res) if hasattr(res, '__iter__') and not isinstance(res, (str, dict, list, bytes, tuple, set, frozenset, range, memoryview, bytearray)) and typ.__name__ != 'DataFrame': for chunk in res: @@ -1513,6 +1515,8 @@ _fix=lambda s:s if 'Infinity' not in s and 'NaN' not in s and '\\u0000' not in s def res_to_json(res, typ): {res_to_json_body} + +{PY_AWAIT_HELPER} {functions} {registrations} @@ -1546,11 +1550,11 @@ for line in sys.stdin: continue kwargs = json.loads(args_json, strict=False) pre_args = entry['pre_transform'](kwargs) - preprocessed = mod.preprocessor(**pre_args) + preprocessed = _await(mod.preprocessor(**pre_args)) preprocessed_json = json.dumps(preprocessed, separators=(',', ':'), default=str).replace('\n', '') sys.stdout.write("wm_res[preprocessed_args]:" + preprocessed_json + "\n") main_args = entry['transform'](preprocessed if preprocessed else {{}}) - res = mod.main(**main_args) + res = _await(mod.main(**main_args)) typ = type(res) res_json = res_to_json(res, typ) sys.stdout.write("wm_res[success]:" + res_json + "\n") @@ -1574,11 +1578,11 @@ for line in sys.stdin: continue kwargs = json.loads(args_json, strict=False) pre_args = entry['pre_transform'](kwargs) - preprocessed = mod.preprocessor(**pre_args) + preprocessed = _await(mod.preprocessor(**pre_args)) preprocessed_json = json.dumps(preprocessed, separators=(',', ':'), default=str).replace('\n', '') sys.stdout.write("wm_res[preprocessed_args]:" + preprocessed_json + "\n") main_args = entry['transform'](preprocessed if preprocessed else {{}}) - res = mod.main(**main_args) + res = _await(mod.main(**main_args)) typ = type(res) res_json = res_to_json(res, typ) sys.stdout.write("wm_res[success]:" + res_json + "\n") @@ -1596,7 +1600,7 @@ for line in sys.stdin: entry = next(iter(scripts.values())) kwargs = json.loads(args_json, strict=False) args = entry['transform'](kwargs) - res = entry['mod'].main(**args) + res = _await(entry['mod'].main(**args)) typ = type(res) res_json = res_to_json(res, typ) sys.stdout.write("wm_res[success]:" + res_json + "\n") @@ -1624,7 +1628,7 @@ for line in sys.stdin: kwargs = json.loads(args_json, strict=False) args = entry['transform'](kwargs) - res = entry['mod'].main(**args) + res = _await(entry['mod'].main(**args)) typ = type(res) res_json = res_to_json(res, typ) sys.stdout.write("wm_res[success]:" + res_json + "\n") @@ -3330,6 +3334,31 @@ This is not normal behavior, please make sure all workers have enough memory.\n }; } +/// Python helper running what an `async def` main or preprocessor returns. +/// One loop per process: a dedicated worker's module-level async clients and +/// locks are bound to the loop they were first used on, so `asyncio.run`'s +/// loop-per-call would break them from the second job on. A loop the script +/// set while importing (`asyncio.set_event_loop`) is reused. asyncio is +/// imported lazily so sync jobs don't pay its import time. +const PY_AWAIT_HELPER: &str = r#"_loop = None +def _await(r): + global _loop + if not hasattr(r, '__await__'): + return r + import asyncio + if _loop is None or _loop.is_closed(): + import warnings + try: + with warnings.catch_warnings(): + warnings.simplefilter('ignore', DeprecationWarning) + _loop = asyncio.get_event_loop() + if _loop.is_closed(): + raise RuntimeError + except RuntimeError: + _loop = asyncio.new_event_loop() + asyncio.set_event_loop(_loop) + return _loop.run_until_complete(r)"#; + /// Python function body for `res_to_json(res, typ)`. /// Handles DataFrame, bytes, dict coercion + JSON serialization. fn python_res_to_json_body(postprocessor: &str) -> String { diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index a2ddac713e..6b51073b84 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -21,6 +21,7 @@ use windmill_common::get_latest_deployed_hash_for_path; use windmill_common::jobs::InlineScriptTarget; use windmill_common::jobs::RunInlineScriptFnParams; use windmill_common::jobs::WorkerInternalServerInlineUtils; +use windmill_common::jobs::MODULES_ARG; use windmill_common::jobs::WORKER_INTERNAL_SERVER_INLINE_UTILS; use windmill_common::otel_oss::{ otel_incr_worker_execution_count, otel_incr_worker_started, @@ -2321,6 +2322,7 @@ fn start_interactive_worker_shell( job_completed_tx: JobCompletedSender, base_internal_url: String, worker_dir: String, + unreported_job: Arc>>, ) -> JoinHandle<()> { tokio::spawn(async move { let started_at = Instant::now(); @@ -2407,6 +2409,8 @@ fn start_interactive_worker_shell( flow_runners, } = extract_job_and_perms(job, &conn).await; + *unreported_job.lock().unwrap() = Some((job.id, job.workspace_id.clone())); + let authed_client = AuthedClient::new( base_internal_url.to_owned(), job.workspace_id.clone(), @@ -2899,6 +2903,11 @@ pub async fn run_worker( // Only jobs run by this process count towards EXIT_AFTER_N_JOBS: the point is the age of // the environment, not the lifetime total. let mut jobs_executed_in_env: u64 = 0; + // The job poller only records a job in `worker_ping` once it has run for a poll interval, so + // the main-loop ping reports the last pulled job for shorter ones. The interactive shell runs + // jobs under this worker's name too and must set the same slot, or the ping would overwrite a + // shell job its poller recorded with an older main-loop job. Sent once, then left to the poller. + let unreported_job: Arc>> = Default::default(); let is_dedicated_worker: bool = { let config = WORKER_CONFIG.load(); @@ -3016,6 +3025,7 @@ pub async fn run_worker( job_completed_tx.clone(), base_internal_url.to_owned(), worker_dir.clone(), + unreported_job.clone(), ); Some(it_shell) @@ -3219,6 +3229,7 @@ pub async fn run_worker( let read_cgroups = *REFRESH_CGROUP_READINGS && last_reading.elapsed().as_secs() > NUM_SECS_READINGS; + let last_job = unreported_job.lock().unwrap().take(); update_worker_ping_full( &conn, read_cgroups, @@ -3228,6 +3239,7 @@ pub async fn run_worker( &mut occupancy_metrics, &killpill_tx, ip, + last_job.as_ref().map(|(id, w_id)| (*id, w_id.as_str())), ) .await; @@ -3562,6 +3574,7 @@ pub async fn run_worker( last_executed_job = None; jobs_executed += 1; + *unreported_job.lock().unwrap() = Some((job.id, job.workspace_id.clone())); let mut dirties_env = dirties_worker_env( job.kind, &job.tag, @@ -4977,6 +4990,7 @@ pub async fn handle_queued_job( hostname, killpill_rx, &mut has_stream, + job_completed_tx.clone(), )) .await } @@ -5531,12 +5545,16 @@ async fn handle_code_execution_job( None => job, }; - // Any job kind, not just previews: whatever is here is what gets written to the job dir - // and built in, so the agent-worker server precomputing a cache name has to resolve - // modules the same way (`windmill-api-agent-workers`, `get_code_and_lock`). + // Whatever is here is what gets written to the job dir and built in, so the agent-worker + // server precomputing a cache name has to resolve modules the same way + // (`windmill-api-agent-workers`, `get_code_and_lock`). `push` stores `_MODULES` for a + // preview only, from its `RawCode`; a job queued by a server predating that may still carry + // a caller's, and honoring it on a deployed runnable would run caller code as that runnable + // (and as its `on_behalf_of` identity). let modules = modules_from_data.clone().or_else(|| { - job.args.as_ref().and_then(|args| { - args.get("_MODULES").and_then(|raw| { + let args = job.args.as_ref().filter(|_| job.kind == JobKind::Preview); + args.and_then(|args| { + args.get(MODULES_ARG).and_then(|raw| { serde_json::from_str::>(raw.get()) .ok() }) @@ -6308,8 +6326,12 @@ pub async fn run_language_executor( ) .await; - let reserved_variables = + let mut reserved_variables = get_reserved_variables(job, &client.token, conn, parent_runnable_path).await?; + // Same reason as in `build_nativets_env_code`. + if windmill_common::worker::TypeScriptAnnotations::parse(&code).no_network { + reserved_variables.remove("WM_TOKEN"); + } let env_code = format!( "const process = {{ env: {{}} }};\nconst BASE_URL = '{base_internal_url}';\nconst BASE_INTERNAL_URL = '{base_internal_url}';\nprocess.env['BASE_URL'] = BASE_URL;process.env['BASE_INTERNAL_URL'] = BASE_INTERNAL_URL;\n{}", diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index a3257ace36..7c1ea23de4 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -3239,7 +3239,7 @@ struct PushNextFlowJobRec { /// - when the flow opts into `preserve_step_tags` and the child declares its own non-empty tag, /// that tag is honored instead of being overridden by the flow tag; /// - otherwise the child inherits the parent flow job's tag. -fn resolve_flow_step_tag( +pub(crate) fn resolve_flow_step_tag( is_preprocessor_step: bool, flow_tag: &str, workspace_id: &str, diff --git a/backend/windmill-worker/src/worker_lockfiles.rs b/backend/windmill-worker/src/worker_lockfiles.rs index 03150beb00..861a9625e9 100644 --- a/backend/windmill-worker/src/worker_lockfiles.rs +++ b/backend/windmill-worker/src/worker_lockfiles.rs @@ -589,6 +589,16 @@ pub async fn handle_dependency_job( // hash whose content cache has not caught up. windmill_common::invalidate_deployed_script_hash_cache(w_id, script_path); + if restart_perpetual_runs_from_args(job.args.as_ref()) { + windmill_queue::restart_perpetual_runs_on_new_version( + db, + w_id, + script_path, + &job.created_by, + ) + .await; + } + if let Err(e) = handle_deployment_metadata( &job.permissioned_as_email, &job.created_by, @@ -1319,6 +1329,14 @@ pub async fn handle_flow_dependency_job( }))) } +/// Set by a deploy someone made of a perpetual script, and by nothing else: a relock this path +/// gets because an imported script changed leaves the runs of the version it replaces alone. +fn restart_perpetual_runs_from_args(args: Option<&Json>>>) -> bool { + args.and_then(|args| args.0.get("restart_perpetual_runs")) + .and_then(|value| serde_json::from_str::(value.get()).ok()) + .unwrap_or(false) +} + fn get_deployment_msg_and_parent_path_from_args( args: Option>>>, ) -> (Option, Option) { diff --git a/backend/windmill-worker/src/worker_utils.rs b/backend/windmill-worker/src/worker_utils.rs index 67e9831481..3a313e9360 100644 --- a/backend/windmill-worker/src/worker_utils.rs +++ b/backend/windmill-worker/src/worker_utils.rs @@ -28,6 +28,7 @@ pub(crate) async fn update_worker_ping_full( occupancy_metrics: &mut OccupancyMetrics, killpill_tx: &KillpillSender, ip: Option<&str>, + last_job: Option<(Uuid, &str)>, ) { let wc = WORKER_CONFIG.load(); let tags = wc.worker_tags.clone(); @@ -67,6 +68,7 @@ pub(crate) async fn update_worker_ping_full( occupancy_rate_30m, native_mode, ip, + last_job, ) }) .retry( @@ -114,6 +116,7 @@ async fn update_worker_ping_full_inner( occupancy_rate_30m: Option, native_mode: bool, ip: Option<&str>, + last_job: Option<(Uuid, &str)>, ) -> anyhow::Result<()> { match conn { Connection::Sql(db) => { @@ -131,6 +134,7 @@ async fn update_worker_ping_full_inner( occupancy_rate_30m, native_mode, ip, + last_job, db, ) .await?; @@ -141,8 +145,8 @@ async fn update_worker_ping_full_inner( UPDATE_PING_URL, None, &Ping { - last_job_executed: None, - last_job_workspace_id: None, + last_job_executed: last_job.map(|(id, _)| id), + last_job_workspace_id: last_job.map(|(_, w_id)| w_id.to_string()), worker_instance: None, ip: ip.map(str::to_string), tags: Some(tags.to_vec()), diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 3edd3c5318..d81cd2891c 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.819.0"; +export const VERSION = "v1.820.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/chat-sdk/README.md b/chat-sdk/README.md index 1d09b68265..62a5467a47 100644 --- a/chat-sdk/README.md +++ b/chat-sdk/README.md @@ -229,7 +229,8 @@ set) means the turn could not run or be followed at all, such as a refused reque Methods: `sendMessage(text, { inputs?, attachments?, attachmentsInput? })`, `stop()`, `newConversation()`, `selectConversation(id)`, `loadConversations({ page?, perPage?, kind? })`, `deleteConversation(id)`, `renameConversation(id, title)`, `loadOlderMessages()`, -`destroy()`. `kind` lists the flow editor's test chats (`'test'`), the deployed flow's +`resumeTurn(turn)`, `refreshMessages()` (reads what another tab added to the open +conversation), `destroy()`. `kind` lists the flow editor's test chats (`'test'`), the deployed flow's own (`'deployed'`, the server's default) or both (`'all'`); each `Conversation` carries `isTest`. A rename keeps the conversation's place in the list. Switching conversations stops following the current answer; the flow keeps running and, with server history, @@ -261,6 +262,27 @@ user needs read and write on `windmill_uploads/*`, which the default rules grant `job_helpers`, so a restricted token needs `job_helpers:write`; a sandboxed raw app cannot request that scope today, so attachments are not available there yet. +## One turn at a time + +A conversation answers one message at a time, and Windmill enforces it: a message sent +while its previous turn still runs (from another tab, or before a reload) is refused. +`sendMessage` then rejects with a `TurnRunningError` and shows nothing of the message. +With server history, a listed `Conversation` also carries `runningTurn` while it is +answering. Either way, `resumeTurn(turn)` follows that turn in the selected +conversation: its answer streams in from the start and the turn finishes as if it had +been sent here, after which the message can be sent again. + +```ts +// `runningTurn` comes from the listing, so read it before selecting. +await chat.loadConversations() +const running = chat.getState().conversations.find((c) => c.id === id)?.runningTurn +await chat.selectConversation(id) +if (running) await chat.resumeTurn(running) +``` + +A dropped connection to the answer is retried; when it keeps failing, the chat stops +streaming and waits for the flow's result instead, so the turn still ends. + ## History Windmill stores every conversation of a chat-mode flow, and each Windmill user sees diff --git a/chat-sdk/package-lock.json b/chat-sdk/package-lock.json index d74b6217ac..652d75acac 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-chat", - "version": "1.819.0", + "version": "1.820.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-chat", - "version": "1.819.0", + "version": "1.820.0", "license": "Apache-2.0", "devDependencies": { "@ai-sdk/react": "^4.0.102", diff --git a/chat-sdk/package.json b/chat-sdk/package.json index 7de263a3b9..eb57f7e969 100644 --- a/chat-sdk/package.json +++ b/chat-sdk/package.json @@ -1,7 +1,7 @@ { "name": "windmill-chat", "description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app", - "version": "1.819.0", + "version": "1.820.0", "author": "Ruben Fiszel", "license": "Apache-2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme", diff --git a/chat-sdk/src/ai-sdk.ts b/chat-sdk/src/ai-sdk.ts index d93bb99e2f..0ff422562b 100644 --- a/chat-sdk/src/ai-sdk.ts +++ b/chat-sdk/src/ai-sdk.ts @@ -169,9 +169,19 @@ function chunkStream( for (const e of event.events) parts.apply(e) continue } + // A result polled after the stream failed: the round that was streaming stopped + // wherever the connection did. Sent chunks cannot be taken back, so the answer's + // rest follows them, or the whole answer when it does not continue them. + const cut = parts.openText ?? '' parts.closeOpen() failure = await failureText(api, entry.jobId, event.result, signal) - if (failure === undefined && !parts.streamedText) { + if (failure === undefined && event.streamLost) { + const answer = extractChatAnswer(event.result) + if (answer !== undefined && answer !== cut) { + parts.text(answer.startsWith(cut) ? answer.slice(cut.length) : answer) + parts.closeOpen() + } + } else if (failure === undefined && !parts.streamedText) { // No agent streamed: the flow's result is the answer. const answer = extractChatAnswer(event.result) if (answer !== undefined) { @@ -216,6 +226,8 @@ async function failureText( */ class PartWriter { streamedText = false + /** The text of the round still streaming, until a tool call or the end closes it. */ + openText: string | undefined #textId: string | undefined #reasoningId: string | undefined #started = new Set() @@ -227,8 +239,10 @@ class PartWriter { this.streamedText = true if (!this.#textId) { this.#textId = randomId() + this.openText = '' this.emit({ type: 'text-start', id: this.#textId }) } + this.openText += delta this.emit({ type: 'text-delta', id: this.#textId, delta }) } @@ -248,6 +262,7 @@ class PartWriter { if (this.#textId) { this.emit({ type: 'text-end', id: this.#textId }) this.#textId = undefined + this.openText = undefined } } diff --git a/chat-sdk/src/api.ts b/chat-sdk/src/api.ts index a14e9800a1..ed97832aae 100644 --- a/chat-sdk/src/api.ts +++ b/chat-sdk/src/api.ts @@ -13,13 +13,53 @@ export interface WindmillChatApiOptions { export class WindmillApiError extends Error { constructor( message: string, - readonly status: number + readonly status: number, + /** The response body, as the server sent it. */ + readonly body?: string ) { super(message) this.name = 'WindmillApiError' } } +/** The turn a conversation is still answering, as the server reports it. */ +export interface RunningTurn { + jobId: string + /** `created_seq` of the user message that started the turn. */ + userSeq: number +} + +/** + * A message was sent to a conversation whose turn is still running. The server refuses + * it (409) so two runs never write one agent memory; `turn` is the run to follow instead. + */ +export class TurnRunningError extends Error { + constructor( + message: string, + readonly turn: RunningTurn + ) { + super(message) + this.name = 'TurnRunningError' + } +} + +/** + * The running turn named by a run's 409 body, or undefined for any other body. Exported + * for a custom `run` that calls Windmill through its own client: it rethrows the refusal + * as a `TurnRunningError` so the chat can follow the running turn. + */ +export function turnRunningError(body: string): TurnRunningError | undefined { + try { + const parsed = JSON.parse(body) as { error?: unknown; running_turn?: FlowConversation['running_turn'] } + const turn = parsed?.running_turn + if (!turn || typeof turn.job_id !== 'string' || typeof turn.user_seq !== 'number') return undefined + const message = typeof parsed.error === 'string' ? parsed.error : 'this conversation is still answering a message' + return new TurnRunningError(message, { jobId: turn.job_id, userSeq: turn.user_seq }) + } catch { + return undefined + } +} + export interface FlowConversation { id: string workspace_id: string @@ -30,6 +70,8 @@ export interface FlowConversation { created_by: string /** Started from the flow editor's test panel rather than a deployed run. */ is_test: boolean + /** Set by the list: the turn this conversation is still answering. */ + running_turn?: { job_id: string; user_seq: number } | null } /** @@ -111,18 +153,27 @@ export class WindmillChatApi { this.#pollDelayMs = options.pollDelayMs } - /** Starts a turn: runs the flow with `memory_id` set to the conversation id. Returns the job id. */ + /** + * Starts a turn: runs the flow with `memory_id` set to the conversation id. Returns the + * job id. Throws `TurnRunningError` when the conversation is still answering. + */ async runFlow( flowPath: string, args: Record, options: { memoryId: string; signal?: AbortSignal } ): Promise { - const res = await this.#request(`jobs/run/f/${encodePath(flowPath)}`, { - method: 'POST', - query: { memory_id: options.memoryId, skip_preprocessor: 'true' }, - body: args, - signal: options.signal - }) + let res: Response + try { + res = await this.#request(`jobs/run/f/${encodePath(flowPath)}`, { + method: 'POST', + query: { memory_id: options.memoryId, skip_preprocessor: 'true' }, + body: args, + signal: options.signal + }) + } catch (e) { + if (e instanceof WindmillApiError && e.status === 409) throw turnRunningError(e.body ?? '') ?? e + throw e + } return (await res.text()).trim() } @@ -146,7 +197,9 @@ export class WindmillChatApi { signal: options.signal }) if (!res.body) { - throw new WindmillApiError('The job update stream has no body', res.status) + // Status 0, whatever the status line said: a response with no stream in it comes from + // something in front of Windmill, so it is followed by a reconnect like any other. + throw new WindmillApiError('The job update stream has no body', 0) } for await (const data of readServerSentEvents(res.body)) { try { @@ -294,7 +347,8 @@ export class WindmillChatApi { const text = await res.text().catch(() => '') throw new WindmillApiError( `${init.method ?? 'GET'} ${path} failed (${res.status})${text ? `: ${text}` : ''}`, - res.status + res.status, + text ) } return res diff --git a/chat-sdk/src/chat.ts b/chat-sdk/src/chat.ts index 2e2c52f3df..dfef438f33 100644 --- a/chat-sdk/src/chat.ts +++ b/chat-sdk/src/chat.ts @@ -1,9 +1,11 @@ import { + TurnRunningError, WindmillApiError, WindmillChatApi, type ConversationKind, type FlowConversation, - type FlowConversationMessage + type FlowConversationMessage, + type RunningTurn } from './api' import { resolveConfig, type ResolvedConfig } from './config' import { followJob } from './follow' @@ -38,6 +40,8 @@ const PERSIST_DEBOUNCE_MS = 250 /** Messages persist from spawned tasks that can land just after the flow completes. */ const RECONCILE_ATTEMPTS = 3 const RECONCILE_DELAY_MS = 400 +/** Rows per read of what a turn wrote; a fuller page is read on from its last row. */ +const ROWS_PAGE = 100 interface Turn { controller: AbortController @@ -73,6 +77,11 @@ class ChatImpl implements Chat { /** The kind the caller last listed, so the refresh after a new turn lists the same rows. */ #conversationKind: ConversationKind | undefined #persistTimer: ReturnType | undefined + /** Settles once the selected conversation's first page has been read. */ + #selecting: Promise = Promise.resolve() + /** Bumped whenever a turn is taken or the conversation is left: a read that started before + * it describes a conversation this chat has moved past. */ + #epoch = 0 constructor(options: ChatOptions) { this.#config = resolveConfig(options) @@ -141,6 +150,7 @@ class ChatImpl implements Chat { streamedText: false } this.#turn = turn + this.#epoch++ const timestamp = now() const conversation: Conversation = this.#state.conversations.find( @@ -186,6 +196,109 @@ class ChatImpl implements Chat { turn.jobId = this.#config.run ? await this.#config.run(args, context) : await this.#api.runFlow(this.#config.flowPath, args, context) + } catch (e) { + try { + // Nothing of this message reached the server, whether the conversation was already + // answering one sent elsewhere or an attachment never uploaded: the message is taken + // back rather than shown as a failed turn, and the caller gets the reason. + if (e instanceof TurnRunningError || !turn.started) { + this.#withdrawTurn(turn) + throw e + } + // stop() and a conversation switch abort the turn and settle the state themselves. + if (turn.controller.signal.aborted || isAbortError(e)) return + this.#failTurn(turn, e) + return + } finally { + if (this.#turn === turn) this.#turn = undefined + } + } + await this.#followTurn(turn, isNew) + } + + resumeTurn = async ({ jobId, userSeq }: RunningTurn): Promise => { + const conversationId = this.#state.conversationId + if (!conversationId || this.#turn) return + if (this.#state.history !== 'server') { + if (this.#state.status === 'submitted') this.#set({ status: 'idle' }) + return + } + const turn: Turn = { + controller: new AbortController(), + conversationId, + userMessageId: '', + jobId, + // Its run was asked for elsewhere and is already going: there is nothing to withdraw, + // and the conversation it belongs to is listed. + isNew: false, + started: true, + withdrawn: false, + streamedText: false + } + this.#turn = turn + this.#epoch++ + this.#set({ status: 'submitted', error: undefined }) + try { + // The conversation's first page may still be on its way; it would land over the turn. + await this.#selecting + if (!this.#turnActive(turn)) return + // A listing is a snapshot: the turn it named can have ended and another one started + // since. The newest user message this chat holds names the turn to follow instead — + // following the one the listing named would drop the newer turn's rows and leave the + // chat idle while it runs. + const newest = this.#latestUserAfter(userSeq) + if (newest) { + if (!newest.jobId) { + // Its row is written and its run is not named yet: there is nothing to follow, so + // it is left to the next listing to name the turn that runs now. + if (this.#turn === turn) this.#turn = undefined + this.#set({ status: 'idle' }) + return + } + turn.jobId = newest.jobId + userSeq = newest.seq! + } + // The stream replays the turn from its start, so what this chat shows of it goes and + // comes back as it replays: its rows, and what an earlier follow of it streamed or + // failed with, its own unread question included. The message that started it stays: + // it is the turn's anchor, and a long turn can have pushed it off the page this chat + // opened on. An earlier turn's answer that only its flow result gave has no row and so + // no seq; it names that turn's job, and it stays wherever the fallback put it. + const held = this.#state.messages + const at = held.findIndex((m) => m.seq === userSeq) + let user = at === -1 ? undefined : held[at] + let messages = held.filter((m, i) => { + if (m.seq !== undefined) return m.seq < userSeq + if (m.pending || m.jobId === turn.jobId) return false + return m.jobId !== undefined || (at !== -1 && i < at) + }) + if (user) messages = [...messages, user] + if (!user) { + const [row] = await this.#api.listMessages(conversationId, { + afterSeq: userSeq - 1, + perPage: 1, + signal: turn.controller.signal + }) + if (!this.#turnActive(turn)) return + if (row?.created_seq !== userSeq || row.message_type !== 'user') { + throw new Error('windmill-chat: the message that started the running turn is gone') + } + user = fromRow(row) + messages = [...messages, user] + } + turn.userMessageId = user.id + this.#set({ messages }) + } catch (e) { + if (!(turn.controller.signal.aborted || isAbortError(e))) this.#failTurn(turn, e) + if (this.#turn === turn) this.#turn = undefined + return + } + await this.#followTurn(turn, false) + } + + async #followTurn(turn: Turn, isNew: boolean): Promise { + let nextTurn: RunningTurn | undefined + try { const stopPolling = this.#state.history === 'server' ? this.#startPolling(turn) : () => {} let result: unknown try { @@ -193,7 +306,7 @@ class ChatImpl implements Chat { } finally { stopPolling() } - await this.#finishTurn(turn, result, isNew) + nextTurn = await this.#finishTurn(turn, result, isNew) } catch (e) { if (!turn.started) { // Nothing ran: the message is withdrawn rather than shown as a failed turn, and the @@ -208,6 +321,7 @@ class ChatImpl implements Chat { } finally { if (this.#turn === turn) this.#turn = undefined } + if (nextTurn) await this.resumeTurn(nextTurn) } stop = async (): Promise => { @@ -249,8 +363,14 @@ class ChatImpl implements Chat { }) } - selectConversation = async (conversationId: string): Promise => { - if (conversationId === this.#state.conversationId) return + selectConversation = (conversationId: string): Promise => { + if (conversationId === this.#state.conversationId) return this.#selecting + const selecting = this.#select(conversationId) + this.#selecting = selecting.catch(() => {}) + return selecting + } + + async #select(conversationId: string): Promise { this.#leaveConversation() this.#page = 1 this.#set({ @@ -379,9 +499,19 @@ class ChatImpl implements Chat { }) if (this.#state.conversationId !== conversationId) return const known = new Set(this.#state.messages.map((m) => m.serverId ?? m.id)) + // Pages count back from the newest row, so rows written since the first page shift + // newer rows into this one; and a resumed turn drops the rows it replays. Only what + // is older than everything held belongs above it. + const oldest = this.#state.messages.reduce( + (min, m) => (m.seq !== undefined && (min === undefined || m.seq < min) ? m.seq : min), + undefined + ) + const older = rows + .map(fromRow) + .filter((m) => !known.has(m.id) && (oldest === undefined || m.seq! < oldest)) this.#page = page this.#set({ - messages: [...rows.map(fromRow).filter((m) => !known.has(m.id)), ...this.#state.messages], + messages: [...older, ...this.#state.messages], hasMoreMessages: rows.length === this.#config.pageSize }) } finally { @@ -389,6 +519,56 @@ class ChatImpl implements Chat { } } + refreshMessages = async (): Promise => { + const conversationId = this.#state.conversationId + if ( + !conversationId || + this.#turn || + this.#state.history !== 'server' || + this.#state.loadingMessages + ) { + return + } + const newestBefore = latestSeq(this.#state.messages) + const failure = lastFailureShown(this.#state.messages) + // A turn that starts and ends while these rows are read, or the conversation being left + // and opened again, leaves the chat holding messages this read knows nothing about, and + // the rows would land under them rather than in their own place. They are left to the + // next read, which sees the conversation as it is now. + const epoch = this.#epoch + await this.#syncFromServer(conversationId, () => this.#epoch === epoch) + if (this.#epoch !== epoch) return + if (!failure || this.#state.conversationId !== conversationId) return + if (!this.#state.messages.some((m) => m.id === failure.id)) return + // The turn this chat lost may have been carried to its end elsewhere, which only a row + // this read brought can say, and only one of that turn's own: an agent writes its answer + // from a task the run does not wait for, so an earlier turn's answer can commit after + // this turn's question and would otherwise settle it with someone else's answer. The + // failed turn's job is on the message it left; unknown jobs accept the row, as everywhere + // else the turn's jobs are read. + const jobs = failure.jobId ? await this.#turnJobIds(failure.jobId) : undefined + if (this.#state.conversationId !== conversationId) return + const held = this.#state.messages + if (!held.some((m) => m.id === failure.id)) return + const answered = held.some( + (m) => + m.seq !== undefined && + m.seq > newestBefore && + m.role === 'assistant' && + m.success && + (jobs === undefined || m.jobId === undefined || jobs.has(m.jobId)) + ) + if (!answered) return + // A turn that ran, or failed, while those jobs were read owns the state now: this one's + // failure still goes, since its answer is here, but that turn's outcome stands. + const settles = + this.#state.status === 'error' && lastFailureShown(held)?.id === failure.id + this.#set({ + messages: held.filter((m) => m.id !== failure.id), + ...(settles ? { status: 'idle' as const, error: undefined } : {}) + }) + } + destroy = (): void => { this.#leaveConversation() } @@ -398,7 +578,10 @@ class ChatImpl implements Chat { async #follow(turn: Turn, onStreamStart: () => void): Promise { let started = false for await (const event of followJob(this.#api, turn.jobId!, { signal: turn.controller.signal })) { - if (event.type === 'completed') return event.result + if (event.type === 'completed') { + if (event.streamLost) this.#dropCutRound(turn) + return event.result + } if (!started) { started = true // Persisted rows for the streaming step would duplicate what is streaming. @@ -409,6 +592,19 @@ class ChatImpl implements Chat { throw new Error('windmill-chat: the job stream ended before the flow completed') } + /** + * The stream failed while a round's text was arriving, so that text stops wherever the + * connection did. It goes: the persisted rows or, without them, the flow result give + * the whole answer instead. Rounds a tool call closed were complete and stay. + */ + #dropCutRound(turn: Turn): void { + if (!this.#turnActive(turn)) return + const cut = turn.assistantId + turn.assistantId = undefined + turn.streamedText = false + if (cut) this.#set({ messages: this.#state.messages.filter((m) => m.id !== cut) }) + } + #applyEvents(turn: Turn, events: AgentStreamEvent[]): void { if (events.length === 0 || !this.#turnActive(turn)) return let messages = [...this.#state.messages] @@ -506,18 +702,20 @@ class ChatImpl implements Chat { this.#set({ messages, status: 'streaming' }) } - async #finishTurn(turn: Turn, result: unknown, isNew: boolean): Promise { + async #finishTurn(turn: Turn, result: unknown, isNew: boolean): Promise { if (!this.#turnActive(turn)) return if (this.#state.history === 'server') { - turn.jobIds = await this.#turnJobIds(turn) + turn.jobIds = await this.#turnJobIds(turn.jobId!, turn.controller.signal) if (!this.#turnActive(turn)) return const reconciled = await this.#reconcileTurn(turn) if (!this.#turnActive(turn)) return if (reconciled) { - this.#set({ status: 'idle' }) + const nextTurn = this.#nextTurnAfter(turn) + this.#set({ status: nextTurn ? 'submitted' : 'idle' }) this.#config.onFinish?.({ conversationId: turn.conversationId, jobId: turn.jobId, messages: this.#state.messages }) if (isNew) await this.loadConversations().catch(() => {}) - return + if (!this.#turnActive(turn)) return + return nextTurn } // Server history just proved unreadable: the turn completes as local history. } @@ -540,9 +738,13 @@ class ChatImpl implements Chat { messages = [...messages, assistantMessage(answer, true, turn.jobId)] } } - this.#set({ messages: finalized(messages), status: 'idle' }) + const nextTurn = this.#state.history === 'server' ? this.#nextTurnAfter(turn) : undefined + this.#set({ messages: finalized(messages), status: nextTurn ? 'submitted' : 'idle' }) this.#persistLocal() this.#config.onFinish?.({ conversationId: turn.conversationId, jobId: turn.jobId, messages: this.#state.messages }) + // `onFinish` can switch conversation, and the next turn belongs to this one. + if (!this.#turnActive(turn)) return + return nextTurn } /** @@ -561,11 +763,7 @@ class ChatImpl implements Chat { for (let attempt = 1; attempt <= RECONCILE_ATTEMPTS; attempt++) { let rows: FlowConversationMessage[] try { - rows = await this.#api.listMessages(turn.conversationId, { - afterSeq: this.#lastSeq(), - perPage: 100, - signal: turn.controller.signal - }) + rows = await this.#rowsAfterLastSeq(turn.conversationId, turn.controller.signal) } catch (e) { if (isAbortError(e)) throw e if (this.#fallBackToLocal(e)) return false @@ -592,22 +790,26 @@ class ChatImpl implements Chat { * a badly delayed one can invert that order at the cost of the reconcile * retries). The content is not compared with the flow result: an image answer, a * structured one and a forwarded agent result are all persisted in a shape the - * result does not reproduce. Rows carrying a job id belong to the turn when the - * job is one of the turn's, which leaves out an earlier turn whose job outlived - * `stop()` (a token without `jobs:write` cannot cancel it); a tool row without one - * (an MCP call runs inside the agent step) belongs to whatever turn is under way. + * result does not reproduce. A row belongs to the turn when it was created after + * the user message and, when it carries a job id, the job is one of the turn's. The + * server refuses a turn while the previous run is queued, but an agent writes its + * answer row from a task the run does not wait for, so that row can still land after + * the next user message; its job says whose it is. A tool row without a job (an MCP + * call runs inside the agent step) belongs to the turn under way. Until the user + * message's own row has been read, its position in the list stands in for its seq. */ #answered(turn: Turn): boolean { const messages = this.#state.messages const from = messages.findIndex((m) => m.id === turn.userMessageId) + const userSeq = messages[from]?.seq const ownJob = (m: ChatMessage) => turn.jobIds === undefined || (m.jobId === undefined ? m.role === 'tool' : turn.jobIds.has(m.jobId)) let latest: ChatMessage | undefined - for (let i = from + 1; i < messages.length; i++) { - const m = messages[i] - if (m.seq === undefined || m.role === 'user' || !ownJob(m)) continue + messages.forEach((m, i) => { + if (m.seq === undefined || m.role === 'user' || !ownJob(m)) return + if (userSeq !== undefined ? m.seq <= userSeq : i <= from) return if (latest === undefined || m.seq > latest.seq!) latest = m - } + }) return latest?.role === 'assistant' } @@ -615,22 +817,29 @@ class ChatImpl implements Chat { * The flow job plus every step job it ran, the failure and preprocessor steps * included (a failure handler's answer is persisted under its own job), and the * jobs an agent step's tool calls ran as (a tool row is persisted under its own - * job too). Unknown when the read fails. + * job too). A failed read is retried like the rows are; unknown when it keeps + * failing or the credential may not read jobs. Unknown accepts every row after the + * question: refusing them would leave a token without job access with no turn ever + * answered, each one finished a second time from its result. */ - async #turnJobIds(turn: Turn): Promise | undefined> { - try { - const job = await this.#api.getFlowJob(turn.jobId!, turn.controller.signal) - const ids = new Set([turn.jobId!]) - const status = job.flow_status - for (const m of [...(status?.modules ?? []), status?.failure_module, status?.preprocessor_module]) { - if (m?.job) ids.add(m.job) - for (const j of m?.flow_jobs ?? []) ids.add(j) - for (const a of m?.agent_actions ?? []) if (a.job_id) ids.add(a.job_id) + async #turnJobIds(jobId: string, signal?: AbortSignal): Promise | undefined> { + for (let attempt = 1; ; attempt++) { + try { + const job = await this.#api.getFlowJob(jobId, signal) + const ids = new Set([jobId]) + const status = job.flow_status + for (const m of [...(status?.modules ?? []), status?.failure_module, status?.preprocessor_module]) { + if (m?.job) ids.add(m.job) + for (const j of m?.flow_jobs ?? []) ids.add(j) + for (const a of m?.agent_actions ?? []) if (a.job_id) ids.add(a.job_id) + } + return ids + } catch (e) { + if (isAbortError(e)) throw e + const refused = e instanceof WindmillApiError && e.status >= 400 && e.status < 500 + if (refused || attempt === RECONCILE_ATTEMPTS) return undefined + await sleep(RECONCILE_DELAY_MS, signal) } - return ids - } catch (e) { - if (isAbortError(e)) throw e - return undefined } } @@ -689,11 +898,7 @@ class ChatImpl implements Chat { } if (stopped) return try { - const rows = await this.#api.listMessages(turn.conversationId, { - afterSeq: this.#lastSeq(), - perPage: 100, - signal - }) + const rows = await this.#rowsAfterLastSeq(turn.conversationId, signal) if (!stopped && this.#turnActive(turn)) this.#mergeRows(rows) } catch { // transient; the completion reconciliation catches up @@ -706,12 +911,22 @@ class ChatImpl implements Chat { } } - async #syncFromServer(conversationId: string): Promise { - const rows = await this.#api.listMessages(conversationId, { - afterSeq: this.#lastSeq(), - perPage: 100 - }) + /** Every row created after the newest one held, however many pages that takes. */ + async #rowsAfterLastSeq(conversationId: string, signal?: AbortSignal): Promise { + const rows: FlowConversationMessage[] = [] + let afterSeq = this.#lastSeq() + while (true) { + const page = await this.#api.listMessages(conversationId, { afterSeq, perPage: ROWS_PAGE, signal }) + rows.push(...page) + if (page.length < ROWS_PAGE) return rows + afterSeq = page[page.length - 1].created_seq + } + } + + async #syncFromServer(conversationId: string, stillCurrent?: () => boolean): Promise { + const rows = await this.#rowsAfterLastSeq(conversationId) if (this.#turn || this.#state.conversationId !== conversationId) return + if (stillCurrent && !stillCurrent()) return this.#mergeRows(rows) this.#set({ messages: finalized(this.#state.messages) }) } @@ -828,6 +1043,21 @@ class ChatImpl implements Chat { return this.#turn === turn && this.#state.conversationId === turn.conversationId } + #latestUserAfter(userSeq: number): ChatMessage | undefined { + return this.#state.messages.reduce( + (found, message) => + message.role === 'user' && message.seq !== undefined && message.seq > (found?.seq ?? userSeq) ? message : found, + undefined + ) + } + + #nextTurnAfter(turn: Turn): RunningTurn | undefined { + const userSeq = this.#state.messages.find((message) => message.id === turn.userMessageId)?.seq + if (userSeq === undefined) return undefined + const next = this.#latestUserAfter(userSeq) + return next?.jobId && next.seq !== undefined ? { jobId: next.jobId, userSeq: next.seq } : undefined + } + /** Stops following the current answer; the flow itself keeps running. */ #detachTurn(): void { const turn = this.#turn @@ -842,6 +1072,7 @@ class ChatImpl implements Chat { * written out now rather than on the debounce that may never fire. */ #leaveConversation(): void { + this.#epoch++ if (this.#turn) { this.#detachTurn() this.#set({ messages: finalized(this.#state.messages), status: 'idle' }) @@ -859,6 +1090,20 @@ class ChatImpl implements Chat { } } +/** The failure this chat is showing: the message `#failTurn` left, which is never a row. */ +function lastFailureShown(messages: readonly ChatMessage[]): ChatMessage | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i] + if (m.seq === undefined && m.success === false) return m + } + return undefined +} + +/** The newest row seq a list holds; 0 when it holds none. */ +function latestSeq(messages: readonly ChatMessage[]): number { + return messages.reduce((newest, m) => (m.seq !== undefined && m.seq > newest ? m.seq : newest), 0) +} + function fromRow(row: FlowConversationMessage): ChatMessage { const toolName = row.message_type === 'tool' @@ -898,7 +1143,10 @@ function fromConversation(row: FlowConversation): Conversation { title: row.title ?? undefined, createdAt: row.created_at, updatedAt: row.updated_at, - isTest: row.is_test + isTest: row.is_test, + runningTurn: row.running_turn + ? { jobId: row.running_turn.job_id, userSeq: row.running_turn.user_seq } + : undefined } } diff --git a/chat-sdk/src/follow.ts b/chat-sdk/src/follow.ts index 50f101393e..6402b0d6f7 100644 --- a/chat-sdk/src/follow.ts +++ b/chat-sdk/src/follow.ts @@ -1,13 +1,18 @@ -import type { WindmillChatApi } from './api' +import { WindmillApiError, type WindmillChatApi } from './api' import { createStreamEventParser, type AgentStreamEvent } from './stream' -import { abortError, sleep } from './utils' +import { abortError, isAbortError, sleep } from './utils' const RECONNECT_DELAY_MS = 300 +const MAX_RECONNECT_DELAY_MS = 5000 +/** Consecutive failed connections before the job is polled instead. */ +const MAX_CONNECTION_FAILURES = 3 +const RESULT_POLL_MS = 2000 export type FollowEvent = /** Agent events decoded from the job's result stream; empty when a chunk ended mid-line. */ | { type: 'stream'; events: AgentStreamEvent[] } - | { type: 'completed'; result: unknown } + /** `streamLost`: the result was polled after the stream failed, so what streamed may stop short. */ + | { type: 'completed'; result: unknown; streamLost?: boolean } /** * Follows a job to completion across the server's stream timeouts: every @@ -18,6 +23,10 @@ export type FollowEvent = * The offset indexes the stream of one sub-job (`flow_stream_job_id`, the flow's * streaming step). A retried step gets a new one, so when the id changes the * offset is dropped and the connection reopened from that sub-job's start. + * + * A connection that fails (a proxy restarting, the network dropping) is retried with + * backoff. The run is still going, so after a few failures in a row the job's result is + * polled instead: the rest of the answer is not streamed, but the turn still ends. */ export async function* followJob( api: WindmillChatApi, @@ -27,45 +36,86 @@ export async function* followJob( let parser = createStreamEventParser() let offset = options.streamOffset let streamJobId: string | undefined - while (true) { + let failures = 0 + while (failures < MAX_CONNECTION_FAILURES) { let reopen = false - for await (const update of api.streamJob(jobId, { streamOffset: offset, signal: options.signal })) { - if (update.type === 'ping') continue - if (update.type === 'timeout') { - reopen = true - break - } - if (update.type === 'error') throw new Error(update.error) - if (update.type === 'notfound') throw new Error(`Job ${jobId} not found`) - if (update.flow_stream_job_id && update.flow_stream_job_id !== streamJobId) { - const switched = streamJobId !== undefined && offset !== undefined - streamJobId = update.flow_stream_job_id - if (switched) { - // This connection skipped the new sub-job's first chunks: start it over. - offset = undefined - options.onOffset?.(undefined) - parser = createStreamEventParser() + try { + for await (const update of api.streamJob(jobId, { streamOffset: offset, signal: options.signal })) { + // Opening a connection does not prove it is carrying the job. Only stream progress + // clears the count, or status snapshots followed by EOF would never reach polling. + if (update.type === 'ping') continue + if (update.type === 'timeout') { reopen = true break } + if (update.type === 'error') throw new Error(update.error) + if (update.type === 'notfound') throw new Error(`Job ${jobId} not found`) + if (update.flow_stream_job_id && update.flow_stream_job_id !== streamJobId) { + const switched = streamJobId !== undefined && offset !== undefined + streamJobId = update.flow_stream_job_id + if (switched) { + // This connection skipped the new sub-job's first chunks: start it over. + failures = 0 + offset = undefined + options.onOffset?.(undefined) + parser = createStreamEventParser() + reopen = true + break + } + } + if (update.stream_offset !== undefined) { + if (update.stream_offset !== offset) failures = 0 + offset = update.stream_offset + options.onOffset?.(offset) + } + if (update.new_result_stream) { + failures = 0 + yield { type: 'stream', events: parser.push(update.new_result_stream) } + } + if (update.completed) { + const rest = parser.flush() + if (rest.length > 0) yield { type: 'stream', events: rest } + yield { type: 'completed', result: update.only_result } + return + } } - if (update.stream_offset !== undefined) { - offset = update.stream_offset - options.onOffset?.(offset) - } - if (update.new_result_stream) { - yield { type: 'stream', events: parser.push(update.new_result_stream) } - } - if (update.completed) { - const rest = parser.flush() - if (rest.length > 0) yield { type: 'stream', events: rest } - yield { type: 'completed', result: update.only_result } - return - } + } catch (e) { + if (options.signal?.aborted || isAbortError(e) || !isConnectionFailure(e)) throw e + failures++ + if (failures >= MAX_CONNECTION_FAILURES) break + await sleep(Math.min(RECONNECT_DELAY_MS * 2 ** failures, MAX_RECONNECT_DELAY_MS), options.signal) + continue } if (options.signal?.aborted) throw abortError() - // The server closes the connection after its timeout; a dropped connection looks - // the same minus the event. Either way the offset lets the next one resume. - if (!reopen) await sleep(RECONNECT_DELAY_MS, options.signal) + // The server closes the connection after its timeout; a connection that ends without + // that event, and without the job completing, carried nothing to its end. The offset + // lets the next one resume, and it counts like a failed one so a gateway closing every + // stream this way still reaches the polling below rather than reconnecting for ever. + if (!reopen) { + failures++ + if (failures >= MAX_CONNECTION_FAILURES) break + await sleep(RECONNECT_DELAY_MS, options.signal) + } + } + while (true) { + await sleep(RESULT_POLL_MS, options.signal) + try { + const { completed, result } = await api.getCompletedResult(jobId, options.signal) + if (completed) { + yield { type: 'completed', result, streamLost: true } + return + } + } catch (e) { + if (options.signal?.aborted || isAbortError(e) || !isConnectionFailure(e)) throw e + } } } + +/** + * A failure that says nothing about the job: the request never reached Windmill, or a + * gateway in front of it answered. A 4xx from Windmill itself (not found, refused) does. + */ +function isConnectionFailure(e: unknown): boolean { + if (e instanceof WindmillApiError) return e.status >= 500 || e.status === 0 + return e instanceof TypeError +} diff --git a/chat-sdk/src/index.ts b/chat-sdk/src/index.ts index 9ed2ed0b7e..a050edce37 100644 --- a/chat-sdk/src/index.ts +++ b/chat-sdk/src/index.ts @@ -3,7 +3,10 @@ export { detectRawApp, type RawAppContext } from './config' export { WindmillChatApi, WindmillApiError, + TurnRunningError, + turnRunningError, readServerSentEvents, + type RunningTurn, type WindmillChatApiOptions, type ConversationKind, type FlowConversation, diff --git a/chat-sdk/src/react.ts b/chat-sdk/src/react.ts index 33193842f2..c32324ee18 100644 --- a/chat-sdk/src/react.ts +++ b/chat-sdk/src/react.ts @@ -6,6 +6,7 @@ export type UseWindmillChat = ChatState & Pick< Chat, | 'sendMessage' + | 'resumeTurn' | 'stop' | 'newConversation' | 'selectConversation' @@ -13,6 +14,7 @@ export type UseWindmillChat = ChatState & | 'deleteConversation' | 'renameConversation' | 'loadOlderMessages' + | 'refreshMessages' > & { chat: Chat } /** @@ -61,13 +63,15 @@ export function useWindmillChat(options: ChatOptions): UseWindmillChat { chat, sendMessage: (text, options) => chat.sendMessage(text, { ...options, inputs: { ...latest.current.inputs, ...options?.inputs } }), + resumeTurn: chat.resumeTurn, stop: chat.stop, newConversation: chat.newConversation, selectConversation: chat.selectConversation, loadConversations: chat.loadConversations, deleteConversation: chat.deleteConversation, renameConversation: chat.renameConversation, - loadOlderMessages: chat.loadOlderMessages + loadOlderMessages: chat.loadOlderMessages, + refreshMessages: chat.refreshMessages }), [state, chat] ) diff --git a/chat-sdk/src/types.ts b/chat-sdk/src/types.ts index 99e16fe304..180238e50c 100644 --- a/chat-sdk/src/types.ts +++ b/chat-sdk/src/types.ts @@ -1,3 +1,5 @@ +import type { RunningTurn } from './api' + export type ChatRole = 'user' | 'assistant' | 'tool' | 'system' /** @@ -61,6 +63,11 @@ export interface Conversation { * server has listed the conversation; unset for one only this client has seen. */ isTest?: boolean + /** + * The turn the conversation was still answering when the server listed it: started in + * another tab, or before this chat was created. Pass it to `resumeTurn` to follow it. + */ + runningTurn?: RunningTurn } export interface ChatState { @@ -164,8 +171,17 @@ export interface Chat { * Sends a message in the current conversation, starting one when there is none. Resolves * when the answer is complete. Rejects when the message could not be sent at all — a turn * already running, an attachment that failed to upload — without touching the transcript. + * A conversation still answering a message sent elsewhere rejects with `TurnRunningError`: + * follow that turn with `resumeTurn`, then send again. */ sendMessage(text: string, options?: SendMessageOptions): Promise + /** + * Follows a turn of the current conversation that this chat did not start, as named by + * `Conversation.runningTurn` or a `TurnRunningError`: its answer streams into `messages` + * from the start and the turn finishes like one sent here. Server history only; resolves + * when the answer is complete, at once when a turn is already being followed. + */ + resumeTurn(turn: RunningTurn): Promise /** Stops following the answer and asks Windmill to cancel the run. */ stop(): Promise newConversation(): void @@ -183,6 +199,11 @@ export interface Chat { /** Sets a conversation's title. The list keeps its order: only a turn moves a conversation. */ renameConversation(conversationId: string, title: string): Promise loadOlderMessages(): Promise + /** + * Reads what the current conversation gained since its newest message held here, such + * as a turn another tab ran. Server history only; does nothing while a turn is followed. + */ + refreshMessages(): Promise /** Stops background work (stream, polling) and writes local history out. The chat stays usable. */ destroy(): void } diff --git a/chat-sdk/test/ai-sdk-chat.test.ts b/chat-sdk/test/ai-sdk-chat.test.ts index a0bbb7b8a1..4cef282a82 100644 --- a/chat-sdk/test/ai-sdk-chat.test.ts +++ b/chat-sdk/test/ai-sdk-chat.test.ts @@ -59,4 +59,26 @@ describe('AI SDK Chat over the Windmill transport', () => { expect(chat.error?.message).toBe('boom') expect(memoryIds.length === 1 || calls.filter((c) => c.method === 'POST')[1].url.searchParams.get('memory_id') === memoryIds[0]).toBe(true) }) + + test('a text part cut by a lost stream is completed from the polled result', async () => { + let streams = 0 + const { fetch } = fetchMock( + (c) => (c.method === 'POST' && c.url.pathname === `/api/w/ws/jobs/run/f/${FLOW}` ? text('job-1') : undefined), + (c) => + c.url.pathname.endsWith('/getupdate_sse/job-1') + ? ++streams === 1 + ? sse([{ type: 'update', new_result_stream: ndjson({ type: 'token_delta', content: 'The ans' }), stream_offset: 1 }]) + : text('bad gateway', 502) + : undefined, + (c) => + c.url.pathname.endsWith('/get_result_maybe/job-1') + ? json({ completed: true, success: true, result: { output: 'The answer is 42', messages: [] } }) + : undefined + ) + const transport = createWindmillChatTransport({ baseUrl: 'http://wm.test', workspace: 'ws', flowPath: FLOW, token: 'tok', fetch }) + const chat = new Chat({ id: 'cut-chat', transport }) + await chat.sendMessage({ text: 'what is it?' }) + const texts = chat.messages[1].parts.filter((p) => p.type === 'text').map((p) => (p as { text: string }).text) + expect(texts.join('')).toBe('The answer is 42') + }, 15000) }) diff --git a/chat-sdk/test/chat.test.ts b/chat-sdk/test/chat.test.ts index a739abf3ad..3845c91ac7 100644 --- a/chat-sdk/test/chat.test.ts +++ b/chat-sdk/test/chat.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test' +import { TurnRunningError } from '../src/api' import { createChat } from '../src/chat' import type { ChatOptions } from '../src/types' import { fetchMock, json, memoryStorage, messageRow, ndjson, sse, sseTimed, text, type Route } from './support' @@ -11,6 +12,15 @@ const run: Route = (c) => const streamPath = '/api/w/ws/jobs_u/getupdate_sse/job-1' +/** Waits for a condition the code under test must reach, and fails saying which one. */ +async function until(done: () => boolean, what: string, timeoutMs = 2000): Promise { + const deadline = Date.now() + timeoutMs + while (!done()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`) + await new Promise((resolve) => setTimeout(resolve, 0)) + } +} + function options(extra: Partial, fetch: ChatOptions['fetch']): ChatOptions { return { flowPath: FLOW, baseUrl: BASE, workspace: 'ws', fetch, storage: memoryStorage(), ...extra } } @@ -945,41 +955,752 @@ describe('createChat with server history', () => { ]) }) - test('a late answer from a stopped job is not taken as the next turn answer', async () => { - let jobs = 0 - let reads = 0 + test('a message refused because a turn is running leaves nothing behind and names that turn', async () => { const { fetch } = fetchMock( - (c) => (c.method === 'POST' && c.url.pathname.includes('/jobs/run/f/') ? text(`job-${++jobs}`) : undefined), - // job-1 never completes: the connection just ends, so the turn keeps waiting. - (c) => (c.url.pathname.endsWith('/getupdate_sse/job-1') ? sse([{ type: 'update' }]) : undefined), (c) => - c.url.pathname.endsWith('/getupdate_sse/job-2') - ? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'second answer' } }]) + c.method === 'POST' && c.url.pathname === `/api/w/ws/jobs/run/f/${FLOW}` + ? text(JSON.stringify({ error: 'still answering', running_turn: { job_id: 'job-9', user_seq: 41 } }), 409) + : undefined, + (c) => (c.url.pathname.endsWith('/messages') ? json([messageRow(40, 'user', 'earlier')]) : undefined) + ) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') + const refused = await chat.sendMessage('again').catch((e) => e) + expect(refused).toBeInstanceOf(TurnRunningError) + expect(refused.turn).toEqual({ jobId: 'job-9', userSeq: 41 }) + const state = chat.getState() + expect(state.messages.map((m) => m.content)).toEqual(['earlier']) + expect(state.status).toBe('idle') + // The conversation is a real one, answering elsewhere: it stays listed, its message gone. + expect(state.conversations.map((c) => c.id)).toEqual(['conv']) + }) + + test('a listing that names a turn already over follows the one running now', async () => { + const { fetch, calls } = fetchMock( + (c) => + c.url.pathname === '/api/w/ws/jobs_u/getupdate_sse/job-2' + ? sse([ + { + type: 'update', + new_result_stream: ndjson({ type: 'token_delta', content: 'second answer' }), + stream_offset: 1, + completed: true, + only_result: { windmill_chat_answer: 'second answer' } + } + ]) : undefined, - // The run-only token cannot cancel: job-1 keeps running after stop(). - (c) => (c.url.pathname.includes('/queue/cancel/') ? text('forbidden', 400) : undefined), (c) => c.url.pathname.endsWith('/jobs_u/get/job-2') ? json({ flow_status: { modules: [{ job: 'step-2' }] } }) : undefined, - // Read 1 is stop()'s sync; the stopped job's answer lands after the second user row. + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + const after = c.url.searchParams.get('after_seq') + if (after === '52') return json([messageRow(53, 'assistant', 'second answer', { job_id: 'step-2' })]) + return json([ + messageRow(50, 'user', 'first'), + messageRow(51, 'assistant', 'first answer'), + messageRow(52, 'user', 'second', { job_id: 'job-2' }) + ]) + } + ) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') + // The listing named the first turn; it ended and the second one started before this select. + await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 }) + expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-1'))).toBe(false) + expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(true) + expect(chat.getState().messages.map((m) => [m.serverId, m.content])).toEqual([ + ['row-50', 'first'], + ['row-51', 'first answer'], + ['row-52', 'second'], + ['row-53', 'second answer'] + ]) + expect(chat.getState().status).toBe('idle') + expect(chat.getState().error).toBeUndefined() + }) + + test('a newer message whose run is not named yet leaves the chat free', async () => { + const { fetch, calls } = fetchMock( (c) => c.url.pathname.endsWith('/messages') - ? json( - ++reads === 1 - ? [messageRow(71, 'user', 'first')] - : reads === 2 - ? [messageRow(72, 'user', 'second'), messageRow(73, 'assistant', 'first answer, late', { job_id: 'step-1' })] - : [messageRow(74, 'assistant', 'second answer', { job_id: 'step-2' })] - ) + ? json([messageRow(50, 'user', 'first'), messageRow(52, 'user', 'second')]) + : undefined + ) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') + await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 }) + expect(chat.getState().status).toBe('idle') + expect(calls.some((c) => c.url.pathname.includes('getupdate_sse'))).toBe(false) + // A message sent now starts its own turn rather than being refused. + expect(chat.getState().error).toBeUndefined() + }) + + test('a turn started while a resumed turn reconciles is followed next', async () => { + const { fetch, calls } = fetchMock( + (c) => + c.url.pathname === streamPath + ? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'first answer' } }]) + : undefined, + (c) => + c.url.pathname === '/api/w/ws/jobs_u/getupdate_sse/job-2' + ? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'second answer' } }]) + : undefined, + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-1') + ? json({ flow_status: { modules: [{ job: 'step-1' }] } }) + : undefined, + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-2') + ? json({ flow_status: { modules: [{ job: 'step-2' }] } }) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + const after = c.url.searchParams.get('after_seq') + if (after === null) return json([messageRow(50, 'user', 'first', { job_id: 'job-1' })]) + if (after === '50') { + return json([ + messageRow(51, 'assistant', 'first answer', { job_id: 'step-1' }), + messageRow(52, 'user', 'second', { job_id: 'job-2' }) + ]) + } + if (after === '52') return json([messageRow(53, 'assistant', 'second answer', { job_id: 'step-2' })]) + return json([]) + } + ) + const chat = createChat(options({}, fetch)) + const statuses: string[] = [] + chat.subscribe((state) => statuses.push(state.status)) + await chat.selectConversation('conv') + await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 }) + expect(calls.some((c) => c.url.pathname === streamPath)).toBe(true) + expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(true) + expect(chat.getState().messages.map((m) => [m.serverId, m.content])).toEqual([ + ['row-50', 'first'], + ['row-51', 'first answer'], + ['row-52', 'second'], + ['row-53', 'second answer'] + ]) + expect(chat.getState().status).toBe('idle') + // Vacuous without this: with no 'submitted' at all, both lookups are -1 and the slice empty. + expect(statuses).toContain('submitted') + expect(statuses.slice(statuses.indexOf('submitted'), statuses.lastIndexOf('submitted') + 1)).not.toContain('idle') + }) + + test('a fallback to local history during handoff does not leave the chat busy', async () => { + let messageFetches = 0 + const { fetch, calls } = fetchMock( + (c) => + c.url.pathname === streamPath + ? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'first answer' } }]) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + messageFetches++ + if (messageFetches === 1) return json([messageRow(50, 'user', 'first', { job_id: 'job-1' })]) + if (messageFetches === 2) return json([messageRow(52, 'user', 'second', { job_id: 'job-2' })]) + return text('forbidden', 403) + } + ) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') + await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 }) + expect(chat.getState().history).toBe('local') + expect(chat.getState().status).toBe('idle') + expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(false) + }) + + test('a conversation list fallback during handoff does not leave the chat busy', async () => { + const { fetch, calls } = fetchMock( + run, + (c) => + c.url.pathname === streamPath + ? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'first answer' } }]) + : undefined, + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-1') + ? json({ flow_status: { modules: [{ job: 'job-1' }] } }) + : undefined, + (c) => + c.url.pathname.endsWith('/messages') + ? json([ + messageRow(50, 'user', 'first', { job_id: 'job-1' }), + messageRow(51, 'assistant', 'first answer', { job_id: 'job-1' }), + messageRow(52, 'user', 'second', { job_id: 'job-2' }) + ]) + : undefined, + (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? text('forbidden', 403) : undefined) + ) + const chat = createChat(options({}, fetch)) + await chat.sendMessage('first') + expect(chat.getState().history).toBe('local') + expect(chat.getState().status).toBe('idle') + expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(false) + }) + + test('a conversation switch during handoff does not resume the next turn elsewhere', async () => { + let releaseList!: (response: Response) => void + const listGate = new Promise((resolve) => { + releaseList = resolve + }) + const { fetch, calls } = fetchMock( + run, + (c) => + c.url.pathname === streamPath + ? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'first answer' } }]) + : undefined, + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-1') + ? json({ flow_status: { modules: [{ job: 'job-1' }] } }) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + if (c.url.pathname.includes('/flow_conversations/other/messages')) return json([]) + return json([ + messageRow(50, 'user', 'first', { job_id: 'job-1' }), + messageRow(51, 'assistant', 'first answer', { job_id: 'job-1' }), + messageRow(52, 'user', 'second', { job_id: 'job-2' }) + ]) + }, + (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? listGate : undefined) + ) + const chat = createChat(options({}, fetch)) + const sent = chat.sendMessage('first') + await until(() => calls.some((c) => c.url.pathname === '/api/w/ws/flow_conversations/list'), 'the list to be asked for') + await chat.selectConversation('other') + releaseList(json([])) + await sent + expect(chat.getState().conversationId).toBe('other') + expect(chat.getState().status).toBe('idle') + expect(chat.getState().messages).toEqual([]) + expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(false) + }) + + test('resuming a later turn keeps an earlier answer that only the flow result gave', async () => { + const { fetch } = fetchMock( + run, + (c) => + c.url.pathname === streamPath + ? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'first answer' } }]) + : undefined, + (c) => + c.url.pathname === '/api/w/ws/jobs_u/getupdate_sse/job-2' + ? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'second answer' } }]) + : undefined, + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-1') + ? json({ flow_status: { modules: [{ job: 'step-1' }] } }) + : undefined, + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-2') + ? json({ flow_status: { modules: [{ job: 'step-2' }] } }) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + const after = c.url.searchParams.get('after_seq') + if (after === '51') return json([messageRow(52, 'user', 'second', { job_id: 'job-2' })]) + if (after === '52') return json([messageRow(53, 'assistant', 'second answer', { job_id: 'step-2' })]) + // The first answer never got a row: the turn finishes from the flow result. + return json([messageRow(50, 'user', 'first', { job_id: 'job-1' })]) + }, + (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined) + ) + const chat = createChat(options({}, fetch)) + await chat.sendMessage('first') + await chat.resumeTurn({ jobId: 'job-2', userSeq: 52 }) + expect(chat.getState().messages.map((m) => m.content)).toEqual([ + 'first', + 'first answer', + 'second', + 'second answer' + ]) + }) + + test('resuming a turn this chat lost drops what it had shown of that turn', async () => { + let streams = 0 + const { fetch } = fetchMock( + (c) => { + if (c.url.pathname !== streamPath) return undefined + return ++streams === 1 + ? sse([ + { type: 'update', new_result_stream: ndjson({ type: 'token_delta', content: 'partial' }), stream_offset: 1 }, + { type: 'error', error: 'stream broke' } + ]) + : sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'the answer' } }]) + }, + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-1') + ? json({ flow_status: { modules: [{ job: 'step-1' }] } }) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + if (c.url.searchParams.get('after_seq') === '50') { + return json([messageRow(51, 'assistant', 'the answer', { job_id: 'step-1' })]) + } + return json([messageRow(50, 'user', 'question', { job_id: 'job-1' })]) + } + ) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') + await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 }) + expect(chat.getState().status).toBe('error') + // The run goes on; following it again replays the whole answer under its question. + await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 }) + expect(chat.getState().messages.map((m) => m.content)).toEqual(['question', 'the answer']) + }) + + test('resuming a turn this chat sent and lost shows its question once', async () => { + let streams = 0 + const { fetch } = fetchMock( + run, + (c) => { + if (c.url.pathname !== streamPath) return undefined + return ++streams === 1 + ? sse([ + { type: 'update', new_result_stream: ndjson({ type: 'token_delta', content: 'partial' }), stream_offset: 1 }, + { type: 'error', error: 'stream broke' } + ]) + : sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'the answer' } }]) + }, + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-1') + ? json({ flow_status: { modules: [{ job: 'step-1' }] } }) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + const after = c.url.searchParams.get('after_seq') + if (after === '49') return json([messageRow(50, 'user', 'question', { job_id: 'job-1' })]) + if (after === '50') return json([messageRow(51, 'assistant', 'the answer', { job_id: 'step-1' })]) + // The stream fails before the question's row is read. + return json([]) + }, + (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined) + ) + const chat = createChat(options({}, fetch)) + await chat.sendMessage('question') + expect(chat.getState().status).toBe('error') + await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 }) + expect(chat.getState().messages.map((m) => m.content)).toEqual(['question', 'the answer']) + }) + + test('a re-read that finds the lost turn answered clears the failure it was left with', async () => { + let answered = false + const { fetch } = fetchMock( + (c) => + c.url.pathname === streamPath + ? sse([{ type: 'error', error: 'stream broke' }]) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + const rows = [messageRow(50, 'user', 'question', { job_id: 'job-1' })] + if (answered) rows.push(messageRow(51, 'assistant', 'the answer', { job_id: 'job-1' })) + return json(rows) + } + ) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') + await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 }) + expect(chat.getState().status).toBe('error') + // Another tab carried the turn to its end and its answer is in the rows now. + answered = true + await chat.refreshMessages() + // The answer stands alone: the failure this chat showed was never a row. + expect(chat.getState().messages.map((m) => m.content)).toEqual(['question', 'the answer']) + expect(chat.getState().status).toBe('idle') + expect(chat.getState().error).toBeUndefined() + }) + + test("a re-read does not settle a failed turn with the previous turn's late answer", async () => { + const { fetch } = fetchMock( + (c) => + c.url.pathname === '/api/w/ws/jobs_u/getupdate_sse/job-b' + ? sse([{ type: 'error', error: 'stream broke' }]) + : undefined, + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-b') + ? json({ flow_status: { modules: [{ job: 'step-b' }] } }) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + const rows = [ + messageRow(50, 'user', 'qA', { job_id: 'job-a' }), + messageRow(51, 'user', 'qB', { job_id: 'job-b' }) + ] + // Turn A's answer commits from its detached task, after B's question. + if (c.url.searchParams.get('after_seq') === '51') { + return json([messageRow(52, 'assistant', "A's answer", { job_id: 'step-a' })]) + } + return json(rows) + } + ) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') + await chat.resumeTurn({ jobId: 'job-b', userSeq: 51 }) + expect(chat.getState().status).toBe('error') + await chat.refreshMessages() + // A's answer is not B's: B's failure stands, and it is still shown. + expect(chat.getState().status).toBe('error') + expect(chat.getState().messages.some((m) => m.success === false && m.seq === undefined)).toBe(true) + }) + + test("a re-read settles the failed turn from its own answer, not only from the newest row", async () => { + const { fetch } = fetchMock( + (c) => + c.url.pathname === streamPath ? sse([{ type: 'error', error: 'stream broke' }]) : undefined, + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-1') + ? json({ flow_status: { modules: [{ job: 'step-1' }] } }) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + if (c.url.searchParams.get('after_seq') === '50') { + // The turn's answer, and behind it the question of a turn started elsewhere. + return json([ + messageRow(51, 'assistant', 'the answer', { job_id: 'step-1' }), + messageRow(52, 'user', 'next question', { job_id: 'job-2' }) + ]) + } + return json([messageRow(50, 'user', 'question', { job_id: 'job-1' })]) + } + ) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') + await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 }) + expect(chat.getState().status).toBe('error') + await chat.refreshMessages() + expect(chat.getState().status).toBe('idle') + expect(chat.getState().messages.map((m) => m.content)).toEqual([ + 'question', + 'the answer', + 'next question' + ]) + }) + + test('a conversation left and opened again while its rows are read drops that read', async () => { + let releaseRows!: (r: Response) => void + const rowsGate = new Promise((resolve) => (releaseRows = resolve)) + let reads = 0 + const { fetch } = fetchMock((c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + if (c.url.pathname.includes('/flow_conversations/other/')) return json([]) + reads++ + // The read the refresh made, answered only after the reader has come back. + if (reads === 2) return rowsGate + return json([messageRow(150, 'user', 'newest page', { job_id: 'job-x' })]) + }) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') + const refreshed = chat.refreshMessages() + await chat.selectConversation('other') + await chat.selectConversation('conv') + // Rows from before the reader left: older than the page the chat holds now. + releaseRows(json([messageRow(51, 'user', 'older page', { job_id: 'job-y' })])) + await refreshed + expect(chat.getState().messages.map((m) => m.content)).toEqual(['newest page']) + }) + + test('a turn that runs while the rows are read leaves them to the next read', async () => { + let releaseRows!: (r: Response) => void + const rowsGate = new Promise((resolve) => (releaseRows = resolve)) + let reads = 0 + const { fetch } = fetchMock( + run, + (c) => + c.url.pathname.includes('/jobs_u/getupdate_sse/') + ? sse([{ type: 'error', error: 'stream broke' }]) + : undefined, + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-1') + ? json({ flow_status: { modules: [{ job: 'job-1' }] } }) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + if (++reads === 1) return json([messageRow(50, 'user', 'qA', { job_id: 'job-a' })]) + // The read the refresh made, answered only after another turn has come and gone. + if (reads === 2) return rowsGate + return json([]) + }, + (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined) + ) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') + const refreshed = chat.refreshMessages() + // A turn starts and fails entirely while that read is in flight. + await chat.sendMessage('qB') + expect(chat.getState().status).toBe('error') + releaseRows(json([messageRow(51, 'assistant', "A's answer", { job_id: 'job-a' })])) + await refreshed + // A's answer is not appended under B: it waits for a read that sees the conversation as + // it is now. + expect(chat.getState().messages.map((m) => m.content)).toEqual([ + 'qA', + 'qB', + 'stream broke' + ]) + }) + + test('a turn that starts while the jobs are read still frees the answered failure', async () => { + let releaseJobs!: (r: Response) => void + const jobsGate = new Promise((resolve) => (releaseJobs = resolve)) + let releaseRun!: (r: Response) => void + const runGate = new Promise((resolve) => (releaseRun = resolve)) + const { fetch, calls } = fetchMock( + (c) => (c.method === 'POST' && c.url.pathname === `/api/w/ws/jobs/run/f/${FLOW}` ? runGate : undefined), + (c) => + c.url.pathname.includes('/jobs_u/getupdate_sse/') + ? sse([{ type: 'error', error: 'stream broke' }]) + : undefined, + (c) => (c.url.pathname.endsWith('/jobs_u/get/job-a') ? jobsGate : undefined), + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-b') + ? json({ flow_status: { modules: [{ job: 'job-b' }] } }) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + if (c.url.searchParams.get('after_seq') === '50') { + return json([messageRow(51, 'assistant', "A's answer", { job_id: 'job-a' })]) + } + return json([messageRow(50, 'user', 'qA', { job_id: 'job-a' })]) + }, + (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined) + ) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') + await chat.resumeTurn({ jobId: 'job-a', userSeq: 50 }) + expect(chat.getState().status).toBe('error') + const refreshed = chat.refreshMessages() + await until( + () => calls.some((c) => c.url.pathname.endsWith('/jobs_u/get/job-a')), + 'the job read to start' + ) + // A turn starts while that read is in flight, and is still running when it answers. + const sent = chat.sendMessage('qB') + await until(() => chat.getState().status === 'submitted', 'the new turn to take the chat') + releaseJobs(json({ flow_status: { modules: [{ job: 'job-a' }] } })) + await refreshed + // A's failure goes with its answer; the running turn keeps the chat busy. + expect(chat.getState().messages.some((m) => m.success === false)).toBe(false) + expect(chat.getState().status).toBe('submitted') + releaseRun(text('job-b')) + await sent + }) + + test('a turn that fails while the jobs are read keeps its own failure', async () => { + let releaseJobs!: (r: Response) => void + const jobsGate = new Promise((resolve) => (releaseJobs = resolve)) + const { fetch, calls } = fetchMock( + run, + (c) => + c.url.pathname === streamPath || c.url.pathname === '/api/w/ws/jobs_u/getupdate_sse/job-1' + ? sse([{ type: 'error', error: 'stream broke' }]) + : undefined, + (c) => (c.url.pathname.endsWith('/jobs_u/get/job-a') ? jobsGate : undefined), + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-1') + ? json({ flow_status: { modules: [{ job: 'job-1' }] } }) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + if (c.url.searchParams.get('after_seq') === '50') { + return json([messageRow(51, 'assistant', "A's answer", { job_id: 'job-a' })]) + } + return json([messageRow(50, 'user', 'qA', { job_id: 'job-a' })]) + }, + (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined) + ) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') + await chat.resumeTurn({ jobId: 'job-a', userSeq: 50 }) + expect(chat.getState().status).toBe('error') + // The refresh stops on the job read; a second turn is sent and fails while it waits. + const refreshed = chat.refreshMessages() + await until( + () => calls.some((c) => c.url.pathname.endsWith('/jobs_u/get/job-a')), + 'the job read to start' + ) + await chat.sendMessage('qB') + expect(chat.getState().status).toBe('error') + releaseJobs(json({ flow_status: { modules: [{ job: 'job-a' }] } })) + await refreshed + // A's failure goes, since its answer is here; the error the chat shows is B's. + expect(chat.getState().status).toBe('error') + const failures = chat.getState().messages.filter((m) => m.success === false) + expect(failures).toHaveLength(1) + expect(chat.getState().messages.map((m) => m.content)).toContain("A's answer") + }) + + test('a re-read that finds no answer leaves the failure standing', async () => { + const { fetch } = fetchMock( + (c) => + c.url.pathname === streamPath ? sse([{ type: 'error', error: 'stream broke' }]) : undefined, + (c) => + c.url.pathname.endsWith('/messages') + ? json([messageRow(50, 'user', 'question', { job_id: 'job-1' })]) + : undefined + ) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') + await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 }) + expect(chat.getState().status).toBe('error') + await chat.refreshMessages() + expect(chat.getState().status).toBe('error') + expect(chat.getState().messages.map((m) => m.content)).toEqual(['question', 'stream broke']) + }) + + test('a conversation switch from onFinish does not resume the next turn elsewhere', async () => { + const { fetch, calls } = fetchMock( + run, + (c) => + c.url.pathname === streamPath + ? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'first answer' } }]) + : undefined, + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-1') + ? json({ flow_status: { modules: [{ job: 'job-1' }] } }) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + if (c.url.pathname.includes('/flow_conversations/other/messages')) return json([]) + // No answer row lands, so the turn finishes from the flow result. + return json([ + messageRow(50, 'user', 'first', { job_id: 'job-1' }), + messageRow(52, 'user', 'second', { job_id: 'job-2' }) + ]) + }, + (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined) + ) + const chat = createChat(options({ onFinish: () => void chat.selectConversation('other') }, fetch)) + await chat.sendMessage('first') + expect(chat.getState().conversationId).toBe('other') + expect(chat.getState().messages).toEqual([]) + expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(false) + }) + + test('stopping during handoff does not resume the next turn', async () => { + let releaseList!: (response: Response) => void + const listGate = new Promise((resolve) => { + releaseList = resolve + }) + const { fetch, calls } = fetchMock( + run, + (c) => + c.url.pathname === streamPath + ? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'first answer' } }]) + : undefined, + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-1') + ? json({ flow_status: { modules: [{ job: 'job-1' }] } }) + : undefined, + (c) => + c.url.pathname.endsWith('/messages') + ? json([ + messageRow(50, 'user', 'first', { job_id: 'job-1' }), + messageRow(51, 'assistant', 'first answer', { job_id: 'job-1' }), + messageRow(52, 'user', 'second', { job_id: 'job-2' }) + ]) + : undefined, + (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? listGate : undefined) + ) + const chat = createChat(options({}, fetch)) + const sent = chat.sendMessage('first') + await until(() => calls.some((c) => c.url.pathname === '/api/w/ws/flow_conversations/list'), 'the list to be asked for') + const stopped = chat.stop() + releaseList(json([])) + await Promise.all([sent, stopped]) + expect(chat.getState().status).toBe('idle') + expect(calls.some((c) => c.url.pathname.includes('getupdate_sse/job-2'))).toBe(false) + }) + + test('a stream that keeps ending before the job completes hands the turn to polling', async () => { + let streams = 0 + const { fetch } = fetchMock( + run, + // Every real connection starts with a status snapshot. It is not stream progress. + (c) => (c.url.pathname === streamPath ? (streams++, sse([{ type: 'update', running: true }])) : undefined), + (c) => + c.url.pathname.endsWith('/get_result_maybe/job-1') + ? json({ completed: true, success: true, result: { windmill_chat_answer: 'polled' } }) + : undefined, + (c) => + c.url.pathname.endsWith('/messages') + ? json([messageRow(81, 'user', 'hi'), messageRow(82, 'assistant', 'polled')]) : undefined, (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined) ) const chat = createChat(options({}, fetch)) - const first = chat.sendMessage('first') - await new Promise((r) => setTimeout(r, 50)) - await chat.stop() - await first + await chat.sendMessage('hi') + expect(streams).toBe(3) + expect(chat.getState().status).toBe('idle') + expect(chat.getState().messages.map((m) => m.content)).toEqual(['hi', 'polled']) + }, 15000) + + test('resuming a turn whose message is off the first page replays it without duplicating rows', async () => { + const { fetch } = fetchMock( + (c) => + c.url.pathname === streamPath && !c.url.searchParams.has('stream_offset') + ? sse([ + { + type: 'update', + new_result_stream: ndjson( + { type: 'tool_call', call_id: 'c1', function_name: 'lookup' }, + { type: 'tool_result', call_id: 'c1', function_name: 'lookup', result: '1', success: true }, + { type: 'token_delta', content: 'Done' } + ), + stream_offset: 3, + completed: true, + only_result: { output: 'Done', messages: [] } + } + ]) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + const after = c.url.searchParams.get('after_seq') + // The first page holds only what the running turn wrote so far. + if (after === null) return json([messageRow(51, 'tool', 'Used lookup tool')]) + if (after === '49') return json([messageRow(50, 'user', 'hi')]) + return json([messageRow(51, 'tool', 'Used lookup tool'), messageRow(52, 'assistant', 'Done')]) + } + ) + const chat = createChat(options({}, fetch)) + void chat.selectConversation('conv') + await chat.resumeTurn({ jobId: 'job-1', userSeq: 50 }) + expect(chat.getState().messages.map((m) => [m.serverId, m.role, m.content, m.pending])).toEqual([ + ['row-50', 'user', 'hi', false], + ['row-51', 'tool', 'Used lookup tool', false], + ['row-52', 'assistant', 'Done', false] + ]) + expect(chat.getState().status).toBe('idle') + }) + + test("the previous run's answer landing after the next message is not that turn's answer", async () => { + let reads = 0 + let jobReads = 0 + const { fetch } = fetchMock( + run, + (c) => + c.url.pathname === streamPath + ? sse([{ type: 'update', completed: true, only_result: { windmill_chat_answer: 'second answer' } }]) + : undefined, + // A transient failure of the job read is retried, not taken as "any row counts". + (c) => + c.url.pathname.endsWith('/jobs_u/get/job-1') + ? ++jobReads === 1 + ? text('bad gateway', 502) + : json({ flow_status: { modules: [{ job: 'step-2' }] } }) + : undefined, + (c) => { + if (!c.url.pathname.endsWith('/messages')) return undefined + if (!c.url.searchParams.has('after_seq')) return json([messageRow(71, 'user', 'first')]) + // The earlier agent wrote its answer from a task its run did not wait for. + return json( + ++reads === 1 + ? [messageRow(72, 'user', 'second'), messageRow(73, 'assistant', 'first answer, late', { job_id: 'step-1' })] + : [messageRow(74, 'assistant', 'second answer', { job_id: 'step-2' })] + ) + } + ) + const chat = createChat(options({}, fetch)) + await chat.selectConversation('conv') await chat.sendMessage('second') expect(chat.getState().messages.map((m) => [m.role, m.content, m.serverId])).toEqual([ ['user', 'first', 'row-71'], @@ -989,6 +1710,64 @@ describe('createChat with server history', () => { ]) }) + test('an answer cut by a lost stream gives way to the polled result', async () => { + let streams = 0 + const { fetch } = fetchMock( + run, + (c) => + c.url.pathname === streamPath + ? ++streams === 1 + ? sse([{ type: 'update', new_result_stream: ndjson({ type: 'token_delta', content: 'Hel' }), stream_offset: 1 }]) + : text('bad gateway', 502) + : undefined, + (c) => + c.url.pathname.endsWith('/get_result_maybe/job-1') + ? json({ completed: true, success: true, result: { windmill_chat_answer: 'Hello, full answer' } }) + : undefined + ) + const chat = createChat(options({ history: 'none' }, fetch)) + await chat.sendMessage('hi') + expect(chat.getState().messages.map((m) => [m.role, m.content, m.pending])).toEqual([ + ['user', 'hi', false], + ['assistant', 'Hello, full answer', false] + ]) + }, 15000) + + test('a stream that keeps failing hands the turn to polling the job', async () => { + // Each connection opens, sends the server's ping, then drops. + const pingThenDrop = () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({ type: 'ping' })}\n\n`)) + setTimeout(() => controller.error(new TypeError('network connection was lost')), 20) + } + }), + { status: 200, headers: { 'content-type': 'text/event-stream' } } + ) + const { fetch } = fetchMock( + run, + (c) => (c.url.pathname === streamPath ? pingThenDrop() : undefined), + (c) => + c.url.pathname.endsWith('/get_result_maybe/job-1') + ? json({ completed: true, success: true, result: { windmill_chat_answer: 'polled' } }) + : undefined, + (c) => + c.url.pathname.endsWith('/messages') + ? json([messageRow(61, 'user', 'hi'), messageRow(62, 'assistant', 'polled')]) + : undefined, + (c) => (c.url.pathname === '/api/w/ws/flow_conversations/list' ? json([]) : undefined) + ) + const chat = createChat(options({}, fetch)) + await chat.sendMessage('hi') + const state = chat.getState() + expect(state.status).toBe('idle') + expect(state.messages.map((m) => [m.role, m.content, m.success])).toEqual([ + ['user', 'hi', true], + ['assistant', 'polled', true] + ]) + }, 15000) + test('a failure handler answer is attributed to the turn', async () => { let reads = 0 const { fetch } = fetchMock( diff --git a/chat-sdk/test/follow.test.ts b/chat-sdk/test/follow.test.ts index 19af6baf38..991b1a7886 100644 --- a/chat-sdk/test/follow.test.ts +++ b/chat-sdk/test/follow.test.ts @@ -23,3 +23,20 @@ test('a retried streaming step reports its offset as lost before the new sub-job } expect(offsets).toEqual([3, undefined, 1]) }) + +test('a 200 carrying no stream is reconnected like any other failed connection', async () => { + let streams = 0 + const { fetch } = fetchMock((c) => { + if (!c.url.pathname.endsWith('/jobs_u/getupdate_sse/job-1')) return undefined + // Something in front of Windmill answers 200 with nothing in it. + if (++streams === 1) return new Response(null, { status: 200 }) + return sse([{ type: 'update', completed: true, only_result: 'ok' }]) + }) + const api = new WindmillChatApi({ baseUrl: 'http://wm.test', workspace: 'ws', token: 'tok', fetch }) + let result: unknown + for await (const event of followJob(api, 'job-1')) { + if (event.type === 'completed') result = event.result + } + expect(streams).toBe(2) + expect(result).toBe('ok') +}) diff --git a/cli/src/commands/app/generate_agents.ts b/cli/src/commands/app/generate_agents.ts index eec86a8b19..6e3bac6888 100644 --- a/cli/src/commands/app/generate_agents.ts +++ b/cli/src/commands/app/generate_agents.ts @@ -186,7 +186,14 @@ export async function regenerateAgentDocs( } // Read local data configuration from raw_app.yaml - let localData: { tables?: string[]; datatable?: string; schema?: string } | undefined; + let localData: + | { + tables?: string[]; + datatable?: string; + schema?: string; + roles?: Record; + } + | undefined; try { const rawApp = (await yamlParseFile(rawAppPath)) as Record; if (rawApp.data && typeof rawApp.data === "object") { diff --git a/cli/src/commands/datatable/datatable.ts b/cli/src/commands/datatable/datatable.ts index 6609d08c50..46ee7b4c39 100644 --- a/cli/src/commands/datatable/datatable.ts +++ b/cli/src/commands/datatable/datatable.ts @@ -111,6 +111,8 @@ const migrateCommand = new Command() ) .action(migrateDown as any); +type DataTableResourceType = "postgresql" | "instance" | "external_instance"; + async function create( opts: GlobalOptions & { resource?: string; force?: boolean }, name?: string, @@ -139,12 +141,12 @@ async function create( const datatables: Record< string, - { database: { resource_type: "postgresql" | "instance"; resource_path?: string } } + { database: { resource_type: DataTableResourceType; resource_path?: string } } > = {}; for (const d of existing) { datatables[d.name] = { database: { - resource_type: d.resource_type as "postgresql" | "instance", + resource_type: d.resource_type as DataTableResourceType, resource_path: d.resource_path ?? undefined, }, }; diff --git a/cli/src/commands/lint/flow_semantics.ts b/cli/src/commands/lint/flow_semantics.ts new file mode 100644 index 0000000000..26b72a8867 --- /dev/null +++ b/cli/src/commands/lint/flow_semantics.ts @@ -0,0 +1,312 @@ +/** + * Flow checks the OpenFlow JSON schema cannot express. Errors are what fails + * a run of the flow or stops the flow editor from drawing it; warnings are + * what the editor tolerates but renders wrong. + */ + +// `TOOL_NAME_REGEX` and `flow_module_tool_name` in +// backend/windmill-worker/src/ai_executor.rs; `getToolNameError` in +// frontend/src/lib/components/flows/agentToolUtils.ts applies the same rules. +const TOOL_NAME_REGEX = /^[a-zA-Z0-9_]+$/; +const WEBSEARCH_ENABLED_NAME = "__wm_web_search"; + +// `forbiddenIds` in frontend/src/lib/components/flows/idUtils.ts. +const RESERVED_IDS = new Set([ + "do", + "bg", + "ctx", + "state", + "if", + "else", + "for", + "delete", + "while", + "new", + "in", + "failure", + "preprocessor", + "__wm_agent_root", + "as", + "Input", + "Result", + "Trigger", +]); + +// `NoteColor` in frontend/src/lib/components/graph/noteColors.ts. +const PALETTE = [ + "yellow", + "blue", + "green", + "purple", + "pink", + "orange", + "red", + "cyan", + "lime", + "gray", +]; + +// Nodes the flow graph synthesizes (`VIRTUAL_NODE_IDS` in +// frontend/src/lib/components/graph/groupDetectionUtils.ts). +const VIRTUAL_NODE_IDS = new Set(["Input", "Result", "Trigger"]); + +export interface FlowSemanticReport { + errors: string[]; + warnings: string[]; +} + +interface ModuleList { + pointer: string; + modules: any[]; +} + +// These checks also run on a flow that failed schema validation, so any +// collection may have the wrong type; treating it as empty leaves reporting it +// to the schema errors instead of aborting the whole lint run. +function asArray(value: unknown): any[] { + return Array.isArray(value) ? value : []; +} + +/** + * Every module list a group can span, and every aiagent module, anywhere in the + * tree. The lists match `getContainerInnerArrays` in + * frontend/src/lib/components/graph/groupEditor.svelte.ts: agent tools are not + * steps of the graph, so no group can reach them. + */ +function walkModules( + modules: unknown, + pointer: string, + lists: ModuleList[], + agents: { pointer: string; module: any }[], +) { + if (!Array.isArray(modules)) return; + lists.push({ pointer, modules }); + modules.forEach((m, i) => { + const value = m?.value; + const at = `${pointer}/${i}/value`; + switch (value?.type) { + case "forloopflow": + case "whileloopflow": + walkModules(value.modules, `${at}/modules`, lists, agents); + break; + case "branchone": + walkModules(value.default, `${at}/default`, lists, agents); + asArray(value.branches).forEach((b: any, bi: number) => + walkModules(b?.modules, `${at}/branches/${bi}/modules`, lists, agents) + ); + break; + case "branchall": + asArray(value.branches).forEach((b: any, bi: number) => + walkModules(b?.modules, `${at}/branches/${bi}/modules`, lists, agents) + ); + break; + case "aiagent": + collectAgents(m, `${pointer}/${i}`, agents); + break; + } + }); +} + +/** An aiagent module plus any agent that is itself one of its tools. */ +function collectAgents( + module: any, + pointer: string, + agents: { pointer: string; module: any }[], +) { + // A step linked to an `ai_agent` resource runs the resource's tools and ignores + // its own (`ai_executor.rs`), so those are left unchecked. + if (typeof module?.value?.agent === "string" && module.value.agent !== "") { + return; + } + agents.push({ pointer, module }); + asArray(module?.value?.tools).forEach((tool: any, i: number) => { + if (tool?.value?.type === "aiagent") { + collectAgents(tool, `${pointer}/value/tools/${i}`, agents); + } + }); +} + +function checkAgentTools( + agent: { pointer: string; module: any }, + report: FlowSemanticReport, +) { + const tools = agent.module?.value?.tools; + if (!Array.isArray(tools)) return; + const seen = new Map(); + tools.forEach((tool: any, i: number) => { + // An mcp entry exposes its server's own tool names and a websearch entry is + // named by `WEBSEARCH_ENABLED_NAME`; neither summary reaches the model. + const kind = tool?.value?.tool_type; + if (kind === "mcp" || kind === "websearch") return; + const at = `${agent.pointer}/value/tools/${i}`; + const name = tool?.summary; + if (typeof name !== "string" || name.length === 0) { + report.errors.push( + `${at} has no summary: a tool's summary is the name the agent calls it by`, + ); + return; + } + if (!TOOL_NAME_REGEX.test(name)) { + report.errors.push( + `${at} tool name '${name}' may only contain letters, numbers and underscores (a run that offers this tool fails with 'Invalid tool name')`, + ); + return; + } + if (name === WEBSEARCH_ENABLED_NAME) { + report.errors.push( + `${at} tool name '${name}' is reserved for enabling web search`, + ); + return; + } + if (seen.has(name)) { + report.errors.push( + `${at} tool name '${name}' is already used by tool ${seen.get(name)} of this agent`, + ); + return; + } + seen.set(name, i); + if (RESERVED_IDS.has(name)) { + report.warnings.push( + `${at} tool name '${name}' is a reserved id; the flow editor refuses it`, + ); + } + }); +} + +function checkColor( + at: string, + color: unknown, + report: FlowSemanticReport, +) { + if (color === undefined || color === null) return; + if (typeof color !== "string" || !PALETTE.includes(color)) { + report.warnings.push( + `${at} color '${color}' renders unstyled; use one of: ${PALETTE.join(", ")}`, + ); + } +} + +/** + * Mirrors `buildStructureTree` in frontend/src/lib/components/graph/flowStructure.ts, + * whose throws make the editor draw no step at all. + */ +function checkGroups( + groups: unknown, + lists: ModuleList[], + report: FlowSemanticReport, +) { + if (!Array.isArray(groups)) return; + const location = new Map(); + lists.forEach((l, list) => + l.modules.forEach((m, index) => { + if (typeof m?.id === "string") location.set(m.id, { list, index }); + }) + ); + + const placed: { at: string; list: number; start: number; end: number }[] = + []; + const keys = new Set(); + groups.forEach((g: any, i: number) => { + const at = `/value/groups/${i}`; + checkColor(at, g?.color, report); + const startId = g?.start_id; + const endId = g?.end_id; + if (typeof startId !== "string" || typeof endId !== "string") return; + + const key = `${startId}:${endId}`; + if (keys.has(key)) { + report.errors.push( + `${at} spans the same steps as another group (${startId} to ${endId})`, + ); + return; + } + keys.add(key); + + for (const id of [startId, endId]) { + if (VIRTUAL_NODE_IDS.has(id)) { + report.errors.push(`${at} cannot include the '${id}' node`); + return; + } + if (!location.has(id)) { + report.errors.push( + id === "preprocessor" || id === "failure" + ? `${at} cannot include the '${id}' module` + : `${at} references '${id}', which is not a step of this flow`, + ); + return; + } + } + const start = location.get(startId)!; + const end = location.get(endId)!; + if (start.list !== end.list) { + report.errors.push( + `${at} starts at '${startId}' and ends at '${endId}', which are not in the same branch`, + ); + return; + } + if (start.index > end.index) { + report.errors.push( + `${at} starts at '${startId}', which comes after its end '${endId}'`, + ); + return; + } + placed.push({ at, list: start.list, start: start.index, end: end.index }); + }); + + for (let a = 0; a < placed.length; a++) { + for (let b = a + 1; b < placed.length; b++) { + const x = placed[a]; + const y = placed[b]; + if (x.list !== y.list) continue; + const disjoint = x.end < y.start || y.end < x.start; + const nested = (x.start <= y.start && y.end <= x.end) || + (y.start <= x.start && x.end <= y.end); + if (!disjoint && !nested) { + report.errors.push( + `${y.at} partly overlaps ${x.at}: groups may nest but not overlap`, + ); + } + } + } +} + +function checkNotes(notes: unknown, report: FlowSemanticReport) { + if (!Array.isArray(notes)) return; + const ids = new Set(); + notes.forEach((n: any, i: number) => { + const at = `/value/notes/${i}`; + checkColor(at, n?.color, report); + if (typeof n?.id === "string") { + if (ids.has(n.id)) { + report.warnings.push(`${at} reuses the note id '${n.id}'`); + } + ids.add(n.id); + } + if (n?.type === "group") { + report.warnings.push( + `${at} is a 'group' note, which is deprecated: use value.groups instead`, + ); + } else if (!n?.position || !n?.size) { + report.warnings.push( + `${at} has no position or size, so the editor places it at the origin and cannot resize it`, + ); + } + }); +} + +export function checkFlowSemantics(flow: unknown): FlowSemanticReport { + const report: FlowSemanticReport = { errors: [], warnings: [] }; + const value = (flow as any)?.value; + if (!value || typeof value !== "object") return report; + + const lists: ModuleList[] = []; + const agents: { pointer: string; module: any }[] = []; + walkModules(value.modules, "/value/modules", lists, agents); + + for (const agent of agents) { + checkAgentTools(agent, report); + } + checkGroups(value.groups, lists, report); + checkNotes(value.notes, report); + return report; +} diff --git a/cli/src/commands/lint/lint.ts b/cli/src/commands/lint/lint.ts index d031c16b3f..7029c851e0 100644 --- a/cli/src/commands/lint/lint.ts +++ b/cli/src/commands/lint/lint.ts @@ -36,6 +36,7 @@ import { getScriptBasePathFromModulePath, } from "../../utils/resource_folders.ts"; import { isFilesetResource } from "../../utils/utils.ts"; +import { checkFlowSemantics } from "./flow_semantics.ts"; import { exts, findContentFile, @@ -823,6 +824,16 @@ export async function runLint( ...formatYamlDiagnostics(result.parsed), ...result.errors.map((error) => formatValidationError(error)), ]; + if (target.type === "flow") { + const semantics = checkFlowSemantics(result.parsed?.data); + fileErrors.push(...semantics.errors); + warnings.push( + ...semantics.warnings.map((message) => ({ + path: normalizedPath, + message, + })), + ); + } if (fileErrors.length > 0) { issues.push({ path: normalizedPath, @@ -979,7 +990,7 @@ async function lintWatch(opts: LintOptions, directory?: string) { const command = new Command() .description( - "Validate Windmill flow, schedule, and trigger YAML files in a directory, and report script metadata that has no deployable content file", + "Validate Windmill flow, schedule, and trigger YAML files in a directory (including AI agent tool names and flow groups/notes), and report script metadata that has no deployable content file", ) .arguments("[directory:string]") .option("--json", "Output results in JSON format") diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 6185decc5a..54887028f0 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -77,6 +77,7 @@ import { import { getEffectiveSettings, inferWsNameFromProfile, + inferWsNameFromForkBranch, resolveWsNameForConfigFromFlags, mergeConfigWithConfigFile, parseSyncBehavior, @@ -772,12 +773,20 @@ export function generateAgentsDocumentation( tables?: string[]; datatable?: string; schema?: string; + roles?: Record; } | undefined, ): string { const tables = data?.tables ?? []; const defaultDatatable = data?.datatable; const defaultSchema = data?.schema; + // A bare `wmill.datatable()` always means `main`, and a datatable under a role + // is only reachable through that role, so the example names both. + const role = defaultDatatable ? data?.roles?.[defaultDatatable] : undefined; + const datatableCall = `wmill.datatable('${defaultDatatable || "main"}'${ + role ? `, { role: '${role}' }` : "" + })`; + const tableRef = defaultSchema ? `${defaultSchema}.table` : "table"; return `# AI Agent Instructions @@ -791,7 +800,13 @@ This file contains **app-specific configuration** for this raw app instance. ${ defaultDatatable - ? `**Default Datatable:** \`${defaultDatatable}\`${defaultSchema ? ` | **Default Schema:** \`${defaultSchema}\`` : ""}` + ? `**Default Datatable:** \`${defaultDatatable}\`${defaultSchema ? ` | **Default Schema:** \`${defaultSchema}\`` : ""}${ + role ? ` | **Role:** \`${role}\` (pass it to every \`wmill.datatable\` call)` : "" + }${ + defaultSchema + ? `\n\nWrite this app's tables as \`${defaultSchema}.
\`, in migrations and queries: an unqualified name means the \`public\` schema.` + : "" + }` : "**No default datatable configured.** Set \`data.datatable\` in \`raw_app.yaml\` to enable database access." } @@ -833,8 +848,8 @@ const result = await backend.({ arg: 'value' }); **Query datatable (TypeScript):** \`\`\`typescript -const sql = wmill.datatable(); -const rows = await sql\`SELECT * FROM table WHERE id = \${id}\`.fetch(); +const sql = ${datatableCall}; +const rows = await sql\`SELECT * FROM ${tableRef} WHERE id = \${id}\`.fetch(); \`\`\` **SQL migrations:** Add \`.sql\` files to \`sql_to_apply/\`, run \`wmill app dev\`, then whitelist tables @@ -3738,9 +3753,12 @@ export async function pull( } } - // If wsNameForConfig wasn't set from flags, infer from the resolved profile + // If wsNameForConfig wasn't set from flags, infer from the resolved profile, + // then (for a fork) from the parent's entry if (!wsNameForConfig) { - wsNameForConfig = inferWsNameFromProfile(opts, workspace); + wsNameForConfig = + inferWsNameFromProfile(opts, workspace) ?? + inferWsNameFromForkBranch(opts, workspace.workspaceId); } // Resolve effective sync options with workspace awareness @@ -4788,9 +4806,12 @@ export async function push( const workspace = await resolveWorkspace(opts, wsNameForConfig); await requireLogin(opts); - // If wsNameForConfig wasn't set from flags, infer from the resolved profile + // If wsNameForConfig wasn't set from flags, infer from the resolved profile, + // then (for a fork) from the parent's entry if (!wsNameForConfig) { - wsNameForConfig = inferWsNameFromProfile(opts, workspace); + wsNameForConfig = + inferWsNameFromProfile(opts, workspace) ?? + inferWsNameFromForkBranch(opts, workspace.workspaceId); } // Resolve effective sync options with workspace awareness diff --git a/cli/src/commands/workspace/fork.ts b/cli/src/commands/workspace/fork.ts index 0aca0984ff..eedd9c0842 100644 --- a/cli/src/commands/workspace/fork.ts +++ b/cli/src/commands/workspace/fork.ts @@ -13,6 +13,7 @@ import { renameCurrentGitBranch, } from "../../utils/git.ts"; import process from "node:process"; +import { apiErrorMessage } from "../../utils/utils.ts"; import { WM_FORK_PREFIX } from "../../core/constants.ts"; import { tryResolveBranchWorkspace } from "../../core/context.ts"; import { @@ -22,6 +23,38 @@ import { readConfigFile, } from "../../core/conf.ts"; +/** Wait for a fork creation started in the background, by the id its request answered with. */ +async function waitForForkCreation(parentWorkspace: string, creationId: string) { + let failingSince: number | undefined; + let lastStep: string | undefined; + while (true) { + let result: + | Awaited> + | undefined; + try { + result = await wmill.getForkCreationStatus({ + workspace: parentWorkspace, + creationId, + }); + failingSince = undefined; + } catch (e) { + // A failed poll says nothing about the fork: it can be lost to a proxy, or reach a replica + // that has no status route yet during a rolling deploy. + failingSince ??= Date.now(); + if (Date.now() - failingSince >= 3 * 60 * 1000) throw e; + } + if (result?.status === "running" && result.step && result.step !== lastStep) { + lastStep = result.step; + log.info(colors.gray(`${lastStep}...`)); + } + if (result?.status === "completed") return; + if (result?.status === "failed") { + throw new Error(result.error ?? "Unknown error"); + } + await new Promise((resolve) => setTimeout(resolve, 1500)); + } +} + async function createWorkspaceFork( opts: GlobalOptions & { createWorkspaceName: string | undefined; @@ -376,8 +409,9 @@ async function createWorkspaceFork( // --- Create the fork workspace --- try { - const result = await wmill.createWorkspaceFork({ + const response = await wmill.createWorkspaceFork({ workspace: workspace.workspaceId, + background: true, requestBody: { id: trueWorkspaceId, name: opts.createWorkspaceName ?? workspaceName ?? trueWorkspaceId, @@ -385,11 +419,17 @@ async function createWorkspaceFork( forked_datatables: forkedDatatables, }, }); + // A server without background forks ignores the flag and answers once the fork is created. + if (!response.startsWith("Created forked workspace")) { + await waitForForkCreation(workspace.workspaceId, response); + } - log.info(colors.green(`✅ ${result}`)); + log.info(colors.green(`✅ Created forked workspace ${trueWorkspaceId}`)); } catch (error) { log.error( - colors.red(`Failed to create forked workspace: ${(error as Error).message}`), + colors.red( + `Failed to create forked workspace: ${apiErrorMessage(error) ?? (error as Error).message}`, + ), ); throw error; } diff --git a/cli/src/core/conf.ts b/cli/src/core/conf.ts index 7c6eb13274..455e876292 100644 --- a/cli/src/core/conf.ts +++ b/cli/src/core/conf.ts @@ -5,6 +5,7 @@ import { stringify as yamlStringify } from "yaml"; import { getCurrentGitBranch, getOriginalBranchForWorkspaceForks, + getWorkspaceIdForWorkspaceForkFromBranchName, isGitRepository, } from "../utils/git.ts"; import { join, dirname, resolve, relative } from "node:path"; @@ -725,6 +726,30 @@ export function inferWsNameFromProfile( return undefined; } +/** + * The workspace config entry a fork inherits from its parent: the one bound to + * the base branch of the checked-out `wm-fork//` branch, when + * `workspaceId` is the fork that branch names. A fork id never has an entry of + * its own, so without this its specific items and file suffix fall back to + * the common/base files and clobber what the fork cloned from its parent. + */ +export function inferWsNameFromForkBranch( + opts: SyncOptions, + workspaceId: string +): string | undefined { + if (!opts.workspaces || !isGitRepository()) return undefined; + const branch = getCurrentGitBranch(); + const baseBranch = getOriginalBranchForWorkspaceForks(branch); + if ( + !branch || + !baseBranch || + getWorkspaceIdForWorkspaceForkFromBranchName(branch) !== workspaceId + ) { + return undefined; + } + return findWorkspaceByGitBranch(opts.workspaces, baseBranch)?.[0]; +} + /** * `syncBehavior` as the workspace being pushed to sees it. The top level alone * misses a `workspaces..overrides.syncBehavior`, which is where a repo diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index 1d14d4f4e2..3246083091 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork"; // (e.g. utils.ts) can read it without importing main.ts and creating a circular // dependency (main → workspace → utils → main) that triggers a TDZ. // Re-exported from main.ts for backwards compatibility. -export const VERSION = "1.819.0"; +export const VERSION = "1.820.0"; diff --git a/cli/src/core/settings.ts b/cli/src/core/settings.ts index f065877244..57e89f5ded 100644 --- a/cli/src/core/settings.ts +++ b/cli/src/core/settings.ts @@ -413,7 +413,7 @@ export async function pushWorkspaceSettings( const localOperatorSettings = localSettings.operator_settings && { ...localSettings.operator_settings, }; - for (const key of ["manage_schedules", "manage_triggers"] as const) { + for (const key of ["builder_flows", "manage_schedules", "manage_triggers"] as const) { const remote = settings.operator_settings?.[key]; if (localOperatorSettings && localOperatorSettings[key] === undefined && remote !== undefined) { localOperatorSettings[key] = remote; diff --git a/cli/src/guidance/core.ts b/cli/src/guidance/core.ts index 202a6feaad..6060f6b8a5 100644 --- a/cli/src/guidance/core.ts +++ b/cli/src/guidance/core.ts @@ -70,7 +70,8 @@ You are a helpful assistant that can help with Windmill scripts, flows, apps, an ## Important Notes - Every new entity MUST be created using the skills listed below. - Every modification of an entity MUST be done using the skills listed below. -- User MUST be asked where to create the entity. It can be in its user folder, under u/{user_name} folder, or in a new folder, /f/{folder_name}/. folder_name and user_name must be provided by the user. +- User MUST be asked where to create the entity: in their user space, \`u/{user_name}/\`, or in a folder, \`f/{folder_name}/\` (no leading \`/\`; a bare \`f/\` with no folder segment is invalid). folder_name and user_name must be provided by the user. +- A new folder needs its \`f/{folder_name}/folder.meta.yaml\`: create it with \`wmill folder new {folder_name}\`. \`wmill sync push\` refuses a folder without one for non-admins. ## Script Writing Guide @@ -82,6 +83,10 @@ For Workflow-as-Code scripts, use the \`write-workflow-as-code\` skill. You MUST use the \`write-flow\` skill to create or modify flows. When a new flow needs to be created, YOU run \`wmill flow new \` yourself (with \`--summary\` and optional \`--description\`) to scaffold the folder and \`flow.yaml\`, then edit \`flow.yaml\` to fill in modules and schema. Do NOT scaffold the folder + yaml by hand and do NOT tell the user to run \`wmill flow new\`. If path or summary are missing from the user's request, ask via \`AskUserQuestion\` (one call, all missing fields) — never invent them. See the \`write-flow\` skill for the procedure. +## Data Pipelines + +A data pipeline is NOT a flow: it is a set of independent scripts in one folder, marked \`pipeline\` and wired together by \`on\` / \`materialize\` annotations. When the user asks for a data pipeline (or to ingest, transform or materialize data across steps), you MUST use the \`write-pipeline\` skill and build pipeline scripts, not a flow. + ## Raw App Development You MUST use the \`raw-app\` skill to create or modify raw apps. diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 81895997b9..808761731e 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -33,6 +33,7 @@ export const SKILLS: SkillMetadata[] = [ { name: "schedules", description: "MUST use when configuring schedules." }, { name: "resources", description: "MUST use when managing resources." }, { name: "write-workflow-as-code", description: "MUST use when writing or modifying Windmill Workflow-as-Code scripts using workflow, task, step, sleep, approvals, taskScript, taskFlow, task_script, or task_flow." }, + { name: "write-pipeline", description: "MUST use when creating or modifying a data pipeline, a set of scripts marked `pipeline` and wired together by `on` / `materialize` annotations." }, { name: "cli-commands", description: "MUST use when using the CLI, including debugging job failures and inspecting run history via `wmill job`." }, { name: "preview", description: "MUST use when opening the Windmill dev page / visual preview of a flow, script, or app. Triggers on words like preview, open, navigate to, visualize, see the flow/app/script, and after writing a flow/script/app for visual verification." }, ]; @@ -89,6 +90,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # Ansible Windmill runs Ansible playbooks with \`ansible-playbook\`. A script is a single YAML @@ -229,6 +253,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # Bash ## Structure @@ -329,6 +376,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # BigQuery Arguments use \`@name\` syntax. @@ -422,6 +492,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # TypeScript (Bun) Bun runtime with full npm ecosystem and fastest execution. **Bun is the default and preferred TypeScript runtime** — choose it for any TypeScript script unless there is a major reason to use Deno for that specific use-case. @@ -476,7 +569,7 @@ import * as wmill from "windmill-client"; **Prefer \`windmill-client\` over raw \`fetch\` for anything that talks to Windmill** — reading resources/variables/states, running scripts and flows, S3 object operations, etc. It handles auth, the workspace, and the base URL for you, so you don't hand-roll URLs or tokens. Reserve \`fetch\` for calling *external* HTTP APIs that aren't Windmill. -The full \`windmill-client\` API reference (every exported function and its signature) is included in this skill below — consult it for the exact method to use instead of guessing or falling back to \`fetch\`. +The full \`windmill-client\` API reference (every exported function and its signature) is included below — consult it for the exact method to use instead of guessing or falling back to \`fetch\`. ## Preprocessor Scripts @@ -494,7 +587,9 @@ type Event = { | "postgres" | "sqs" | "mqtt" - | "gcp"; + | "amqp" + | "gcp" + | "azure"; body: any; headers: Record; query: Record; @@ -542,7 +637,6 @@ const result: wmill.S3Object = await wmill.writeS3File( ); \`\`\` - # TypeScript SDK (windmill-client) Import: import * as wmill from 'windmill-client' @@ -1218,6 +1312,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # TypeScript (Bun Native) Native TypeScript execution. Native scripts are Bun scripts that run on the native worker — a lightweight V8 isolate that exposes \`fetch\` and the JavaScript standard library — and can be heavily parallelized. Every script MUST start with \`//native\` on its first line so Windmill routes it to the native worker; without it the exact same script runs on the regular Bun worker. You may import npm packages and other Windmill scripts (e.g. \`./helper.ts\`) — imports are resolved and bundled just like a regular Bun script — as long as everything (your code and its dependencies) relies only on \`fetch\` and the standard library. Libraries that need Node/Bun runtime APIs (filesystem, \`node:*\` modules, child processes, native addons) will not work on the native worker; use the regular \`bun\` language for those. @@ -1265,11 +1382,23 @@ export async function main(url: string) { } \`\`\` +## Pure computation: \`//no_network\` + +A native script that only transforms its inputs can declare \`//no_network\` in its leading comment block (right after \`//native\`). The runtime then refuses every connection the script attempts: \`fetch\` to any host, the Windmill API (so \`windmill-client\` calls fail too), raw sockets and unix sockets, and \`WM_TOKEN\` is not set. It only takes effect on native scripts: without \`//native\` it is ignored. Use it only when the script needs no external data beyond its arguments: + +\`\`\`typescript +//native +//no_network +export async function main(items: { price: number; qty: number }[]) { + return items.reduce((sum, i) => sum + i.price * i.qty, 0); +} +\`\`\` + ## Windmill Client \`windmill-client\` works on the native worker (its calls go over \`fetch\`), so use it as the **preferred way to talk to Windmill** — reading resources/variables/states, running scripts and flows, and the S3 helpers below (\`loadS3File\`, \`loadS3FileStream\`, \`writeS3File\`, \`S3Object\`). It handles auth, the workspace, and the base URL for you. Reserve raw \`fetch\` for calling *external* HTTP APIs that aren't Windmill. -The full \`windmill-client\` API reference (every exported function and its signature) is included in this skill below — consult it for the exact method instead of hand-rolling a \`fetch\` against the Windmill API. +The full \`windmill-client\` API reference (every exported function and its signature) is included below — consult it for the exact method instead of hand-rolling a \`fetch\` against the Windmill API. ## Preprocessor Scripts @@ -1288,7 +1417,9 @@ type Event = { | "postgres" | "sqs" | "mqtt" - | "gcp"; + | "amqp" + | "gcp" + | "azure"; body: any; headers: Record; query: Record; @@ -1338,7 +1469,6 @@ const result: wmill.S3Object = await wmill.writeS3File( ); \`\`\` - # TypeScript SDK (windmill-client) Import: import * as wmill from 'windmill-client' @@ -2014,6 +2144,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # C# The script must contain a public static \`Main\` method inside a class: @@ -2106,6 +2259,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # TypeScript (Deno) Deno runtime with npm support via \`npm:\` prefix and native Deno libraries. @@ -2162,7 +2338,7 @@ import * as wmill from "windmill-client"; **Prefer \`windmill-client\` over raw \`fetch\` for anything that talks to Windmill** — reading resources/variables/states, running scripts and flows, S3 object operations, etc. It handles auth, the workspace, and the base URL for you. Reserve \`fetch\` for calling *external* HTTP APIs that aren't Windmill. -The full \`windmill-client\` API reference (every exported function and its signature) is included in this skill below — consult it for the exact method instead of guessing or falling back to \`fetch\`. +The full \`windmill-client\` API reference (every exported function and its signature) is included below — consult it for the exact method instead of guessing or falling back to \`fetch\`. ## Preprocessor Scripts @@ -2180,7 +2356,9 @@ type Event = { | "postgres" | "sqs" | "mqtt" - | "gcp"; + | "amqp" + | "gcp" + | "azure"; body: any; headers: Record; query: Record; @@ -2228,7 +2406,6 @@ const result: wmill.S3Object = await wmill.writeS3File( ); \`\`\` - # TypeScript SDK (windmill-client) Import: import * as wmill from 'windmill-client' @@ -2904,6 +3081,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # DuckDB Arguments are defined with comments and used with \`$name\` syntax: @@ -3030,6 +3230,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # Go ## Structure @@ -3139,6 +3362,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # GraphQL ## Structure @@ -3235,6 +3481,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # Java The script must contain a Main public class with a \`public static main()\` method: @@ -3324,6 +3593,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # Microsoft SQL Server (MSSQL) Arguments use \`@P1\`, \`@P2\`, etc. @@ -3416,6 +3708,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # MySQL Arguments use \`?\` placeholders. @@ -3509,6 +3824,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # PHP ## Structure @@ -3617,6 +3955,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # PostgreSQL Arguments are obtained directly in the statement with \`$1::{type}\`, \`$2::{type}\`, etc. @@ -3708,6 +4069,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # PowerShell ## Structure @@ -3814,6 +4198,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # Python ## Structure @@ -3897,7 +4304,7 @@ For preprocessor scripts, the function should be named \`preprocessor\` and rece from typing import TypedDict, Literal, Any class Event(TypedDict): - kind: Literal["webhook", "http", "websocket", "kafka", "email", "nats", "postgres", "sqs", "mqtt", "gcp"] + kind: Literal["webhook", "http", "websocket", "kafka", "email", "nats", "postgres", "sqs", "mqtt", "amqp", "gcp", "azure"] body: Any headers: dict[str, str] query: dict[str, str] @@ -3948,7 +4355,6 @@ result: S3Object = wmill.write_s3_file( ) \`\`\` - # Python SDK (wmill) Import: import wmill @@ -4745,7 +5151,6 @@ async def parallel(items, fn, *, concurrency: Optional[int] = None) # partition: Partition number (from event['partition']) # offset: Message offset to commit (from event['offset']) def commit_kafka_offsets(trigger_path: str, topic: str, partition: int, offset: int) -> None - `, "write-script-rlang": `--- name: write-script-rlang @@ -4797,6 +5202,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # R ## Structure @@ -4933,6 +5361,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # Rust ## Structure @@ -5059,6 +5510,29 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. +# Windmill Script Writing Guide + +## General Principles + +- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters +- Libraries are installed automatically - do not show installation instructions +- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it \`main\` (\`Main\` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no \`main\`: their language section shows how they take arguments +- Where the language has a Windmill client (\`wmill\`), use it to interact with the platform + +## Return Values + +- A script can return any JSON-serializable value; a SQL script returns the rows its query produces +- Return values become available to subsequent flow steps via \`results.step_id\` + +## Preprocessor Scripts + +Preprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean. + +A preprocessor is written in TypeScript or Python: its function is named \`preprocessor\` instead of \`main\`, and it receives a single parameter called \`event\` (the language section gives its type). + +The returned object determines the parameter values passed to the flow. +e.g., \`{ b: 1, a: 2 }\` calls the flow with \`a = 2\` and \`b = 1\`, assuming the flow has two inputs called \`a\` and \`b\`. + # Snowflake Arguments use \`?\` placeholders. @@ -5189,7 +5663,6 @@ An input typed as a resource (\`format: resource-\` in the schema) takes t To open the flow visually in the dev page (graph + live reload), use the \`preview\` skill. Always **offer** it as a one-sentence next step (e.g. "Want me to open the visual preview?") rather than opening it automatically — opening the dev page has side effects (browser window, possibly a \`launch.json\` entry under MCP-preview branches) the user should consent to. If the user already asked to see/preview/visualize the flow in their original request, skip the offer and just invoke the skill. - # Windmill Flow Building Guide ## OpenFlow Schema @@ -5327,7 +5800,7 @@ needs becomes unreachable. }, "user_message": { "type": "javascript", "expr": "flow_input.user_message" }, "user_attachments": { "type": "javascript", "expr": "flow_input.files" }, - "memory": { "type": "static", "value": { "kind": "window", "context_length": 10 } }, + "memory": { "type": "static", "value": { "kind": "compaction" } }, "streaming": { "type": "static", "value": true }, "output_type": { "type": "static", "value": "text" } }, @@ -5362,8 +5835,8 @@ names, so neither is name-checked at all — leave those summaries as they are. - Always set \`summary\`. It must be unique among that agent's tools, and must not be one of the reserved ids (\`do\`, \`bg\`, \`ctx\`, \`state\`, \`if\`, \`else\`, \`for\`, \`delete\`, \`while\`, \`new\`, \`in\`, \`failure\`, \`preprocessor\`, \`as\`, \`Input\`, \`Result\`, \`Trigger\`) -- A tool name outside that character set is rejected: flow write tools refuse it, and a flow that - reaches the worker with one fails every run with \`Invalid tool name\` +- A tool name outside that character set fails any run that offers the tool to the agent, with \`Invalid tool name\`. + \`wmill lint \` reports it before anything runs. - Tool \`id\` follows the same rules as any module ID — unique across the flow, underscores not spaces - \`description\` is optional free text telling the agent when and how to call the tool. Set it whenever the name alone does not make that obvious; it overrides the description derived from the @@ -5386,9 +5859,11 @@ names, so neither is name-checked at all — leave those summaries as they are. ## Loop Structure Rules +- A \`forloopflow\` runs its \`modules\` once per element of \`iterator\`, a javascript expression returning an array (e.g. \`results.get_items\`); \`parallel: true\` runs the iterations concurrently, and \`skip_failures: true\` carries on past a failed iteration - For \`whileloopflow\`, break the loop with a module-level \`stop_after_if\`: on the loop module itself, or on an inner step (required when that step carries state via its own \`results\` — see below) - \`stop_after_if\` is always a sibling of \`id\` and \`value\` on a flow module — never a direct key of the loop's \`value\` object - \`stop_after_all_iters_if\` is for checks after the whole loop finishes, not the normal per-iteration break condition +- \`stop_after_if\` is evaluated after each iteration: on the loop module, \`result\` is that iteration's result (what its last step returned); on an inner step, it is that step's result - \`flow_input.iter.value\` in a \`whileloopflow\` is just the iteration index (same number as \`flow_input.iter.index\`) — it never carries state, so \`flow_input.iter.value.\` is always undefined and a loop whose stop condition depends on it never terminates - To carry state across iterations, a step reads its own previous-iteration result via \`results.\` with a first-iteration fallback (e.g. \`results.b ?? flow_input.start\`) — but then the loop's \`stop_after_if\` MUST sit on that inner step, not on the loop module: a body that is exactly one plain step with the stop condition on the loop module runs on a fast path where \`results.\` is null on every iteration and the loop never terminates (bodies with 2+ steps, or whose single step has its own \`stop_after_if\`, retry or similar, resolve \`results\` across iterations regardless of stop placement) - For state that is just a counter, derive it from the index instead (e.g. \`flow_input.iter.index + 1\`) — that works in every configuration, including with \`stop_after_if\` on the loop module @@ -5526,6 +6001,7 @@ Incorrect shape (identity has no resume URLs — not a real approval): ## Branch Result Scope Rules +- A \`branchone\` runs the first of its \`branches\` whose \`expr\` is true, in order, and its \`default\` modules when none is; a \`branchall\` runs every branch (concurrently with \`parallel: true\`) - Inside a branch, you may reference earlier outer steps and earlier steps in the same branch - Outside a \`branchone\`, do NOT reference ids of steps that only exist inside its branches or default branch. Use \`results.\` instead - Outside a \`branchall\`, do NOT reference ids of steps inside its branches. Use \`results.\` instead @@ -5585,6 +6061,41 @@ JavaScript transform (dynamic expression): - For flow inputs: Use type \`"object"\` with format \`"resource-{type}"\` (e.g., \`"resource-postgresql"\`) - For step inputs: Use static value \`"$res:path/to/resource"\` +## Reusing Existing Scripts and Flows + +Unless the user asked for new code, look for a workspace script or flow that already does a step's job before writing it, and reuse it by path instead of copying its logic into a rawscript: + +- a workspace script: \`type: script\` with \`path\` (e.g. \`f/folder/send_email\`) +- a workspace flow, run as a subflow: \`type: flow\` with \`path\` +- a Hub script: \`type: script\` with a \`hub///\` path + +The step's \`input_transforms\` must cover the reused item's inputs, so read its input schema first. +Find candidates in the local tree (a \`.script.yaml\` sits next to each script and holds its input schema, a \`flow.yaml\` in each flow folder) and on the workspace with \`wmill script list\` / \`wmill flow list\`; \`wmill script get \` and \`wmill flow get \` show an item's details. + +## Organizing Flows: Groups and Notes + +Groups and notes shape how a flow reads in the editor; neither changes what it does. + +**Segment every non-trivial flow into groups without waiting to be asked.** Whenever a flow has more than a couple of steps, or consecutive steps form a stage ("fetch", "transform", "notify"), put them in a group, and aim for every meaningful step to belong to one. Use notes sparingly, for flow-wide information that belongs to no span of steps: the flow's purpose, key assumptions, warnings, TODOs. One note is usually enough; never label a run of steps with a note, which is what a group is for. + +\`value.groups\` lists the groups, each spanning the steps from \`start_id\` to \`end_id\`: + +- \`start_id\`, \`end_id\` (required): ids of the group's first and last step; the same id for both makes a one-step group +- \`summary\`: the group's title +- \`note\`: markdown shown under the title +- \`color\`: one of \`yellow\`, \`blue\`, \`green\`, \`purple\`, \`pink\`, \`orange\`, \`red\`, \`cyan\`, \`lime\`, \`gray\`, never a hex code or CSS color; leave it out and the editor picks one +- \`autocollapse\`: \`true\` shows the group collapsed by default + +The editor refuses to draw a flow whose groups break any of these rules: + +- \`start_id\` and \`end_id\` are steps of the same list: both top-level, or both in the same loop body or branch. A group can hold a loop or branch step whole, but cannot start outside one and end inside it +- \`start_id\` does not come after \`end_id\` in that list +- groups nest (one entirely inside another) but never partly overlap, and no two groups share both \`start_id\` and \`end_id\` +- groups hold ordinary steps only: never \`preprocessor\`, \`failure\`, \`Input\`, \`Result\`, \`Trigger\`, or an AI agent's tools + +\`value.notes\` lists sticky notes, each with a unique \`id\`, markdown \`text\`, a \`color\` from the same list, and \`type: free\`. The \`group\` note type is deprecated; use \`value.groups\` instead. +Give each note a \`position\` (\`{ x, y }\`) and a \`size\` (\`{ width, height }\`): the editor draws a note without them at the origin and cannot resize it. \`x: -400\` with \`width: 275\` places it beside the graph. + ## Final Structural Self-Check Before finalizing a flow, verify: @@ -5594,6 +6105,8 @@ Before finalizing a flow, verify: - any approval step has module-level \`suspend\` - no downstream step references inner branch step ids from outside the branch - every AI agent flowmodule tool has a unique \`summary\` made only of letters, numbers and underscores +- every group starts and ends on steps of the same list, start before end, nesting without partial overlap +- \`wmill lint \` reports no error ## S3 Object Operations @@ -5648,10 +6161,10 @@ Reference a specific resource using \`$res:\` prefix: } \`\`\` - ## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \\"yellow\\", \\"#ffff00\\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\\n\`memory_id\`). Without a memory id the agent runs without memory.\\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the \`memory_id\` here.\\nThe step's own \`memory_id\` is not read while this kind is set; switch the kind to \`window\`\\nto use it. Without a \`context_length\`, or with 0, it is \`off\` and reads \`previous_messages\`.\\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to \`off\` with \`previous_messages\` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see \`memory_id\`. While it is off, a step can supply its history in \`previous_messages\`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\\nflow.input syntax. Required unless memory is off and \`previous_messages\` supplies\\nthe prompt; image output always needs it.\\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\\nwas started with (the chat conversation, an app chat session or the \`memory_id\` run\\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\\nacross every run; an expression such as \`flow_input.customer_id\` keeps one memory per\\nkey. When it evaluates to an empty value the agent runs without memory. Read only\\nwhile \`memory\` is \`window\`: it is ignored when memory is off, and an older \`auto\` or\\n\`manual\` memory reads neither history input.\\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\\nand the user message. Read only while \`memory\` is off or absent: managed memory\\nignores it, and an older \`auto\` or \`manual\` memory reads neither history input.\\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in \`tools\` the agent may call\\nthis run. Leaving it unset carries every one of them; an empty array carries none.\\nA tool is named as the model is shown it. An entry the model is shown nothing of is\\nnamed by what identifies it instead: an MCP server by its resource path, carrying\\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\\n(no tool may take that name).\\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default \`flow\`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable \`ai_agent\` resource (hybrid linking). When set, the agent brain\\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\\nthat resource; the module's input_transforms then only carry the flow-local inputs\\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\\nshared resource; overlaid onto the tools' input_transforms at runtime \\u2014 including when\\n\`agent\` is unset, since a step forked for editing keeps these overrides until it is saved\\nback or unlinked.\\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`, +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names."},"on_behalf_of":{"type":"string","description":"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled."},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead."},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one."}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"},"skin":{"type":"string","enum":["detailed","minimal"],"description":"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","azure_foundry","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"},"reasoning_effort":{"type":"string","description":"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default."}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryWindow":{"type":"object","description":"Keeps the most recent messages of the memory named by the run's memory id (or the step's\\n\`memory_id\`). Without a memory id the agent runs without memory.\\n","properties":{"kind":{"type":"string","enum":["window"]},"context_length":{"type":"integer","description":"Number of most recent messages to load and store. 0 turns memory off."}},"required":["kind","context_length"]},"MemoryAuto":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written: the run's memory id, else the \`memory_id\` here.\\nThe step's own \`memory_id\` is not read while this kind is set; switch the kind to \`window\`\\nto use it. Without a \`context_length\`, or with 0, it is \`off\` and reads \`previous_messages\`.\\n","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryCompaction":{"type":"object","description":"Keeps the whole memory named by the run's memory id (or the step's \`memory_id\`), replacing\\nits older part with a summary as the conversation approaches the model's context window.\\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\\n","properties":{"kind":{"type":"string","enum":["compaction"]},"context_window":{"type":"integer","description":"Overrides the context window looked up from the model, in tokens. Only a model\\nWindmill does not know needs one; those fall back to 128000.\\n"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","deprecated":true,"description":"Deprecated, still read as it was written. Move the step to \`off\` with \`previous_messages\` instead.","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see \`memory_id\`. While it is off, a step can supply its history in \`previous_messages\`.","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryWindow"},{"$ref":"#/components/schemas/MemoryCompaction"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","window":"#/components/schemas/MemoryWindow","compaction":"#/components/schemas/MemoryCompaction","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","bunnative","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume","dbt"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'."},"description":{"type":"string","description":"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script."},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with\\nflow.input syntax. Required unless memory is off and \`previous_messages\` supplies\\nthe prompt; image output always needs it.\\n"},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"memory_id":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"String. Names the memory this step reads and writes, overriding the memory id the run\\nwas started with (the chat conversation, an app chat session or the \`memory_id\` run\\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\\nacross every run; an expression such as \`flow_input.customer_id\` keeps one memory per\\nkey. When it evaluates to an empty value the agent runs without memory. Read only\\nwhile \`memory\` is \`window\` or \`compaction\`: it is ignored when memory is off, and an\\nolder \`auto\` or \`manual\` memory reads neither history input.\\n"},"previous_messages":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\\nand the user message. Read only while \`memory\` is off or absent: managed memory\\nignores it, and an older \`auto\` or \`manual\` memory reads neither history input.\\n"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"enabled_tools":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of strings naming which of the tools configured in \`tools\` the agent may call\\nthis run. Leaving it unset carries every one of them; an empty array carries none.\\nA tool is named as the model is shown it. An entry the model is shown nothing of is\\nnamed by what identifies it instead: an MCP server by its resource path, carrying\\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\\n(no tool may take that name).\\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"}}},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"tag":{"type":"string","description":"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default \`flow\`)"},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"agent":{"type":"string","description":"Path of a reusable \`ai_agent\` resource (hybrid linking). When set, the agent brain\\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\\nthat resource; the module's input_transforms then only carry the flow-local inputs\\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages).\\n"},"tool_inputs":{"type":"object","description":"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\\nshared resource; overlaid onto the tools' input_transforms at runtime \\u2014 including when\\n\`agent\` is unset, since a step forked for editing keeps these overrides until it is saved\\nback or unlinked.\\n","additionalProperties":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}}},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}} +`, "raw-app": `--- name: raw-app description: MUST use when creating raw apps. @@ -5822,7 +6335,7 @@ The \`data\` block in \`raw_app.yaml\` controls which tables the app can query. \`\`\`yaml data: datatable: main # Default datatable - schema: app_schema # Default schema (optional) + schema: app_schema # Schema the app's tables go in (optional); still write them as app_schema.
tables: - main/users # Table in public schema - main/app_schema:items # Table in specific schema @@ -5867,6 +6380,8 @@ data: - main/users \`\`\` +A migration runs with no default schema, so a table outside \`public\` is created with its schema, \`CREATE TABLE IF NOT EXISTS app_schema.items (...)\`, listed as \`main/app_schema:items\`, and queried as \`app_schema.items\`. + ### Migration best practices - **Use idempotent SQL**: \`CREATE TABLE IF NOT EXISTS\`, etc. @@ -5876,8 +6391,9 @@ data: ## CLI Commands -Two commands you run yourself, not the user: +Commands you run yourself, not the user: - \`wmill app new\` — run it with flags, per the "Creating a Raw App" section above. +- \`wmill app lint \` — checks the app's structure and that it builds. Run it after editing, before offering a preview or a deploy; a bundle that compiles still says nothing about behavior, so a preview is what checks that. - \`wmill generate-metadata\` — (re)generates local lock files and refreshes \`wmill-lock.yaml\` content hashes; writes local files only (not a deploy). After adding or editing a runnable, offer it and run it on agreement — or automatically if the project's \`AGENTS.md\` opts into that (see "After creating a runnable" above). For the rest, tell the user which command fits their intent and let them run it — these deploy to the workspace, overwrite local files, or launch a long-running server, so the user should consent each time: @@ -5889,8 +6405,6 @@ For the rest, tell the user which command fits their intent and let them run it | \`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. @@ -5941,6 +6455,8 @@ Import the generated bindings and call the runnable like a function. \`./wmill\` | \`getJob(jobId)\` | a \`Job\` (\`{ type, success, result, duration_ms, ... }\`) | polling status without blocking | | \`streamJob(jobId, onUpdate?)\` | the final result, calling \`onUpdate\` per chunk | showing output as it is produced | +A runnable is always called with **one object** whose keys are its \`main\` parameters — \`main(user_id: string, limit: number)\` is called as \`backend.get_users({ user_id, limit })\`, never with positional arguments. A runnable without parameters is called with no argument. Resource and variable ids handed to the \`wmill\` client are paths (\`u//\` or \`f//\`). + Run and wait — the common case: \`\`\`tsx @@ -6014,9 +6530,10 @@ Each runnable has a unique key (used to call it from the frontend) and one of fo ### 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. +Inline runnables carry their own source code, and must expose a \`main\` function as their entrypoint. +On disk, a runnable's language is determined by its backend file extension. -**TypeScript example** (\`backend/get_user.ts\`): +**TypeScript example** (runnable \`get_user\`): \`\`\`typescript import * as wmill from 'windmill-client'; @@ -6028,7 +6545,7 @@ export async function main(user_id: string) { } \`\`\` -**Python example** (\`backend/get_user.py\`): +**Python example** (runnable \`get_user\`): \`\`\`python import wmill @@ -6045,12 +6562,14 @@ An inline runnable runs as an ordinary Windmill job. \`import * as wmill from 'w **Don't read \`WM_TOKEN\` or \`BASE_INTERNAL_URL\` and build an API URL to \`fetch\`.** The client's own \`setClient\` already reads exactly those, and it also sets the credentials mode a raw app needs (\`WM_RAW_APP\` suppresses credentials, because a sandboxed bundle calls the API from an opaque origin that can never pair with \`Access-Control-Allow-Origin: *\`). Rebuilding that by hand drops the parts you can't see. Use \`wmill.*\` for everything Windmill, and \`fetch\` only for third-party APIs. -Prefer the \`wmill\` functions that appear in the SDK reference; for an endpoint none of them covers, the generated service classes (\`JobService\`, \`ScriptService\`, ...) are importable from \`windmill-client\`. What is not available is a name you guessed at: \`getBaseUrl\` and \`getWorkspaceToken\` are inventions, not API. +Use only \`wmill\` functions the SDK actually exports; for an endpoint none of them covers, the generated service classes (\`JobService\`, \`ScriptService\`, ...) are importable from \`windmill-client\`. What is not available is a name you guessed at: \`getBaseUrl\` and \`getWorkspaceToken\` are inventions, not API. ### Path runnables (script / flow / hubscript) 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. +Before writing an inline runnable, look for a workspace script or flow that already does the job, or a Hub script (a prebuilt integration at \`hub///\`) for a third-party service, and reference it instead of copying its logic. + ### Draft code vs deployed code This decides whether an app works before anything is deployed: @@ -6070,15 +6589,29 @@ Prefer a **path runnable of type \`flow\`** over an inline runnable that calls \ \`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. +## Who can open a deployed app + +A draft app is reachable by nobody; deploying is what exposes it, and its backend runnables with it. Otherwise only users with access to the app can open it, unless the app is opened to: + +- **anonymous** users: anyone with the URL, without logging in; +- **guests**: anyone the instance's identity provider authenticates, whether or not they belong to the workspace. + +Either one lets those people run the app's backend runnables, so never open an app up unless the user asks. +\`public: true\` in \`raw_app.yaml\` deploys the app for anonymous users, and \`guests: true\` for guests. Remove the line and the next push closes the app again. + ## Data Tables -Data tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the \`wmill\` client; the frontend never queries them directly. +Data tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the \`wmill\` client; the frontend never queries them directly. **When the app needs to store or persist data** (user data, settings, application state, records, logs), use a data table. ### 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. +1. **Check what exists first**: look up the workspace's data tables and their tables before designing storage, and reuse a suitable table rather than creating another. Never assume a \`main\` data table exists. +2. **Whitelisted tables only**: a runnable can only query tables listed in the app's \`data.tables\` config. Queries against unlisted tables fail at runtime, so register a new table there before using it. +3. **No DDL inside runnables**: runnables only read and write rows (SELECT, INSERT, UPDATE, DELETE) on existing tables. Never CREATE, ALTER or DROP a table from a runnable. +4. **Qualify table names**: an unqualified name means the \`public\` schema, so write every other table as \`schema.table\`, in table creation and queries alike. The app's \`data\` config sets the default datatable and schema its tables go in; use them consistently across runnables. +5. **Pass the role**: when the app's \`data.roles\` gives a data table a role, every \`wmill.datatable\` call on it passes that role (\`wmill.datatable('main', { role: 'analyst' })\` in TypeScript, \`wmill.datatable('main', role='analyst')\` in Python). The role only reaches what it was granted, so a query outside it fails with \`permission denied\`. + +\`wmill datatable list\` lists the workspace's data tables. Create or change tables with a migration in \`sql_to_apply/\` (see "SQL Migrations" above), then add them to \`data.tables\` in \`raw_app.yaml\`. ### Querying in TypeScript (Bun/Deno) @@ -6313,6 +6846,14 @@ Reference variables in resource values: - \`$var:u/username/name\` - User variable - \`$var:f/folder/name\` - Folder variable +## Secrets + +Never put a secret (password, API key, token) inline in a resource value. Store it in a secret variable and reference that variable as \`$var:\`. + +- \`$var:\` is a reference, not a value: it resolves to the variable's value at run time. Never invent a value for a variable. +- A resource that references a variable needs the variable to exist first, so create or deploy the variable before the resource. +- A secret's plaintext never goes in a file of the repo: a \`.variable.yaml\` holding it would be committed. Ask the user to create the secret on the workspace instead, e.g. \`wmill variable add '' \` (a secret by default), then reference it by path. + ## Resource References Reference other resources: @@ -6594,14 +7135,13 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than \`script p Use \`wmill resource-type list --schema\` to discover available resource types. -Workflow-as-Code files use the normal script CLI workflow. There are no separate WAC deploy commands. - # Windmill Workflow-as-Code Writing Guide ## Scope Use this guide when writing or modifying Windmill Workflow-as-Code (WAC) scripts. WAC is authored as a Windmill script and deployed with the normal script workflow. It is not an OpenFlow YAML flow. +Workflow-as-Code files use the normal script CLI workflow. There are no separate WAC deploy commands. Supported WAC authoring targets: - Bun TypeScript scripts that import from \`windmill-client\` @@ -6786,7 +7326,6 @@ TypeScript: avoid broad \`try/catch\` around WAC SDK calls. The SDK uses an inte A caught failure reads the same whether it came from a task or from a \`step()\`, and the same in the round that ran the failing body as in every round replaying it. It carries \`step_key\`, \`child_job_id\` (absent for a \`step()\`, which runs in the workflow job and has no child job), a \`message\` that is the failure's own message, and \`result\` = \`{"error": {"name", "message", "stack"?, "extra"?}}\`. \`name\`, \`message\` and \`stack\` are the fields that read the same whichever side failed; \`name\` and \`message\` are always there, \`stack\` only when the failure had a traceback to give. \`extra\` carries the failure's own custom fields (an exception's attributes, an error's properties) and is best-effort: it is absent when there were none, and a task can report entries a step does not, so read it defensively and don't branch on its absence. \`extra\` is dropped when it is too large to keep in the checkpoint, and \`extra_omitted: true\` says so — absent \`extra\` with no \`extra_omitted\` means the failure simply had no custom fields. Branch on those, not on the original exception type: the workflow body re-runs from the top every round and a replay rebuilds the failure from the checkpoint, so nothing outside that record survives. Python raises \`TaskError\`; TypeScript throws an \`Error\` named \`TaskError\` carrying the same fields. Nothing is chained onto \`__cause__\` / \`cause\` — the traceback is in \`result.error.stack\`, and is also printed to the job log when the step fails. - ## TypeScript Workflow-as-Code API (windmill-client) Import: \`import { workflow, task, taskScript, taskFlow, step, sleep, waitForApproval, getApprovalUrls, getResumeUrls, parallel } from "windmill-client"\` @@ -7155,6 +7694,132 @@ def get_approval_urls(step_key: str = 'approval', approver: str = None) -> dict # results = await parallel(items, process, concurrency=5) async def parallel(items, fn, *, concurrency: Optional[int] = None) \`\`\` +`, + "write-pipeline": `--- +name: write-pipeline +description: MUST use when creating or modifying a data pipeline, a set of scripts marked \`pipeline\` and wired together by \`on\` / \`materialize\` annotations. +--- + +# Data pipeline authoring + +A **data pipeline** is NOT a flow. A flow is one runnable that orchestrates steps internally. A data pipeline is a set of **independent scripts**, each deployed on its own, that form a DAG by reading and writing shared **storage assets** (DuckLake tables, data tables, S3 objects, volumes, resources) and by declaring execution **triggers**. The pipeline is visualized and edited at \`/pipeline/\`; every node is a normal workspace script that happens to carry pipeline annotations. When the user asks for a "data pipeline" (or to "ingest / transform / materialize" data across steps), build pipeline-annotated scripts — do NOT build a flow. + +## Default to DuckDB + DuckLake + +A pipeline node that produces a table should almost always be a **\`duckdb\`** node that materializes its output into a **DuckLake** table with \`-- materialize ducklake:///
\` (in a DuckDB node the annotation uses SQL \`--\` comment syntax; write the body as a bare \`SELECT\` and let the runtime do the write). DuckLake is the default lakehouse store for pipelines and is the shape the pipeline editor is built around, so prefer it unless the work specifically calls for something else: + +- \`postgresql\` / data tables — only for row-level, OLTP-style mutations against an existing Postgres data table (frequent single-row upserts/updates, transactional reads that an app queries live). +- \`bun\` / \`python3\` — only for non-tabular work that doesn't map to SQL: calling an external API, wrangling files, arbitrary glue. When such a node still produces tabular data for downstream steps, land it in DuckLake (write it with the wmill SDK / ducklake helpers) rather than inventing a parallel store. + +Do not spread a pipeline across postgres, S3, and DuckLake when one DuckLake lake would do; a consistent DuckLake lakehouse is the goal. + +## Storage prerequisites + +A DuckLake pipeline only runs once the workspace has **object storage** (S3 / Azure Blob / GCS) **and a DuckLake catalog** configured — DuckLake tables and \`s3://\` assets can't be materialized or read without it. Check which DuckLake catalogs the workspace has before you build. +\`wmill ducklake list\` lists them. +Drafting the annotated scripts does not require storage, but the pipeline can't ingest, materialize, or read its assets until it exists. So if there is none (or the user hits "storage not configured" errors), say so and give the right next step **by role**: + +- a workspace **admin** sets it up in Workspace settings → Object Storage (add an S3/Azure/GCS storage), then adds a DuckLake catalog on top of it; +- anyone **without admin rights** should ask a workspace admin to configure object storage + a DuckLake catalog. + +Never hand back a DuckLake pipeline that cannot run without flagging the missing storage and pointing to who sets it up. + +## What makes a script a pipeline node + +A script joins the pipeline when its source begins with the \`pipeline\` annotation as a top-of-file comment, **written in the script's own comment syntax** — \`//\` for TS/JS (bun), \`--\` for SQL (DuckDB/Postgres), \`#\` for Python/Bash. So it's \`-- pipeline\` in a DuckDB node, \`# pipeline\` in a Python node, \`// pipeline\` in a bun node. Every annotation below uses that same prefix (the \`//\` shown is the TS form). All other wiring is expressed as annotation comments near the top of the file: + +- \`// on \` — declares an execution-DAG **input** (what triggers/feeds this node). \`\` is either: + - an **asset URI** (the node runs when that asset is produced upstream): \`ducklake://main/orders\`, \`datatable://main/users\`, \`$res:f/folder/my_resource\`, \`volume://name/path\`, or an S3 object (see the S3 storage-form rule below). + - a **native trigger kind**: \`schedule\`, \`webhook\`, \`email\`, \`kafka\`, \`mqtt\`, \`amqp\`, \`nats\`, \`postgres\`, \`sqs\`, \`gcp\`, or \`data_upload\` (a user-uploaded S3 file). For these the actual trigger row (cron, topic, …) is created separately; the annotation only declares the binding. **\`data_upload\` is special**: there is no trigger row — the node instead declares an **\`S3Object\` input parameter** fed by the auto-generated upload picker; it never hard-codes a key. Any language can be the \`data_upload\` node: + - Python (has \`import wmill\`): \`def main(file: wmill.S3Object):\` then \`wmill.load_s3_file(file)\`; TS (has \`import * as wmill from "windmill-client"\`): \`export async function main(file: wmill.S3Object)\`. Qualify the type as \`wmill.S3Object\` (or add \`from wmill import S3Object\` / \`import { S3Object } from "windmill-client"\`) — a bare \`S3Object\` is undefined. + - **DuckDB** takes the s3object arg via a \`-- $ (s3object)\` declaration and reads it directly, so a single DuckDB node can ingest **and** materialize: + \`\`\` + -- pipeline + -- on data_upload + -- materialize ducklake://main/raw_uploads + -- $file (s3object) + SELECT * FROM read_csv($file) + \`\`\` +- **Outputs** are inferred from what the body writes — a \`CREATE TABLE\`, a \`wmill.writeS3File(...)\`, a DuckLake/datatable write. To declare a managed output explicitly, use \`// materialize \`. +- Optional badges: \`// partitioned \`, \`// freshness \` (e.g. \`1h\`), \`// tag \`, \`// retry [delay]\`, \`// data_test ...\` (managed DuckLake targets only — deploy rejects it beside a \`dbt://\` one), \`// measure = [where ]\` and \`// dimension = \` (see "Declared metrics" below). + +## S3 object wiring (storage form matters) + +An \`s3://\` URI's first slashes select the **storage**, not part of the key — get this wrong and the producer/consumer edge silently won't connect: + +- \`s3:///\` (**triple** slash, empty first segment) = the **default** workspace storage. A downstream node reading or triggering on that object uses \`s3:///\` — e.g. DuckDB \`-- on s3:///orders/2024.parquet\` and \`read_parquet('s3:///orders/2024.parquet')\`. +- \`s3:///\` (**double** slash, non-empty first segment) = a **named secondary** storage called \`\` — so \`s3://ingest/x\` means storage \`ingest\`, key \`x\`, NOT key \`ingest/x\`. Only use this when the object genuinely lives in a configured secondary storage; never invent a bucket/storage name for a default-storage object (it breaks the edge). + +To make the producer side visible to lineage, a Python/TS node MUST pass the **\`S3Object\` form**, not a bare key string: Python \`wmill.write_s3_file(wmill.S3Object(s3=""), data)\` (or the import-free dict \`{"s3": ""}\`), TS \`wmill.writeS3File({ s3: "" }, data)\`. That records the default-storage asset \`/\`, which a downstream \`s3:///\` reader connects to (same key both sides). A bare \`write_s3_file("", ...)\` records **no** asset and produces **no** edge. Add \`storage=""\` only for a named secondary storage. + +The key must be a **string literal** — the graph parser is static and cannot follow a variable, f-string, or computed path, so \`write_s3_file(wmill.S3Object(s3=key_var), ...)\` records no edge. Inline the literal (\`s3="events/user_events.parquet"\`) on both the writing and reading node. The same rule applies to every asset URI in an annotation or SDK call (\`ducklake://\`, \`datatable://\`, \`s3://\`): write them literally, not via a variable. + +## Materialize (the managed output) + +> **A MANAGED \`// materialize\` is DuckDB-only**, and its target must be a DuckLake table (\`ducklake:///
\`). Deploy **rejects** a \`ducklake://\` \`// materialize\` on any other language (\`python3\`, \`bun\`, \`postgresql\`). For a non-DuckDB node writing the lake, do **not** use \`// materialize\` — write the output via the SDK (\`wmill.writeS3File(...)\`, ducklake helpers, …) and let it be inferred. Use \`duckdb\` when a node should materialize a DuckLake table. +> +> The one target ANY language may declare (except a dbt script, whose writes come from its manifest) is a **warehouse relation**: \`// materialize manual dbt:////\`, where \`\` is a warehouse the workspace configures under Settings → dbt. \`manual\` is the only mode it has — nothing generates warehouse DDL, so the node issues its own write (a postgresql \`CREATE TABLE\` / \`INSERT\`, an SDK load, …) and the annotation records the outcome. Use it on an ingestion node whose output a dbt project reads as a \`source\`: the declared relation and the dbt model land on ONE graph node, and a downstream \`// on dbt:////\` fires when the ingestion node completes. + +A managed \`// materialize ducklake:///
\` tells the runtime to write the node's output table **for you**: write the body as a single \`SELECT\` and the runtime wraps it in the create/replace — do **not** also write your own \`CREATE TABLE\` / \`INSERT\`. (The opposite holds for the \`dbt://\` target above: there the node writes its own DDL and the strategies below do not apply.) Write strategy: + +- no option → **replace** the whole table each run (full refresh; the only mode whose output columns may change); +- \`// materialize append\` → INSERT-append rows (incremental); +- \`// materialize key=\` → merge/upsert on \`\`. + +\`// materialize manual \` opts **out** of managed writes — the script writes its own DDL and the annotation only records the output asset for lineage. + +\`materialize\` pairs with partitioning for incremental pipelines: a \`// partitioned \` node runs **once per partition** (append/merge into a fixed-schema table). The \`{partition}\` token, usable in any asset URI **and** in the body SQL, is replaced at run time by the current partition's **identity string**: + +- To filter the source to the active slice on a time grain, use the runtime-injected macro: \`WHERE wm_partition() = {partition}\`. \`wm_partition(ts)\` buckets a timestamp in exactly the identity format the runtime uses for daily/hourly/weekly/monthly, so it always matches; never hand-write a \`strftime\` format. +- Do NOT write \`= TIMESTAMP {partition}\`: the identity string is not a valid timestamp literal for hourly/weekly/monthly and errors at run time. +- For \`dynamic\` partitioning the identity is the caller-supplied key (not a timestamp, no macro), so filter on it directly: \`WHERE = {partition}\`. + +\`materialize\` is an output **declaration** on a node — not a command. There is no "materialize run". + +## Declared metrics (\`measure\` / \`dimension\`) + +On a node that materializes a DuckLake table, \`// measure = [where ]\` names the canonical way to aggregate that table (e.g. \`// measure revenue = sum(amount) where not is_refund\`), and \`// dimension = \` names a way to slice it (e.g. \`// dimension region = region\`, \`// dimension month = date_trunc('month', ordered_at)\`). They execute nothing: they are catalogued at deploy so the editor and other agents reuse the definition instead of re-deriving it and silently disagreeing. + +- Keep the predicate in the \`where\` clause rather than folding it into the aggregate: it is rendered as \` FILTER (WHERE )\`, which is what lets two measures with different predicates share one GROUP BY. +- DuckLake-only, and only meaningful next to \`// materialize\`. +- Declare one when a number carries a judgement call someone else would get wrong (refunds excluded, test rows dropped, which column is the amount); do NOT blanket every table with measures — an obvious \`count(*)\` earns nothing. +- To use a metric another node declares, read that node and reuse its exact expression rather than guessing it. + +## How to build one + +1. Put every node in the **same folder**: \`f//\`. The folder is the pipeline. +2. Write each node as its own script. Default to \`duckdb\` materializing into DuckLake (see "Default to DuckDB + DuckLake" above); pick \`postgresql\`, \`bun\`, or \`python3\` only when that section says the work calls for it. +3. Start each body with \`// pipeline\`, then the \`// on\` input declarations, then the transform that writes the output. +4. **Chain nodes by asset URI**: read an upstream node's output asset, then \`// on \` in the downstream node so the edge forms. Reuse exact asset paths from existing nodes rather than inventing parallel ones. +5. Don't deploy nodes unless the user asks to. A pipeline only "runs" once its scripts are deployed and their triggers exist. + +Locally: + +- create each node with \`wmill script new f// \`, then write its body; +- \`wmill pipeline show --local\` draws the graph from your working tree — check that every edge you meant to form is there; +- \`wmill pipeline dev \` live-previews the pipeline, and \`wmill pipeline run --local\` runs the cascade from local files without deploying (\`--dry-run\` prints the plan first); +- a trigger such as \`// on schedule\` only declares the binding: the schedule or trigger itself is created separately (see the \`schedules\` and \`triggers\` skills). + +## Example (DuckDB → DuckLake, scheduled ingest + downstream transform) + +Node \`f/sales/orders_ingest\` (runs on a schedule, materializes a DuckLake table): + +\`\`\`sql +-- pipeline +-- on schedule +-- materialize ducklake://main/orders +SELECT * FROM read_csv('s3:///raw/orders/*.csv') +\`\`\` + +Node \`f/sales/orders_daily\` (runs when \`orders\` is produced, writes a rollup): + +\`\`\`sql +-- pipeline +-- on ducklake://main/orders +-- materialize ducklake://main/orders_daily +SELECT date_trunc('day', ts) AS day, count(*) AS n +FROM ducklake.main.orders GROUP BY 1 +\`\`\` `, "cli-commands": `--- name: cli-commands @@ -7538,7 +8203,7 @@ Manage jobs (import/export) ### lint -Validate Windmill flow, schedule, and trigger YAML files in a directory, and report script metadata that has no deployable content file +Validate Windmill flow, schedule, and trigger YAML files in a directory (including AI agent tool names and flow groups/notes), and report script metadata that has no deployable content file **Arguments:** \`[directory:string]\` diff --git a/cli/test/fork_specific_items_unit.test.ts b/cli/test/fork_specific_items_unit.test.ts new file mode 100644 index 0000000000..0201958a8e --- /dev/null +++ b/cli/test/fork_specific_items_unit.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { execSync } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; + +import { inferWsNameFromForkBranch, type SyncOptions } from "../src/core/conf.ts"; + +// A fork id never has a wmill.yaml entry of its own. Unless it resolves to its +// parent's entry, a sync on the fork branch pushes the base files over the +// workspace-specific values the fork cloned from its parent. +test("a fork branch resolves to its parent's workspace entry", async () => { + const repoDir = await mkdtemp(path.join(os.tmpdir(), "wmill_fork_specific_")); + const originalCwd = process.cwd(); + try { + execSync( + `git init -q -b wm-fork/dev-branch/t1 && git -c user.email=t@t -c user.name=t commit -q --allow-empty -m init`, + { cwd: repoDir }, + ); + process.chdir(repoDir); + + const config: SyncOptions = { + workspaces: { + dev: { + baseUrl: "http://localhost:8000/", + workspaceId: "dev-ws", + gitBranch: "dev-branch", + specificItems: { folders: ["f/team"] }, + }, + }, + }; + + expect(inferWsNameFromForkBranch(config, "wm-fork-t1")).toEqual("dev"); + // Only the fork the branch names inherits the parent's entry. + expect(inferWsNameFromForkBranch(config, "other-ws")).toBeUndefined(); + } finally { + process.chdir(originalCwd); + await rm(repoDir, { recursive: true, force: true }); + } +}); diff --git a/cli/test/lint_command_unit.test.ts b/cli/test/lint_command_unit.test.ts index 10ecd80bdb..4afe905b3f 100644 --- a/cli/test/lint_command_unit.test.ts +++ b/cli/test/lint_command_unit.test.ts @@ -386,3 +386,167 @@ raw_string: false ).toBeTruthy(); }); }); + +async function lintFlow(tempDir: string, value: string) { + await mkdir(`${tempDir}/f/sem/check.flow`, { recursive: true }); + await writeFile( + `${tempDir}/f/sem/check.flow/flow.yaml`, + `summary: Semantic checks\nvalue:\n${value}`, + "utf-8", + ); + return await runLint({} as any, tempDir); +} + +test("lint: rejects AI agent tool names the worker refuses", async () => { + await withTempDir(async (tempDir) => { + const report = await lintFlow( + tempDir, + ` modules: + - id: agent + value: + type: aiagent + input_transforms: {} + tools: + - id: t_spaced + summary: Search docs + value: { tool_type: flowmodule, type: script, path: f/sem/search, input_transforms: {} } + - id: t_missing + value: { tool_type: flowmodule, type: script, path: f/sem/search, input_transforms: {} } + - id: t_first + summary: get_user + value: { tool_type: flowmodule, type: script, path: f/sem/get_user, input_transforms: {} } + - id: t_dup + summary: get_user + value: { tool_type: flowmodule, type: script, path: f/sem/get_user, input_transforms: {} } + - id: t_reserved + summary: if + value: { tool_type: flowmodule, type: script, path: f/sem/branch, input_transforms: {} } + - id: t_mcp + summary: "" + value: { tool_type: mcp, resource_path: f/sem/mcp } + - id: t_web + summary: Web Search + value: { tool_type: websearch } + - id: linked + value: + type: aiagent + agent: f/sem/support_agent + input_transforms: {} + tools: + - id: t_ignored + summary: Stale name + value: { tool_type: flowmodule, type: script, path: f/sem/search, input_transforms: {} } +`, + ); + + const errors = report.issues.flatMap((i) => i.errors); + expect(errors).toEqual([ + "/value/modules/0/value/tools/0 tool name 'Search docs' may only contain letters, numbers and underscores (a run that offers this tool fails with 'Invalid tool name')", + "/value/modules/0/value/tools/1 has no summary: a tool's summary is the name the agent calls it by", + "/value/modules/0/value/tools/3 tool name 'get_user' is already used by tool 2 of this agent", + ]); + expect(report.warnings.map((w) => w.message)).toEqual([ + "/value/modules/0/value/tools/4 tool name 'if' is a reserved id; the flow editor refuses it", + ]); + expect(report.exitCode).toEqual(1); + }); +}); + +test("lint: rejects flow groups the editor cannot draw", async () => { + await withTempDir(async (tempDir) => { + const step = (id: string) => + ` - id: ${id}\n value: { type: identity }\n`; + const report = await lintFlow( + tempDir, + ` modules: +${step("a")}${step("b")}${step("c")} - id: loop + value: + type: forloopflow + iterator: { type: javascript, expr: "[1]" } + skip_failures: false + modules: + - id: inner + value: { type: identity } + groups: + - { start_id: c, end_id: a } + - { start_id: a, end_id: inner } + - { start_id: a, end_id: gone } + - { start_id: a, end_id: b } + - { start_id: b, end_id: c } + - { start_id: a, end_id: b } +`, + ); + + expect(report.issues.flatMap((i) => i.errors)).toEqual([ + "/value/groups/0 starts at 'c', which comes after its end 'a'", + "/value/groups/1 starts at 'a' and ends at 'inner', which are not in the same branch", + "/value/groups/2 references 'gone', which is not a step of this flow", + "/value/groups/5 spans the same steps as another group (a to b)", + "/value/groups/4 partly overlaps /value/groups/3: groups may nest but not overlap", + ]); + }); +}); + +test("lint: accepts nested groups and flags notes the editor renders wrong", async () => { + await withTempDir(async (tempDir) => { + const report = await lintFlow( + tempDir, + ` modules: + - { id: a, value: { type: identity } } + - { id: b, value: { type: identity } } + - id: loop + value: + type: forloopflow + iterator: { type: javascript, expr: "[1]" } + skip_failures: false + modules: + - { id: x, value: { type: identity } } + - { id: y, value: { type: identity } } + groups: + - { summary: Fetch, start_id: a, end_id: loop, color: blue } + - { summary: Prep, start_id: a, end_id: b } + - { summary: Body, start_id: x, end_id: y, color: "#ff0000" } + notes: + - { id: n1, text: Purpose, color: yellow, type: free, position: { x: -400, y: 0 }, size: { width: 275, height: 80 } } + - { id: n2, text: Unplaced, color: yellow, type: free } + - { id: n3, text: Old, color: gray, type: group, contained_node_ids: [a] } +`, + ); + + expect(report.issues).toEqual([]); + expect(report.warnings.map((w) => w.message)).toEqual([ + "/value/groups/2 color '#ff0000' renders unstyled; use one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray", + "/value/notes/1 has no position or size, so the editor places it at the origin and cannot resize it", + "/value/notes/2 is a 'group' note, which is deprecated: use value.groups instead", + ]); + expect(report.exitCode).toEqual(0); + }); +}); + +test("lint: reports a flow with malformed collections instead of aborting the run", async () => { + await withTempDir(async (tempDir) => { + await mkdir(`${tempDir}/f/sem/other.flow`, { recursive: true }); + await writeFile( + `${tempDir}/f/sem/other.flow/flow.yaml`, + `summary: Other\nvalue:\n modules: {}\n`, + "utf-8", + ); + const report = await lintFlow( + tempDir, + ` modules: + - id: route + value: { type: branchall, branches: {} } + - id: agent + value: { type: aiagent, input_transforms: {}, tools: {} } + groups: {} + notes: {} +`, + ); + + expect(report.issues.map((i) => i.path).sort()).toEqual([ + "f/sem/check.flow/flow.yaml", + "f/sem/other.flow/flow.yaml", + ]); + expect(report.exitCode).toEqual(1); + }); +}); diff --git a/cli/test/preview.test.ts b/cli/test/preview.test.ts index ffa900c8f7..b0b00a2c7e 100644 --- a/cli/test/preview.test.ts +++ b/cli/test/preview.test.ts @@ -455,6 +455,12 @@ schema: // SCRIPT WITH MODULES PREVIEW TESTS // ============================================================================= +// Pinned: a release commit runs these tests while the same release publishes a new +// windmill-client, and the worker resolving that version before npm serves it fails the job. +// These are the only CLI tests running task/taskScript/workflow on a real client: bump the pin +// to a published release whenever the WAC client surface changes. +const WINDMILL_CLIENT = "windmill-client@1.819.0"; + test("script preview: script with modules (taskScript pattern)", async () => { await withTestBackend(async (backend, tempDir) => { await createWmillConfig(tempDir, { defaultTs: "bun" }); @@ -463,7 +469,7 @@ test("script preview: script with modules (taskScript pattern)", async () => { await createScript( tempDir, "f/test/wac_script.ts", - `import { task, taskScript, workflow } from "windmill-client"; + `import { task, taskScript, workflow } from "${WINDMILL_CLIENT}"; const helper = taskScript("./helper.ts"); @@ -512,7 +518,7 @@ test("script preview: script with modules (folder layout)", async () => { // Entry point script await writeFile( `${modDir}/script.ts`, - `import { task, taskScript, workflow } from "windmill-client"; + `import { task, taskScript, workflow } from "${WINDMILL_CLIENT}"; const helper = taskScript("./helper.ts"); diff --git a/cli/test/raw_app_sync.test.ts b/cli/test/raw_app_sync.test.ts index 315b274d80..33f1d3f6a6 100644 --- a/cli/test/raw_app_sync.test.ts +++ b/cli/test/raw_app_sync.test.ts @@ -61,8 +61,7 @@ root.render(); const PACKAGE_JSON = `{ "dependencies": { "react": "19.0.0", - "react-dom": "19.0.0", - "windmill-client": "^1" + "react-dom": "19.0.0" }, "devDependencies": { "@types/react-dom": "^19.0.0", diff --git a/docs/external-instance-datatables.md b/docs/external-instance-datatables.md new file mode 100644 index 0000000000..acfb93b90a --- /dev/null +++ b/docs/external-instance-datatables.md @@ -0,0 +1,88 @@ +# External instance data tables + +A data table is backed by one of three things: a Postgres resource a workspace brings +(`postgresql`), a database on Windmill's own cluster (`instance`), or a database on a separate +cluster Windmill administers (`external_instance`, Enterprise Edition). The third is what this +document covers; Ducklake catalogs take the same three shapes. + +Windmill administers the external cluster the way it administers its own: it creates and drops +databases there, owns `custom_instance_user` and `custom_instance_replication_user`, and creates +the data table roles of that cluster. It logs in as the admin in the `external_instance_pg` +instance setting, and keeps what it generates in the hidden `external_instance_pg_state` setting. + +## Code + +| Where | What | +|---|---| +| `windmill-common/src/external_instance_pg.rs` | Setting, state, usage accounting, the lifecycle lock, the OSS forwarders | +| `windmill-common/src/external_instance_pg_ee.rs` | Setup, database create and drop, the admin connection | +| `windmill-common/src/datatable_roles.rs` | Per-cluster role catalogs (`DatatableRoleCluster`) | +| `windmill-common/src/workspaces.rs` | Resolution (`resolve_datatable_connection_unchecked`), `managed_database_uses` | +| `windmill-api-settings/src/lib.rs` | `/settings/external_instance_pg/*`, `/settings/datatable_roles` | + +## What holds it together + +- **One lifecycle lock.** `lock_external_instance_pg_state` serializes everything that changes + which databases exist on the cluster or which entries name them: setup, create, drop, data table + and Ducklake saves, external role DDL, and writes to the setting itself. Anything reading the + configuration to reach the cluster reads it under that lock, so a database is never created on + one cluster and registered while the setting names another. +- **Windmill only touches what it made.** Databases it creates carry a comment, and a drop + requires it. The two managed roles and every data table role carry their own comment, and setup + refuses a `custom_instance_user` without it rather than resetting the password of someone else's + role. +- **Creation needs a successful setup.** `set_up_for` records the `host:port` the last successful + setup converged. Creating a database on a cluster that setup has not succeeded on is refused. +- **Nothing is dropped from under a user.** `managed_database_uses` lists every data table naming + a database, every fork pointing at those, every Ducklake catalog on it, and every fork Ducklake + metadata schema still to be dropped. Fork cleanup exempts exactly the entry it is cleaning up. +- **Fork copies belong to a workspace.** `wm_fork_*` is a name, not an authorization: every + database of a cluster answers to the same `custom_instance_user`. The registry records the + workspace a copy was created for, and a member can only import into or fork onto a copy of their + own workspace. +- **Roles are per cluster.** `datatable_role.cluster` splits the catalog, so the same role name can + exist on both clusters. Role names are unique per cluster, as they are in Postgres. + +## Running one locally + +```bash +docker run -d --name wm-external-pg -e POSTGRES_PASSWORD=external -p 5497:5432 postgres:18 \ + -c wal_level=logical +psql "postgresql://postgres:external@127.0.0.1:5497/postgres" \ + -c "CREATE ROLE wm_admin LOGIN PASSWORD 'adminpw' CREATEDB CREATEROLE REPLICATION" +``` + +A non-superuser admin with `CREATEDB` and `CREATEROLE` is the realistic case: managed Postgres +gives nothing more. `REPLICATION` is only needed for Postgres triggers on external data tables. + +Then, as superadmin (`$T` is a token): + +```bash +api=http://localhost:8000/api +curl -s -X POST $api/settings/global/external_instance_pg -H "Authorization: Bearer $T" \ + -H 'Content-Type: application/json' \ + --data '{"value":{"host":"127.0.0.1","port":5497,"user":"wm_admin","password":"adminpw","sslmode":"disable"}}' +curl -s -X POST $api/settings/external_instance_pg/setup -H "Authorization: Bearer $T" \ + -H 'Content-Type: application/json' --data '{}' # report per step +curl -s -X POST $api/settings/external_instance_pg/databases/dt_demo -H "Authorization: Bearer $T" \ + -H 'Content-Type: application/json' --data '{}' +curl -s -X POST $api/w/admins/workspaces/edit_datatable_config -H "Authorization: Bearer $T" \ + -H 'Content-Type: application/json' \ + --data '{"settings":{"datatables":{"demo":{"database":{"resource_type":"external_instance","resource_path":"dt_demo"}}}}}' +``` + +`sslmode` defaults to `verify-full`; `disable` is for a local container only. With `verify-full` +against a server with a private CA, put the CA in `root_certificate_pem` — `pg_dump`, `psql` and +DuckDB attaches all verify against the system trust store plus that certificate. + +Jobs then reach it as any data table: `ATTACH 'datatable://demo' AS d` from DuckDB, or +`datatable://demo` as the database of a PostgreSQL script, with `-- role ` to connect as a +data table role of that cluster. + +Worth knowing while testing: + +- A worker needs the `postgresql` and `duckdb` tags for those jobs + (`update config set config = jsonb_set(config, '{worker_tags}', …) where name = 'worker__default'`). +- DuckDB jobs load `libwindmill_duckdb_ffi_internal.so` by name, so a binary built into its own + `CARGO_TARGET_DIR` needs that library on `LD_LIBRARY_PATH`. +- Setup holds the lifecycle lock for its whole run, so a settings save during it waits. diff --git a/docs/feature-telemetry.md b/docs/feature-telemetry.md index 9dacfc1cbd..c0d7b9c027 100644 --- a/docs/feature-telemetry.md +++ b/docs/feature-telemetry.md @@ -4,7 +4,7 @@ anonymous usage-stats payload. It answers "does anyone use this, and which variant do they pick" without any identifying data leaving the instance. -It currently carries 58 registered actions across twenty features (`ai_session`, `ai_chat`, +It currently carries 61 registered actions across twenty features (`ai_session`, `ai_chat`, `ai_fix`, `ai_agent`, `ai_agent_eval`, `app_sandbox`, `datatable`, `db_manager`, `flow_editor`, `flow_run`, `flow_step`, `home`, `run_form`, `debugger`, `trigger`, `command_script`, `hub_script`, `usage_meter`, `sso_groups_claim`, `cloud_trial_offer`). Nearly all of the @@ -48,7 +48,7 @@ vocabulary closed and small — enumerate the values in a TS union next to the c ## The recipe -Four steps. Skipping step 1 or 3 fails quietly. +Four steps. Skipping step 1 fails quietly. **1. Register the pair** in `FEATURE_USAGE_KINDS` (`backend/windmill-common/src/feature_usage_ee.rs`, tracked in `windmill-ee-private`). An @@ -68,9 +68,10 @@ Fire-and-forget. Events sum locally per `(workspace, feature, kind, key, entityI every 30s, on `visibilitychange` → hidden, and on `pagehide`; 50 events per request, and a failed batch is dropped rather than retried. -**3. Update the disclosure.** `InstanceSettings.svelte` lists what a non-minimal payload contains -(two places — the copy appears twice). A new counter that isn't named there means the instance -under-discloses what it sends. This has already drifted once. +**3. Check the disclosure.** `InstanceSettings.svelte` describes a non-minimal payload by +category, not counter by counter (two places — the copy appears twice); keep it short. A plain +counter needs no edit. Only a key that sends a new kind of value beyond a count — a name or an +identifier, like the AI model and hub names it already lists — must be added there. **4. Verify a row lands.** The silent-drop path means "no error" proves nothing: diff --git a/docs/operator-builder-rights.md b/docs/operator-builder-rights.md new file mode 100644 index 0000000000..4db2a69511 --- /dev/null +++ b/docs/operator-builder-rights.md @@ -0,0 +1,76 @@ +# Operator builder rights + +`operator_settings.builder_flows` lets every operator of a workspace compose flows out of runnables +that already exist. It does not make them authors: the boundary the operator role draws is +**authoring code and running arbitrary code**, and this does not move it. + +It is a write right, unlike the visibility flags beside it, and unlike the withdrawable rights in +`docs/operator-write-rights.md` it is granted on request and costs a seat. Read it with +`windmill_common::workspaces::operator_can_build_flows` (60s cache, shared with the withdrawable +rights) and gate a write with `check_operator_can_build_flows`. + +## What the check has to cover + +`check_flow_is_composition_only` (`windmill-common/src/flows.rs`) walks a `FlowValue` and refuses +anything that carries code. Three of its rules exist because the obvious walk misses them: + +- **`FlowScript` and any populated `modules_node` / `default_node`.** These name code hoisted into + a `flow_node` row. Only the dependency job produces them, so an authored value carrying one + names code stored under some other flow. The walk covers `modules`, so a node reference is a way + past it. +- **An AI agent step's `tools`.** `ToolValue::FlowModule` wraps a whole `FlowModuleValue`, so a + tool can be a raw script. +- **An AI agent step's `agent` link.** A linked agent resolves its tools from an `ai_agent` + resource at run time, and operators may write resources, so the tool list is outside this check + and can be swapped for a raw script after the flow is approved. + +It also returns what a value-only walk cannot authorize, for the caller to check against its own +permissions: + +- **the worker tags the steps pin**, or a builder routes a job onto a privileged worker group; +- **every runnable a step references**. `script_to_payload` resolves a step's path with the root DB + handle (`db_authed = None`) and returns the referenced runnable's `on_behalf_of`, which + `worker_flow` then applies to the step job. So composing a path is enough to run it, and to run + it as whoever it runs as: `validate_operator_composed_flow` re-checks each path under the + caller's RLS. This is the general case; the one below is on top of it, not instead of it. +- **the `(path, hash)` of every version-pinned step**. A step carrying a `hash` is dispatched by + that hash alone, with the path beside it never consulted, so a readable path paired with another + script's hash still runs that other script. + +The value is not the only code a flow carries: the schema's `x-windmill-dyn-select-code` fills its +dynamic dropdowns, run as whoever loads the form, so `validate_operator_flow` refuses it like a +step's code. A builder therefore cannot save a developer's flow that has dropdowns; its editor +still previews them through the deployed flow rather than the inline route operators are refused. +Which developer flows a builder may edit at all is left to permissions: write access to the flow +or its folder. + +Call it on every write **and** every preview: `run_preview_flow_job` and +`push_flow_dependencies_job` both take a request-supplied flow value, so leaving either out makes +it the way to run what the write path refuses. A flow draft goes through `validate_operator_flow` +too: a developer who loads a builder's draft in the editor runs its code as themselves. + +## Billing + +An operator of a builder workspace consumes a full author seat: composing deployable artifacts +makes them an author, and there is no half-author. `consumes_operator_seat` is the seat-role +helper; the EE counting queries share `OPERATOR_SEAT_SQL` so the displayed, enforced and reported +numbers agree. The one exception is `get_user_usage` in `stats_ee.rs`: it is a compile-checked +`query!`, which cannot interpolate the constant, so it spells the predicate out. Change both +together. + +On cloud, `billable_seats` applies the same rule; see its doc for the out-of-repo invoice it must +match. + +Granting the right runs `check_seat_cap_for_operator_builder`, which prices the change by counting +seats twice rather than by counting the workspace's operators: an operator who already authors +elsewhere must not be charged again, so re-saving settings that already have the right on is a +zero delta and never blocks. + +## Accepted risks + +- All-or-nothing per workspace: there is no per-user builder role. +- `operator_settings` is git-synced, so a pull can flip every operator's class in a workspace and + the billed seat count with it. +- A builder writes JavaScript expressions: step inputs, branch predicates, `stop_after_if` and + `suspend`. They run in QuickJS with no filesystem or network access, and forbidding them would + leave nothing to compose with. diff --git a/docs/operator-write-rights.md b/docs/operator-write-rights.md index fb9d4051bb..2c18ecd290 100644 --- a/docs/operator-write-rights.md +++ b/docs/operator-write-rights.md @@ -79,7 +79,7 @@ authorizing writes on every other replica until its own entry expires. These name capabilities operators already hold, so absence has to mean "never configured", not a value. That is easy to get wrong in two places, and the obvious implementation gets both wrong: -- The read coalesces to **true** (`operator_manage_rights`), including for a workspace with no +- The read coalesces to **true** (`operator_rights`), including for a workspace with no `workspace_settings` row, which is what `OperatorManageRights::default` is for. Coalescing to false instead revokes the right on upgrade for every workspace that ever saved operator settings, since those rows carry explicit keys and none of them is this one. diff --git a/docs/reusable-ai-agents.md b/docs/reusable-ai-agents.md index 2f703bc0b5..5eac5e964c 100644 --- a/docs/reusable-ai-agents.md +++ b/docs/reusable-ai-agents.md @@ -44,8 +44,10 @@ which memory it is: - **Agent: managed memory.** `memory` is a brain key, so it moves with a saved agent. `{ kind: window, context_length }` has Windmill store the conversation and replay its last N - messages; `{ kind: off }` keeps none. An absent `memory` means off, the default: the editor turns - it on when chat input is enabled. `auto` and `manual` are the older spellings and are still read. + messages; `{ kind: compaction }` replays all of it and summarizes the older part as it fills the + model's context window (see below); `{ kind: off }` keeps none. An absent `memory` means off, the + default: the editor turns it on when chat input is enabled, as compaction, since a chat has no + end. `auto` and `manual` are the older spellings and are still read. - **Run: memory id.** `flow_status.memory_id`, set when the run is queued: the chat conversation id, an app chat session id, or the `memory_id` run parameter. Any string is accepted, and one that is not a uuid is hashed to a v5 uuid scoped to the workspace and the flow the run started @@ -69,25 +71,90 @@ The worker reconciles them once per agent invocation, nested agent tools include 1. A legacy `auto` or `manual` memory: read as the editor that wrote it ran it. `manual` replays its list; `auto` uses the run's memory id, else the id baked into it, else runs stateless. Neither history input is read. An `auto` without a count, or with 0, is off and read as such. -2. Managed memory: the memory id is the step's, else the run's. With no memory id the agent runs - stateless, and a step `previous_messages` is ignored. +2. Managed memory, `window` or `compaction`: the memory id is the step's, else the run's. With no + memory id the agent runs stateless, and a step `previous_messages` is ignored. Compaction still + bounds a stateless run's own loop, which is where a long tool sequence overflows. 3. Memory off: the history is `previous_messages`, else nothing. Memory is neither read nor written, and a step `memory_id` is ignored. Each ignored input and each stateless fallback is written to the job log. +### Compaction + +`{ kind: compaction }` keeps the whole conversation and lets a summary, rather than a message count, +decide what leaves the prompt. + +The window it plans against comes from the model, through `MODEL_CONTEXT_WINDOWS` in +`windmill-ai/src/model_context.rs`, falling back to 128000 for an id the table does not list. That +table mirrors the one the AI session's own compaction reads +(`frontend/src/lib/components/copilot/modelConfig.ts`) and the two have to be updated together. The +step's `context_window` overrides it, for a Custom AI deployment or a model the table cannot name; +setting it too large never trips the trigger and the provider raises the context error itself. +Workspace AI chat `context_window_per_model` overrides are separate and are not inherited by +agent steps; configure a custom deployment's size on the step itself. + +`windmill-worker/src/ai/compaction.rs` keeps two histories: model context, which can be compacted, +and the execution record, which retains the loaded history and every message produced by the run. +Returned results and max-iteration partial results use the execution record. Compaction cannot remove or +reorder the action messages the flow viewer indexes, or the MCP results it finds by call ID. + +Before each provider request, including the first, the worker checks the projected context size. +It reserves the larger of 20% of the model window and the configured maximum output tokens. +At the remaining input budget it summarizes an older prefix, retaining up to 20K estimated tokens +of recent complete exchanges (at most half the input budget on smaller models). +The newest exchange is always retained. A user prompt stays with its +first response; later tool rounds can be compacted within a single turn, but a call and its results +are never split. A prefix containing only an earlier summary is not summarized again. + +The summarization request carries no tools; its tool exchanges are rendered as text because +Bedrock rejects tool blocks without definitions. A dedicated system instruction asks for a factual +handoff from a labelled transcript, keeping the compaction instruction outside that transcript; +media parts remain available. Its output cap matches the reserved summary budget, +and its temperature and reasoning settings are independent of the step's answer settings. +A replacement is built separately and installed only if it reduces projected context and fits +the estimated input budget. An empty, oversized or failed summary leaves the context and its +usage measurement untouched; compaction never evicts messages. Three consecutive failed attempts +disable summary requests for the run. Estimates schedule compaction but do not reject model calls. + +The projection uses normalized `TokenUsage::input_tokens` from the last request plus a `bytes/4` +estimate of appended messages. Without usage, or after rewriting context, it estimates the whole +prompt including tools. S3 descriptors get a nominal attachment allowance; actual attachment costs +and tokenizer differences remain approximate. Provider parsing owns usage normalization: Anthropic +and Bedrock report cached input separately, while OpenAI-shaped providers include it in input tokens. +If a provider explicitly rejects the context size, the worker summarizes all older complete +exchanges and retries that request once if a usable replacement was produced. This also recovers +from undercounted attachments loaded from a previous run, without provider-specific tokenizers. +Unrelated errors are not retried this way. The newest exchange is still retained, so a request +that cannot fit even after recovery fails; estimates do not guarantee every first request fits. + +If recovery fails or the retry is rejected, the provider error is returned without saving changed +memory. A successful checkpoint uses the same execution record as before compaction. + Memory is stored per (memory id, step id), in `ai_agent_memory` or S3 at `memory/{workspace}/{memory id}/{step}.json`. The chat transcript (`flow_conversation_message`) -always follows the run's id, even when a step sets its own. Nothing expires stored memory: deleting -a chat conversation deletes its memory, and a memory named by a string id stays until it is -overwritten. +always follows the run's id, even when a step sets its own. Persistence independently checks +serialized memory against the database's 100KB limit (`MAX_MEMORY_SIZE_BYTES`), +when `memory_storage_capacity_bytes` reports one. System messages and tool definitions are not +stored, so they do not count towards this byte limit. If oversized, it requests one checkpoint +of all older exchanges, then measures bytes again. If memory still cannot fit, persistence saves +the largest suffix of complete exchanges that fits and starts with a user message, dropping +older exchanges and logging that loss. This selection changes neither model context nor the +returned execution record. +If no user-starting suffix fits, the write is skipped and the flow log explains that the +next run will load the previous saved memory. The completed answer succeeds in either case. +If object storage fails and falls back to the database, an oversized compaction write is +rejected without changing existing memory; the flow log reports the failed save. +There is no model-window compaction after the final answer unless storage needs +it. Persistence reads model context independently of the execution record returned by the step. +Nothing expires stored memory: deleting a chat conversation deletes its memory, and a memory named +by a string id stays until it is overwritten. Compatibility runs one way. New workers read every older shape. The editor rewrites a legacy step only when the author changes it, so a flow nobody edits keeps running on older workers, while a -step saved with `window` or a history input needs a worker that knows them. An id an older editor -baked into `memory` stays a fallback behind the run's id until the author chooses *Keep as memory -id* or *Use the run's memory id*. In a chat flow it is dropped on save, since the conversation id -always took precedence there. +step saved with `window`, `compaction` or a history input needs a worker that knows them. An id an +older editor baked into `memory` stays a fallback behind the run's id until the author chooses +*Keep as memory id* or *Use the run's memory id*. In a chat flow it is dropped on save, since the +conversation id always took precedence there. ## Drafts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 42e9804adc..4da3930df8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.819.0", + "version": "1.820.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.819.0", + "version": "1.820.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index db71f92e1c..3efd1155ef 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.819.0", + "version": "1.820.0", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/frontend/scripts/ui_builder_artifact.json b/frontend/scripts/ui_builder_artifact.json index 68c1c558b1..8940f265ce 100644 --- a/frontend/scripts/ui_builder_artifact.json +++ b/frontend/scripts/ui_builder_artifact.json @@ -1,5 +1,5 @@ { "baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev", - "version": "60b6904", - "sha256": "85b68e8418c97d96f3a419b3e345ef8a1987976943b8439a7df841f9217a9b34" + "version": "9fddc52", + "sha256": "b45558951f3d4c9dce7b88b5139d66b0f5f01fe561a22efcb0b8400a13240522" } diff --git a/frontend/serve_ui_builder.sh b/frontend/serve_ui_builder.sh index e396c6f1b4..4bf7e48a4d 100755 --- a/frontend/serve_ui_builder.sh +++ b/frontend/serve_ui_builder.sh @@ -9,8 +9,6 @@ os.chdir('ui_builder_serve') from http.server import HTTPServer, SimpleHTTPRequestHandler class H(SimpleHTTPRequestHandler): def end_headers(self): - self.send_header('Cross-Origin-Opener-Policy', 'same-origin') - self.send_header('Cross-Origin-Embedder-Policy', 'require-corp') self.send_header('Cross-Origin-Resource-Policy', 'cross-origin') super().end_headers() HTTPServer(('', 4000), H).serve_forever() diff --git a/frontend/src/lib/attachments/newTabModifier.dom.test.ts b/frontend/src/lib/attachments/newTabModifier.dom.test.ts deleted file mode 100644 index 115877298c..0000000000 --- a/frontend/src/lib/attachments/newTabModifier.dom.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { newTabModifier } from './newTabModifier.svelte' - -const onPlatform = (userAgent: string) => vi.stubGlobal('navigator', { userAgent }) -const LINUX = 'Mozilla/5.0 (X11; Linux x86_64)' -const MAC = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' - -const attached: (() => void)[] = [] - -/** Attach to a fresh element and return it with its cleanup, as `{@attach}` would. */ -function pill() { - const node = document.createElement('span') - document.body.append(node) - const modifier = newTabModifier() - const cleanup = modifier.attach(node) as () => void - attached.push(cleanup) - const hover = (init: MouseEventInit = {}) => - node.dispatchEvent(new MouseEvent('mouseenter', init)) - const move = (init: MouseEventInit = {}) => node.dispatchEvent(new MouseEvent('mousemove', init)) - const unhover = () => node.dispatchEvent(new MouseEvent('mouseleave')) - return { modifier, hover, move, unhover, cleanup } -} - -const keydown = (init: KeyboardEventInit) => - window.dispatchEvent(new KeyboardEvent('keydown', init)) - -describe('newTabModifier', () => { - // The window listeners outlive the DOM, so every case has to be torn down through the - // attachment rather than by emptying the body. - afterEach(() => { - attached.splice(0).forEach((cleanup) => cleanup()) - document.body.replaceChildren() - vi.unstubAllGlobals() - }) - - // The hover event carries the live modifier state, so a modifier pressed before the pointer - // arrived (or while this window was unfocused) is picked up rather than read as false. - it('seeds from the hover event, per platform', () => { - onPlatform(LINUX) - const linux = pill() - linux.hover({ ctrlKey: true }) - expect(linux.modifier.held).toBe(true) - - onPlatform(MAC) - const mac = pill() - // macOS ctrl+click is a secondary click, so it must not read as a new-tab modifier. - mac.hover({ ctrlKey: true }) - expect(mac.modifier.held).toBe(false) - mac.hover({ metaKey: true }) - expect(mac.modifier.held).toBe(true) - }) - - // Editors and menus stop keydown propagation to keep their own shortcuts, so a bubble-phase - // listener would go blind whenever focus sits in one. - it('sees a keydown that a focused element stops from propagating', () => { - onPlatform(LINUX) - const { modifier, hover } = pill() - hover() - const input = document.createElement('input') - input.addEventListener('keydown', (e) => e.stopPropagation()) - document.body.append(input) - - input.dispatchEvent( - new KeyboardEvent('keydown', { key: 'Control', ctrlKey: true, bubbles: true }) - ) - expect(modifier.held).toBe(true) - }) - - // A modifier held across a keyboard app switch is cleared by the blur and delivers no keydown - // on the way back, while the pointer parked on the pill fires no fresh mouseenter either. - it('re-seeds from pointer movement after the window lost focus', () => { - onPlatform(LINUX) - const { modifier, hover, move } = pill() - hover({ ctrlKey: true }) - window.dispatchEvent(new Event('blur')) - expect(modifier.held).toBe(false) - - move({ ctrlKey: true }) - expect(modifier.held).toBe(true) - }) - - it('stops tracking once unhovered', () => { - onPlatform(LINUX) - const { modifier, hover, unhover } = pill() - hover({ ctrlKey: true }) - unhover() - expect(modifier.held).toBe(false) - - keydown({ key: 'Control', ctrlKey: true }) - expect(modifier.held).toBe(false) - }) - - it('stops tracking when the element is destroyed while hovered', () => { - onPlatform(LINUX) - const { modifier, hover, cleanup } = pill() - hover({ ctrlKey: true }) - // Hovering again without leaving must not strand the first hover's listeners, which nothing - // would then hold a reference to. - hover({ ctrlKey: true }) - cleanup() - expect(modifier.held).toBe(false) - - keydown({ key: 'Control', ctrlKey: true }) - expect(modifier.held).toBe(false) - }) -}) diff --git a/frontend/src/lib/attachments/newTabModifier.svelte.ts b/frontend/src/lib/attachments/newTabModifier.svelte.ts deleted file mode 100644 index 06c69f36cc..0000000000 --- a/frontend/src/lib/attachments/newTabModifier.svelte.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { Attachment } from 'svelte/attachments' -import { isMac } from '$lib/utils' - -/** - * Tracks whether the modifier that turns a click into a new browser tab is held, but only while - * the attached element is hovered, which is the only moment the answer is used. - */ -export function newTabModifier() { - let held = $state(false) - - // Only the modifier that actually yields a tab: shift opens a window, alt can start a - // download, and on macOS ctrl+click is a secondary click. - // Taken from each event rather than accumulated across keydown/keyup pairs, so a keyup lost to - // a focus change cannot strand the flag on. - const sync = (event: KeyboardEvent | MouseEvent) => { - held = isMac() ? event.metaKey : event.ctrlKey - } - const clear = () => { - held = false - } - - const attach: Attachment = (node) => { - // One controller per hover: a mirrored remove list leaks any listener whose options drift. - let hover: AbortController | undefined - const leave = () => { - hover?.abort() - hover = undefined - clear() - } - const enter = (event: MouseEvent) => { - // Seeded from the hover itself: mouse events carry the same modifier flags as key events, - // so a modifier already held before the pointer arrived reads correctly. - sync(event) - // Re-entering without an intervening leave would strand the previous controller: nothing - // else references it, so its listeners could never be removed. - hover?.abort() - hover = new AbortController() - const { signal } = hover - // Same reason the hover seeds: a modifier held across a keyboard app switch delivers no - // keydown on the way back, so the pointer is all that is left to re-read it from. - node.addEventListener('mousemove', sync, { signal }) - // Capture: editors and menus stopPropagation the keys they handle, hiding the modifier - // from a bubble-phase listener whenever focus sits in one. - window.addEventListener('keydown', sync, { capture: true, signal }) - window.addEventListener('keyup', sync, { capture: true, signal }) - // Not capture, unlike the two above: blur does not bubble but does reach the window while - // capturing, so it would fire for every element that loses focus. - window.addEventListener('blur', clear, { signal }) - } - - const life = new AbortController() - node.addEventListener('mouseenter', enter, { signal: life.signal }) - node.addEventListener('mouseleave', leave, { signal: life.signal }) - return () => { - life.abort() - leave() - } - } - - return { - get held() { - return held - }, - attach - } -} diff --git a/frontend/src/lib/common.ts b/frontend/src/lib/common.ts index 9eb019e838..daba01ab73 100644 --- a/frontend/src/lib/common.ts +++ b/frontend/src/lib/common.ts @@ -49,6 +49,8 @@ export interface SchemaProperty { hideWhenChatEnabled?: boolean /** Why the oneOf variant is chat mode's to pick. Set = selector disabled, reason shown. */ lockOneOfWhenChatEnabled?: string + /** One line under the oneOf selector explaining the selected variant, keyed by its title. */ + oneOfHints?: Record password?: boolean order?: string[] nullable?: boolean diff --git a/frontend/src/lib/components/AddUser.svelte b/frontend/src/lib/components/AddUser.svelte index aef1dbd400..1369e33fb2 100644 --- a/frontend/src/lib/components/AddUser.svelte +++ b/frontend/src/lib/components/AddUser.svelte @@ -261,13 +261,13 @@ {/snippet} diff --git a/frontend/src/lib/components/ApiConnectForm.svelte b/frontend/src/lib/components/ApiConnectForm.svelte index 9aff73d827..2e4e4b526c 100644 --- a/frontend/src/lib/components/ApiConnectForm.svelte +++ b/frontend/src/lib/components/ApiConnectForm.svelte @@ -5,7 +5,6 @@ import SchemaForm from './SchemaForm.svelte' import Toggle from './Toggle.svelte' import TestConnection from './TestConnection.svelte' - import SupabaseIcon from './icons/SupabaseIcon.svelte' import Popover from './meltComponents/Popover.svelte' import Button from './common/button/Button.svelte' import { Loader2 } from 'lucide-svelte' @@ -16,8 +15,6 @@ import { isCloudHosted } from '$lib/cloud' import ResourceGen from './copilot/ResourceGen.svelte' import SyncResourceTypes from './SyncResourceTypes.svelte' - import { base } from '$lib/base' - import { isDataTableWizardEnabled } from './workspaceSettings/utils.svelte' import { parsePostgresConnectionString } from '$lib/utils/postgresConnectionString' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' @@ -139,10 +136,6 @@ let rawCodeEditor: { setCode: (code: string) => void } | undefined = $state(undefined) let textFileContent: string | undefined = $state(undefined) - // The wizard's Supabase entry point is opt-in for now; without it the form keeps the link - // that hands the whole leg over to the resources page. - const wizardEnabled = isDataTableWizardEnabled() - function applySupabasePick(value: Record) { args = { ...(args ?? {}), ...value } rawCode = JSON.stringify(args, null, 2) @@ -228,28 +221,13 @@ {/if} {#if resourceType == 'postgresql' && supabaseWizard} - {#if wizardEnabled} - - {#await import('./workspaceSettings/SupabaseResourceConnect.svelte')} - - {:then Module} - - {/await} - {:else} - - - -
Connect Supabase
-
- {/if} + + {#await import('./workspaceSettings/SupabaseResourceConnect.svelte')} + + {:then Module} + + {/await} {/if} { isGoogleSignin = step == 2 && !manual && (resourceType == 'google' || - resourceType == 'gmail' || - resourceType == 'gcal' || - resourceType == 'gdrive' || - resourceType == 'gsheets') + (registryEntryFor(resourceType)?.auth_url?.startsWith('https://accounts.google.com/') ?? + false)) }) run(() => { @@ -606,16 +645,18 @@ value = data.res.access_token! valueToken = data.res responseExtra = data.extra ?? {} + fillGeneratedDescription() step = 4 // `fillPath` decides the path as surely as express does, so neither stops here. if (fillPath || express) { - path = fillPath ?? `u/${$userStore?.username}/${resourceType}_${new Date().getTime()}` + path = fillPath ?? `u/${$userStore?.username}/${defaultName}_${new Date().getTime()}` next() } } } async function getScopesAndParams() { + useUserToken = false if (!connects?.includes(connectClient)) { // No instance OAuth client (registry-declared CC-only provider): // defaults come from the static registry instead. @@ -746,9 +787,10 @@ ...tokenResponse, grant_type: 'client_credentials' // Mark this token as client_credentials } + fillGeneratedDescription() step = 4 if (fillPath || express) { - path = fillPath ?? `u/${$userStore?.username}/${resourceType}_${new Date().getTime()}` + path = fillPath ?? `u/${$userStore?.username}/${defaultName}_${new Date().getTime()}` next() } } catch (error) { @@ -764,7 +806,11 @@ * Opens popup for user to authenticate with OAuth provider */ const url = new URL(`/api/oauth/connect/${connectClient}`, window.location.origin) - url.searchParams.append('scopes', scopes.join('+')) + // An empty `scopes` still overrides the instance's bot scopes. + url.searchParams.append('scopes', useUserToken ? '' : scopes.join('+')) + if (useUserToken) { + url.searchParams.append('user_scope', scopes.join(',')) + } if (extra_params.length > 0) { extra_params.forEach(([key, value]) => url.searchParams.append(key, value)) } @@ -1147,8 +1193,6 @@ if (step !== 1) return highlight.onKeydown(e) } - - let editScopes = $state(false) {#if !express} @@ -1557,24 +1601,29 @@ {/if} -
-

Scopes

- - {#if editScopes} - - {:else} -
- {#each scopes as scope} -
- {scope}
- {/each} + {#if registryEntry()?.user_scopes && !useClientCredentials} +
+

Connect as

+
+ selectUserToken(false)} + /> + selectUserToken(true)} + />
- {/if} +
+ {/if} + +
+

Scopes

+
{/if} @@ -1588,7 +1637,7 @@
{/snippet} diff --git a/frontend/src/lib/components/EditableSchemaForm.svelte b/frontend/src/lib/components/EditableSchemaForm.svelte index 79173424d9..11429cb6fe 100644 --- a/frontend/src/lib/components/EditableSchemaForm.svelte +++ b/frontend/src/lib/components/EditableSchemaForm.svelte @@ -19,6 +19,7 @@ import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' import Label from './Label.svelte' import { sendUserToast } from '$lib/toast' + import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' import Toggle from './Toggle.svelte' import { DynamicInput, @@ -35,9 +36,14 @@ import Section from '$lib/components/Section.svelte' import Editor from './Editor.svelte' import AddPropertyV2 from './schema/AddPropertyV2.svelte' - import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { + useOperatingUser, + useOperatingWorkspace + } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() + const operatingUser = useOperatingUser() + const operatorBuilderFlows = useOperatorBuilderFlows() // export let openEditTab: () => void = () => {} const dispatch = createEventDispatcher() @@ -88,6 +94,8 @@ schemaFormClassName?: string onChange?: (args: Record) => void workspace?: string | undefined + /** The deployed flow an operator's dropdown previews read their options from. */ + deployedFlowPath?: string } let { @@ -127,7 +135,8 @@ extraTab, schemaFormClassName = undefined, onChange = undefined, - workspace = undefined + workspace = undefined, + deployedFlowPath = undefined }: Props = $props() let ws = $derived(workspace ?? $operatingWorkspace) @@ -469,11 +478,12 @@ order: e.detail } }} - helperScript={{ - source: 'inline', - code: dynCode!, - lang: dynLang! - }} + helperScript={DynamicInput.flowHelperScript( + dynCode, + dynLang, + deployedFlowPath, + operatingUser.current?.operator + )} prettifyHeader={isAppInput} disabled={!!previewSchema} {diff} @@ -486,7 +496,7 @@ {@render runButton?.()} - {#if dynamicFunctions.length > 0} + {#if dynamicFunctions.length > 0 && !$operatorBuilderFlows}
{/each} - {#if showDynOpt} + {#if showDynOpt && !$operatorBuilderFlows} {#each DYNAMIC_OPTIONS as x} {/each} diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index 5ec3c62b5e..f9df8adcef 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -1831,9 +1831,15 @@ scriptLang === 'bunnative' || scriptLang === 'nativets') ) { - const resourceTypes = await ResourceService.listResourceType({ - workspace: $operatingWorkspace ?? '' - }) + // The generated client resolves `undefined` for any response whose body it + // cannot read — `getResponseBody` swallows a parse failure and returns + // nothing, which `catchErrorCodes` passes through on a 2xx. Typing says + // otherwise, so without this the editor dies on mount rather than losing + // its resource-type autocomplete. + const resourceTypes = + (await ResourceService.listResourceType({ + workspace: $operatingWorkspace ?? '' + })) ?? [] const namespace = formatResourceTypes( resourceTypes, diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 60577ef124..289dfa21d4 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -106,12 +106,13 @@ import { UserDraft } from '$lib/userDraft.svelte' import { setOpenInSessionHandoff } from './sessions/openInSessionContext' import { getEditorStoragePath, setEditorStoragePath } from './editorStoragePathContext' - import { useTriggerLock } from '$lib/operatorWriteRights' + import { useOperatorBuilderFlows, useTriggerLock } from '$lib/operatorWriteRights' import { useOperatingUser, useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const triggerLock = useTriggerLock() + const operatorBuilderFlows = useOperatorBuilderFlows() const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() @@ -1111,10 +1112,11 @@ onClick: () => void }> = [] - // In a session pane every one of these leaves the session (details page, new - // tab), so the deploy button carries no dropdown there — as in ScriptBuilder. - if (untrack(() => customUi).topBar?.extraDeployOptions != false && !inSessionPane) { - if (!newFlow) { + if (untrack(() => customUi).topBar?.extraDeployOptions != false) { + // The one option that stays in a session pane: the session hosts the details page + // itself, so this flips the tab to it rather than leaving the session. Everything + // below opens a details page or a new tab outside the session, so it stays hidden. + if (!newFlow && savedFlow?.no_deployed !== true) { dropdownItems.push({ label: 'Exit & see details', // Use the deployed path, not the live `$pathStore` — the latter @@ -1124,7 +1126,7 @@ }) } - if (!untrack(() => newFlow)) { + if (!untrack(() => newFlow) && !inSessionPane) { dropdownItems.push({ label: 'Fork', onClick: () => window.open(`/flows/add?template=${initialPath}`) @@ -1133,6 +1135,7 @@ if ( !untrack(() => newFlow) && + !inSessionPane && !isCloudHosted() && editInForkAllowed(opWorkspace, $userWorkspaces) ) { @@ -1519,7 +1522,7 @@ {#key renderCount} - {#if !actingUser?.operator} + {#if !actingUser?.operator || $operatorBuilderFlows} {#if $pathStore} {/if} @@ -1673,7 +1676,9 @@ {forceTestTab} {highlightArg} aiChatOpen={aiChatManager.open} - showFlowAiButton={!disableAi && customUi?.topBar?.aiBuilder != false} + showFlowAiButton={!disableAi && + customUi?.topBar?.aiBuilder != false && + !$operatorBuilderFlows} toggleAiChat={() => aiChatManager.toggleOpen()} {sessionOpen} onOpenPreview={flowPreviewButtons?.openPreview} @@ -1702,7 +1707,9 @@ {/if} {:else} - Flow Builder not available to operators +
+ Flow builder not available to operators +
{/if} {/key} diff --git a/frontend/src/lib/components/FlowPreviewContent.svelte b/frontend/src/lib/components/FlowPreviewContent.svelte index 9ccac1d8c6..151d35be77 100644 --- a/frontend/src/lib/components/FlowPreviewContent.svelte +++ b/frontend/src/lib/components/FlowPreviewContent.svelte @@ -1,5 +1,6 @@ + +
{#if tooBig && !loadTooBigAnyway}
JSON is too big @@ -100,3 +109,4 @@ {/if}
{/if} +
diff --git a/frontend/src/lib/components/JsonInputs.svelte b/frontend/src/lib/components/JsonInputs.svelte index bb39c0aa77..2f4b04f8e4 100644 --- a/frontend/src/lib/components/JsonInputs.svelte +++ b/frontend/src/lib/components/JsonInputs.svelte @@ -1,6 +1,7 @@ -
+ +
{#if !loaded && render} diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index 8a0f11c5a1..5ea5a804f1 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -219,7 +219,7 @@ // UserDraft handle in that case, so the cleanup calls here must skip too // (otherwise we'd wipe a non-session tab's autosave at the same path). The // session-side equivalent is the View's `onDeploy` → - // `runtime.syncPreviewWithDeployed`, which discards the fork draft + reloads + // `runtime.itemDeployed`, which discards the fork draft + reloads // the preview to the deployed version. const inSessionPane = !!getContext('aiChatManager') diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte index 4f27eb6b26..d062247fa4 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -76,10 +76,9 @@ hideSecretUrl?: boolean preserveOnBehalfOf?: boolean labels?: string[] | undefined - // Raw apps need cross-origin isolation (wm_coep) to be embeddable. Classic - // (low-code) apps must NOT get the flag — it would force COEP on the - // document and break no-CORP cross-origin subresources (external images, - // {@html} embeds, CDN imports). + // Only raw apps are offered the `wm_coep` embedding hint: on a classic + // (low-code) app COEP breaks no-CORP cross-origin subresources (external + // images, {@html} embeds, CDN imports) that the app cannot opt out of. rawApp?: boolean /** True while the editor is on a draft-only URL (`/edit/u/{user}/draft_{uuid}` * with no deployed row yet). Suppresses the public-secret-URL fetch @@ -195,15 +194,9 @@ // token as a seatless guest. Uses the custom URL when set, else the public secret URL. let guestJwtBase = $derived(customPath !== undefined ? fullCustomUrl : secretUrlHref) - // When embedding a raw app in an iframe inside another Windmill app (or any - // cross-origin-isolated page), the embedded document must set COEP. The - // `wm_coep` flag opts the public app into the cross-origin isolation headers. - // Only raw apps get it — for classic (low-code) apps COEP would break - // no-CORP cross-origin subresources, so their snippet stays a plain iframe. let embedMode = $state(false) function toEmbedSnippet(url: string): string { - const finalUrl = rawApp ? `${url}${url.includes('?') ? '&' : '?'}wm_coep=on` : url - return `` + return `` } async function getSecretUrl() { secretUrl = await AppService.getPublicSecretOfApp({ @@ -568,11 +561,12 @@ {#if embedMode} Paste this iframe snippet into another app. {#if rawApp} - The wm_coep flag Sets the cross-origin isolation headers (COEP) so the app can be embedded inside - another Windmill app or any cross-origin-isolated page. Without it the browser blocks - the iframe. lets it load inside a cross-origin-isolated page. + To embed it in a cross-origin isolated page, add wm_coep=on + Sets the cross-origin isolation headers (COEP) on the app, which such a page requires + of its iframes. The app then only loads cross-origin resources that send CORS or a + Cross-Origin-Resource-Policy header. to the URL. {/if} (if requiring login, top-level domain of embedding app must be the same as the one of Windmill) {:else} diff --git a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte index 99ea07c8fd..e004c17a04 100644 --- a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte +++ b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte @@ -10,28 +10,61 @@ * scoped embed token / opaque isolation. */ import { base } from '$lib/base' + import { sendUserToast } from '$lib/toast' import PublicApp from '$lib/components/apps/editor/PublicApp.svelte' import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte' import { Button } from '$lib/components/common' import { AppService, OpenAPI } from '$lib/gen' - import { userStore } from '$lib/stores' + import type { UserExt } from '$lib/stores' import { canWrite } from '$lib/utils' import { getUserExt } from '$lib/user' import { Pen } from 'lucide-svelte' import { page } from '$app/state' + import { + setOperatingWorkspace, + useOperatingUser + } from '$lib/components/operatingWorkspace.svelte' let { workspace, - path + path, + onEdit, + onLoadState, + syncHashToUrl = true }: { workspace: string path: string + /** Handle Edit in place instead of following `editHref`. An AI session shows + * this viewer inside a preview tab, where a plain link would navigate the whole + * page out of the session rather than flipping the tab to its editor. */ + onEdit?: () => void + /** How the load ended, for a host that renders its own state around this viewer. + * A 403 is deliberately neither: the app exists, this member just cannot open it. */ + onLoadState?: (state: 'loaded' | 'not_found') => void + /** Whether a raw app's route lives in the page URL's hash; see `RawAppPreview`. */ + syncHashToUrl?: boolean } = $props() + // The app, its permission check and everything this viewer renders belong to `workspace`, + // which on a session preview tab is the session's and not the one the nav points at. + setOperatingWorkspace(() => workspace) + const operatingUser = useOperatingUser() + + /** This workspace's membership for the viewer, which is what the app's `ctx.username` / + * `ctx.groups` must describe — they sit beside `ctx.workspace` in the same object. */ + let appUser: UserExt | undefined = $state(undefined) let app: any = $state(undefined) let notExists = $state(false) let noPermission = $state(false) - let canWriteApp = $state(false) + /** The app's own permissions, kept apart from the verdict: `loadPerms` resolves before the + * acting user's `whoami` does in a workspace that is not the navigation one, and a verdict + * computed there would stick at "cannot write" with nothing to recompute it. */ + let appPerms = $state<{ path: string; extraPerms: Record } | undefined>( + undefined + ) + const canWriteApp = $derived( + !!appPerms && canWrite(appPerms.path, appPerms.extraPerms, operatingUser.current) + ) /** Raw vs low-code, read from the app itself rather than from the route: * both kinds render here and either route serves either kind (links to a raw * app point at /apps/get all over the app), so only the app can say which @@ -75,11 +108,13 @@ // getAppByPath returns bundle_secret + runnables for raw apps, which // PublicApp -> RawAppPreview needs. async function loadApp() { - try { - userStore.set(await getUserExt(workspace)) - } catch (e) { - console.warn('Anonymous user') - } + // Kept local and handed to PublicApp rather than written to `userStore`: this is the + // membership in `workspace`, and a session preview tab shows a workspace the rest of + // the page is not on — writing it globally would answer every permission check on + // that page (the sidebar, the session bar's fork button) for the wrong workspace. + // The routes that mount this are all under `(logged)`, whose layout has already + // populated `userStore` for the workspace the page *is* on. + appUser = await getUserExt(workspace) try { const loaded: any = await AppService.getAppByPath({ workspace, path }) // Raw apps need the bundle secret to load their bundle. getAppByPath @@ -99,10 +134,16 @@ app = loaded noPermission = false notExists = false + onLoadState?.('loaded') } catch (e: any) { if (e.status == 401) refresh?.() else if (e.status == 403) noPermission = true - else notExists = true + else { + notExists = true + // Only a 404 is "nothing deployed here"; anything else is a failure to say so. + if (e.status == 404) onLoadState?.('not_found') + else sendUserToast('Could not load app: ' + (e.body ?? e.message ?? e), true) + } } } @@ -112,10 +153,10 @@ async function loadPerms() { try { const lite: any = await AppService.getAppLiteByPath({ workspace, path }) - canWriteApp = canWrite(lite?.path, lite?.extra_perms ?? {}, $userStore) + appPerms = { path: lite?.path ?? path, extraPerms: lite?.extra_perms ?? {} } isRawApp = !!lite?.raw_app } catch (_) { - canWriteApp = false + appPerms = undefined } } @@ -136,11 +177,13 @@ loadApp()} > {/snippet} @@ -148,6 +191,12 @@ {#if canWriteApp && !hideEditBtn}
- +
{/if} diff --git a/frontend/src/lib/components/apps/editor/PublicApp.svelte b/frontend/src/lib/components/apps/editor/PublicApp.svelte index 6de8f9df0c..58b6eaf838 100644 --- a/frontend/src/lib/components/apps/editor/PublicApp.svelte +++ b/frontend/src/lib/components/apps/editor/PublicApp.svelte @@ -1,6 +1,6 @@ + +{#if count > 0} + + {count > 9 ? '9+' : count} + +{/if} + + diff --git a/frontend/src/lib/components/common/button/Button.svelte b/frontend/src/lib/components/common/button/Button.svelte index 7233cb197c..f684d79a9f 100644 --- a/frontend/src/lib/components/common/button/Button.svelte +++ b/frontend/src/lib/components/common/button/Button.svelte @@ -80,6 +80,9 @@ startIcon?: ButtonType.Icon | undefined endIcon?: ButtonType.Icon | undefined destructive?: boolean + /** Colors the `accent`, `default` and `subtle` variants to match an Alert of this type. + * Ignored when `dropdownItems` is set: split buttons keep the neutral palette. */ + tone?: ButtonType.Tone shortCut?: { key?: string; hide?: boolean; Icon?: any; withoutModifier?: boolean } | undefined tooltipPopover?: | { @@ -130,6 +133,7 @@ startIcon = undefined, endIcon = undefined, destructive = false, + tone = undefined, shortCut = undefined, tooltipPopover = undefined, dropdownBtnClasses = '', @@ -143,6 +147,8 @@ ...rest }: Props = $props() + const activeTone = $derived(dropdownItems ? undefined : tone) + function computeDropdowns(menuItems: MenuItem[] | (() => MenuItem[])): Item[] { const items = typeof menuItems === 'function' ? menuItems() : menuItems return items.map((item) => ({ @@ -185,7 +191,11 @@ if (['accent-secondary', 'accent', 'default', 'subtle'].includes(variant)) { let style = destructive ? ButtonType.DestructiveVariantStyles[variant] - : ButtonType.VariantStyles[variant] + : activeTone + ? ButtonType.ToneVariantStyles[activeTone][ + variant === 'accent-secondary' ? 'accent' : variant + ] + : ButtonType.VariantStyles[variant] // For default variant with dropdowns, remove border from button since it's on wrapper if ( variant === 'default' && @@ -272,9 +282,11 @@ 'justify-center items-center text-center inline-flex gap-2', 'active:opacity-80 transition-[background-color,opacity] duration-150', disabled - ? ['default', 'subtle'].includes(variant) - ? '!text-disabled' - : '!bg-surface-disabled !text-disabled' + ? activeTone && !destructive + ? 'disabled:opacity-50' + : ['default', 'subtle'].includes(variant) + ? '!text-disabled' + : '!bg-surface-disabled !text-disabled' : '', loading ? 'cursor-wait' : '', selected && ['default', 'subtle'].includes(variant) diff --git a/frontend/src/lib/components/common/button/model.ts b/frontend/src/lib/components/common/button/model.ts index 8126ef8667..8497ab1a87 100644 --- a/frontend/src/lib/components/common/button/model.ts +++ b/frontend/src/lib/components/common/button/model.ts @@ -199,6 +199,47 @@ export namespace ButtonType { 'bg-transparent hover:bg-red-500 hover:text-white dark:hover:bg-red-600 text-primary focus-visible:bg-red-100 dark:focus-visible:bg-red-900/30 focus-visible:ring-red-300' } + export type Tone = 'info' | 'warning' | 'error' | 'success' + + // Colors a button to match the Alert it sits in: text and borders take the alert's hue so a + // gray button never lands on a tinted background. Hovers are gated on `enabled:` because + // `:hover` still matches a disabled button, and a disabled one must not react to the cursor. + export const ToneVariantStyles: Record> = + { + info: { + accent: + 'bg-blue-500 enabled:hover:bg-blue-600 focus-visible:bg-blue-600 text-white focus-visible:ring-blue-300', + default: + 'border border-blue-300 dark:border-blue-400/40 bg-transparent enabled:hover:bg-blue-500/10 dark:enabled:hover:bg-blue-400/15 text-blue-700 dark:text-blue-200 focus-visible:ring-blue-300', + subtle: + 'bg-transparent enabled:hover:bg-blue-500/10 dark:enabled:hover:bg-blue-400/15 text-blue-700 dark:text-blue-200 focus-visible:ring-blue-300' + }, + warning: { + accent: + 'bg-yellow-400 enabled:hover:bg-yellow-500 focus-visible:bg-yellow-500 text-yellow-950 focus-visible:ring-yellow-300', + default: + 'border border-yellow-300 dark:border-yellow-400/40 bg-transparent enabled:hover:bg-yellow-500/15 dark:enabled:hover:bg-yellow-400/15 text-yellow-800 dark:text-yellow-300 focus-visible:ring-yellow-300', + subtle: + 'bg-transparent enabled:hover:bg-yellow-500/15 dark:enabled:hover:bg-yellow-400/15 text-yellow-800 dark:text-yellow-300 focus-visible:ring-yellow-300' + }, + error: { + accent: + 'bg-red-500 enabled:hover:bg-red-600 focus-visible:bg-red-600 text-white focus-visible:ring-red-300', + default: + 'border border-red-300 dark:border-red-400/40 bg-transparent enabled:hover:bg-red-500/10 dark:enabled:hover:bg-red-400/15 text-red-800 dark:text-red-300 focus-visible:ring-red-300', + subtle: + 'bg-transparent enabled:hover:bg-red-500/10 dark:enabled:hover:bg-red-400/15 text-red-800 dark:text-red-300 focus-visible:ring-red-300' + }, + success: { + accent: + 'bg-green-600 enabled:hover:bg-green-700 focus-visible:bg-green-700 text-white focus-visible:ring-green-300', + default: + 'border border-green-300 dark:border-green-400/40 bg-transparent enabled:hover:bg-green-500/10 dark:enabled:hover:bg-green-400/15 text-green-800 dark:text-green-300 focus-visible:ring-green-300', + subtle: + 'bg-transparent enabled:hover:bg-green-500/10 dark:enabled:hover:bg-green-400/15 text-green-800 dark:text-green-300 focus-visible:ring-green-300' + } + } + export const VariantSpacingClasses: Record = { xs3: 'px-0.5 py-[1px]', xs2: 'px-2 py-[4px]', diff --git a/frontend/src/lib/components/common/table/AgentRow.svelte b/frontend/src/lib/components/common/table/AgentRow.svelte new file mode 100644 index 0000000000..ecde985be4 --- /dev/null +++ b/frontend/src/lib/components/common/table/AgentRow.svelte @@ -0,0 +1,122 @@ + + +{#snippet chatBadge()} + +{/snippet} + + + {#snippet badges()} + + dispatch('change')} + /> + + {/snippet} + + {#snippet actions()} + {#if agent.canWrite} + + {/if} + + agentMenuItems({ + deployUiSettings: await getDeployUiSettings(), + path: agent.path, + canWrite: agent.canWrite, + draftOnly: Boolean(agent.draft_only), + wsSpecific: agent.ws_specific, + onPermissions: () => shareModal.openDrawer?.(agent.path, 'resource'), + onDeploy: () => deploymentDrawer.openDrawer(agent.path, 'resource'), + onDelete: (event) => { + const { path } = agent + if (event?.shiftKey) remove(path) + else deleteConfirmedCallback = () => remove(path) + } + })} + on:open={() => { + menuOpen = true + }} + /> + {/snippet} + diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index f9af803694..e000ee25bc 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -9,6 +9,7 @@ import type ShareModal from '$lib/components/ShareModal.svelte' import { FlowService, type Flow } from '$lib/gen' import { userStore, userWorkspaces, workspaceStore } from '$lib/stores' + import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { createEventDispatcher } from 'svelte' import Badge from '../badge/Badge.svelte' @@ -42,6 +43,8 @@ import EditInForkButton from './EditInForkButton.svelte' import { isCloudHosted } from '$lib/cloud' + const operatorBuilderFlows = useOperatorBuilderFlows() + interface Props { flow: Flow & { draft_only?: boolean @@ -217,6 +220,7 @@ let { draft_only, path, archived } = flow let owner = isOwner(path, $userStore, $workspaceStore) const canEdit = flow.canWrite && showEditButton + const hideForOperator = $userStore?.operator && !$operatorBuilderFlows if (draft_only) { return [ ...selectMenuItems(rowSelection), @@ -251,7 +255,7 @@ // list endpoint only surfaces own/legacy draft-only rows), so // discarding it never requires write permission on the path. disabled: !showEditButton, - hide: $userStore?.operator + hide: hideForOperator } ] } @@ -267,7 +271,7 @@ icon: GitFork, href: `${base}/flows/add?template=${path}`, disabled: !showEditButton, - hide: $userStore?.operator + hide: hideForOperator }, { displayName: editInForkLabel($workspaceStore, $userWorkspaces), @@ -293,7 +297,7 @@ moveDrawer.openDrawer(path, flow.summary, 'flow') }, disabled: !owner || archived || !canEdit, - hide: $userStore?.operator + hide: hideForOperator }, { displayName: 'Copy path', @@ -321,7 +325,7 @@ action: () => { flowHistory?.open() }, - hide: $userStore?.operator + hide: hideForOperator }, { displayName: 'Schedule', @@ -330,7 +334,7 @@ scheduleEditor?.openNew(true, path) }, disabled: archived, - hide: $userStore?.operator + hide: hideForOperator }, { displayName: 'Permissions', @@ -338,7 +342,7 @@ action: () => { shareModal.openDrawer && shareModal.openDrawer(path, 'flow') }, - hide: $userStore?.operator + hide: hideForOperator }, { displayName: archived ? 'Unarchive' : 'Archive', @@ -348,7 +352,7 @@ }, type: 'delete', disabled: !owner || !canEdit, - hide: $userStore?.operator + hide: hideForOperator }, { displayName: 'Delete', @@ -365,7 +369,7 @@ }, type: 'delete', disabled: !owner || !canEdit, - hide: $userStore?.operator + hide: hideForOperator } ] }} diff --git a/frontend/src/lib/components/common/table/Row.svelte b/frontend/src/lib/components/common/table/Row.svelte index 4fb90151c1..76cd30640a 100644 --- a/frontend/src/lib/components/common/table/Row.svelte +++ b/frontend/src/lib/components/common/table/Row.svelte @@ -39,6 +39,7 @@ | 'flow' | 'app' | 'raw_app' + | 'agent' | 'resource' | 'variable' | 'resource_type' diff --git a/frontend/src/lib/components/common/table/RowIcon.svelte b/frontend/src/lib/components/common/table/RowIcon.svelte index 9f0395b924..a7c99345a3 100644 --- a/frontend/src/lib/components/common/table/RowIcon.svelte +++ b/frontend/src/lib/components/common/table/RowIcon.svelte @@ -8,6 +8,7 @@ import AzureIcon from '$lib/components/icons/AzureIcon.svelte' import GoogleCloudIcon from '$lib/components/icons/GoogleCloudIcon.svelte' import { + Bot, Boxes, Calendar, Code2, @@ -29,6 +30,7 @@ | 'app' | 'raw_app' | 'raw_app_file' + | 'agent' | 'resource' | 'variable' | 'resource_type' @@ -99,6 +101,8 @@ {:else if effectiveKind === 'script'} + {:else if effectiveKind === 'agent'} + {:else if effectiveKind === 'variable'} {:else if effectiveKind === 'resource'} diff --git a/frontend/src/lib/components/copilot/AIFormAssistant.svelte b/frontend/src/lib/components/copilot/AIFormAssistant.svelte index 9c7b3cb411..aab3c73a1e 100644 --- a/frontend/src/lib/components/copilot/AIFormAssistant.svelte +++ b/frontend/src/lib/components/copilot/AIFormAssistant.svelte @@ -4,7 +4,7 @@ import { aiChatManager } from './chat/AIChatManager.svelte' import OpenInSessionButton from '$lib/components/sessions/OpenInSessionButton.svelte' import { AIBtnClasses } from './chat/AIButtonStyle' - import { workspaceStore } from '$lib/stores' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' import { copilotInfo } from '$lib/aiStore' import { logFeatureUsage } from '$lib/utils/featureUsage' @@ -17,6 +17,8 @@ const { onEditInstructions, instructions, runnableType, path }: Props = $props() + const operatingWorkspace = useOperatingWorkspace() + // Anonymous counter for this card being acted on, keyed by what it sits above. The two // branches share one counter: they are the same intent, and which of them is on screen // follows the user's session gate rather than a choice made here. `beforeOpen` is the @@ -39,7 +41,7 @@ path ? { target: { kind: runnableType, path } as const, - workspaceId: $workspaceStore ?? undefined, + workspaceId: $operatingWorkspace ?? undefined, beforeOpen: logAsked, seedPrompt: `Run the deployed ${runnableType} \`${path}\` for me. Pick sensible inputs, ` + diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 2d88d1deb7..d5406aeca2 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -50,6 +50,8 @@ import { getChatViewHost } from './chatViewHost' import { getAiChatManager } from './aiChatManagerContext' import ChatTypingIndicator from './ChatTypingIndicator.svelte' + import ToolGroupDisplay from './ToolGroupDisplay.svelte' + import { chatItemKey, groupToolRuns } from './toolGroups' import AIChatInput from './AIChatInput.svelte' import AttachedFilesBar from './files/AttachedFilesBar.svelte' import QueuedMessageChip from './QueuedMessageChip.svelte' @@ -237,6 +239,7 @@ const mcpMenu = new McpMenu(aiChatManager, () => assistantSettings?.open('mcp')) let plusMenuOpen = $state(false) let editingMessageIndex = $state(null) + const chatItems = $derived(groupToolRuns(messages)) // Escape stops the generation when focus is on the chat (or parked on // body), but stays with other widgets (e.g. the session's Monaco editor). @@ -247,8 +250,16 @@ function onWindowKeydownCapture(e: KeyboardEvent) { if (e.key !== 'Escape' || !chatHost.loading) return const active = document.activeElement + // Focus parked on the body answers for the panel on screen only: the flow chat keeps + // a panel per conversation mounted, several of which can be loading, and a hidden + // one's listener would otherwise swallow the press (immediate form, below) and stop + // its own turn instead. `inert` marks the panels behind, and is matched as an + // attribute so the browsers without `checkVisibility` read it too. + const hidden = + panelEl?.closest('[inert]') != null || + panelEl?.checkVisibility?.({ visibilityProperty: true }) === false const focusOnChat = - !active || active === document.body || (panelEl?.contains(active) ?? false) + !hidden && (!active || active === document.body || panelEl?.contains(active) === true) // An Escape while a run form is open must not discard what the user typed, so the action // row alone stops the turn — wherever it is mounted, since the preview panel holds the // form outside `panelEl`. Matched by call: two chats can be loading at once, and one's @@ -487,22 +498,30 @@ const flatFiles = Array.from(dt.files ?? []) const topLevelImages = flatFiles.filter(isImageFile) const imageWork: Promise[] = [] + // The composer this drop landed on, held for the whole routing: a panel destroyed + // while a file is being read clears the binding, and the file would then reach no + // composer at all. + const dropped = aiChatInput if (topLevelImages.length > 0) { - imageWork.push(aiChatInput?.addImages(topLevelImages) ?? Promise.resolve()) + imageWork.push(dropped?.addImages(topLevelImages) ?? Promise.resolve()) } // Text-file routing must await handle/entry resolution before it can call // addTextFiles — hold sending across that window (taken BEFORE the first // await) or a send mid-resolution would land the drop on the next message. - const releaseSendHold = aiChatInput?.holdSendForIngestion() + const releaseSendHold = dropped?.holdSendForIngestion() try { - await routeDroppedTextAndFolders(dt, flatFiles) + await routeDroppedTextAndFolders(dt, flatFiles, dropped) } finally { releaseSendHold?.() } await Promise.all(imageWork) } - async function routeDroppedTextAndFolders(dt: DataTransfer, flatFiles: File[]) { + async function routeDroppedTextAndFolders( + dt: DataTransfer, + flatFiles: File[], + input: typeof aiChatInput + ) { if (canUseFsAccess) { // getAsFileSystemHandle calls are kicked off synchronously inside this call. const handles = await handlesFromDataTransfer(dt) @@ -514,7 +533,10 @@ ? flatFiles : await Promise.all(handles.filter(isFileHandle).map((h) => h.getFile())) // Loose files attach to the message, like images. - await attachNonImageFiles(looseFiles.filter((f) => !isImageFile(f))) + await attachNonImageFiles( + looseFiles.filter((f) => !isImageFile(f)), + input + ) // Folders link as a live handle. const dirs = handles.filter(isDirectoryHandle) if (dirs.length > 0 && !canLinkFolders) { @@ -551,7 +573,7 @@ if (canLinkFolders) await handleAddFiles(folderEntries) else sendUserToast('Folders cannot be attached in this chat — drop individual files.', true) } - await attachNonImageFiles(topLevelText) + await attachNonImageFiles(topLevelText, input) } } @@ -563,8 +585,10 @@ input.value = '' // allow re-selecting the same file } - async function attachNonImageFiles(files: File[]) { - await aiChatInput?.addNonImageFiles(files) + /** `input` is the composer the files are for, captured before any await: read off the + * binding afterwards it would be null once the panel has been destroyed. */ + async function attachNonImageFiles(files: File[], input: typeof aiChatInput) { + await input?.addNonImageFiles(files) } async function attachPickedFiles(picked: File[]) { @@ -572,7 +596,7 @@ const others = picked.filter((f) => !isImageFile(f)) // Reserved before the other work is awaited — see onPanelDrop. const imageWork = imageFiles.length > 0 ? aiChatInput?.addImages(imageFiles) : undefined - await attachNonImageFiles(others) + await attachNonImageFiles(others, aiChatInput) await imageWork } @@ -851,15 +875,45 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> onscroll={onScroll} >
- {#each messages as message, messageIndex (messageIndex)} + {#snippet messageRow( + message: DisplayMessage, + messageIndex: number, + isLast: boolean, + inGroup: boolean + )} + {/snippet} + {#snippet groupRow(message: DisplayMessage, messageIndex: number)} + {@render messageRow(message, messageIndex, false, true)} + {/snippet} + {#each chatItems as item (chatItemKey(item))} + {#if item.kind === 'group'} +
+
+ +
+
+ {:else} + {@render messageRow( + item.message, + item.index, + item.index === messages.length - 1, + false + )} + {/if} {/each} {#if freeTierExhausted} {@render freeTierExhaustedBanner()} diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 6e51f80601..7bf9685e87 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -59,7 +59,8 @@ import { getEffectiveModelContextWindow } from '../modelConfig' import { getCompactionSummaryPrompt, formatCompactSummary, - buildSummaryMessageContent + buildSummaryMessageContent, + toolExchangesAsText } from './compactionPrompt' import { dfs } from '$lib/components/flows/previousResults' import { redactFileArgs, redactSecretArgs } from '$lib/components/job_args' @@ -88,10 +89,11 @@ import { import type { FlowModuleState, FlowState } from '$lib/components/flows/flowState' import type { CurrentEditor, ExtendedOpenFlow } from '$lib/components/flows/types' import { untrack } from 'svelte' -import { get } from 'svelte/store' +import { fromStore, get } from 'svelte/store' +import type { ProviderToolSummary } from './agentContext' import { BROWSER } from 'esm-env' import { workspaceStore, type DBSchemas } from '$lib/stores' -import { copilotInfo } from '$lib/aiStore' +import { copilotInfo, copilotSessionModel } from '$lib/aiStore' import { copilotWorkspaceRequested, loadCopilot } from '$lib/components/copilot/loadCopilot' import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core' import { readDocsPageTool, searchDocsTool } from './docs/core' @@ -109,7 +111,12 @@ import { import type { Selection } from 'monaco-editor' import type AIChatInput from './AIChatInput.svelte' import { prepareApiSystemMessage, prepareApiUserMessage } from './api/core' -import { closeInterruptedToolBatch, runChatLoop, truncateToToolPairedPrefix } from './chatLoop' +import { + closeInterruptedToolBatch, + runChatLoop, + sendsWebSearch, + truncateToToolPairedPrefix +} from './chatLoop' import { FREE_TIER_OUTPUT_TOKEN_LIMIT_MESSAGE, OutputTokenLimitError } from './outputTokenLimit' import { sanitizeToolCallArguments } from './toolCallArguments' import { billedTokens, normalizeContextUsage, type ChatTokenUsage } from './tokenUsage' @@ -161,6 +168,7 @@ import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRout import { appendAttachedFilesRoster } from './files/fileTools' import { ENTER_PLAN_MODE_TOOL, EXIT_PLAN_MODE_TOOL } from './planMode' import { PlanModeController, type PlanModeHost } from './planModeController.svelte' +import { navigationOperatorBuilderFlows } from '$lib/operatorWriteRights' // Compaction of the stored history: once the projected request size // (contextTokens — the provider's report when current, a fresh chars/4 @@ -171,7 +179,10 @@ import { PlanModeController, type PlanModeHost } from './planModeController.svel // cannot see — the upcoming completion and tool results, system-prompt/tool- // schema changes from mode switches, and the estimate's chars/4 error. const COMPACTION_TRIGGER_RATIO = 0.8 -const COMPACTION_TARGET_RATIO = 0.7 +// The gap below the trigger is what one compaction buys: each summarization request +// carries most of the window, and a target close to the trigger spends that on a few +// turns of room and summarizes its own previous summary again soon after. +const COMPACTION_TARGET_RATIO = 0.5 // How often a running turn is offered to the mid-turn checkpoint (see // sendRequest). The whole transcript is rewritten on each accepted checkpoint, // so this bounds the write rate; it also bounds how much of a turn a tab that @@ -290,6 +301,8 @@ export function supportsPlanMode(mode: AIMode): boolean { return PLAN_MODES.has(mode) } +const isOperatorBuilderFlows = fromStore(navigationOperatorBuilderFlows) + export function isAIModeVisible(mode: AIMode): boolean { return mode !== AIMode.GLOBAL || isGlobalAiEnabled() } @@ -485,7 +498,7 @@ export class AIChatManager implements ChatViewHost { abortController: AbortController | undefined = undefined inlineAbortController: AbortController | undefined = undefined // Flag to skip Responses API if it's not available (e.g., Azure region doesn't support it) - skipResponsesApi = false + skipResponsesApi = $state(false) mode = $state(AIMode.NAVIGATOR) pipelineAiChatHelpers = $state(undefined) @@ -746,6 +759,28 @@ export class AIChatManager implements ChatViewHost { * transitions, whichever one is offered right now. A reference answer, so flipping the * autonomy picker must not change it. */ availableTools: Tool[] = $derived(this.#shipped(this.planMode.availableTools)) + #copilotInfo = fromStore(copilotInfo) + #sessionModel = fromStore(copilotSessionModel) + /** Capabilities the provider runs on its own servers, attached by the adapter at send + * time. Never part of `tools`: the request path would try to dispatch them. The two + * `void` reads only subscribe: the model helpers read through `get()`, which tracks + * nothing, so a change of model or of the workspace's AI config would otherwise not + * re-derive. A workspace switch re-derives through the AI config it loads. */ + get providerTools(): ProviderToolSummary[] { + void this.#copilotInfo.current + void this.#sessionModel.current + const model = tryGetCurrentModel() + if (!model || (model.provider === 'openai' && this.skipResponsesApi)) return [] + const enabled = isWebSearchEnabledForProvider(model.provider) + return sendsWebSearch(this.operatingWorkspace ?? '', model, enabled) + ? [ + { + name: 'Web search', + description: "Runs on the provider's servers, no confirmation." + } + ] + : [] + } helpers = $state(undefined) scriptEditorOptions = $state(undefined) @@ -1403,12 +1438,19 @@ export class AIChatManager implements ChatViewHost { .map((s) => ({ ...s, kind: 'skill' as const })) ]) + // The flow and script builders both write code, which the backend refuses from an operator + // with the builder right: leaving them reachable would only produce work that cannot be + // deployed. allowedModes: Record = $derived({ script: this.flowAiChatHelpers === undefined && this.scriptEditorOptions !== undefined && - !this.disabledModes.script, - flow: this.flowAiChatHelpers !== undefined && !this.disabledModes.flow, + !this.disabledModes.script && + !isOperatorBuilderFlows.current, + flow: + this.flowAiChatHelpers !== undefined && + !this.disabledModes.flow && + !isOperatorBuilderFlows.current, app: this.appAiChatHelpers !== undefined && !this.disabledModes.app, navigator: !this.disabledModes.navigator, ask: !this.disabledModes.ask, @@ -1552,7 +1594,7 @@ export class AIChatManager implements ChatViewHost { [ // Strip image blobs from the summarizer input — the summary text stands in // for them, so re-sending base64 to the summarizer only wastes tokens. - ...stripImagePartsFromMessages(sanitizeToolCallArguments(prefix)), + ...toolExchangesAsText(stripImagePartsFromMessages(sanitizeToolCallArguments(prefix))), { role: 'user', content: getCompactionSummaryPrompt() } ], abortController, diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index 3b8470fc2e..97a1afb2ff 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -127,6 +127,7 @@ vi.mock('$lib/aiStore', () => ({ // `sendRequest` reads it before anything else, so a test that goes through a real turn // rather than driving the manager directly needs it present and enabled. copilotInfo: writable({ enabled: true, workspaceDisabled: false, aiModels: [] }), + copilotSessionModel: writable(undefined), getCurrentModel: mocks.getCurrentModel, tryGetCurrentModel: mocks.tryGetCurrentModel, getCombinedCustomPrompt: () => '', @@ -142,7 +143,8 @@ vi.mock('../lib', () => ({ getOpenaiClient: mocks.getOpenaiClient, getAnthropicClient: mocks.getAnthropicClient }, - getNonStreamingCompletion: mocks.getNonStreamingCompletion + getNonStreamingCompletion: mocks.getNonStreamingCompletion, + providerSupportsWebSearch: (provider: string) => provider === 'openai' || provider === 'anthropic' })) vi.mock('./api/apiTools', () => ({ @@ -590,6 +592,27 @@ describe('AIChatManager request errors', () => { }) }) +describe('AIChatManager provider tools', () => { + it('lists web search exactly when a turn would attach it', () => { + const manager = new AIChatManager() + const withModel = (provider: string) => + mocks.tryGetCurrentModel.mockReturnValue({ provider, model: 'm' }) + + withModel('anthropic') + mocks.isWebSearchEnabledForProvider.mockReturnValue(true) + expect(manager.providerTools.map((t) => t.name)).toEqual(['Web search']) + // Never mixed into what the request path dispatches. + expect(manager.availableTools.map((t) => t.def.function.name)).not.toContain('web_search') + + mocks.isWebSearchEnabledForProvider.mockReturnValue(false) + expect(manager.providerTools).toEqual([]) + + mocks.isWebSearchEnabledForProvider.mockReturnValue(true) + withModel('mistral') + expect(manager.providerTools).toEqual([]) + }) +}) + describe('AIChatManager global skills', () => { const model = { provider: 'openai', model: 'gpt-4o' } @@ -3061,14 +3084,14 @@ describe('AIChatManager context compaction', () => { it('compacts the stored history before sending once reported usage projects over the trigger', async () => { const manager = new AIChatManager() manager.messages = [ - { role: 'user', content: 'a'.repeat(400_000) }, // ~100k estimated tokens - { role: 'assistant', content: 'b'.repeat(400_000) }, // ~100k + { role: 'user', content: 'a'.repeat(1_200_000) }, // ~300k estimated tokens + { role: 'assistant', content: 'b'.repeat(1_200_000) }, // ~300k { role: 'user', content: 'c'.repeat(400) }, { role: 'assistant', content: 'd'.repeat(400) } ] - // Provider fact: 850k used. Projected past the 800k trigger, so ~150k - // must be freed to come back to the 700k target — the first user + - // assistant pair (~200k estimated). + // Provider fact: 850k used. Projected past the 800k trigger, so ~350k + // must be freed to come back to the 500k target — the first user + + // assistant pair (~600k estimated). manager.contextUsage = 850_000 manager.instructions = 'next question' const saveChat = vi.spyOn(manager.historyManager, 'saveChat') @@ -3086,7 +3109,7 @@ describe('AIChatManager context compaction', () => { // compaction-time save) so a rolled-back turn keeps a consistent value // 4th arg: the modified-items mask rides on every save (undefined here — // this bare manager never initialised tracking). - expect(saveChat).toHaveBeenCalledWith(expect.anything(), expect.anything(), 650_000, undefined) + expect(saveChat).toHaveBeenCalledWith(expect.anything(), expect.anything(), 250_000, undefined) // At commit, the no-report turn clears the stored value; the readable // number falls back to estimating the now-tiny compacted history expect(manager.contextUsage).toBeUndefined() diff --git a/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte b/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte index d639255c93..d006307967 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatMessage.svelte @@ -47,6 +47,8 @@ editingMessageIndex: number | null isLast?: boolean showAnswerActions?: boolean + /** Rendered inside a tool group, which owns the horizontal padding and the preview chip. */ + inGroup?: boolean } let { @@ -55,7 +57,8 @@ availableContext, editingMessageIndex = $bindable(null), isLast = false, - showAnswerActions = true + showAnswerActions = true, + inGroup = false }: Props = $props() // The edit box edits a copy of THIS message's original context, not the live @@ -146,7 +149,8 @@ class={twMerge( 'text-sm px-2', message.role === 'user' && 'py-1', - message.role === 'tool' && 'text-primary' + message.role === 'tool' && 'text-primary', + inGroup && 'px-0' )} > {#if message.role === 'assistant'} @@ -159,7 +163,10 @@ > {:else if message.role === 'tool'}
{:else} {#if message.role === 'user' && message.images && message.images.length > 0} diff --git a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte index 5767085c92..1a1412b9f8 100644 --- a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte +++ b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte @@ -132,7 +132,7 @@ {/await}
{#if showSessionsBetaBanner} - + {/if} {/if} diff --git a/frontend/src/lib/components/copilot/chat/AssistantSettingsModal.svelte b/frontend/src/lib/components/copilot/chat/AssistantSettingsModal.svelte index 8e299a4d3e..5c8eda40ad 100644 --- a/frontend/src/lib/components/copilot/chat/AssistantSettingsModal.svelte +++ b/frontend/src/lib/components/copilot/chat/AssistantSettingsModal.svelte @@ -14,7 +14,15 @@ callers that already know which section they mean, such as the "+" menu's Manage -{#if allowedHref} - {#if wmKind} - - - - - - - - - - - {#if previewAction && !modifier.held} - - {:else} - - {/if} - - - {@render children?.()} - - {#if drawerAction} - + +
+ {/snippet} + + {:else if wmKind} + {@render pill()} {:else} {@render children?.()} diff --git a/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte b/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte index 4aff51d002..ca5e717891 100644 --- a/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte +++ b/frontend/src/lib/components/copilot/chat/RunScriptCard.svelte @@ -208,9 +208,12 @@ // Keyed by call id: a bare flag would carry one card's collapse onto the next message // reusing this instance. Open by default, since the run is what was asked for — // except for an inspection, which is usually a step in the reasoning rather than - // the answer, and which pays a fetch for being opened. + // the answer, and which pays a fetch for being opened. A run cancelled before it started + // is closed too: the row already says Cancelled, and there is no run to show. let toggled = $state<{ id: string; open: boolean } | undefined>(undefined) - const expanded = $derived(toggled?.id === message.tool_call_id ? toggled.open : !inspected) + const expanded = $derived( + toggled?.id === message.tool_call_id ? toggled.open : !inspected && (ran || !canceled) + ) $effect(() => { const target = inspected diff --git a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index aa8dc47cec..e9eac7e8f4 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -51,9 +51,10 @@ interface Props { message: ToolDisplayMessage + hidePreviewChip?: boolean } - let { message }: Props = $props() + let { message, hidePreviewChip = false }: Props = $props() // Recorded by the call itself, from the connected-server list rather than from the // model's arguments — which is what lets a reloaded transcript still resolve it, and @@ -142,7 +143,11 @@ // shown once the tool settled, never while loading/erroring/awaiting confirmation. const showPreviewChip = $derived( Boolean( - message.previewCard && !message.isLoading && !message.error && !message.needsConfirmation + !hidePreviewChip && + message.previewCard && + !message.isLoading && + !message.error && + !message.needsConfirmation ) ) @@ -317,6 +322,7 @@ onToggle={() => (isExpanded = !isExpanded)} toggleable={detailsAvailable || message.isStreamingArguments === true} shimmer={isRunning} + settleLabel class={message.isQueued && !message.error ? 'opacity-60 hover:opacity-100 transition-opacity' : ''} diff --git a/frontend/src/lib/components/copilot/chat/ToolGroupDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolGroupDisplay.svelte new file mode 100644 index 0000000000..3754467214 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/ToolGroupDisplay.svelte @@ -0,0 +1,87 @@ + + +{#snippet serverMark()} + {#if mcpServer} + {#await resolveMcpServerMark(mcpServer.workspace, mcpServer.path) then mark} + + {/await} + {/if} +{/snippet} + +{#snippet status()} +
+ {#if failedCount > 0} + {failedCount} failed + {/if} + {#if previewCard} + + {/if} +
+{/snippet} + + (expanded = !expanded)} + shimmer={running} + settleLabel + {liveLine} + class={queued ? 'opacity-60 hover:opacity-100 transition-opacity' : ''} + labelClass="truncate" + headerRight={failedCount > 0 || previewCard ? status : undefined} + headerLeft={mcpServer ? serverMark : undefined} + contentClass="border-0 border-l rounded-none bg-transparent p-0 pl-2 ml-1.5 mt-0.5" +> + {#each group.entries as { message, index } (index)} + {@render entry(message, index)} + {/each} + diff --git a/frontend/src/lib/components/copilot/chat/ToolPreviewCard.svelte b/frontend/src/lib/components/copilot/chat/ToolPreviewCard.svelte index b5f4fa8bae..d808003d14 100644 --- a/frontend/src/lib/components/copilot/chat/ToolPreviewCard.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolPreviewCard.svelte @@ -1,5 +1,5 @@ -
+ +
{#if useDesktopLayout}
- {@render header?.()} + {@render header?.({ wide: useDesktopLayout })}
@@ -101,7 +105,7 @@
{:else}
- {@render header?.()} + {@render header?.({ wide: useDesktopLayout })}
+ {#if workspace === $workspaceStore} + + {/if} + {/if} + + {#if flow?.value?.priority != undefined} + + {/if} + {#if flow?.value?.concurrent_limit != undefined && flow?.value?.concurrency_time_window_s != undefined} + + {/if} + + {/snippet} + {#snippet form({ graphInline }: { graphInline: boolean })} +
+ (showEditButtons = v)} /> +
+ {#if flow} +
+
+
+ + + + + {#if chatInputEnabled} + + + {:else} + {@const hasSchema = + flow.schema && Object.keys(flow.schema.properties ?? {}).length > 0} + +
+ {#if hasSchema || inputSelected} +
+ { + savedInputsV2?.resetSelected() + }} + {inputSelected} + /> + + {#if hasSchema} + + {/if} +
+ {/if} + + {#if flow.schema?.prompt_for_ai !== undefined} + { + onNavigate(`/flows/edit/${flow?.path}`) + }} + runnableType="flow" + path={flow?.path} + /> + {/if} + + +
+ +
+ + Edited by {flow.edited_by} + +
+ {/if} +
+
+ {#if graphInline} +
+ { + if (e.detail) { + stepDetail = e.detail + rightPaneSelected = 'flow_step' + } else { + stepDetail = undefined + rightPaneSelected = 'saved_inputs' + } + }} + on:triggerDetail={(e) => { + detailLayout?.showTriggers() + }} + noBorder={true} + /> +
+ {/if} +
+ {/if} + {/snippet} + {#snippet save_inputs()} + { + const nargs = JSON.parse(JSON.stringify(e.detail)) + args = nargs + if (jsonView) { + runForm?.syncJsonEditor() + } + }} + /> + {/snippet} + + {#snippet flow_step({ onBack }: { onBack?: () => void })} + {#if flow} + {#if stepDetail} + + {/if} + {/if} + {/snippet} + {#snippet triggers()} + {#if flow} + + {/if} + {/snippet} + + {#snippet flow_graph()} + {#if flow} +
+ { + if (e.detail) { + stepDetail = e.detail + rightPaneSelected = 'flow_step' + } else { + stepDetail = undefined + rightPaneSelected = 'saved_inputs' + } + }} + on:triggerDetail={(e) => { + detailLayout?.showTriggers() + }} + /> +
+ {/if} + {/snippet} + + + diff --git a/frontend/src/lib/components/details/ScriptDetail.svelte b/frontend/src/lib/components/details/ScriptDetail.svelte new file mode 100644 index 0000000000..432ffbd74a --- /dev/null +++ b/frontend/src/lib/components/details/ScriptDetail.svelte @@ -0,0 +1,1324 @@ + + + { + await onNavigate('/scripts/get/' + e.detail + `?workspace=${workspace}`) + loadScript(routeHash ?? '') + }} +/> + + + + + + + + + (versionsDrawerOpen = false)} noPadding> + {#if script} + { + if (script) { + onNavigate(`/scripts/get/${e.detail.version}?workspace=${workspace}`) + } + versionsDrawerOpen = false + }} + /> + {/if} + + +{#key script?.hash} + + {#snippet header({ wide }: { wide: boolean })} + script?.ws_error_handler_muted ?? false, + (v) => { + if (script !== undefined) script.ws_error_handler_muted = v + } + } + errorHandlerKind="script" + scriptOrFlowPath={script?.path ?? ''} + tag={script?.tag ?? ''} + labels={script?.labels} + inheritedLabels={script?.inherited_labels} + on:seeTriggers={() => { + rightPaneSelected = 'triggers' + }} + summary={script?.summary} + path={script?.path} + onSaved={can_write + ? async (newPath) => { + if (newPath !== script?.path) { + await onNavigate(`/scripts/get/${newPath}?workspace=${workspace}`) + } else { + loadScript(newPath) + } + } + : undefined} + > + {#snippet trigger_badges()} + { + if (rightPaneSelected !== 'triggers') { + rightPaneSelected = 'triggers' + } + await tick() + triggersState.selectedTriggerIndex = triggerIndex + }} + /> + {/snippet} + {#if workspace && script} + + {#if workspace === $workspaceStore} + + {/if} + {/if} + {#if script?.auto_kind === 'wac'} + + {#snippet text()} + Workflow-as-Code + {/snippet} + wac + + {/if} + {#if script?.auto_kind === 'test'} + + {#snippet text()} + CI test script + {/snippet} + CI test + + {/if} + {#if script?.codebase} + bundleThis script is deployed as a bundle and can only be deployed from the CLI for now + {/if} + + {#if script?.priority != undefined} + + {/if} + {#if script?.restart_unless_cancelled ?? false} + + {/if} + {#if script?.concurrent_limit != undefined && script.concurrency_time_window_s != undefined} + + {/if} + + {/snippet} + {#snippet form()} +
+ (showEditButtons = v)} /> +
+ {#if script} + {@const showsDbtGraph = script.language === 'dbt' && !!script.path} +
+
+ {#if script?.path} + + {/if} + +
+ {#if script.lock_error_logs || topHash || script.archived || script.deleted} +
+ {#if script.lock_error_logs} + +

+ This script has not been deployed successfully because of the following + errors: +

+ +
+ {/if} + {#if topHash} +
+ + This hash is not HEAD (latest non-archived version at this path) : + Go to the HEAD of this path + + {/if} + {#if script.archived && !topHash} + This path was archived + {/if} + {#if script.deleted} + +

The content of this script was deleted (by an admin, no less)

+
+ {/if} +
+ {/if} + + {#if !emptyString(script.description)} +
+ +
+
+ {/if} +
+ + {#if deploymentInProgress} +
+ + + Deployment in progress + {#if deploymentJobId} + view job + {/if} + +
+ {/if} + +
+ {#if (script.schema && Object.keys(script.schema.properties ?? {}).length > 0) || inputSelected} + {@const hasSchema = + script.schema && Object.keys(script.schema.properties ?? {}).length > 0} +
+ { + savedInputsV2?.resetSelected() + }} + {inputSelected} + /> + {#if hasSchema} + + {/if} +
+ {/if} + + + {#if dbtRetryFrom && (args?.['command'] as any)?.label !== 'retry'} +
+ +
+ + The run you came from failed part-way. dbt retry + rebuilds only its failed and skipped nodes, with the arguments it ran with. + + +
+
+
+ {/if} + + {#if script?.schema?.prompt_for_ai !== undefined} + { + onNavigate(`/scripts/edit/${script?.path}?metadata_open=true`) + }} + runnableType="script" + path={script?.path} + /> + {/if} + + +
+ +
+ {#if !isHubScript} + + Edited by {script.created_by || + 'unknown'} + + {/if} +
+ {#if !isHubScript} + + {truncateHash(script?.hash ?? '')} + + {/if} + {#if script?.is_template} + Template + {/if} + {#if script && script.kind !== 'script'} + + {script?.kind} + + {/if} + + +
+
+
+ {#if showsDbtGraph} + +
+ +
+ {:else if isWac && script.content} +
+ +
+ {/if} +
+ {/if} + {/snippet} + {#snippet save_inputs()} + {#if args} + { + const nargs = JSON.parse(JSON.stringify(e.detail)) + args = nargs + if (jsonView) { + runForm?.syncJsonEditor() + } + }} + /> + {/if} + {/snippet} + {#snippet triggers()} + {#if script} + + {/if} + {/snippet} + {#snippet scriptRender()} +
+ + + + + + + {#snippet content()} + {#if script} + +
+ +
+ {#if script?.modules} + {#each Object.entries(script.modules) as [modulePath, mod]} +
+ + {#if expandedModuleCode[modulePath]} +
+ +
+ {/if} +
+ {/each} + {/if} +
+ +
+ {#if script?.lock} +
+
+ {:else} +

+ There is no lock file for this script +

+ {/if} + {#if script?.modules} + {@const moduleEntries = Object.entries(script.modules).filter( + ([_, m]) => m.lock + )} + {#each moduleEntries as [modulePath, mod]} +
+ + {#if expandedModuleLocks[modulePath]} +
+
+ {/if} +
+ {/each} + {/if} +
+
+ +
+ + +
+
+ {/if} + {/snippet} +
+
+ + {#if script?.envs && script.envs.length > 0} +

Static Env Variables

+
    + {#each script?.envs as e} +
  • {e}
  • + {/each} +
+ {/if} + {/snippet} +
+{/key} diff --git a/frontend/src/lib/components/details/errorHandlerToggle.ts b/frontend/src/lib/components/details/errorHandlerToggle.ts index 1a7ab5296a..51f291dde7 100644 --- a/frontend/src/lib/components/details/errorHandlerToggle.ts +++ b/frontend/src/lib/components/details/errorHandlerToggle.ts @@ -1,16 +1,16 @@ -import { get } from 'svelte/store' import { FlowService, ScriptService } from '$lib/gen' import { sendUserToast } from '$lib/toast' -import { workspaceStore } from '$lib/stores' /** Flips the workspace error handler for one script or flow and reports the outcome in a - * toast. Returns the new muted state, or undefined when nothing changed. */ + * toast. Returns the new muted state, or undefined when nothing changed. The workspace is the + * caller's to supply — a detail page embedded in an AI session acts on that session's + * workspace, which is not the one the navigation store names. */ export async function toggleWorkspaceErrorHandler( + workspace: string | undefined, kind: 'script' | 'flow', path: string, muted: boolean | undefined ): Promise { - const workspace = get(workspaceStore) if (workspace === undefined) return undefined const next = !muted try { diff --git a/frontend/src/lib/components/details/interceptNav.ts b/frontend/src/lib/components/details/interceptNav.ts new file mode 100644 index 0000000000..9c44b651be --- /dev/null +++ b/frontend/src/lib/components/details/interceptNav.ts @@ -0,0 +1,18 @@ +/** + * Route a link's plain left-click through the detail page's `onNavigate` instead of + * letting the browser follow it. The detail pages keep real `href`s so a reader can + * still middle-click or ⌘-click one into a new tab — those clicks are left alone — + * but a plain click has to reach `onNavigate`, which in an AI session preview moves + * within the panel rather than taking the browser out of the session. + */ +export function interceptNav( + onNavigate: (url: string) => void | Promise, + url: string +): (e?: Event) => void { + return (e?: Event) => { + const m = e as MouseEvent | undefined + if (m && (m.metaKey || m.ctrlKey || m.shiftKey || m.altKey || (m.button ?? 0) !== 0)) return + e?.preventDefault() + void onNavigate(url) + } +} diff --git a/frontend/src/lib/components/flows/CreateActionsApp.svelte b/frontend/src/lib/components/flows/CreateActionsApp.svelte index 7ae5e9850c..d75d1b33d3 100644 --- a/frontend/src/lib/components/flows/CreateActionsApp.svelte +++ b/frontend/src/lib/components/flows/CreateActionsApp.svelte @@ -23,8 +23,7 @@ async function importRaw() { const parsed = importType === 'yaml' ? YAML.parse(pendingRaw) : JSON.parse(pendingRaw) if (appKind === 'fullcode') { - // Navigation to /apps_raw/add triggers a full page reload (for cross-origin isolation), - // so the in-memory importStore would be lost. Use sessionStorage instead. + // The raw app editor reads the payload from sessionStorage on mount. sessionStorage.setItem('rawAppImport', JSON.stringify(parsed)) await goto('/apps_raw/add') } else { diff --git a/frontend/src/lib/components/flows/FlowHistoryInner.svelte b/frontend/src/lib/components/flows/FlowHistoryInner.svelte index 87c2306339..45007d90e9 100644 --- a/frontend/src/lib/components/flows/FlowHistoryInner.svelte +++ b/frontend/src/lib/components/flows/FlowHistoryInner.svelte @@ -207,7 +207,7 @@ variant="accent" on:click={() => window.open( - `/flows/add?template_id=${selectedVersion?.id}&template=${path}`, + `/flows/add?template_id=${selectedVersion?.id}&template=${path}&workspace=${$operatingWorkspace}`, '_blank' )} > diff --git a/frontend/src/lib/components/flows/agentActions.ts b/frontend/src/lib/components/flows/agentActions.ts new file mode 100644 index 0000000000..daaec527df --- /dev/null +++ b/frontend/src/lib/components/flows/agentActions.ts @@ -0,0 +1,55 @@ +import { FileUp, Shield, Trash } from 'lucide-svelte' +import { ResourceService } from '$lib/gen' +import { sendUserToast } from '$lib/toast' +import type { Item } from '$lib/utils' +import { isDeployable } from '$lib/utils_deployable' +import type { WorkspaceDeployUISettings } from '$lib/gen' + +/** Deletes a saved agent, saying how it went. Resolves whether it was deleted. */ +export async function deleteAgent(workspace: string, path: string): Promise { + try { + await ResourceService.deleteResource({ workspace, path }) + sendUserToast(`Deleted agent ${path}`) + return true + } catch (err) { + sendUserToast(`Could not delete agent ${path}: ${err}`, true) + return false + } +} + +/** An agent's menu, as its home row and its page both show it. */ +export function agentMenuItems(agent: { + path: string + canWrite: boolean + /** Never deployed: no resource yet to hold permissions or to deploy onward. */ + draftOnly?: boolean + wsSpecific?: boolean + /** Undefined until loaded, when nothing is offered for deploy. */ + deployUiSettings: WorkspaceDeployUISettings | undefined + onPermissions: () => void + onDeploy: () => void + onDelete: (event?: MouseEvent) => void +}): Item[] { + const deployable = + !agent.wsSpecific && + !agent.draftOnly && + isDeployable('resource', agent.path, agent.deployUiSettings) + return [ + { + displayName: 'Permissions', + icon: Shield, + disabled: !agent.canWrite || Boolean(agent.draftOnly), + action: agent.onPermissions + }, + ...(deployable + ? [{ displayName: 'Deploy to prod/staging', icon: FileUp, action: agent.onDeploy }] + : []), + { + displayName: 'Delete', + icon: Trash, + type: 'delete' as const, + disabled: !agent.canWrite, + action: agent.onDelete + } + ] +} diff --git a/frontend/src/lib/components/flows/agentDraft.svelte.ts b/frontend/src/lib/components/flows/agentDraft.svelte.ts index 5ee65cd98a..b76f9bb471 100644 --- a/frontend/src/lib/components/flows/agentDraft.svelte.ts +++ b/frontend/src/lib/components/flows/agentDraft.svelte.ts @@ -7,6 +7,11 @@ import { canWrite } from '$lib/utils' import { userStore } from '$lib/stores' import { getUserExt } from '$lib/user' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' +import { UserDraft } from '$lib/userDraft.svelte' +import { onUserInput } from '$lib/userDraftEditGate' +import { DEFAULT_AGENT_MEMORY } from './agentFormFields' +import { getUsernameForNamespace } from '$lib/userNamespace' +import { random_adj } from '$lib/components/random_positive_adjetive' import { useTriggerDraftSync, type TriggerDraftSync } from '../triggers/useTriggerDraftSync.svelte' import { logReusableAgentUsage } from './agentTelemetry' import { @@ -177,10 +182,30 @@ async function writeAgentResource( return { ok: true } } +/** A `u//_agent` no resource holds yet, as the path field picks one for a new + * script or flow. Gives up on availability after a few draws rather than hold the editor. */ +async function freeAgentPath(workspace: string): Promise { + const mint = () => `u/${getUsernameForNamespace()}/${random_adj()}_agent` + let candidate = mint() + for (let i = 0; i < 10; i++) { + const taken = await ResourceService.existsResource({ workspace, path: candidate }).catch( + () => false + ) + if (!taken) break + candidate = mint() + } + return candidate +} + export interface AgentDraftOptions { /** The `ai_agent` resource being edited. */ path: () => string | undefined workspace: () => string | undefined + /** A missing row is a new agent to start empty, not a load failure. */ + isNew?: () => boolean + /** Only the deployed agent, as a page that runs it shows it: no draft is loaded, restored or + * written, and a write from another tab does not land here. */ + deployedOnly?: () => boolean } export interface AgentDraftHandle { @@ -221,7 +246,8 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { const sync = useTriggerDraftSync({ itemKind: 'resource', - path: () => opts.path() ?? '', + // An empty path holds no draft handle, which is what keeps a deployed-only view off the draft. + path: () => (opts.deployedOnly?.() ? '' : (opts.path() ?? '')), workspace: () => opts.workspace(), drawerLoading: () => loading || refusal != null, // `$state.snapshot` deep-reads, so the sync effects re-run when a nested field of `args` @@ -234,6 +260,20 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { deployed: () => deployed as Record | undefined }) + /** A new agent has no deployed value for the sync to absorb the form's settling into, so until + * the user's first input the draft cell follows the form as a seed instead: what the editor + * fills in on its own, and the name the path field shows, is not an edit to save. */ + let seedNewUntilInput = $state<{ ws: string; path: string } | undefined>(undefined) + onUserInput(() => { + seedNewUntilInput = undefined + }) + $effect(() => { + const target = seedNewUntilInput + if (!target || !state) return + const settled = $state.snapshot(state) + untrack(() => UserDraft.seed('resource', target.path, settled, { workspace: target.ws })) + }) + function refuse(reason: string) { loading = false refusal = reason @@ -260,12 +300,13 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { loadedFor = key loading = true refusal = undefined + seedNewUntilInput = undefined // The user alongside the resource, as the generic resource editor loads it: a session or // fork editor operates on a workspace that is not the one being navigated, and groups, // folders and the admin flag are all per workspace, so the nav user would answer for the // wrong membership in both directions. Promise.all([ - ResourceService.getResource({ workspace: ws, path, getDraft: true }), + ResourceService.getResource({ workspace: ws, path, getDraft: !opts.deployedOnly?.() }), getUserExt(ws).catch(() => undefined) ]) // The rejection handler is `then`'s second argument rather than a trailing `catch`, so @@ -319,18 +360,48 @@ export function useAgentDraft(opts: AgentDraftOptions): AgentDraftHandle { // conflict or failure for the key: a conflict is deliberately sticky (the retry // keeps the same baseline), and nothing else mounts a resolver for `resource` // drafts, so re-opening the agent is the only place it can be resolved. - UserDraftDbSyncer.recordRemoteSync( - { workspace: ws, itemKind: 'resource', path }, - (r as { draft_saved_at?: string }).draft_saved_at - ) + if (!opts.deployedOnly?.()) { + UserDraftDbSyncer.recordRemoteSync( + { workspace: ws, itemKind: 'resource', path }, + (r as { draft_saved_at?: string }).draft_saved_at + ) + } loading = false await sync.maybeRestore() }, - (err) => { + async (err) => { + if (loadedFor !== key) return + // Nothing is written until the first edit: the sync saves only on user input, and + // the first deploy creates the resource at whatever path the form then holds. The + // draft stays at the minted `draft_` storage path, and the form starts on the + // free name a new script or flow gets. Named here rather than by the path field: a + // name minted after the seed below would differ from it, and the first click would + // save it. + if (opts.isNew?.() && (err as { status?: number })?.status === 404) { + const name = await freeAgentPath(ws) + if (loadedFor !== key) return + noDeployed = true + deployed = undefined + canWriteResource = true + state = { + path: name, + description: '', + // Opens on a working chat, the way a new agent is first tried. The editor says + // what memory is for, and how to turn it off, while it is on. + args: { memory: structuredClone(DEFAULT_AGENT_MEMORY) }, + resource_type: 'ai_agent', + wsSpecific: false + } + loading = false + // Seeds the draft cell with the empty agent, or its first-write guard swallows the + // first edit. Not `sync.maybeRestore`: with nothing deployed to compare against, it + // takes the form for a restored draft and autosaves it before any input. + seedNewUntilInput = { ws, path } + return + } // A failed load knows neither the resource's type nor its value, so it refuses: // clearing `loading` alone would let the sync restore a persisted draft into a form // that would then deploy over a resource nobody read. - if (loadedFor !== key) return refuse(`Could not load agent ${path}: ${err}`) } ) diff --git a/frontend/src/lib/components/flows/agentEditorStore.svelte.ts b/frontend/src/lib/components/flows/agentEditorStore.svelte.ts index 025a426d28..24a44df0ad 100644 --- a/frontend/src/lib/components/flows/agentEditorStore.svelte.ts +++ b/frontend/src/lib/components/flows/agentEditorStore.svelte.ts @@ -14,10 +14,13 @@ export interface AgentEditorTarget { workspace?: string toolId?: string /** A level of the editor that is not the form. Mutually exclusive with `toolId`. */ - view?: 'evals' + view?: 'evals' | 'settings' /** Where to re-resolve a graph's tool nodes after a deploy, when opened from a flow step. Only a * real flow sets it: the agent editor offers no way to open a second editor from inside itself. */ host?: { flowPath: string; moduleId: string } + /** The path was minted for an agent that does not exist yet: a missing row is an empty agent to + * start from rather than a load failure. */ + isNew?: boolean } let target = $state(undefined) diff --git a/frontend/src/lib/components/flows/agentFormFields.test.ts b/frontend/src/lib/components/flows/agentFormFields.test.ts index 48b4c94d92..9dcf11fba0 100644 --- a/frontend/src/lib/components/flows/agentFormFields.test.ts +++ b/frontend/src/lib/components/flows/agentFormFields.test.ts @@ -110,8 +110,9 @@ describe('memoryOptionLabel', () => { // The ignored-input note names the setting by the same label its own button carries. it('names each memory option the way the field renders it', () => { expect(memoryOptionLabel({ kind: 'manual', messages: [] })).toBe('Previous messages (legacy)') - expect(memoryOptionLabel({ kind: 'auto', context_length: 4 })).toBe('On (legacy)') - expect(memoryOptionLabel({ kind: 'window', context_length: 10 })).toBe('On') + expect(memoryOptionLabel({ kind: 'auto', context_length: 4 })).toBe('Legacy (older)') + expect(memoryOptionLabel({ kind: 'window', context_length: 10 })).toBe('Legacy') + expect(memoryOptionLabel({ kind: 'compaction', context_window: 128000 })).toBe('On') // Keeping no messages runs as off, whichever kind says so. expect(memoryOptionLabel({ kind: 'window', context_length: 0 })).toBe('Off') expect(memoryOptionLabel({ kind: 'auto' })).toBe('Off') @@ -125,14 +126,15 @@ describe('memoryPropertyFor', () => { memoryPropertyFor(property, value).oneOf.map((variant: { title: string }) => variant.title) it('adds a legacy kind as an option only while the value holds it', () => { - expect(memoryPropertyFor(property, { kind: 'window', context_length: 10 })).toBe(property) + expect(memoryPropertyFor(property, { kind: 'compaction' })).toBe(property) expect(memoryPropertyFor(property, undefined)).toBe(property) + expect(kinds({ kind: 'window', context_length: 10 })).toEqual(['off', 'compaction', 'window']) expect(kinds({ kind: 'auto', context_length: 4, memory_id: 'x' })).toEqual([ 'off', - 'window', + 'compaction', 'auto' ]) - expect(kinds({ kind: 'manual', messages: [] })).toEqual(['off', 'window', 'manual']) + expect(kinds({ kind: 'manual', messages: [] })).toEqual(['off', 'compaction', 'manual']) const autoVariant = (value: unknown) => memoryPropertyFor(property, value).oneOf.at(-1) expect(autoVariant({ kind: 'auto', context_length: 4 }).properties.memory_id).toBeUndefined() expect( diff --git a/frontend/src/lib/components/flows/agentFormFields.ts b/frontend/src/lib/components/flows/agentFormFields.ts index 7f09afdf7e..e8c5584407 100644 --- a/frontend/src/lib/components/flows/agentFormFields.ts +++ b/frontend/src/lib/components/flows/agentFormFields.ts @@ -52,16 +52,20 @@ export function agentTestInputTransforms( } } -/** What turning managed memory on writes. */ -export const DEFAULT_AGENT_MEMORY: MemoryConfig = { kind: 'window', context_length: 10 } +/** What turning managed memory on writes. Both call sites are chat mode, and a chat conversation + * is open-ended: it keeps everything and summarizes the older part rather than dropping messages + * off the front. No context window, so the run reads the one known for the model it ends up on. */ +export const DEFAULT_AGENT_MEMORY: MemoryConfig = { kind: 'compaction' } /** The docs section on how an agent's memory is named and kept. */ export const AGENT_MEMORY_DOCS_URL = 'https://www.windmill.dev/docs/core_concepts/ai_agents#memory-auto--manual' -/** Whether Windmill stores and replays the agent's conversation, mirroring the worker: `window`, or - * its older spelling `auto`, with a message count above 0. A legacy `manual` list is not managed. */ +/** Whether Windmill stores and replays the agent's conversation, mirroring the worker: `compaction`, + * which keeps all of it, or `window` and its older spelling `auto` with a message count above 0. A + * legacy `manual` list is not managed. */ export function keepsManagedMemory(memory: any): boolean { + if (memory?.kind === 'compaction') return true return (memory?.kind === 'window' || memory?.kind === 'auto') && Boolean(memory.context_length) } @@ -90,6 +94,11 @@ export function historyInputApplies( /** A memory setting in words, for a linked agent's summary. */ export function describeMemoryPolicy(memory: any): string { + if (memory?.kind === 'compaction') { + return memory.context_window + ? `Whole conversation, summarized near ${memory.context_window} tokens` + : "Whole conversation, summarized as it fills the model's context window" + } if (keepsManagedMemory(memory)) return `Last ${memory.context_length} messages` if (memory?.kind === 'manual') return 'Off, sends previous messages saved with the agent' return 'Off' @@ -165,7 +174,8 @@ export const AGENT_FIELDS: AgentFieldSpec[] = [ key: 'memory', group: 'messages', label: 'Managed memory', - tooltip: 'Windmill stores the conversation and sends its last messages with each request.', + tooltip: + 'Windmill stores the conversation and sends it with each request. On keeps all of it, summarizing the older part once it nears the context window. Legacy keeps only a fixed number of the last messages. Without instance object storage, saved memory is limited to 100KB: On tries a summary, then keeps the newest complete conversation that fits, starting with a user message. If none fits, memory is not updated.', implicit: { kind: 'off' }, defaultHint: 'Default: off', textOnly: true diff --git a/frontend/src/lib/components/flows/common/FlowCardHeader.svelte b/frontend/src/lib/components/flows/common/FlowCardHeader.svelte index 6d14463c6b..a2fa8e08ba 100644 --- a/frontend/src/lib/components/flows/common/FlowCardHeader.svelte +++ b/frontend/src/lib/components/flows/common/FlowCardHeader.svelte @@ -20,6 +20,7 @@ import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui' import DropdownV2 from '$lib/components/DropdownV2.svelte' import { hubBaseUrlStore } from '$lib/stores' + import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' import { DEFAULT_HUB_BASE_URL, PRIVATE_HUB_MIN_VERSION } from '$lib/hub' import { getLatestHashForScript } from '$lib/scripts' import { sendUserToast, type Item } from '$lib/utils' @@ -30,6 +31,7 @@ import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() + const operatorBuilderFlows = useOperatorBuilderFlows() interface Props { flowModuleValue?: FlowModuleValue | undefined @@ -102,7 +104,7 @@ const scriptItems: Item[] = $derived.by(() => { if (flowModuleValue?.type !== 'script') return [] const items: Item[] = [] - if (!isHub && customUi?.scriptEdit != false) { + if (!isHub && customUi?.scriptEdit != false && !$operatorBuilderFlows) { items.push({ displayName: "Edit the script's code", icon: Pen, @@ -146,7 +148,7 @@ }) } } - if (customUi?.scriptFork != false) { + if (customUi?.scriptFork != false && !$operatorBuilderFlows) { items.push({ displayName: 'Fork into an inline script', icon: GitFork, diff --git a/frontend/src/lib/components/flows/content/AgentConfigModal.svelte b/frontend/src/lib/components/flows/content/AgentConfigModal.svelte new file mode 100644 index 0000000000..5fe009b57b --- /dev/null +++ b/frontend/src/lib/components/flows/content/AgentConfigModal.svelte @@ -0,0 +1,151 @@ + + + + + {#snippet headerLeft()} +

+ What the agent runs with. Edit the agent to change it. +

+ {/snippet} + +
+
+ (section = id as Section)} + /> +
+ +
+ {#if section === 'model'} +
+ {#each settings as row (row.label)} +
+
{row.label}
+
{row.value}
+
+ {:else} + Not configured + {/each} +
+ {:else if section === 'instructions'} +
+ {#if systemPrompt} +

+ {systemPrompt} +

+ {:else} + No system message + {/if} +
+ {/if} + +
+ +
+
+
+
diff --git a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte index f0d65068f0..9c2a57b43f 100644 --- a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte +++ b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte @@ -1,7 +1,8 @@ + +{#snippet configButton()} + + - -

Chat needs managed memory

-

- {chatGap.memoryCanTurnOn - ? 'Without it, every message would be answered without the ones before it.' - : 'This agent replays a fixed list of messages. Switch its memory to managed to chat with it.'} -

- {#if chatGap.memoryCanTurnOn && !readOnly} - - {/if} -
- {:else if chatGap?.noStream} -
- - - {chatGap.noStream === 'image' - ? 'Image answers do not stream: each one shows once its run ends.' - : 'Streaming is off: each answer shows once its run ends.'} - - {#if chatGap.noStream === 'off' && !readOnly} - - {/if} -
- {/if} - -
- + + {/if} + + +
+
-
- {/if} + + + + +
- - -
+ {/if} + {#if chatMounted} +
+ {#if chatGap?.memory} +
+ +

Chat needs managed memory

+

+ {chatGap.memoryCanTurnOn + ? 'Without it, every message would be answered without the ones before it.' + : 'This agent replays a fixed list of messages. Switch its memory to managed to chat with it.'} +

+ {#if chatGap.memoryCanTurnOn && !readOnly} + + {/if} +
+ {:else if chatGap?.noStream} + + Each answer shows once its run ends. + {#if chatGap.noStream === 'off' && !readOnly} +
+ +
+ {/if} +
+ {/if} + +
+ +
+
+ {/if} + + {/snippet} - true, (open) => !open && close()} - kind="X" - fillHeight - enterConfirms={false} - paginated - title={target.path} - {trail} - {description} - class="w-[92vw] sm:w-[92vw] max-w-[1500px] sm:max-w-[1500px] h-[88vh]" - > - {#snippet titleBadge()} - - {#if version != undefined && !refused} - - v{version} - - {/if} - {/snippet} - {#snippet levelBadge()} - - {#if inEvals && !evalsLocation} - Beta - {/if} - {/snippet} - - {#snippet settings()} -
- {#if !inEvals && !refused} - - {#if testPane?.mode} - testPane?.mode, - (mode) => { - if (testPane) testPane.mode = mode - } - } - noWFull - > - {#snippet children({ item })} - - - {/snippet} - - {/if} - {#if readOnly} - - Read only - - {/if} - - {#if !draftOnly} - - {/if} - - {/if} -
- {/snippet} + {#if layout === 'modal'} + true, (open) => !open && close()} + kind="X" + fillHeight + enterConfirms={false} + paginated + title={shownPath ?? target.path} + {trail} + {description} + {titleBadge} + {levelBadge} + {settings} + class="w-[92vw] sm:w-[92vw] max-w-[1500px] sm:max-w-[1500px] h-[88vh]" + > + {@render body()} + + {:else}
- -
- draft?.deployed} - getCurrent={() => draft?.state} - onDiscard={() => draft?.sync.resetToDeployed(target?.path ?? '')} - title="Deployed <> Unsaved agent changes" - /> +
+
+
+ {#each trail as segment, i (i)} + {#if i > 0} + + {/if} + {#if segment.onclick} + + {:else} + {segment.label} + {/if} + {#if i === 0} + {@render titleBadge()} + {/if} + {/each} + {@render levelBadge()} +
+ {#if description} + {description} + {/if} +
+
+ {@render settings()} +
+
+ {@render body()}
- -
- + {/if} {/key} + {#snippet titleBadge()} + + {#if version != undefined && !refused} + + v{version} + + {/if} + {/snippet} + {#snippet levelBadge()} + + {#if inEvals && !evalsLocation} + Beta + {/if} + {/snippet} + + {#snippet settings()} +
+ {#if !inEvals && !refused} + + {#if testPane?.mode && !inSettings} + testPane?.mode, + (mode) => { + if (testPane) testPane.mode = mode + } + } + noWFull + > + {#snippet children({ item })} + + + {/snippet} + + {/if} + {#if readOnly} + + Read only + + {/if} + {#if !inSettings} + + {/if} + + {/if} +
+ {/snippet} + {#snippet body()} +
+ +
+ draft?.deployed} + getCurrent={() => draft?.state} + onDiscard={() => draft?.sync.resetToDeployed(target?.path ?? '')} + title="Deployed <> Unsaved agent changes" + /> +
+ + +
+ {/snippet} + + and the config a draft run is offered on. The target is read optionally: a deploy that + navigates away clears it while these are still rendered. --> {#snippet agentPage()} showAgentEditorTool(id)} {onSaved} + isNew={target?.isNew} /> {/snippet} + {#snippet settingsFields()} + {#if draft} + host?.pathError(), (error) => host?.setPathError(error)} + /> + {/if} + {/snippet} + + {#snippet settingsPage()} +
{@render settingsFields()}
+ {/snippet} + {#snippet evalsPage()} {/snippet} + {#if layout === 'page' && ws} + + {/if} + + + settingsDrawer?.closeDrawer()}> + {@render settingsFields()} + + + versionDrawer?.closeDrawer()} noPadding> + import { Badge } from '$lib/components/common' + import Modal from '$lib/components/common/modal/Modal.svelte' + import EvalsPane from '$lib/components/aiEvals/EvalsPane.svelte' + import type { EvalsLocation } from '$lib/components/aiEvals/evalUtils' + import type { AgentDraft } from '$lib/gen' + + /** An agent's evals in a dialog of their own, over the page that opened them. */ + interface Props { + agentPath: string + workspace: string + /** The unsaved edits, offered to a run as an alternative to the deployed version. */ + editedConfig?: () => AgentDraft + } + + let { agentPath, workspace, editedConfig = undefined }: Props = $props() + + let open = $state(false) + // Where the evals pane is within itself, so its levels extend the dialog's trail. Cleared on the + // way in: the pane reports a level once it is on one, and never that it is back at its root. + let location = $state(undefined) + + export function openModal() { + location = undefined + open = true + } + + +{#if open} + + {#snippet titleBadge()} + {#if !location} + Beta + {/if} + {/snippet} + + +{/if} diff --git a/frontend/src/lib/components/flows/content/AgentMemoryNotes.svelte b/frontend/src/lib/components/flows/content/AgentMemoryNotes.svelte index 491a26daa3..81db3a1dad 100644 --- a/frontend/src/lib/components/flows/content/AgentMemoryNotes.svelte +++ b/frontend/src/lib/components/flows/content/AgentMemoryNotes.svelte @@ -1,6 +1,7 @@ -{#if on && !s3StorageConfigured} -

- Without S3 storage on the workspace, memory is kept in the database, up to 100KB per memory. -

-{/if} {#if legacyMessages}
@@ -133,9 +124,7 @@
- An earlier version of the editor saved this setting. It works the same as {on - ? 'On' - : 'Off'}. + An earlier version of the editor saved this setting. It works the same as {equivalentLabel}.
diff --git a/frontend/src/lib/components/flows/content/AgentResourceBar.svelte b/frontend/src/lib/components/flows/content/AgentResourceBar.svelte index 83be7c876e..dc7c0948eb 100644 --- a/frontend/src/lib/components/flows/content/AgentResourceBar.svelte +++ b/frontend/src/lib/components/flows/content/AgentResourceBar.svelte @@ -8,6 +8,8 @@ import ResourcePathHint from '$lib/components/ResourcePathHint.svelte' import { ResourceService, type InputTransform, type Resource } from '$lib/gen' import { sendUserToast } from '$lib/toast' + import { userStore } from '$lib/stores' + import { canWrite } from '$lib/utils' import { Bot, ChevronDown, ChevronUp, Save, Unlink, Pencil } from 'lucide-svelte' import { AGENT_BRAIN_KEYS, @@ -116,6 +118,8 @@ fromDraft: boolean providerPath?: string providerOk: boolean + /** The agent's own sharing, to tell whether this user may edit it. */ + extraPerms?: Record /** The link cannot be read. `missing` (404): nothing exists at the path, the agent having been * renamed or deleted. `forbidden` (401/403): it exists and this user is refused it, a folder * they cannot read included, which says nothing about whether a run of the flow can read it. @@ -197,7 +201,8 @@ tools, fromDraft: draft != undefined, providerPath, - providerOk + providerOk, + extraPerms: (response.extra_perms ?? {}) as Record } } ) @@ -228,6 +233,11 @@ let providerPath = $derived(linkedInfo?.providerPath) let providerOk = $derived(linkedInfo?.providerOk ?? true) let unavailable = $derived(linkedInfo?.unavailable ?? false) + let canEditAgent = $derived( + !!agent && + !$userStore?.operator && + canWrite(agent, linkedInfo?.extraPerms ?? {}, $userStore ?? undefined) + ) // The hint flips on the first keystroke in the agent editor, so the badge does not wait for the // debounced autosave and the refetch behind it; the fetched answer covers a draft written // elsewhere, which no editor here has published an opinion about. @@ -678,10 +688,19 @@
{:else if !providerOk}
- - This agent's model provider{#if providerPath} - ({providerPath}){/if} isn't accessible in this workspace. - Unlink to fork the agent, or gain access to the provider resource. + + + {#if providerPath} + You don't have access to {providerPath}. + {:else} + You don't have access to this agent's model provider. + {/if} + {#if canEditAgent && !fromAgentEditor} + Edit the agent to use another, unlink it, or ask for access. + {:else} + Unlink it or ask for access. + {/if}
{/if} diff --git a/frontend/src/lib/components/flows/content/AgentSettings.svelte b/frontend/src/lib/components/flows/content/AgentSettings.svelte new file mode 100644 index 0000000000..449edfccff --- /dev/null +++ b/frontend/src/lib/components/flows/content/AgentSettings.svelte @@ -0,0 +1,79 @@ + + +{#if draft.state} +
+ + {#if readOnly} + {#if draft.state.labels?.length} +
+ {#each draft.state.labels as label (label)} + {label} + {/each} +
+ {/if} + {:else} + + {/if} + {#if deployTo.current} + + {/if} + +
+{/if} diff --git a/frontend/src/lib/components/flows/content/AiAgentStepInputs.svelte b/frontend/src/lib/components/flows/content/AiAgentStepInputs.svelte index e5529dce5c..68a0d1b4e2 100644 --- a/frontend/src/lib/components/flows/content/AiAgentStepInputs.svelte +++ b/frontend/src/lib/components/flows/content/AiAgentStepInputs.svelte @@ -580,7 +580,6 @@ bind:args {chatInputEnabled} historyOnStep={scopedFields.some((f) => f.key === 'previous_messages')} - s3StorageConfigured={s3Storage.current} /> {:else if spec.key === 'memory_id' && memoryIdOffered} {@render transformField( diff --git a/frontend/src/lib/components/flows/content/FlowInput.svelte b/frontend/src/lib/components/flows/content/FlowInput.svelte index ca5c6d8073..c6744282e2 100644 --- a/frontend/src/lib/components/flows/content/FlowInput.svelte +++ b/frontend/src/lib/components/flows/content/FlowInput.svelte @@ -820,6 +820,7 @@ workspace={opWs} editTab={chatInputsEditTab ? 'inputEditor' : undefined} showDynOpt + deployedFlowPath={$initialPathStore} bind:dynCode bind:dynLang on:delete={(e) => { @@ -878,6 +879,7 @@ addPropertyV2?.handleDeleteArgument([e.detail]) }} showDynOpt + deployedFlowPath={$initialPathStore} displayWebhookWarning editTab={$flowInputEditorState?.selectedTab} {previewSchema} diff --git a/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte b/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte index ae28a377c4..4e8b97f76f 100644 --- a/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte +++ b/frontend/src/lib/components/flows/content/FlowInputsQuick.svelte @@ -8,6 +8,7 @@ import FlowScriptPickerQuick from '../pickers/FlowScriptPickerQuick.svelte' import { defaultScriptLanguages, processInlineLangs } from '$lib/scripts' import { defaultScripts, enterpriseLicense, hubBaseUrlStore } from '$lib/stores' + import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' import type { SupportedLanguage } from '$lib/common' import { createEventDispatcher, getContext, untrack } from 'svelte' import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui' @@ -34,6 +35,7 @@ } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() + const operatorBuilderFlows = useOperatorBuilderFlows() const operatingUser = useOperatingUser() const actingUser = $derived(operatingUser.current) @@ -155,7 +157,10 @@ preFilter: 'all' | 'workspace' | 'hub', selectedKind: 'script' | 'flow' | 'approval' | 'trigger' | 'preprocessor' | 'failure' ) { - if (['script', 'trigger', 'failure', 'approval', 'preprocessor'].includes(selectedKind)) { + if ( + !$operatorBuilderFlows && + ['script', 'trigger', 'failure', 'approval', 'preprocessor'].includes(selectedKind) + ) { if (!selected && preFilter == 'all') { inlineScripts = langs.filter((lang) => { return ( @@ -248,6 +253,7 @@ let showAiRows = $derived( !disableAi && !$copilotInfo.workspaceDisabled && + !$operatorBuilderFlows && funcDesc?.length > 0 && kind != 'failure' && kind != 'preprocessor' && @@ -276,9 +282,14 @@ preFilter === 'all' && !selected && customUi?.aiSandbox != false && + !$operatorBuilderFlows && matchesAiSandbox ) + // Hub runnables carry code the workspace never reviewed, and the backend refuses them in a + // flow a builder authors, so the hub browser and its integration filters are not offered. + let showHub = $derived(!$operatorBuilderFlows) + // Every result row lives in one keyboard index space, and hovering a row moves that index, so // mouse and keyboard can never highlight two different rows. Offsets follow the render order. let inlineOffset = $derived(topLevelNodes.length) @@ -331,7 +342,7 @@ {/if} {/if} - {#if preFilter === 'hub' || preFilter === 'all'} + {#if showHub && (preFilter === 'hub' || preFilter === 'all')} {#if preFilter == 'all'}
Integrations
{/if} @@ -536,7 +547,7 @@ }} /> {/if} - {#if selectedKind != 'preprocessor' && selectedKind != 'flow'} + {#if showHub && selectedKind != 'preprocessor' && selectedKind != 'flow'} {#if (!selected || selected?.kind === 'integrations') && (preFilter === 'hub' || preFilter === 'all')} {#if !selected && preFilter !== 'hub'}
Hub
diff --git a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte index a256a9998a..577a6275d9 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte @@ -39,6 +39,7 @@ import type { ButtonProp } from '$lib/components/diffEditorTypes' import { loadSchemaFromModule } from '../flowInfers' import { type Job } from '$lib/gen' + import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' import { checkIfParentLoop } from '../utils.svelte' import { useWorkspaceScriptSettings } from '../useWorkspaceScriptSettings.svelte' import ScriptSettingsBadges from '$lib/components/ScriptSettingsBadges.svelte' @@ -74,6 +75,7 @@ import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() + const operatorBuilderFlows = useOperatorBuilderFlows() const { selectionManager, @@ -235,8 +237,11 @@ !flowModule.value.path?.startsWith('hub/') && flowModule.value.hash == undefined && customUi?.scriptEdit != false && + !$operatorBuilderFlows && $workspaceScriptSettingsDrawer != undefined ) + // Same rule as the graph node's Run button (FlowModuleSchemaItem). + let builderCannotTestStep = $derived($operatorBuilderFlows && flowModule.value.type === 'script') let workspaceScriptNoEditReason = $derived( flowModule.value.type !== 'script' || canEditWorkspaceScriptSettings ? undefined @@ -288,7 +293,7 @@ } function onKeyDown(event: KeyboardEvent) { - if ((event.ctrlKey || event.metaKey) && event.key == 'Enter') { + if ((event.ctrlKey || event.metaKey) && event.key == 'Enter' && !builderCannotTestStep) { event.preventDefault() selected = 'test' modulePreview?.runTestWithStepArgs() @@ -1121,7 +1126,9 @@ {#if !preprocessorModule} {/if} - + {#if !builderCannotTestStep} + + {/if} {#if canShowChatTab && flowModule.value.type === 'aiagent'}
- {:else if visibleSelected === 'test'} + {:else if visibleSelected === 'test' && !builderCannotTestStep} {#if debugMode && isDebuggableScript}
import { enterpriseLicense } from '$lib/stores' import { sendUserToast } from '$lib/toast' - import { createChat, type Chat, type ChatState } from 'windmill-chat' + import { + createChat, + turnRunningError, + WindmillChatApi, + type Chat, + type ChatOptions, + type ChatState + } from 'windmill-chat' import FlowConversationsSidebar from './FlowConversationsSidebar.svelte' import FlowChatInterface from './FlowChatInterface.svelte' - import { getContext } from 'svelte' + import { getContext, untrack } from 'svelte' import type { FlowEditorContext } from '../types' - import type { FlowModule } from '$lib/gen' - import { FRAME_CLASS, type ChatFrame } from './flowChatProps' + import { ApiError, type FlowModule } from '$lib/gen' + import { FlowChatPool, type FlowChatPoolState } from './flowChatPool' + import { FlowChatViewHost, type ComposerAttachment } from './flowChatViewHost.svelte' + import { FRAME_CLASS, loadFlowChatInputs, type ChatFrame } from './flowChatProps' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() @@ -52,6 +61,8 @@ /** What a message runs, as the chat names it. An agent has no deployed chats, so its * sidebar offers no filter between those and the test ones. */ subject?: 'flow' | 'agent' + /** The host's own controls, after the model's in the composer's footer. */ + composerSettings?: import('svelte').Snippet } let { @@ -66,31 +77,59 @@ wideLayout = false, frame = 'top', conversationKind = 'deployed', - subject = 'flow' + subject = 'flow', + composerSettings = undefined }: Props = $props() const flowEditorContext = getContext('FlowEditorContext') // The editor may act on a workspace other than the nav store's (AI-session live editor). const workspace = $derived(flowEditorContext?.opWorkspace?.() ?? $operatingWorkspace) - let chat = $state(undefined) - let chatState = $state(undefined) + // The sidebar lists, renames and deletes through `listChat`; each conversation runs its + // turns on its own chat in the pool, so several can answer at once. + let listChat = $state(undefined) + let listState = $state(undefined) + let pool = $state | undefined>(undefined) + let poolState = $state(undefined) let sidebar = $state(undefined) + /** Each conversation's kind as a listing gave it, kept past a filter that stops listing it. */ + const conversationKinds = new Map() + + // What the reader chose for this flow — the model among them — held here rather than in + // each panel, so every conversation's composer sends and shows the same values. Read again + // wherever the chat is rebuilt below, since this component is reused from one flow to the + // next and would otherwise send one flow's settings with another's messages. + let inputValues = $state>({}) $effect(() => { const ws = workspace const flowPath = path if (!ws || !flowPath) return - const created = createChat({ + // Untracked: the identity only names what the path already changed, and reading it here + // would rebuild every chat of a flow whose identity merely arrived late. + inputValues = loadFlowChatInputs({ path: flowPath, identity: untrack(() => identity) }) + conversationKinds.clear() + const baseUrl = window.location.origin + const options: ChatOptions = { flowPath, workspace: ws, - baseUrl: window.location.origin, + baseUrl, history: 'server', // Only an enterprise server honours it; elsewhere it would just log a warning per // poll. The license loads asynchronously, so a cold load may create the chat twice. pollDelayMs: $enterpriseLicense ? 50 : undefined, run: async ({ user_message, ...inputs }, { conversationId }) => { - const jobId = await onRunFlow(String(user_message), conversationId, inputs) + let jobId: string | undefined + try { + jobId = await onRunFlow(String(user_message), conversationId, inputs) + } catch (e) { + // The conversation is still answering a message sent elsewhere; the chat + // follows that turn instead of failing this one. + if (e instanceof ApiError && e.status === 409) { + throw turnRunningError(String(e.body)) ?? e + } + throw e + } if (!jobId) throw new Error(`the ${subject} did not start`) // The server creates the conversation with the run, so the sidebar can list // it now, whatever becomes of the turn. @@ -98,15 +137,46 @@ return jobId }, onError: (error) => sendUserToast(`Failed to run ${subject}: ${error.message}`, true) + } + const api = new WindmillChatApi({ baseUrl, workspace: ws }) + const createdList = createChat(options) + const createdPool = new FlowChatPool({ + createChat: () => createChat(options), + createHost: (turns) => new FlowChatViewHost(turns), + disposeHost: (host) => host.dispose(), + holdsDraft: (host) => host.holdsDraft(), + // Every kind: a conversation the reader follows keeps running whatever the filter lists. + listRecent: async (page) => + (await api.listConversations(flowPath, { page, perPage: 50, kind: 'all' })).map((row) => ({ + id: row.id, + runningTurn: row.running_turn + ? { jobId: row.running_turn.job_id, userSeq: row.running_turn.user_seq } + : undefined + })) }) - const unsubscribe = created.subscribe((s) => (chatState = s)) - chat = created + const unsubscribeList = createdList.subscribe((s) => { + listState = s + // The kind filter narrows what the listing holds, while a conversation of the other + // kind keeps its panel mounted behind it. Remembered as each row is seen, so that + // panel's composer keeps saying why it cannot be written to. + for (const row of s.conversations) { + if (row.isTest !== undefined) conversationKinds.set(row.id, row.isTest) + } + }) + const unsubscribePool = createdPool.subscribe((s) => (poolState = s)) + listChat = createdList + pool = createdPool return () => { - unsubscribe() - created.destroy() + unsubscribeList() + unsubscribePool() + createdPool.destroy() + createdList.destroy() } }) + // The panel on screen. Every other panel the pool holds stays mounted behind it. + const shownKey = $derived(poolState?.shownKey) + // Derive additional inputs schema (excluding user_message) for chat mode const additionalInputsSchema = $derived.by(() => { const props = inputSchema?.properties ?? {} @@ -123,12 +193,13 @@
- {#if chat && chatState} + {#if listChat && listState && pool && poolState} {#if !hideSidebar} @@ -136,24 +207,46 @@ -
- - {#key chat} - - {/key} +
+ + {#each poolState.mounted as key (key)} + {@const panel = pool.get(key)} + {#if panel} + {@const shown = key === shownKey} +
+ + listState?.conversations.find((c) => c.id === id)?.isTest ?? + conversationKinds.get(id)} + {deploymentInProgress} + {additionalInputsSchema} + {flowModules} + {path} + {identity} + {workspace} + {description} + {wideLayout} + {conversationKind} + {subject} + extraSettings={composerSettings} + /> +
+ {/if} + {/each}
{/if}
diff --git a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte index cad5688d89..f6cc9af6f5 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte @@ -3,14 +3,15 @@ import { Loader2, MessageCircle, Settings2 } from 'lucide-svelte' import AIChatDisplay from '$lib/components/copilot/chat/AIChatDisplay.svelte' import { setChatViewHost } from '$lib/components/copilot/chat/chatViewHost' - import { FlowChatViewHost } from './flowChatViewHost.svelte' + import type { FlowChatViewHost } from './flowChatViewHost.svelte' import Modal from '$lib/components/common/modal/Modal.svelte' import SchemaForm from '$lib/components/SchemaForm.svelte' import GfmMarkdown from '$lib/components/GfmMarkdown.svelte' - import { emptyString, type DynamicInput } from '$lib/utils' - import { onDestroy, tick, untrack } from 'svelte' + import { emptyString, DynamicInput } from '$lib/utils' + import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte' + import { tick, untrack } from 'svelte' import type { Chat } from 'windmill-chat' - import { chatFlowKey } from './flowChatProps' + import { saveFlowChatInputs } from './flowChatProps' import type { FlowModule } from '$lib/gen' import { useWorkspaceStorageConfigured } from '$lib/components/inputTransformEnv.svelte' import { @@ -30,6 +31,14 @@ interface Props { chat: Chat + /** The conversation's host, which outlives this panel. One panel follows one chat for + * its whole life, so a later value of either prop never reaches it. */ + chatHost: FlowChatViewHost + /** The flow inputs the reader chose, shared by every conversation in the flow: FlowChat + * holds them, since each conversation has a panel of its own mounted. */ + inputValues: Record + /** Whether a conversation is a test chat, once the list has said. */ + isTestOf?: (conversationId: string) => boolean | undefined deploymentInProgress?: boolean additionalInputsSchema?: Record /** The flow's modules, read for the AI agent inputs the composer drives: the provider wiring @@ -46,10 +55,15 @@ conversationKind?: 'test' | 'deployed' /** What a message runs, as the composer names it. */ subject?: 'flow' | 'agent' + /** The host's own controls, after the model's in the composer's footer. */ + extraSettings?: import('svelte').Snippet } let { chat, + chatHost: chatHostProp, + inputValues = $bindable(), + isTestOf = undefined, deploymentInProgress = false, additionalInputsSchema, flowModules, @@ -59,18 +73,20 @@ description = undefined, wideLayout = false, conversationKind = 'deployed', - subject = 'flow' + subject = 'flow', + extraSettings = undefined }: Props = $props() + const operatingUser = useOperatingUser() // Derive helperScript for dynamic inputs from schema - const dynamicInputHelperScript = $derived.by((): DynamicInput.HelperScript | undefined => { - const dynCode = additionalInputsSchema?.['x-windmill-dyn-select-code'] - const dynLang = additionalInputsSchema?.['x-windmill-dyn-select-lang'] - if (dynCode && dynLang) { - return { source: 'inline', code: dynCode, lang: dynLang } - } - return undefined - }) + const dynamicInputHelperScript = $derived( + DynamicInput.flowHelperScript( + additionalInputsSchema?.['x-windmill-dyn-select-code'], + additionalInputsSchema?.['x-windmill-dyn-select-lang'], + path, + operatingUser.current?.operator + ) + ) // The composer's attachments feed this input, and the paperclip is its whole editor. const attachmentsTarget = $derived.by(() => { @@ -97,12 +113,7 @@ const modelGap = $derived(agentModelGap(modelWiring, subject)) const showModelButton = $derived(showsModelButton(modelWiring)) - // LocalStorage helpers - const STORAGE_KEY_PREFIX = 'windmill_flow_chat_inputs_' - let showInputsModal = $state(false) - // Conversation settings, persisted per flow: what the reader chose, and nothing else. - let inputValues = $state>(loadInputsFromStorage() ?? {}) let modalDraft = $state>({}) /** What the flow's own form would open on. */ @@ -128,31 +139,9 @@ // value, an author's default — is made safe before it reaches the provider. const runInputs = $derived(withoutRejectedEffort(modelWiring, effectiveInputs)) - function getStorageKey(): string { - return `${STORAGE_KEY_PREFIX}${chatFlowKey({ path, identity })}` - } - - function loadInputsFromStorage(): Record | null { - try { - const stored = localStorage.getItem(getStorageKey()) - return stored ? JSON.parse(stored) : null - } catch (e) { - console.error('Failed to load inputs from localStorage:', e) - return null - } - } - - function saveInputsToStorage(values: Record) { - try { - localStorage.setItem(getStorageKey(), JSON.stringify(values)) - } catch (e) { - console.error('Failed to save inputs to localStorage:', e) - } - } - function setInputValue(name: string, value: any) { inputValues = { ...inputValues, [name]: value } - saveInputsToStorage(inputValues) + saveFlowChatInputs({ path, identity }, inputValues) } function handleModalConfirm() { @@ -167,47 +156,48 @@ ) ) inputValues = kept - saveInputsToStorage(inputValues) + saveFlowChatInputs({ path, identity }, inputValues) showInputsModal = false } function openInputsModal() { - modalDraft = { ...effectiveInputs, ...(loadInputsFromStorage() ?? inputValues) } + modalDraft = { ...effectiveInputs, ...inputValues } showInputsModal = true } - // The host follows the chat it was built on for the life of this component: FlowChat - // remounts the interface under `{#key chat}`, so a later value of the prop never reaches it. - const chatHost = new FlowChatViewHost( - untrack(() => chat), - { - additionalInputs: () => (additionalInputsSchema ? { ...runInputs } : undefined), - attachmentsTarget: () => attachmentsTarget, - attachmentsUnavailable: () => - workspaceStorage.current - ? undefined - : 'This workspace has no object storage, so files cannot be attached.', - workspace: () => workspace, - sendDisabled: () => deploymentInProgress || !!modelGap || !!wrongKindReason, - // The model controls only: a retry changes model when the reader did, but replays - // the run's own attachments rather than whatever the composer holds now. - inputsShownInComposer: () => composerOwnedInputs(modelWiring, undefined) - } - ) + // The host belongs to the conversation, not to this panel: the pool keeps it alive so a + // message queued here still goes out once the reader has moved on. What it reads is this + // panel's, set on mount; one panel shows one conversation for its whole life. + const chatHost = untrack(() => chatHostProp) + chatHost.setOptions({ + additionalInputs: () => (additionalInputsSchema ? { ...runInputs } : undefined), + attachmentsTarget: () => attachmentsTarget, + attachmentsUnavailable: () => + workspaceStorage.current + ? undefined + : 'This workspace has no object storage, so files cannot be attached.', + workspace: () => workspace, + sendDisabled: () => deploymentInProgress || !!modelGap || !!wrongKindReason, + // The model controls only: a retry changes model when the reader did, but replays + // the run's own attachments rather than whatever the composer holds now. + inputsShownInComposer: () => composerOwnedInputs(modelWiring, undefined) + }) setChatViewHost(chatHost) + // Read off the host's own conversation: a message queued here goes out after the reader + // has moved on, when the shown conversation may be of the other kind. + const isTest = $derived.by(() => { + const id = chatHost.state.conversationId + return id === undefined ? undefined : isTestOf?.(id) + }) // A chat of the other kind can be read from here but not added to: the server refuses a // preview run into a deployed conversation and the reverse, so the composer says why first. const wrongKindReason = $derived.by(() => { - const { conversationId, conversations } = chatHost.state - const open = conversations.find((c) => c.id === conversationId) - if (open?.isTest === undefined || open.isTest === (conversationKind === 'test')) - return undefined - return open.isTest + if (isTest === undefined || isTest === (conversationKind === 'test')) return undefined + return isTest ? 'This chat was run from the flow editor. Start a new chat to continue here.' : 'This chat belongs to the deployed flow. Start a new chat to test.' }) - onDestroy(() => chatHost.dispose()) // What the Configure-inputs modal asks for: every flow input the composer does not // edit itself. @@ -314,6 +304,7 @@ {workspace} /> {/if} + {@render extraSettings?.()} {/snippet} + + {#snippet resting()} + + {/snippet} + +{/snippet} + {/if} @@ -299,7 +341,6 @@ onClick={(e) => { e?.stopPropagation() draft = false - chat.newConversation() }} title="Discard draft" destructive @@ -314,7 +355,7 @@
{:else} + {@const unread = poolState.unread[conversation.id] ?? 0} + {@const queued = !!poolState.queued[conversation.id]} - {#if savedAgentsLoading} + {#if $operatorBuilderFlows} + + {:else if savedAgentsLoading}
Loading saved agents
diff --git a/frontend/src/lib/components/home/CreateActionsMenu.svelte b/frontend/src/lib/components/home/CreateActionsMenu.svelte index 7c5bb4986e..4b9e7f1e9b 100644 --- a/frontend/src/lib/components/home/CreateActionsMenu.svelte +++ b/frontend/src/lib/components/home/CreateActionsMenu.svelte @@ -8,6 +8,7 @@ import Tab from '$lib/components/common/tabs/Tab.svelte' import { Plus, + Bot, Code2, LayoutDashboard, ChevronDown, @@ -25,11 +26,14 @@ import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte' import { importStore } from '$lib/components/apps/store' import { conditionalMelt, getLocalSetting, storeLocalSetting } from '$lib/utils' + import { useOperatorBuilderFlows } from '$lib/operatorWriteRights' import { createDropdownMenu, melt } from '@melt-ui/svelte' import YAML from 'yaml' import type { Snippet } from 'svelte' import { logFeatureUsage } from '$lib/utils/featureUsage' + const operatorBuilderFlows = useOperatorBuilderFlows() + interface Props { /** Replaces the default `New` button, e.g. with an inline text link. */ trigger?: Snippet @@ -136,6 +140,21 @@ } ] as Option[]) : []), + { + key: 'agent', + label: 'AI agent', + icon: Bot, + accent: 'purple', + tagline: 'Chat with it, or reuse it in flows', + description: + 'Pick a model, write its instructions and give it tools: scripts, flows, MCP servers or other agents. Chat with it from its own page, and add the same agent to any flow as a step.', + bullets: [ + 'Chat that remembers the conversation', + 'Reusable as a step in any flow', + 'Tools from scripts, flows and MCP' + ], + onSelect: () => goto(`${base}/agents/add`) + }, ...(HOME_SHOW_CREATE_FLOW ? ([ { @@ -247,6 +266,14 @@ } } + // A builder composes runnables that already exist, so only flows are offered: everything else + // here writes code, which the backend refuses from an operator. + // Derived, not computed once: switching workspace only sets `workspaceStore`, it does not + // remount this component, so a snapshot would keep the previous workspace's kinds. + const options: Option[] = $derived( + $operatorBuilderFlows ? allOptions.filter((o) => o.key === 'flow') : allOptions + ) + // the doc panel only shows while an option is hovered or focused, so the menu opens compact let activeKey: string | undefined = $state(undefined) // every option's import action, surfaced together under the bottom "Import" submenu. @@ -256,7 +283,7 @@ ...(onImportHubProject ? [{ label: 'Import a hub project', onSelect: onImportHubProject }] : []), - ...allOptions.flatMap((o) => o.extras ?? []) + ...options.flatMap((o) => o.extras ?? []) ]) // melt dropdown menu: arrow-key nav, typeahead, focus management and outside/escape @@ -372,7 +399,7 @@ activeKey = undefined } }) - let active = $derived(allOptions.find((o) => o.key === activeKey)) + let active = $derived(options.find((o) => o.key === activeKey)) let activeAc = $derived(active ? accentClasses[active.accent] : undefined) // shared YAML/JSON import drawer, reused by every "Import …" extra @@ -405,8 +432,7 @@ importScriptStore.set(parsed) await goto(`${base}/scripts/add?import=true`) } else if (importKind === 'app-fullcode') { - // /apps_raw/add does a full reload (cross-origin isolation), so the in-memory - // store would be lost — hand the payload over via sessionStorage instead. + // The raw app editor reads the payload from sessionStorage on mount. sessionStorage.setItem('rawAppImport', JSON.stringify(parsed)) await goto(`${base}/apps_raw/add`) } else { @@ -525,7 +551,7 @@ {/if} {/snippet} - {#each allOptions as option (option.key)} + {#each options as option (option.key)} {@const ac = accentClasses[option.accent]} {@const rowClass = 'w-full flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover'} @@ -540,10 +566,13 @@ {#if $wacSubOpen} +
{#each option.variants ?? [] as variant (variant.label)} {@const VariantIcon = variant.icon} @@ -593,7 +622,7 @@
{#each importActions as action, i (action.label)}
@@ -1876,9 +2039,12 @@ the menu itself does no permission check. --> {#if canCreateHere} + script and flow hub pickers observe. Nor for a builder: a hub project brings + scripts and apps along. --> (hubPickerOpen = true)} + onImportHubProject={$disableHubStore || $operatorBuilderFlows + ? undefined + : () => (hubPickerOpen = true)} /> {/if}
diff --git a/frontend/src/lib/components/home/TreeView.svelte b/frontend/src/lib/components/home/TreeView.svelte index 0581700316..4c6f3e8ae6 100644 --- a/frontend/src/lib/components/home/TreeView.svelte +++ b/frontend/src/lib/components/home/TreeView.svelte @@ -1,6 +1,6 @@ + +
+ + {#if tree.treeView && !forceExpanded} + + {/if} +
diff --git a/frontend/src/lib/components/home/TreeViewRoot.svelte b/frontend/src/lib/components/home/TreeViewRoot.svelte index 062dfb69f8..753feb6a8b 100644 --- a/frontend/src/lib/components/home/TreeViewRoot.svelte +++ b/frontend/src/lib/components/home/TreeViewRoot.svelte @@ -1,5 +1,5 @@ + +
+ {#if !$enterpriseLicense} + + {/if} + + {#if prefill} + + Review the connection below, then save and run setup. Nothing is saved until you do. Its + user needs CREATEDB and + CREATEROLE: setup reports it if it lacks either. + + {/if} + + + Windmill creates the databases for data tables and Ducklake catalogs on this cluster, and + manages the roles they connect as. The admin login below needs CREATEDB + and CREATEROLE, and is never handed to a job. + + +
+
+ + + +
+ +
+
+ Admin password + +
+ +
+ + SSL mode + + {#snippet text()} + Anything below verify-ca sends the managed roles' passwords to whichever server + answers. Windmill trusts the system roots plus the certificate below. + {/snippet} + + + + +
+ +
+ + + + {#if status} +
+ {#if status.configured} + + Configured · {status.database_count} database{status.database_count === 1 ? '' : 's'} + {:else} + + Not configured yet + {/if} +
+ {/if} +
+ + {#if loadError} + {loadError} + {/if} + + {#if status?.last_setup} + {@const report = status.last_setup} +
+
+ Last setup + {new Date(report.finished_at).toLocaleString()} +
+
+ {#each report.steps as step} +
+
+ {#if step.status === 'ok'} + + {:else if step.status === 'warning'} + + {:else} + + {/if} +
+
+ {step.name} + {step.message} +
+
+ {/each} +
+
+ {/if} + + {#if status?.configured} +
+ Databases on the cluster + + Only databases created here can back a data table or a Ducklake catalog, and one in use + cannot be dropped. + + + +
+ Name + Tag + Used by + + + + + {#each databaseEntries as [name, db]} + + {name} + {db.tag ?? '-'} + + {db.used_by_workspaces?.length ? db.used_by_workspaces.join(', ') : '-'} + + +
+
+
+
+ {:else} + + + No database yet. Create one below, then pick it in a workspace's data table or + Ducklake settings. + + + {/each} +
+ +
+ +
+ setName(e.target.value)} placeholder="Your name" /> + + +
+ {error ? ( + {error} + ) : result ? ( + <> + {result.message} + Ran at {new Date(result.ran_at).toLocaleTimeString()} + + ) : ( + The result shows up here. + )} +
+ + ) +} + +type Stats = { words: number; characters: number; sentences: number; top_words: string[] } + +function StatsDemo() { + const [text, setText] = useState(${JSON.stringify(STATS_SAMPLE_TEXT)}) + const [stats, setStats] = useState() + const [error, setError] = useState() + const [loading, setLoading] = useState(false) + + async function run() { + setLoading(true) + setError(undefined) + try { + setStats(await backend.word_stats({ text })) + } catch (e) { + setError(errorMessage(e)) + } + setLoading(false) + } + + return ( + + +
+ +
+ {#if statsError} +
{statsError}
+ {/if} + {#if stats} +
+
{stats.words}words
+
{stats.sentences}sentences
+
{stats.characters}characters
+
+ {#each stats.top_words as word (word)} + {word} + {/each} +
+
+ {/if} +
+
const stats = await backend.word_stats({ text })
+ + +
+ {@render header('3', 'Run jobs in the background', 'Start long jobs without blocking the UI, then poll their status. Start several at once.')} +
+
+ +
+ {#if jobsError} +
{jobsError}
+ {/if} +
    + {#each jobs as job (job.id)} +
  • + {job.status} + {job.id.slice(-8)} + {job.seconds}s +
    +
    +
    +
  • + {:else} +
  • No jobs yet.
  • + {/each} +
+
+
const id = await backendAsync.slow_task({ seconds })
+const job = await getJob(id)
+
+ +
+ {@render header('4', 'Stream results', 'A runnable can yield its output bit by bit. Render it as it arrives, like an LLM answer.')} +
+
+ +
+
+ {#if streamError} + {streamError} + {:else if streamed || streaming} + {streamed}{#if streaming}{/if} + {:else} + Streamed text shows up here. + {/if} +
+
+
await streamJob(id, (u) => append(u.new_result_stream))
+
+
- -` +` const indexSvelte = ` import { mount } from 'svelte'; @@ -78,12 +517,221 @@ export default app; ` const appVue = ` ` const indexVue = `import { createApp } from 'vue' @@ -92,16 +740,386 @@ import "./index.css"; createApp(App).mount('#root')` -const indexCss = `.myclass { - border: 1px solid gray; - padding: 2px; -}` +const indexCss = `/* The app renders inside an iframe whose host page has its own theme the + iframe cannot read, so paint an opaque surface here: without one the host's + background shows through and dark text lands on it unreadable. */ +:root { + color-scheme: light; + --bg: #f4f5f8; + --card: #ffffff; + --sunken: #efeff4; + --border: #e2e3eb; + --fg: #3d4758; + --fg-strong: #1d2430; + --muted: #718096; + --accent: #5074f6; + --accent-hover: #2c5beb; + --accent-soft: #e8ebfb; + --success: #15803d; + --success-soft: #dcfce7; + --error: #b91c1c; + --error-soft: #fef2f2; + --warning: #a16207; + --warning-soft: #fef9c3; +} + +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --bg: #242832; + --card: #2e3441; + --sunken: #272c35; + --border: #3b4252; + --fg: #d4d7dd; + --fg-strong: #eeeff2; + --muted: #a9b0ba; + --accent: #7085db; + --accent-hover: #5670d5; + --accent-soft: #343d5c; + --success: #86efac; + --success-soft: #1f3a2b; + --error: #fca5a5; + --error-soft: #431a1a; + --warning: #fde68a; + --warning-soft: #3d3419; + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--fg); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +h1, +h2 { + margin: 0; + color: var(--fg-strong); +} + +code, +pre { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.muted { + color: var(--muted); +} + +.error { + color: var(--error); +} + +.app { + max-width: 1040px; + margin: 0 auto; + padding: 40px 20px 56px; +} + +.hero { + margin-bottom: 28px; +} + +.hero h1 { + font-size: 1.75rem; + font-weight: 650; + letter-spacing: -0.01em; + margin: 8px 0 6px; +} + +.hero p { + max-width: 620px; + margin: 0; +} + +.eyebrow { + display: inline-block; + padding: 2px 10px; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); + font-size: 12px; + font-weight: 600; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); + gap: 16px; +} + +.card { + display: flex; + flex-direction: column; + background: var(--card); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 1px 2px rgb(16 24 40 / 0.04); + overflow: hidden; +} + +.card-header { + display: flex; + gap: 12px; + padding: 18px 18px 0; +} + +.card-header h2 { + font-size: 15px; + font-weight: 600; +} + +.card-header p { + margin: 2px 0 0; + font-size: 13px; +} + +.card-step { + flex: none; + display: grid; + place-items: center; + width: 26px; + height: 26px; + border-radius: 7px; + background: var(--accent-soft); + color: var(--accent); + font-size: 13px; + font-weight: 700; +} + +.card-body { + flex: 1; + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px 18px 18px; +} + +.snippet { + margin: 0; + padding: 10px 18px; + border-top: 1px solid var(--border); + background: var(--sunken); + color: var(--muted); + font-size: 12px; + white-space: pre-wrap; +} + +.row { + display: flex; + gap: 8px; +} + +input, +textarea { + flex: 1; + min-width: 0; + padding: 7px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--card); + color: var(--fg-strong); + font: inherit; + resize: vertical; +} + +input:focus, +textarea:focus { + outline: 2px solid var(--accent); + outline-offset: -1px; +} + +button { + padding: 7px 14px; + border: none; + border-radius: 6px; + background: var(--accent); + color: #fff; + font: inherit; + font-weight: 500; + cursor: pointer; + transition: background 0.15s; +} + +button:hover:not(:disabled) { + background: var(--accent-hover); +} + +button:disabled { + opacity: 0.6; + cursor: default; +} + +.output { + min-height: 44px; + padding: 11px 12px; + border-radius: 6px; + background: var(--sunken); + word-break: break-word; +} + +.output.error { + background: var(--error-soft); +} + +.output.stream { + min-height: 96px; + white-space: pre-wrap; +} + +.caret { + display: inline-block; + width: 7px; + height: 1em; + margin-left: 2px; + vertical-align: text-bottom; + background: var(--accent); + animation: blink 1s steps(1) infinite; +} + +@keyframes blink { + 50% { + opacity: 0; + } +} + +.stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.stats > div { + display: flex; + flex-direction: column; + padding: 10px 12px; + border-radius: 6px; + background: var(--sunken); +} + +.stats strong { + font-size: 20px; + color: var(--fg-strong); +} + +.stats span { + font-size: 12px; +} + +.stats .tags { + grid-column: 1 / -1; + flex-direction: row; + flex-wrap: wrap; + gap: 6px; + padding: 0; + background: none; +} + +.tag { + padding: 2px 8px; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); +} + +.jobs { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} + +.jobs li { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 6px; + background: var(--sunken); + font-size: 13px; +} + +.pill { + min-width: 64px; + padding: 1px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + text-align: center; +} + +.pill.queued { + background: var(--warning-soft); + color: var(--warning); +} + +.pill.running { + background: var(--accent-soft); + color: var(--accent); +} + +.pill.success { + background: var(--success-soft); + color: var(--success); +} + +.pill.failure { + background: var(--error-soft); + color: var(--error); +} + +.bar { + flex: 1; + height: 4px; + border-radius: 999px; + background: var(--border); + overflow: hidden; +} + +.bar-fill { + width: 0; + height: 100%; + border-radius: inherit; + background: var(--accent); +} + +.bar-fill.running { + animation: fill linear forwards; +} + +.bar-fill.success { + width: 100%; + background: var(--success); +} + +.bar-fill.failure { + width: 100%; + background: var(--error); +} + +@keyframes fill { + to { + width: 95%; + } +} +` export const react19Template = { - '/index.tsx': reactIndex, - '/App.tsx': appTsx, - '/index.css': indexCss, - '/package.json': `{ + '/index.tsx': reactIndex, + '/App.tsx': appTsx, + '/index.css': indexCss, + '/package.json': `{ "dependencies": { "react": "19.0.0", "react-dom": "19.0.0" @@ -114,10 +1132,10 @@ export const react19Template = { } export const react18Template = { - '/index.tsx': reactIndex, - '/App.tsx': appTsx, - '/index.css': indexCss, - '/package.json': `{ + '/index.tsx': reactIndex, + '/App.tsx': appTsx, + '/index.css': indexCss, + '/package.json': `{ "dependencies": { "react": "18.3.1", "react-dom": "18.3.1" @@ -130,10 +1148,10 @@ export const react18Template = { } export const svelte5Template = { - '/index.ts': indexSvelte, - '/App.svelte': appSvelte, - '/index.css': indexCss, - '/package.json': `{ + '/index.ts': indexSvelte, + '/App.svelte': appSvelte, + '/index.css': indexCss, + '/package.json': `{ "dependencies": { "svelte": "^5.56.8" } @@ -141,10 +1159,10 @@ export const svelte5Template = { } export const vueTemplate = { - '/index.ts': indexVue, - '/App.vue': appVue, - '/index.css': indexCss, - '/package.json': `{ + '/index.ts': indexVue, + '/App.vue': appVue, + '/index.css': indexCss, + '/package.json': `{ "dependencies": { "core-js": "3.26.1", "vue": "3.5.13" @@ -153,41 +1171,120 @@ export const vueTemplate = { } export const FRAMEWORK_TEMPLATES = { - react19: react19Template, - react18: react18Template, - svelte5: svelte5Template, - vue: vueTemplate + react19: react19Template, + react18: react18Template, + svelte5: svelte5Template, + vue: vueTemplate } export type FrameworkKey = keyof typeof FRAMEWORK_TEMPLATES -export const STARTER_RUNNABLE_KEY = 'a' +type StarterArg = { type: 'string' | 'number' } -// Matches the starter runnable that the apps_raw/add page seeds, so the -// React/Svelte demo button (`backend.a({ x: 42 })`) works on first render. -export const STARTER_RUNNABLE = { - name: 'a', - fields: {}, - type: 'inline', - inlineScript: { - content: - '// import * as wmill from "windmill-client"\n\nexport async function main(x: string) {\n return x\n}\n', - language: 'bun', - schema: { - $schema: 'https://json-schema.org/draft/2020-12/schema', - properties: { - x: { - default: null, - description: '', - originalType: 'string', - type: 'string' - } - }, - required: ['x'], - type: 'object' - } +function inlineRunnable( + language: 'bun' | 'python3', + content: string, + args: Record, + fields: Record = {} +) { + return { + fields, + type: 'inline', + inlineScript: { + content, + language, + schema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + properties: Object.fromEntries( + Object.entries(args).map(([k, { type }]) => [ + k, + { default: null, description: '', originalType: type, type } + ]) + ), + required: Object.keys(args), + type: 'object' + } + } + } +} + +// The runnables every framework template calls, seeded by the apps_raw/edit page and +// the AI chat's app creation so the starter works on first render. +export const STARTER_RUNNABLES = { + greet: { + name: 'greet', + ...inlineRunnable( + 'bun', + `// import * as wmill from "windmill-client" + +// \`username\` is a context field: the app fills it with the viewer's username. +// Direct API callers can pass any value, so don't use it for access control. +export async function main(name: string, username: string) { + return { + message: \`Hello \${name}! \${username ? \`You are signed in as \${username}.\` : 'You are not signed in.'}\`, + ran_at: new Date().toISOString() } } +`, + { name: { type: 'string' }, username: { type: 'string' } }, + { username: { type: 'ctx', ctx: 'username' } } + ) + }, + word_stats: { + name: 'word_stats', + ...inlineRunnable( + 'python3', + `import re +from collections import Counter + + +def main(text: str): + words = re.findall(r"[a-z']+", text.lower()) + top = Counter(w for w in words if len(w) > 4).most_common(5) + return { + "words": len(words), + "characters": len(text), + "sentences": len(re.findall(r"[.!?]+", text)), + "top_words": [word for word, _ in top], + } +`, + { text: { type: 'string' } } + ) + }, + slow_task: { + name: 'slow_task', + ...inlineRunnable( + 'bun', + `export async function main(seconds: number) { + await new Promise((resolve) => setTimeout(resolve, seconds * 1000)) + return \`Done after \${seconds}s\` +} +`, + { seconds: { type: 'number' } } + ) + }, + stream_text: { + name: 'stream_text', + ...inlineRunnable( + 'bun', + `// Returning an async generator streams each yielded chunk to the caller. +export async function main() { + const text = + 'Every word of this text is yielded by a Bun script as soon as it is ready, ' + + 'and the app renders it the moment it arrives. Use the same pattern to ' + + 'stream an LLM answer, a long report or live logs.' + return (async function* () { + for (const word of text.split(' ')) { + await new Promise((resolve) => setTimeout(resolve, 60)) + yield word + ' ' + } + })() +} +`, + {} + ) + } +} export const appVueRouter = `