Merge remote-tracking branch 'origin/main' into enable-operator-ai-chat-nav

# Conflicts:
#	frontend/src/lib/components/RunsPage.svelte
This commit is contained in:
Guilhem Lemouel
2026-10-06 10:06:57 -07:00
181 changed files with 6937 additions and 1514 deletions
+28 -29
View File
@@ -1,25 +1,22 @@
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.
# The global (AI chat) and cli 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: one global pass at --runs 3 is about 45M tokens per
# model. Each mode and model is its own job, so a slow model neither delays nor
# shares a timeout with the others. Run it by hand to measure a branch against
# main, or to include the flow, app and script suites.
on:
schedule:
- cron: "0 3 * * 1"
workflow_dispatch:
inputs:
modes:
description: "Space-separated modes"
default: "global flow app script cli"
description: "Space-separated modes (global, cli, flow, app, script)"
default: "global 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"
description: "Space-separated model aliases (bun run cli -- models); cli runs only the Claude ones"
default: "sonnet-5.5 opus-5.5 gpt-6-sol gpt-6-astra deepseek-v4-pro"
runs:
description: "Runs per case"
default: "3"
@@ -31,8 +28,7 @@ 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' }}
MODELS: ${{ inputs.models || 'sonnet-5.5 opus-5.5 gpt-6-sol gpt-6-astra deepseek-v4-pro' }}
RUNS: ${{ inputs.runs || '3' }}
REASONING: ${{ inputs.reasoning }}
# Routes on the cloud instance are prefixed with their workspace.
@@ -42,14 +38,20 @@ jobs:
setup:
runs-on: ubuntu-latest
outputs:
modes: ${{ steps.modes.outputs.modes }}
matrix: ${{ steps.matrix.outputs.matrix }}
steps:
- id: modes
- id: matrix
env:
MODES: ${{ inputs.modes || 'global flow app script cli' }}
MODES: ${{ inputs.modes || 'global cli' }}
# cli drives the Claude Agent SDK, so it only takes the Claude aliases
# (`cli` entries in ai_evals/core/models.ts).
run: |
echo "modes=$(jq -cn --arg m "$MODES" '$m | split(" ")
| map(select(IN("global", "flow", "app", "script", "cli")))')" >> "$GITHUB_OUTPUT"
echo "matrix=$(jq -cn --arg modes "$MODES" --arg models "$MODELS" '
def words: split(" ") | map(select(. != ""));
[ $modes | words[] | select(IN("global", "flow", "app", "script", "cli")) as $mode
| $models | words[]
| select($mode != "cli" or test("^(claude-)?(haiku|sonnet|opus)"; "i"))
| {mode: $mode, model: .} ]')" >> "$GITHUB_OUTPUT"
evals:
needs: setup
@@ -58,7 +60,7 @@ jobs:
strategy:
fail-fast: false
matrix:
mode: ${{ fromJSON(needs.setup.outputs.modes) }}
include: ${{ fromJSON(needs.setup.outputs.matrix) }}
services:
postgres:
image: postgres:16
@@ -118,10 +120,11 @@ jobs:
working-directory: ./cli
run: bun install && ./gen_wm_client.sh && ./windmill-utils-internal/gen_wm_client.sh
- name: Run ${{ matrix.mode }} evals
- name: Run ${{ matrix.mode }} evals on ${{ matrix.model }}
working-directory: ./ai_evals
env:
MODE: ${{ matrix.mode }}
MODEL: ${{ matrix.model }}
WMILL_AI_EVAL_BACKEND_URL: http://localhost:8000
WMILL_AI_EVAL_BACKEND_WORKSPACE: integration-tests
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -131,14 +134,10 @@ jobs:
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
bun run cli -- run "$MODE" --model "$MODEL" --runs "$RUNS" "${reasoning[@]}" \
--output "$PWD/results/$MODE-$MODEL.json" || echo "::warning::$MODE on $MODEL errored"
- name: Post results to the dashboard
if: always()
@@ -166,7 +165,7 @@ jobs:
uses: actions/upload-artifact@v4
if: always()
with:
name: ai-evals-${{ matrix.mode }}
name: ai-evals-${{ matrix.mode }}-${{ matrix.model }}
path: |
ai_evals/logs
ai_evals/results
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.823.1"
".": "1.825.0"
}
+6
View File
@@ -90,6 +90,12 @@ are for a plain checkout with nothing running.
- **Login**: `admin@windmill.dev` / `changeme`
- **Instance settings**: navigate to `/#superadmin-settings`
- **Migrations**: use `cargo sqlx migrate add -r <name>` from `backend/` to create new migrations (never generate timestamps manually)
- **Resource types ship through the hub, not migrations.** A new type, a provider's credential
included, is a `hub/<app>/<app>.resource_type.json` in `windmill-labs/windmill-integrations`
and reaches instances by hub sync; a product name its identifier misses (`typesafe` →
"TypeSafe") is that file's `display_name`, not a word in `resourceTypeDisplay.ts`'s map. Only a
type Windmill itself defines and reads as a product object (`ai_skill`, `ai_instruction`) is
also seeded by a migration, so the feature works before a sync. For local testing, insert the row.
### Per-worktree ports and database
+51
View File
@@ -1,5 +1,56 @@
# Changelog
## [1.825.0](https://github.com/windmill-labs/windmill/compare/v1.824.1...v1.825.0) (2026-10-06)
### Features
* choose build with AI or code when creating a raw app ([#11560](https://github.com/windmill-labs/windmill/issues/11560)) ([431f861](https://github.com/windmill-labs/windmill/commit/431f86138815fce432e282e8ac495953c5ecf98a))
* confine jobs:read to the runnable paths it names ([#11550](https://github.com/windmill-labs/windmill/issues/11550)) ([b371af3](https://github.com/windmill-labs/windmill/commit/b371af31c96e6890fe9223233d2c6e53ddea67d4))
* open a run as a pushed page on the narrow runs page ([#11541](https://github.com/windmill-labs/windmill/issues/11541)) ([fd8de45](https://github.com/windmill-labs/windmill/commit/fd8de45cc7cfcba2c374dd86f87f4d3e8aec6ded))
* say a canceled team plan is canceled, not past due ([#11539](https://github.com/windmill-labs/windmill/issues/11539)) ([18e44d3](https://github.com/windmill-labs/windmill/commit/18e44d31742b8d16852b8c6ae663979666e064d7))
### Bug Fixes
* group raw app edits that carry a file diff in the ai chat ([#11558](https://github.com/windmill-labs/windmill/issues/11558)) ([5912d30](https://github.com/windmill-labs/windmill/commit/5912d300a550a33674fd73740d613d1b434fbba1))
* honor custom timeout in setup phases and name the limit hit ([#11557](https://github.com/windmill-labs/windmill/issues/11557)) ([4fd4f76](https://github.com/windmill-labs/windmill/commit/4fd4f76d0df740cb3eebed1a0625b970484807fc))
* search slow runs filters by time windows instead of timing out ([#11549](https://github.com/windmill-labs/windmill/issues/11549)) ([2bf9cfc](https://github.com/windmill-labs/windmill/commit/2bf9cfcd59e1101695160524c31a4da1a41699dc))
* serve the raw-app preview shell sandboxed, load a blob copy in the editor ([#11554](https://github.com/windmill-labs/windmill/issues/11554)) ([5f92daa](https://github.com/windmill-labs/windmill/commit/5f92daab6d0ecac30dd384e1b0f302af13fdd9ab))
* simplify the run page AI card to a prompt and a chat button ([#11561](https://github.com/windmill-labs/windmill/issues/11561)) ([8c74f75](https://github.com/windmill-labs/windmill/commit/8c74f75de3fa62b07186ca4f56dae4a7e4758034))
## [1.824.1](https://github.com/windmill-labs/windmill/compare/v1.824.0...v1.824.1) (2026-10-05)
### Bug Fixes
* keep monaco's diff engine out of the app shell bundle ([#11542](https://github.com/windmill-labs/windmill/issues/11542)) ([0908dee](https://github.com/windmill-labs/windmill/commit/0908dee897e0f87b97418a23ede3662d96270340))
## [1.824.0](https://github.com/windmill-labs/windmill/compare/v1.823.1...v1.824.0) (2026-10-05)
### Features
* add an AI decision flow step (TypeSafe's Jev, Cloudflare's Clef) ([#11474](https://github.com/windmill-labs/windmill/issues/11474)) ([3cba8a7](https://github.com/windmill-labs/windmill/commit/3cba8a7e4cca3ef20ba73a7dad79d892bdc9fed8))
* add notion oauth provider ([#11528](https://github.com/windmill-labs/windmill/issues/11528)) ([8b2dc1e](https://github.com/windmill-labs/windmill/commit/8b2dc1e46c7f4e3859263f18b686ce6f3ce6fd30))
* **ai-chat:** show per-file diffs for raw app edits ([#11521](https://github.com/windmill-labs/windmill/issues/11521)) ([ec0883a](https://github.com/windmill-labs/windmill/commit/ec0883a627b06b6ba2a393d5cf66ddf6ebc4cd0d))
* bill team-plan service accounts as half a seat ([#11298](https://github.com/windmill-labs/windmill/issues/11298)) ([3aee181](https://github.com/windmill-labs/windmill/commit/3aee1815746f0116f5662f162ad1e234717af90d))
* card layout and load-more for the audit logs page ([#11507](https://github.com/windmill-labs/windmill/issues/11507)) ([af1a29a](https://github.com/windmill-labs/windmill/commit/af1a29a3cb33386f61a759ec0dd5eec0ccefefc2))
* offer audit-log and drive-metadata scopes for google oauth connects ([#11527](https://github.com/windmill-labs/windmill/issues/11527)) ([c4e7c71](https://github.com/windmill-labs/windmill/commit/c4e7c714e455f958d9a927c4bc4eb23256324780))
* offer slack read scopes for files, user groups, emoji and dms ([#11526](https://github.com/windmill-labs/windmill/issues/11526)) ([b5fda7d](https://github.com/windmill-labs/windmill/commit/b5fda7d5af7105fde69beb68410984350af2ac7e))
### Bug Fixes
* bound s3 ai attachment downloads before encoding ([#11534](https://github.com/windmill-labs/windmill/issues/11534)) ([92fbfc0](https://github.com/windmill-labs/windmill/commit/92fbfc03d219544c16e8f7db47a547f9ff402c27))
* collapse queued and running chat tool calls into a waiting row ([#11523](https://github.com/windmill-labs/windmill/issues/11523)) ([4f30982](https://github.com/windmill-labs/windmill/commit/4f30982ce3f7abc64a31890f8e88c31c1c2e0803))
* disable postgres triggers whose replication slot was invalidated ([#11536](https://github.com/windmill-labs/windmill/issues/11536)) ([85248ee](https://github.com/windmill-labs/windmill/commit/85248eee786b633def40c42b6007fc7b907fb09e))
* evaluate field conditions and step test inputs in QuickJS ([#11487](https://github.com/windmill-labs/windmill/issues/11487)) ([c962d37](https://github.com/windmill-labs/windmill/commit/c962d3766b1a250ba960e413556890d5919ebc94))
* flush proxied responses before the dev supervisor closes the client ([#11530](https://github.com/windmill-labs/windmill/issues/11530)) ([cefb195](https://github.com/windmill-labs/windmill/commit/cefb1953764e714d79556e2f8065cfbec78acb86))
* label the default slack scopes and list them first in the connect dialog ([#11529](https://github.com/windmill-labs/windmill/issues/11529)) ([e76781c](https://github.com/windmill-labs/windmill/commit/e76781cc2709fda08403e14c1a0a01957e430db1))
* run each ai eval model in its own job and let the docs tools reach the backend ([#11533](https://github.com/windmill-labs/windmill/issues/11533)) ([222dad1](https://github.com/windmill-labs/windmill/commit/222dad1d8cc9cbb6925fdff709e00a49bb63bc4a))
* show the flow row edit button to builder operators ([#11535](https://github.com/windmill-labs/windmill/issues/11535)) ([6c4e34c](https://github.com/windmill-labs/windmill/commit/6c4e34cd0dd9b4329084214c40695f5915947cc2))
## [1.823.1](https://github.com/windmill-labs/windmill/compare/v1.823.0...v1.823.1) (2026-10-05)
+2 -2
View File
@@ -142,10 +142,10 @@ FROM ${DEBIAN_IMAGE}
ARG TARGETPLATFORM
ARG POWERSHELL_VERSION=7.5.0
ARG KUBECTL_VERSION=1.36.2
ARG KUBECTL_VERSION=1.37.1
ARG HELM_VERSION=3.21.4
# NOTE: If changing, also change go version in workspace dependencies template at WorkspaceDependenciesEditor.svelte
ARG GO_VERSION=1.26.8
ARG GO_VERSION=1.27.1
ARG APP=/usr/src/app
ARG WITH_POWERSHELL=true
ARG WITH_KUBECTL=true
@@ -6,6 +6,8 @@ import { dirname, resolve } from 'path'
// @ts-ignore - Node.js url
import { fileURLToPath } from 'url'
import { handleBenchmarkApiFetch, hasBenchmarkApiHandler } from './mockBackend'
import { resolveWindmillBackendSettings } from '../../core/windmillBackendSettings'
import { WindmillBackendClient } from './windmillBackend'
const FRONTEND_DIR = fileURLToPath(new URL('../../../frontend/', import.meta.url))
@@ -28,6 +30,11 @@ globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
if (typeof url === 'string' && hasBenchmarkApiHandler(url)) {
return handleBenchmarkApiFetch(url, init)
}
// The docs tools read the documentation bundled into the backend: hand them to the
// benchmark's real backend, logged in like the AI proxy calls.
if (typeof url === 'string' && url.startsWith('/api/docs/')) {
return new WindmillBackendClient(resolveWindmillBackendSettings()).request(url.slice('/api'.length), 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.
@@ -684,6 +691,7 @@ benchmarkIt(
resetBenchmarkMockBackend()
}
},
// Full-suite runs (30+ cases at concurrency 2-3) routinely exceed 10 minutes.
7_200_000
// A full suite on a slow reasoning model runs for hours (gpt-6-astra on global went past
// 2h); the CI job's own timeout is the real bound.
18_000_000
)
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n COUNT(*) FILTER (WHERE NOT operator AND NOT disabled AND NOT is_service_account) AS \"developers!\",\n COUNT(*) FILTER (WHERE operator AND NOT disabled AND NOT is_service_account) AS \"operators!\"\n FROM usr WHERE workspace_id = $1",
"query": "SELECT\n COUNT(*) FILTER (WHERE NOT operator AND NOT disabled AND NOT is_service_account) AS \"developers!\",\n COUNT(*) FILTER (WHERE operator AND NOT disabled AND NOT is_service_account) AS \"operators!\",\n COUNT(*) FILTER (WHERE is_service_account AND NOT disabled) AS \"service_accounts!\"\n FROM usr WHERE workspace_id = $1",
"describe": {
"columns": [
{
@@ -12,6 +12,11 @@
"ordinal": 1,
"name": "operators!",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "service_accounts!",
"type_info": "Int8"
}
],
"parameters": {
@@ -20,9 +25,10 @@
]
},
"nullable": [
null,
null,
null
]
},
"hash": "ebce1633afe3d3f8a1a68bc8d26adcdd88b137bb892437a3ca67fd716772512b"
"hash": "d61e8523269a26051079b0ee837bf33c1ce2e063aaf80fd155dbbbf99eb2fab3"
}
+93 -93
View File
@@ -2894,9 +2894,9 @@ dependencies = [
[[package]]
name = "ctutils"
version = "0.4.2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
dependencies = [
"cmov",
]
@@ -9563,9 +9563,9 @@ dependencies = [
[[package]]
name = "powerfmt"
version = "0.2.0"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
[[package]]
name = "ppv-lite86"
@@ -10595,9 +10595,9 @@ dependencies = [
[[package]]
name = "rmcp-macros"
version = "3.5.0"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7e66877136e969d5a2d561ee5841b17717a0619aebd64abb47a2a2f46e34683"
checksum = "a1da498700030856ba63114bceefa30ba21f5e278cb975e5b9de3529fe842b40"
dependencies = [
"darling 0.24.1",
"proc-macro2",
@@ -14195,9 +14195,9 @@ dependencies = [
[[package]]
name = "unicase"
version = "2.9.0"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
[[package]]
name = "unicode-bidi"
@@ -14503,9 +14503,9 @@ dependencies = [
[[package]]
name = "want"
version = "0.3.1"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b"
dependencies = [
"try-lock",
]
@@ -14829,7 +14829,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"async-nats",
@@ -14918,7 +14918,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"async-stream",
"async-trait",
@@ -14952,7 +14952,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14965,7 +14965,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"argon2",
@@ -15105,7 +15105,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15128,7 +15128,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15145,7 +15145,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15171,7 +15171,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -15181,7 +15181,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15198,7 +15198,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"axum 0.8.9",
"base64 0.22.1",
@@ -15220,7 +15220,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15243,7 +15243,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15259,7 +15259,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15281,7 +15281,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15303,7 +15303,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15317,7 +15317,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"async-nats",
@@ -15353,7 +15353,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15378,7 +15378,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15406,7 +15406,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15428,7 +15428,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15448,7 +15448,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15490,7 +15490,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15519,7 +15519,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"lazy_static",
"serde",
@@ -15531,7 +15531,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"argon2",
"axum 0.8.9",
@@ -15555,7 +15555,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15569,7 +15569,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -15608,7 +15608,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"chrono",
"lazy_static",
@@ -15622,7 +15622,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -15641,7 +15641,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -15755,7 +15755,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"chrono",
"futures",
@@ -15775,7 +15775,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"regex",
"serde",
@@ -15792,7 +15792,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -15819,7 +15819,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"futures",
@@ -15836,7 +15836,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -15852,7 +15852,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15873,7 +15873,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15904,7 +15904,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -15929,7 +15929,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"async-stream",
@@ -15965,7 +15965,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"futures",
@@ -15983,7 +15983,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -15992,7 +15992,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16004,7 +16004,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16016,7 +16016,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"gosyn",
@@ -16028,7 +16028,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16040,7 +16040,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16052,7 +16052,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -16063,7 +16063,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16074,7 +16074,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16086,7 +16086,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -16097,7 +16097,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16119,7 +16119,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16131,7 +16131,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16145,7 +16145,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16162,7 +16162,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16175,7 +16175,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"serde",
@@ -16187,7 +16187,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16205,7 +16205,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -16221,7 +16221,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -16237,7 +16237,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16251,7 +16251,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16290,7 +16290,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"const_format",
@@ -16330,7 +16330,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -16341,7 +16341,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16376,7 +16376,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16400,7 +16400,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16433,7 +16433,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-amqp"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16460,7 +16460,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16493,7 +16493,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16513,7 +16513,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16547,7 +16547,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16584,7 +16584,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16607,7 +16607,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16631,7 +16631,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"async-nats",
@@ -16655,7 +16655,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16690,7 +16690,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16718,7 +16718,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16743,7 +16743,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"bitflags 2.13.2",
@@ -16762,7 +16762,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -16881,7 +16881,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"bytes",
"futures",
@@ -17590,18 +17590,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.59"
version = "0.8.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
checksum = "6400fc4bb7426f6faac3fb07566122bb18622e3022bd2c36426a92ed489b1dbe"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.59"
version = "0.8.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
checksum = "99c27cc7525bad4df59a45e073c45b2c2aa820efb588234cc23a547ec69a34db"
dependencies = [
"proc-macro2",
"quote",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.823.1"
version = "1.825.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.823.1"
version = "1.825.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
259ad3bfeef5285ba80eedc86309b11dca001220
065c2c64c3904d5c0b2766b424210d34f9e54d2d
+47 -5
View File
@@ -45,12 +45,39 @@
"files:read"
],
"scope_options": [
{ "value": "chat:write", "label": "Send messages as the app" },
{ "value": "chat:write.public", "label": "Send messages to public channels the app is not in" },
{ "value": "channels:join", "label": "Join public channels" },
{ "value": "files:write", "label": "Upload, edit and delete files" },
{ "value": "users:read", "label": "View people in the workspace" },
{ "value": "users:read.email", "label": "View people's email addresses" },
{ "value": "channels:read", "label": "View basic information about public channels" },
{ "value": "channels:history", "label": "Read messages in public channels the app is in" },
{ "value": "reactions:write", "label": "Add emoji reactions" },
{ "value": "channels:manage", "label": "Create, archive and manage public channels, invite members, set topics" },
{ "value": "groups:write", "label": "Create and manage private channels the app is in" },
{ "value": "usergroups:write", "label": "Edit user groups (workspace settings often block bots)" }
{ "value": "usergroups:write", "label": "Edit user groups (workspace settings often block bots)" },
{ "value": "files:read", "label": "Read and download files shared where the app is a member" },
{ "value": "groups:read", "label": "List private channels the app is in" },
{ "value": "im:read", "label": "List the app's direct messages" },
{ "value": "mpim:read", "label": "List the app's group direct messages" },
{ "value": "usergroups:read", "label": "List user groups" },
{ "value": "emoji:read", "label": "List custom emojis" }
],
"user_scope_options": [
{ "value": "channels:history", "label": "Read messages in public channels" },
{ "value": "groups:history", "label": "Read messages in your private channels" },
{ "value": "im:history", "label": "Read your direct messages" },
{ "value": "mpim:history", "label": "Read your group direct messages" },
{ "value": "search:read", "label": "Search messages and files" },
{ "value": "users:read", "label": "View people in the workspace" },
{ "value": "chat:write", "label": "Send messages as you" },
{ "value": "users:read.email", "label": "View people's email addresses" },
{ "value": "channels:read", "label": "View basic information about public channels" },
{ "value": "groups:read", "label": "View basic information about your private channels" },
{ "value": "im:read", "label": "View basic information about your direct messages" },
{ "value": "mpim:read", "label": "View basic information about your group direct messages" },
{ "value": "files:read", "label": "View files shared in your conversations" },
{ "value": "reactions:write", "label": "Add emoji reactions" },
{ "value": "channels:write", "label": "Create, archive and manage public channels" },
{ "value": "channels:write.invites", "label": "Invite members to public channels" },
@@ -58,7 +85,10 @@
{ "value": "groups:write", "label": "Create and manage private channels" },
{ "value": "users.profile:write", "label": "Set your status and edit your profile" },
{ "value": "reminders:write", "label": "Create reminders for yourself" },
{ "value": "usergroups:write", "label": "Edit user groups" }
{ "value": "usergroups:write", "label": "Edit user groups" },
{ "value": "files:write", "label": "Upload and delete files as yourself" },
{ "value": "usergroups:read", "label": "List user groups" },
{ "value": "emoji:read", "label": "List custom emojis" }
],
"token_response_path": "authed_user"
},
@@ -73,7 +103,8 @@
"scopes": ["https://www.googleapis.com/auth/spreadsheets"],
"scope_options": [
{ "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" }
{ "value": "https://www.googleapis.com/auth/spreadsheets.readonly", "label": "Read spreadsheets" },
{ "value": "https://www.googleapis.com/auth/drive.metadata.readonly", "label": "List and search your spreadsheets in Drive" }
],
"extra_params": {
"access_type": "offline",
@@ -150,7 +181,9 @@
"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" }
{ "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, export and search all Drive files and comments" },
{ "value": "https://www.googleapis.com/auth/drive", "label": "Read, edit, create and delete all Drive files" }
],
"extra_params": {
"access_type": "offline",
@@ -205,7 +238,8 @@
{ "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" }
{ "value": "https://www.googleapis.com/auth/admin.directory.user.security", "label": "Manage user security settings" },
{ "value": "https://www.googleapis.com/auth/admin.reports.audit.readonly", "label": "Read audit logs (login, admin, Drive, token activity)" }
],
"extra_params": {
"access_type": "offline",
@@ -220,6 +254,14 @@
"type": "web_server"
}
},
"notion": {
"auth_url": "https://api.notion.com/v1/oauth/authorize",
"token_url": "https://api.notion.com/v1/oauth/token",
"scopes": [],
"extra_params": {
"owner": "user"
}
},
"linkedin": {
"auth_url": "https://www.linkedin.com/oauth/v2/authorization",
"token_url": "https://www.linkedin.com/oauth/v2/accessToken",
+24 -24
View File
@@ -6216,7 +6216,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6302,7 +6302,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6314,7 +6314,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"convert_case",
"serde",
@@ -6323,7 +6323,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6335,7 +6335,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6347,7 +6347,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6359,7 +6359,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6371,7 +6371,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6383,7 +6383,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6394,7 +6394,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6405,7 +6405,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6417,7 +6417,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6428,7 +6428,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6450,7 +6450,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6462,7 +6462,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6476,7 +6476,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6493,7 +6493,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6506,7 +6506,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"serde",
@@ -6518,7 +6518,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6536,7 +6536,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6552,7 +6552,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6568,7 +6568,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6600,7 +6600,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6614,7 +6614,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.823.1"
version = "1.825.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.823.1"
version = "1.825.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+125
View File
@@ -0,0 +1,125 @@
use axum::{routing::post, Json, Router};
use serde_json::{json, Value};
use sqlx::{Pool, Postgres};
use std::time::Duration;
use windmill_common::{flows::FlowValue, jobs::JobPayload};
use windmill_test_utils::{
completed_job, in_test_worker, listen_for_completed_jobs, ApiServer, RunJob, StreamFind,
};
const MEMORY_ID: &str = "0199c0de-0000-7000-8000-000000000001";
/// A model that puts a U+0000 in its tool call arguments, then in its answer.
async fn model(Json(body): Json<Value>) -> ([(&'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":"are\u{0}tired"})
} else {
json!({"role":"assistant", "tool_calls":[{
"index":0, "id":"call_0", "type":"function",
"function":{"name":"echo", "arguments":"{\"x\":\"a\\u0000b\"}"}
}]})
};
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_flow(db: &Pool<Postgres>, modules: Value) -> anyhow::Result<Value> {
let server = ApiServer::start(db.clone()).await?;
let flow: FlowValue = serde_json::from_value(json!({ "modules": modules }))?;
let id = RunJob::from(JobPayload::RawFlow {
value: flow,
path: Some("u/test/nul".into()),
restarted_from: None,
})
.push(db)
.await;
let notifications = listen_for_completed_jobs(db).await;
tokio::time::timeout(
Duration::from_secs(45),
in_test_worker(db, notifications.find(&id), server.addr.port()),
)
.await?;
let job = completed_job(id, db).await;
server.close().await?;
assert!(job.success, "{:?}", job.result);
Ok(job.json_result().expect("flow result"))
}
#[sqlx::test(fixtures("base"))]
#[serial_test::serial]
async fn agent_step_survives_nul_in_model_output(db: Pool<Postgres>) -> anyhow::Result<()> {
std::env::set_var("ALLOW_PRIVATE_AI_BASE_URLS", "true");
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let base = format!("http://{}", listener.local_addr()?);
let stub = tokio::spawn(async move {
let router = Router::new().route("/v1/chat/completions", post(model));
axum::serve(listener, router).await.unwrap();
});
let result = run_flow(
&db,
json!([{"id":"agent","value":{
"type":"aiagent",
"tools":[{"id":"t0","summary":"echo","value":{
"type":"rawscript", "language":"bun", "input_transforms":{},
"content":"export async function main(x: string) { return x }"
}}],
"input_transforms":{
"provider":{"type":"static","value":{"kind":"customai","model":"nul-test","resource":{"base_url":format!("{base}/v1")}}},
"user_message":{"type":"static","value":"echo"},
"memory":{"type":"static","value":{"kind":"auto","context_length":10,"memory_id":MEMORY_ID}}
}
}}]),
)
.await?;
stub.abort();
assert_eq!(result["output"], "aretired");
let tool_message = result["messages"]
.as_array()
.unwrap()
.iter()
.find(|m| m["role"] == "tool")
.expect("tool message");
assert_eq!(tool_message["content"], "\"ab\"");
// The model's own words are also written to a jsonb column the job result never passes through.
let memory: String = sqlx::query_scalar(
"SELECT messages::text FROM ai_agent_memory WHERE conversation_id = $1::uuid",
)
.bind(MEMORY_ID)
.fetch_one(&db)
.await?;
assert!(memory.contains("aretired"), "{memory}");
Ok(())
}
/// A step's args are evaluated from the previous step's result as the worker holds it, not as
/// the completed job stored it.
#[sqlx::test(fixtures("base"))]
#[serial_test::serial]
async fn flow_step_args_survive_nul_in_previous_result(db: Pool<Postgres>) -> anyhow::Result<()> {
let result = run_flow(
&db,
json!([
{"id":"a","value":{
"type":"rawscript", "language":"bun", "input_transforms":{},
"content":"export async function main() { return 'a\\u0000b' }"
}},
{"id":"b","value":{
"type":"rawscript", "language":"bun",
"input_transforms":{"x":{"type":"javascript","expr":"results.a"}},
"content":"export async function main(x: string) { return x }"
}}
]),
)
.await?;
assert_eq!(result, "ab");
Ok(())
}
+129
View File
@@ -0,0 +1,129 @@
//! AI decision steps run end to end against a stand-in for System One endpoints: TypeSafe's, and
//! Cloudflare Workers AI's, which serves each model at its own URL inside an API envelope.
use axum::{routing::post, Json, Router};
use serde_json::{json, Value};
use sqlx::{Pool, Postgres};
use windmill_common::{flows::FlowValue, jobs::JobPayload};
use windmill_test_utils::*;
/// Answers every question as a choice of `refund`, the way TypeSafe shapes an answer.
fn refund_answers(body: &Value) -> Value {
let answers = body["questions"]
.as_object()
.into_iter()
.flatten()
.map(|(name, _)| {
let answer = json!({
"type": "choice",
"choice": "refund",
"probabilities": {"refund": 0.9, "bug": 0.1},
"confidence": 0.8
});
(name.clone(), answer)
})
.collect::<serde_json::Map<_, _>>();
json!({"model": body["model"], "answers": answers, "usage": {"input_tokens": 10}})
}
async fn start_decision_stub() -> anyhow::Result<u16> {
let app = Router::new()
.route(
"/v1/systemone",
post(|Json(body): Json<Value>| async move { Json(refund_answers(&body)) }),
)
.route(
"/accounts/acc/ai/run/@cf/cloudflare/clef-flash",
post(|Json(body): Json<Value>| async move {
Json(json!({"result": refund_answers(&body), "success": true, "errors": []}))
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let port = listener.local_addr()?.port();
tokio::spawn(async move { axum::serve(listener, app).await });
Ok(port)
}
/// A decision's answers reach later steps as `results.<id>.output`, which a Branch to one after it
/// routes on.
#[cfg(feature = "quickjs")]
#[sqlx::test(fixtures("base"))]
async fn test_ai_decision_answers_route_a_following_branch(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
// The stub listens on loopback, which the SSRF check refuses without this.
std::env::set_var("ALLOW_PRIVATE_AI_BASE_URLS", "true");
let stub_port = start_decision_stub().await?;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
for provider in [
json!({
"kind": "typesafe",
"resource": {"api_key": "key", "base_url": format!("http://127.0.0.1:{stub_port}/v1")},
"model": "jev-latest"
}),
json!({
"kind": "cloudflare",
"resource": {
"token": "key",
"base_url": format!("http://127.0.0.1:{stub_port}/accounts/acc/ai/run/@cf/cloudflare")
},
"model": "clef-flash"
}),
] {
let flow: FlowValue = serde_json::from_value(json!({
"modules": [
{"id": "d", "value": {
"type": "aidecision",
"input_transforms": {
"provider": {"type": "static", "value": provider},
"state": {"type": "static", "value": "I was charged twice"},
"questions": {"type": "static", "value": {"intent": {
"type": "choice",
"instructions": "What does the customer want?",
"criteria": {"refund": "Money back", "bug": "Something is broken"}
}}}
}
}},
{"id": "route", "value": {
"type": "branchone",
"branches": [
{
"expr": "results.d.output.intent.choice === 'bug'",
"modules": [{"id": "b", "value": {"type": "identity"}}]
},
{
"expr": "results.d.output.intent.choice === 'refund'",
"modules": [{"id": "r", "value": {
"type": "rawscript",
"language": "deno",
"content": "export function main(choice){ return 'handled ' + choice }",
"input_transforms": {"choice": {
"type": "javascript",
"expr": "results.d.output.intent.choice"
}}
}}]
}
],
"default": []
}}
]
}))?;
let job = run_job_in_new_worker_until_complete(
&db,
false,
JobPayload::RawFlow { value: flow, path: None, restarted_from: None },
port,
)
.await;
assert_eq!(
job.json_result(),
Some(json!("handled refund")),
"{provider}"
);
}
Ok(())
}
+7
View File
@@ -29,6 +29,13 @@ INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, sc
ARRAY['jobs:run:flows:f/shared/flow1']
);
-- A path-scoped read token for test-user-2: it reads the runs of `f/shared/flow1` only.
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES (
encode(sha256('READ_SCOPED_TOKEN'::bytea), 'hex'), 'READ_SCOP', 'READ_SCOPED_TOKEN',
'test2@windmill.dev', 'flow history token', false,
ARRAY['jobs:read:f/shared/flow1']
);
-- Same, scoped to a script. The two jobs below both run through a `singlestepflow`
-- wrapper (native retry / scheduled runs produce these) — one wrapping a script, one
-- wrapping a flow — so the confinement has to project each onto the runnable it wraps
+59
View File
@@ -17,6 +17,15 @@ async fn insert_job(
id: &str,
parent: Option<&str>,
scopes: &[&str],
) -> anyhow::Result<()> {
insert_job_with(db, id, parent, Some(scopes)).await
}
async fn insert_job_with(
db: &Pool<Postgres>,
id: &str,
parent: Option<&str>,
scopes: Option<&[&str]>,
) -> anyhow::Result<()> {
let id = Uuid::parse_str(id)?;
sqlx::query(
@@ -353,3 +362,53 @@ async fn test_deploy_without_the_field_keeps_the_restriction(
assert_eq!(stored("u/test-user/renamed").await?, None);
Ok(())
}
/// The OIDC `job_token_scopes` claim states the scopes of the token that asked for it.
#[cfg(all(feature = "enterprise", feature = "private", feature = "openidconnect"))]
#[sqlx::test(fixtures("base"))]
async fn test_oidc_claim_carries_the_presented_scopes(db: Pool<Postgres>) -> anyhow::Result<()> {
use base64::Engine;
initialize_tracing().await;
const OPEN_JOB: &str = "b0000000-0000-0000-0000-000000000005";
insert_script(&db, "u/test-user-3/agent", 535353, None).await?;
insert_job(&db, OIDC_JOB, None, &["oidc:write"]).await?;
insert_job_with(&db, OPEN_JOB, None, None).await?;
sqlx::query("UPDATE v2_job SET runnable_id = 535353 WHERE id = ANY($1)")
.bind([Uuid::parse_str(OIDC_JOB)?, Uuid::parse_str(OPEN_JOB)?])
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO v2_job_queue (id, workspace_id, running, scheduled_for, tag)
SELECT id, workspace_id, true, now(), tag FROM v2_job WHERE id = ANY($1)",
)
.bind([Uuid::parse_str(OIDC_JOB)?, Uuid::parse_str(OPEN_JOB)?])
.execute(&db)
.await?;
let server = ApiServer::start(db.clone()).await?;
set_jwt_secret().await;
// The issuer of the minted token.
windmill_common::BASE_URL.store(std::sync::Arc::new("http://localhost".to_string()));
let base = format!(
"http://localhost:{}/api/w/test-workspace",
server.addr.port()
);
let client = reqwest::Client::new();
for (job, expected) in [(OIDC_JOB, json!(["oidc:write"])), (OPEN_JOB, json!(null))] {
let resp = client
.post(format!("{base}/oidc/token/test-audience"))
.bearer_auth(job_token(&db, job).await?)
.send()
.await?;
let status = resp.status();
let id_token = resp.text().await?;
assert_eq!(status, StatusCode::OK, "{id_token}");
let payload = id_token.split('.').nth(1).expect("a JWT payload");
let claims: serde_json::Value = serde_json::from_slice(
&base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload)?,
)?;
assert_eq!(claims["job_token_scopes"], expected, "{job}");
}
Ok(())
}
+50
View File
@@ -564,6 +564,56 @@ async fn test_single_job_read_authorization(db: Pool<Postgres>) -> anyhow::Resul
"run-scoped token must not enumerate jobs (got {status}): {body}"
);
// ---- PATH-SCOPED READ TOKEN: READ_SCOPED_TOKEN is test-user-2's
// `jobs:read:f/shared/flow1`. It reads that flow's runs and the steps beneath
// them, and nothing else its owner created.
for (path, expected) in [
(
format!("completed/get_result/{FLOW_JOB}"),
r#""flow": "done""#,
),
(
format!("completed/get_result/{STEP_JOB}"),
"STEP_RESULT_INHERITED",
),
] {
let (status, body) = get(&base, &path, Some("READ_SCOPED_TOKEN")).await;
assert!(
status.is_success() && body.contains(expected),
"read-scoped token must read a run of its flow ({path}, got {status}): {body}"
);
}
for path in [
format!("completed/get_result/{VICTIM}"),
format!("get/{VICTIM}"),
format!("flow/approval_info/{VICTIM}"),
] {
let (status, body) = get(&base, &path, Some("READ_SCOPED_TOKEN")).await;
assert_eq!(
status,
reqwest::StatusCode::NOT_FOUND,
"read-scoped token must not read a job outside its paths ({path}, got {status}): {body}"
);
}
// The lists answer with the top-level runs at its paths only, where the same user's
// unscoped token sees the other runs too.
for list in ["list", "completed/list"] {
let (status, body) = get(&authed_base, list, Some("READ_SCOPED_TOKEN")).await;
assert!(status.is_success(), "{list} (got {status}): {body}");
let jobs: Vec<serde_json::Value> = serde_json::from_str(&body)?;
let mut ids: Vec<&str> = jobs.iter().filter_map(|j| j["id"].as_str()).collect();
ids.sort();
assert_eq!(ids, [WRAPPED_FLOW_JOB, FLOW_JOB], "{list}: {body}");
}
let (_, body) = get(&authed_base, "list", Some("SECRET_TOKEN_2")).await;
assert!(body.contains(VICTIM), "unscoped list: {body}");
let (status, body) = get(&authed_base, "completed/count", Some("READ_SCOPED_TOKEN")).await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"read-scoped token must not count the workspace's jobs (got {status}): {body}"
);
// ---- APP EMBED TOKEN: cancellation confined to the app's own jobs. The token
// may cancel a job it launched (created_by == viewer), but `cancel_job_api`
// denies (NotFound) a job created by someone else, even one the (admin)
+31
View File
@@ -39,6 +39,7 @@ lazy_static::lazy_static! {
pub const OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
pub const DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/v1";
pub const GOOGLE_AI_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta";
pub const TYPESAFE_BASE_URL: &str = "https://api.typesafe.ai/v1";
/// Hosts that serve the OpenAI API with Azure conventions: Azure OpenAI
/// (`*.openai.azure.com`), AI Foundry (`*.services.ai.azure.com`,
@@ -122,9 +123,35 @@ pub enum AIProvider {
CustomAI,
#[serde(rename = "aws_bedrock")]
AWSBedrock,
/// TypeSafe's Jev decision model. It answers typed questions rather than chatting, so only
/// an AI decision runs it (`run_systemone`), never the agent loop.
TypeSafe,
/// Cloudflare Workers AI, for its Jev-compatible decision models (Clef). Decisions only, as
/// for TypeSafe; its base URL comes from the resource's account id.
Cloudflare,
}
/// The Workers AI models URL of a Cloudflare account. The id goes into the path, so it is held to
/// the characters an account id has rather than trusted to stay inside its segment.
pub fn cloudflare_workers_ai_base_url(account_id: Option<&str>) -> Result<String> {
match account_id {
Some(id) if !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric()) => Ok(format!(
"https://api.cloudflare.com/client/v4/accounts/{id}/ai/run/@cf/cloudflare"
)),
Some(_) => Err(Error::BadRequest(
"The Cloudflare account_id must be letters and digits only".to_string(),
)),
None => Err(Error::BadRequest(
"A Cloudflare resource needs an account_id".to_string(),
)),
}
}
impl AIProvider {
/// A provider that answers decisions (typed questions) and serves no chat.
pub fn is_decision_provider(&self) -> bool {
matches!(self, AIProvider::TypeSafe | AIProvider::Cloudflare)
}
/// Get the base URL for the AI provider
pub async fn get_base_url(&self, resource_base_url: Option<String>, db: &DB) -> Result<String> {
if let Some(base_url) = resource_base_url {
@@ -177,6 +204,10 @@ impl AIProvider {
AIProvider::TogetherAI => Ok("https://api.together.xyz/v1".to_string()),
AIProvider::Anthropic => Ok("https://api.anthropic.com/v1".to_string()),
AIProvider::Mistral => Ok("https://api.mistral.ai/v1".to_string()),
AIProvider::TypeSafe => Ok(TYPESAFE_BASE_URL.to_string()),
AIProvider::Cloudflare => Err(Error::BadRequest(
"A Cloudflare resource needs an account_id".to_string(),
)),
p @ (AIProvider::CustomAI | AIProvider::AzureOpenAI | AIProvider::AzureFoundry) => {
Err(Error::BadRequest(format!(
"{:?} provider requires a base URL in the resource",
+258
View File
@@ -0,0 +1,258 @@
//! AI decision steps: one call to a System One endpoint (TypeSafe's Jev, or Cloudflare's
//! Jev-compatible Clef on Workers AI), which answers typed questions (`choice`, `score`, `noul`)
//! about a state with calibrated probabilities instead of text.
use std::time::Duration;
use serde::{Deserialize, Serialize};
use serde_json::{value::RawValue, Map, Value};
use windmill_common::error::{Error, Result};
use crate::{
ai_providers::AIProvider,
credentials::ProviderCredentials,
proxy::{common_outbound_headers, retain_effective_credentials},
types::{ProviderWithResource, TokenUsage},
utils::pinned_ai_client_for,
};
/// The inputs of an AI decision step or tool, as its `input_transforms` resolve them.
#[derive(Deserialize, Debug)]
pub struct AIDecisionArgs {
pub provider: ProviderWithResource,
#[serde(default)]
pub state: Option<Value>,
#[serde(default)]
pub questions: Option<Value>,
}
/// The alias TypeSafe keeps on its current model, sent when the step names none.
pub const TYPESAFE_DEFAULT_MODEL: &str = "jev-latest";
/// Cloudflare's larger decision model, run when the step names none.
pub const CLOUDFLARE_DEFAULT_MODEL: &str = "clef";
#[derive(Serialize)]
struct SystemOneRequest<'a> {
model: &'a str,
state: &'a Value,
questions: &'a Map<String, Value>,
}
#[derive(Deserialize)]
struct SystemOneResponse {
#[serde(default)]
model: Option<String>,
answers: Box<RawValue>,
#[serde(default)]
usage: Option<SystemOneUsage>,
}
/// Workers AI wraps the System One response in its API envelope.
#[derive(Deserialize)]
struct CloudflareEnvelope {
result: SystemOneResponse,
}
#[derive(Deserialize)]
struct SystemOneUsage {
input_tokens: Option<i32>,
output_tokens: Option<i32>,
}
/// A decision's result. `output` holds the answers keyed by question name, where an agent's
/// holds its answer, so a later step reads both as `results.<step>.output`.
#[derive(Serialize)]
pub struct DecisionResult {
pub output: Box<RawValue>,
/// The version that answered, which `jev-latest` resolves to.
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<TokenUsage>,
}
/// The state and questions of a decision, refused here rather than by the endpoint when
/// either is missing or of a shape TypeSafe rejects: a flow that passes an empty expression result
/// should read what it forgot, without a request going out.
pub fn decision_inputs<'a>(
state: Option<&'a Value>,
questions: Option<&'a Value>,
) -> Result<(&'a Value, &'a Map<String, Value>)> {
let state = match state {
None | Some(Value::Null) => {
return Err(Error::BadRequest(
"'state' must be provided for an AI decision".to_string(),
))
}
Some(Value::String(s)) if s.trim().is_empty() => {
return Err(Error::BadRequest(
"'state' must be provided for an AI decision".to_string(),
))
}
Some(state @ (Value::String(_) | Value::Object(_) | Value::Array(_))) => state,
Some(_) => {
return Err(Error::BadRequest(
"'state' must be a text, an object or an array for an AI decision".to_string(),
))
}
};
let questions = match questions {
Some(Value::Object(questions)) if !questions.is_empty() => questions,
_ => {
return Err(Error::BadRequest(
"'questions' must be an object naming at least one question for an AI decision"
.to_string(),
))
}
};
Ok((state, questions))
}
pub async fn run_systemone(
credentials: &ProviderCredentials,
model: &str,
state: &Value,
questions: &Map<String, Value>,
timeout: Duration,
) -> Result<DecisionResult> {
let cloudflare = credentials.provider == AIProvider::Cloudflare;
let model = match model.trim() {
"" if cloudflare => CLOUDFLARE_DEFAULT_MODEL,
"" => TYPESAFE_DEFAULT_MODEL,
model => model,
};
let base = credentials.base_url.trim_end_matches('/');
// Workers AI serves each model at its own URL; the model goes into the path, so it is held to
// the characters a model id has.
let endpoint = if cloudflare {
if !model
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
{
return Err(Error::BadRequest(format!(
"Invalid Cloudflare decision model: {model}"
)));
}
format!("{base}/{model}")
} else {
format!("{base}/systemone")
};
let vendor = if cloudflare { "Cloudflare" } else { "TypeSafe" };
let auth_headers = retain_effective_credentials(
credentials,
vec![(
"Authorization",
format!(
"Bearer {}",
credentials.api_key.as_deref().unwrap_or_default()
),
)],
);
// The endpoint derives from the resource's base URL, so the connect is pinned to the
// address the SSRF check validated.
let client = pinned_ai_client_for(&credentials.base_url).await?;
let mut request = client.post(&endpoint).timeout(timeout);
for (name, value) in auth_headers {
request = request.header(name, value);
}
for (name, value) in common_outbound_headers(credentials) {
request = request.header(name, value);
}
let response = request
.json(&SystemOneRequest { model, state, questions })
.send()
.await
.map_err(|e| Error::ExecutionErr(format!("Failed to call {vendor}: {e}")))?;
let status = response.status();
if !status.is_success() {
let body = response
.text()
.await
.unwrap_or_else(|_| "<failed to read body>".to_string());
return Err(Error::ExecutionErr(format!(
"{vendor} error calling {endpoint}: {status} - {body}"
)));
}
let body = response
.text()
.await
.map_err(|e| Error::ExecutionErr(format!("Failed to read the {vendor} response: {e}")))?;
// Tried in turn rather than as an untagged enum, which cannot hold the raw `answers`.
let parsed = match serde_json::from_str::<SystemOneResponse>(&body) {
Ok(parsed) => parsed,
Err(e) => match serde_json::from_str::<CloudflareEnvelope>(&body) {
Ok(envelope) => envelope.result,
Err(_) => {
return Err(Error::ExecutionErr(format!(
"Unexpected {vendor} response ({e}): {body}"
)))
}
},
};
Ok(DecisionResult {
output: parsed.answers,
model: parsed.model,
// Dropped when empty, as an agent's is, so `usage` is either counted or absent.
usage: parsed
.usage
.map(|u| TokenUsage::from_input_output(u.input_tokens, u.output_tokens))
.filter(|u| !u.is_empty()),
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// The account id goes into the URL path, so anything that could leave its segment is refused.
#[test]
fn cloudflare_account_id_stays_in_its_segment() {
use crate::ai_providers::cloudflare_workers_ai_base_url;
assert_eq!(
cloudflare_workers_ai_base_url(Some("abc123")).unwrap(),
"https://api.cloudflare.com/client/v4/accounts/abc123/ai/run/@cf/cloudflare"
);
for id in [None, Some("a/b"), Some("../x"), Some("a?b"), Some("")] {
assert!(cloudflare_workers_ai_base_url(id).is_err(), "{id:?}");
}
}
/// Refused are the values a flow produces when it forgot the input (no key, `null`, a blank
/// string from an empty expression) and the scalars TypeSafe rejects. A text, an object or an
/// array is a state, whatever it holds.
#[test]
fn decision_inputs_refuse_what_typesafe_would() {
let questions = json!({"urgent": {"type": "noul", "instructions": "Is it urgent?"}});
for state in [
None,
Some(json!(null)),
Some(json!(" ")),
Some(json!(false)),
Some(json!(0)),
] {
let err = decision_inputs(state.as_ref(), Some(&questions)).unwrap_err();
assert!(err.to_string().contains("'state'"), "{err}");
}
for state in [json!("0"), json!({}), json!([]), json!({"m": "hi"})] {
assert!(
decision_inputs(Some(&state), Some(&questions)).is_ok(),
"{state}"
);
}
for questions in [
None,
Some(json!({})),
Some(json!([])),
Some(json!("urgent")),
] {
let err = decision_inputs(Some(&json!("hi")), questions.as_ref()).unwrap_err();
assert!(err.to_string().contains("'questions'"), "{err}");
}
}
}
+9 -1
View File
@@ -47,6 +47,9 @@ pub async fn upload_image_to_s3(
})
}
// Bounds each attachment's download before base64 expansion; provider limits may be lower.
const MAX_S3_MEDIA_BYTES: usize = 30 * 1024 * 1024;
/// Download an S3 image and convert it to a base64 data URL.
///
/// The caller must provide an AuthedClient authorized for `workspace_id`.
@@ -57,7 +60,12 @@ pub async fn download_and_encode_s3_image(
) -> Result<(String, String), Error> {
// Download the image from S3
let image_bytes = client
.download_s3_file(workspace_id, &image.s3, image.storage.clone())
.download_s3_file(
workspace_id,
&image.s3,
image.storage.clone(),
Some(MAX_S3_MEDIA_BYTES),
)
.await
.map_err(|e| Error::internal_err(format!("Failed to download S3 image: {}", e)))?;
+1
View File
@@ -5,6 +5,7 @@ pub mod ai_google;
pub mod ai_providers;
pub mod ai_types;
pub mod credentials;
pub mod decision;
pub mod image_handler;
pub mod model_context;
pub mod providers;
+6
View File
@@ -204,6 +204,12 @@ pub fn create_query_builder(
AIProvider::AzureFoundry if AIProvider::is_anthropic_model(model) => Box::new(
AnthropicQueryBuilder::new(AIProvider::AzureFoundry, AIPlatform::Standard),
),
// Only for forwarding through the API proxy, which is bearer auth on `<base>/<path>` as
// TypeSafe and Cloudflare expect. Neither serves a chat route for its decision models, and
// `run_agent` refuses both before a chat request could be built.
AIProvider::TypeSafe | AIProvider::Cloudflare => {
Box::new(OtherQueryBuilder::new(credentials.provider.clone()))
}
_ => Box::new(OtherQueryBuilder::new(credentials.provider.clone())),
}
}
+5 -1
View File
@@ -72,7 +72,9 @@ pub fn proxy_execution_mode(provider: &AIProvider) -> ProxyExecutionMode {
| AIProvider::Groq
| AIProvider::OpenRouter
| AIProvider::TogetherAI
| AIProvider::CustomAI => ProxyExecutionMode::HttpForward,
| AIProvider::CustomAI
| AIProvider::TypeSafe
| AIProvider::Cloudflare => ProxyExecutionMode::HttpForward,
AIProvider::GoogleAI => ProxyExecutionMode::NativeGoogleAi,
AIProvider::AWSBedrock => ProxyExecutionMode::NativeAwsBedrock,
}
@@ -444,6 +446,8 @@ mod tests {
(AIProvider::OpenRouter, ProxyExecutionMode::HttpForward),
(AIProvider::TogetherAI, ProxyExecutionMode::HttpForward),
(AIProvider::CustomAI, ProxyExecutionMode::HttpForward),
(AIProvider::TypeSafe, ProxyExecutionMode::HttpForward),
(AIProvider::Cloudflare, ProxyExecutionMode::HttpForward),
(AIProvider::GoogleAI, ProxyExecutionMode::NativeGoogleAi),
(AIProvider::AWSBedrock, ProxyExecutionMode::NativeAwsBedrock),
];
+36 -1
View File
@@ -1,5 +1,7 @@
use std::borrow::Cow;
use async_trait::async_trait;
use windmill_common::{client::AuthedClient, error::Error};
use windmill_common::{client::AuthedClient, error::Error, utils::strip_json_nul};
use windmill_types::s3::S3Object;
use crate::ai_types::OpenAIToolCall;
@@ -92,6 +94,39 @@ pub enum ParsedResponse {
},
}
impl ParsedResponse {
/// Drops every U+0000 the model emitted. Postgres stores neither a NUL in `text` nor its
/// `\u0000` escape in `jsonb`, and this output is written to both: tool job args, the flow
/// status, agent memory, conversation rows.
pub fn without_nul(mut self) -> Self {
fn strip(s: &mut String) {
if s.contains('\0') {
s.retain(|c| c != '\0');
}
}
if let ParsedResponse::Text { content, reasoning, tool_calls, annotations, .. } = &mut self
{
for text in [content, reasoning].into_iter().flatten() {
strip(text);
}
for annotation in annotations {
strip(&mut annotation.url);
annotation.title.as_mut().into_iter().for_each(strip);
}
for tool_call in tool_calls {
strip(&mut tool_call.id);
strip(&mut tool_call.function.name);
// Arguments are serialized JSON, where a NUL is spelled as its escape.
strip(&mut tool_call.function.arguments);
if let Cow::Owned(stripped) = strip_json_nul(&tool_call.function.arguments) {
tool_call.function.arguments = stripped;
}
}
}
self
}
}
/// Trait for streaming AI events to a sink (e.g., database persistence).
/// Implemented by the worker's StreamEventProcessor.
#[async_trait]
+19 -1
View File
@@ -269,6 +269,12 @@ pub struct ProviderResource {
/// credentials exchange this crate does not perform.
#[serde(default, deserialize_with = "empty_string_as_none")]
pub token_url: Option<String>,
/// The API token of a `cloudflare` resource, which names it `token` rather than `api_key`.
#[serde(default, deserialize_with = "empty_string_as_none")]
pub token: Option<String>,
/// The account of a `cloudflare` resource, which its Workers AI URL is built from.
#[serde(default, deserialize_with = "empty_string_as_none")]
pub account_id: Option<String>,
}
#[derive(Deserialize, Debug)]
@@ -301,6 +307,11 @@ impl ProviderWithResource {
}
pub async fn get_base_url(&self, db: &DB) -> Result<String, Error> {
if self.kind == AIProvider::Cloudflare && self.resource.base_url.is_none() {
return crate::ai_providers::cloudflare_workers_ai_base_url(
self.resource.account_id.as_deref(),
);
}
self.kind
.get_base_url(self.resource.base_url.clone(), db)
.await
@@ -320,7 +331,14 @@ impl ProviderWithResource {
Ok(ProviderCredentials {
provider: self.kind.clone(),
base_url,
api_key: self.resource.api_key.clone(),
api_key: match self.kind {
AIProvider::Cloudflare => self
.resource
.api_key
.clone()
.or_else(|| self.resource.token.clone()),
_ => self.resource.api_key.clone(),
},
access_token: None,
organization_id: None,
user: None,
+1 -1
View File
@@ -107,7 +107,7 @@ pub struct ExpiringAuthCache {
/// Whether `target` belongs to the flow run of the job `job_id`: the job itself, one of its
/// ancestors, or a job whose parent or root is one of them (a sibling step, a loop or branch
/// iteration, any job pushed as a child of one of them). A lookup that fails reads as no.
async fn job_in_same_flow_run(db: &DB, job_id: uuid::Uuid, target: uuid::Uuid) -> bool {
pub async fn job_in_same_flow_run(db: &DB, job_id: uuid::Uuid, target: uuid::Uuid) -> bool {
if job_id == target {
return true;
}
+287 -53
View File
@@ -302,7 +302,7 @@ fn extract_domain_from_route(
/// Every entry is keyed by a job id and confines an authenticated caller to its own
/// runnable — through `require_job_read_access`, through its own `jobs:run:flows:<path>`
/// check, or, where an approval token or resume secret bypasses that gate, through a
/// direct `require_job_within_run_scope`. The one exception is
/// direct `require_job_within_read_scope`. The one exception is
/// `jobs_u/get_root_job_id/`, which has no check at all but discloses only flow lineage,
/// to anyone, authenticated or not. Workspace-wide enumeration (`jobs/list`, counts,
/// exports) and credential minting (`job_view_token`) are deliberately absent — those are
@@ -519,23 +519,163 @@ fn resource_metadata_route_allowed(suffix: &str) -> bool {
|| suffix.starts_with("resources/type/")
}
/// The `jobs:run` scopes a token's job reads are confined to, or `None` when they are
/// not confined to particular runnables.
/// The by-id and list routes a path-scoped `jobs:read:<paths>` reaches, as workspaced
/// route suffixes. Every other read in the domain is refused to it: counts, exports,
/// uuid listings, queue positions and dispatch edges aggregate over the workspace, and
/// the view-token and resume-url routes mint a credential (`approval_info` withholds
/// its view token from a confined caller for the same reason).
///
/// Each entry confines the caller in its handler, through `require_job_read_access` or
/// a direct `require_job_within_read_scope`. `jobs/result_by_id/` is absent because it
/// resolves a node up through the enclosing flows, past the job the check admitted.
/// A route added here without that check serves a path-scoped token every job.
const READ_PATH_SCOPED_BY_ID_PATHS: [&'static str; 25] = [
"jobs_u/get/",
"jobs_u/get_logs/",
"jobs_u/get_completed_logs_tail/",
"jobs_u/get_flow_all_logs/",
"jobs_u/get_flow_all_logs_structured/",
"jobs_u/get_flow_all_results/",
"jobs_u/get_args/",
"jobs_u/get_flow_debug_info/",
"jobs_u/completed/get/",
"jobs_u/completed/get_result/",
"jobs_u/completed/get_result_maybe/",
"jobs_u/completed/get_timing/",
"jobs_u/dispatch_events/",
"jobs_u/getupdate/",
"jobs_u/getupdate_sse/",
"jobs_u/get_log_file/",
"jobs_u/get_flow/",
"jobs_u/flow/approval_info/",
"jobs/run_progress/",
"jobs/flow/user_states/",
"jobs/completed/get/",
"jobs/completed/get_result/",
"jobs/completed/get_result_maybe/",
"jobs/completed/get_timing/",
"jobs/get_otel_traces/",
];
/// Matched whole: `jobs/list` is also a prefix of the uuid listings. Their handlers
/// filter in SQL to [`job_list_path_filter`].
const READ_PATH_SCOPED_LIST_PATHS: [&'static str; 3] =
["jobs/list", "jobs/completed/list", "jobs/queue/list"];
fn is_read_path_scoped_route(suffix: &str) -> bool {
READ_PATH_SCOPED_LIST_PATHS.contains(&suffix)
|| READ_PATH_SCOPED_BY_ID_PATHS
.iter()
.any(|p| suffix.starts_with(p))
}
/// The paths of a `jobs:read:<paths>` scope, or `None` when it spans every job: no
/// path part, or `*` among them.
fn job_read_scope_paths(scope: &ScopeDefinition) -> Option<&[String]> {
scope
.resource
.as_deref()
.filter(|paths| !paths.iter().any(|p| p == "*"))
}
/// The runnables a token's job reads are confined to: those its `jobs:run` scopes may
/// start, and those its `jobs:read:<paths>` scopes name.
pub struct JobReadConfinement {
run: Vec<ScopeDefinition>,
read_paths: crate::ScopePathFilter,
}
impl JobReadConfinement {
/// Whether a job that ran `runnable_path` as `kind` (`scripts`, `flows` or `agents`)
/// is inside the confinement. A read path admits every kind; a run scope only its own.
pub fn admits(&self, kind: &str, runnable_path: &str) -> bool {
if self.read_paths.allows(runnable_path) {
return true;
}
let required = ScopeDefinition::new(
ScopeDomain::Jobs.as_str(),
ScopeAction::Run.as_str(),
Some(kind),
Some(vec![runnable_path.to_string()]),
);
self.run.iter().any(|scope| scope.includes(&required))
}
/// Whether a `jobs:read:<paths>` scope is part of the confinement.
pub fn has_read_paths(&self) -> bool {
matches!(&self.read_paths, crate::ScopePathFilter::Restricted { exact, prefix }
if !exact.is_empty() || !prefix.is_empty())
}
}
fn push_read_paths(paths: &[String], exact: &mut Vec<String>, prefix: &mut Vec<String>) {
for path in paths {
match path.strip_suffix("/*") {
Some(p) => prefix.push(p.to_string()),
None => exact.push(path.clone()),
}
}
}
/// The paths a token's job lists are filtered to. Decided by its read grants alone,
/// unlike [`job_read_confinement`]: a `jobs:run` scope grants no enumeration, so even a
/// bare one beside a `jobs:read:<paths>` leaves the lists on those paths. Unrestricted
/// for an unscoped token, a `jobs:read` naming no path (or `*`), and `jobs:write`.
pub fn job_list_path_filter(scopes: Option<&[String]>) -> crate::ScopePathFilter {
let mut exact = Vec::new();
let mut prefix = Vec::new();
let mut scoped = false;
for scope in scopes
.unwrap_or_default()
.iter()
.filter(|s| !s.starts_with("if_jobs:filter_tags:"))
{
scoped = true;
let Ok(scope) = ScopeDefinition::from_scope_string(scope) else {
continue;
};
if ScopeDomain::from_str(&scope.domain) != Some(ScopeDomain::Jobs) {
continue;
}
match ScopeAction::from_str(&scope.action) {
Some(ScopeAction::Read) => match job_read_scope_paths(&scope) {
Some(paths) => push_read_paths(paths, &mut exact, &mut prefix),
None => return crate::ScopePathFilter::AllowAll,
},
Some(ScopeAction::Write) => return crate::ScopePathFilter::AllowAll,
_ => continue,
}
}
if scoped {
crate::ScopePathFilter::Restricted { exact, prefix }
} else {
crate::ScopePathFilter::AllowAll
}
}
/// What a token's job reads are confined to, or `None` when they are not confined to
/// particular runnables.
///
/// A run scope is what the trigger UI mints per script or flow and hands to a webhook
/// caller / CI job: it may start the runnables it names and follow those runs, so its
/// by-id job reads must stay within what it can start (enforced by
/// `require_job_read_access`). Both the path (`jobs:run:flows:f/team/etl`) and the
/// kind-only (`jobs:run:scripts`, which legacy `jobs:runscript` tokens carry) forms
/// confine, since `ScopeDefinition::includes` already matches a candidate
/// `jobs:run:<kind>:<path>` against either.
/// by-id job reads must stay within what it can start. Both the path
/// (`jobs:run:flows:f/team/etl`) and the kind-only (`jobs:run:scripts`, which legacy
/// `jobs:runscript` tokens carry) forms confine, since `ScopeDefinition::includes`
/// already matches a candidate `jobs:run:<kind>:<path>` against either.
///
/// A `jobs:read:<paths>` scope reads the runs of the runnables at those paths and
/// nothing else. The two kinds of grant add up: a job either admits is readable.
///
/// Returns `None` — unconfined — when the token is effectively unscoped, or carries a
/// jobs scope that grants job reads in its own right: `jobs:read`/`jobs:write`, or a
/// bare `jobs:run` (it can start anything, so confining its reads to "what it may run"
/// would restrict nothing).
pub fn job_read_run_confinement(scopes: Option<&[String]>) -> Option<Vec<ScopeDefinition>> {
let mut confinement = Vec::new();
/// jobs scope that grants every job read in its own right: a `jobs:read` naming no path
/// (or `*`), a bare `jobs:run` (it can start anything, so confining its reads to "what
/// it may run" would restrict nothing), or `jobs:write`, whose paths are ignored on
/// reads as they are on every job write.
pub fn job_read_confinement(scopes: Option<&[String]>) -> Option<JobReadConfinement> {
let mut run = Vec::new();
let mut exact = Vec::new();
let mut prefix = Vec::new();
let mut confined = false;
for scope in scopes?
.iter()
.filter(|s| !s.starts_with("if_jobs:filter_tags:"))
@@ -548,7 +688,15 @@ pub fn job_read_run_confinement(scopes: Option<&[String]>) -> Option<Vec<ScopeDe
}
match ScopeAction::from_str(&scope.action) {
Some(ScopeAction::Run) if scope.kind.is_some() || scope.resource.is_some() => {
confinement.push(scope)
confined = true;
run.push(scope)
}
Some(ScopeAction::Read) => {
let Some(paths) = job_read_scope_paths(&scope) else {
return None;
};
confined = true;
push_read_paths(paths, &mut exact, &mut prefix);
}
// Grants no reads (see `scope_grants_access`), so it neither confines nor
// frees them.
@@ -557,23 +705,10 @@ pub fn job_read_run_confinement(scopes: Option<&[String]>) -> Option<Vec<ScopeDe
None => continue,
}
}
(!confinement.is_empty()).then_some(confinement)
}
/// Whether a job that ran `runnable_path` as `kind` (`scripts` or `flows`) is inside a
/// [`job_read_run_confinement`] set.
pub fn run_confinement_admits(
confinement: &[ScopeDefinition],
kind: &str,
runnable_path: &str,
) -> bool {
let required = ScopeDefinition::new(
ScopeDomain::Jobs.as_str(),
ScopeAction::Run.as_str(),
Some(kind),
Some(vec![runnable_path.to_string()]),
);
confinement.iter().any(|scope| scope.includes(&required))
confined.then_some(JobReadConfinement {
run,
read_paths: crate::ScopePathFilter::Restricted { exact, prefix },
})
}
/// The `jobs:cancel:<paths>` scopes a token's cancels are confined to, or `None` when
@@ -683,6 +818,17 @@ fn scope_grants_access(
}));
}
// A path-scoped `jobs:read:<paths>` is the one job scope this layer does not treat
// as resource-blind: it reaches only the routes whose handlers confine it to those
// paths, so a job read route is refused to it until it is listed there.
if scope_domain == ScopeDomain::Jobs
&& scope_action == ScopeAction::Read
&& required_action == ScopeAction::Read
&& job_read_scope_paths(scope).is_some()
{
return Ok(route_path.is_some_and(is_read_path_scoped_route));
}
if !scope_action.includes(&required_action) {
return Ok(false);
}
@@ -1137,28 +1283,16 @@ mod tests {
#[test]
fn run_scopes_confine_job_reads_by_kind_and_path() {
let confinement =
job_read_run_confinement(Some(&["jobs:run:flows:f/team/*".to_string()])).unwrap();
assert!(run_confinement_admits(&confinement, "flows", "f/team/etl"));
job_read_confinement(Some(&["jobs:run:flows:f/team/*".to_string()])).unwrap();
assert!(confinement.admits("flows", "f/team/etl"));
// Right path, wrong kind — a script named like the flow is not the flow.
assert!(!run_confinement_admits(
&confinement,
"scripts",
"f/team/etl"
));
assert!(!run_confinement_admits(
&confinement,
"flows",
"f/other/etl"
));
assert!(!confinement.admits("scripts", "f/team/etl"));
assert!(!confinement.admits("flows", "f/other/etl"));
// A kind-only scope confines to that kind, at any path.
let kind_only = job_read_run_confinement(Some(&["jobs:run:scripts".to_string()])).unwrap();
assert!(run_confinement_admits(
&kind_only,
"scripts",
"u/admin/anything"
));
assert!(!run_confinement_admits(&kind_only, "flows", "f/team/etl"));
let kind_only = job_read_confinement(Some(&["jobs:run:scripts".to_string()])).unwrap();
assert!(kind_only.admits("scripts", "u/admin/anything"));
assert!(!kind_only.admits("flows", "f/team/etl"));
// Scopes that grant job reads in their own right leave reads unconfined.
for scopes in [
@@ -1171,11 +1305,111 @@ mod tests {
vec!["if_jobs:filter_tags:deno".to_string()],
] {
assert!(
job_read_run_confinement(Some(&scopes)).is_none(),
job_read_confinement(Some(&scopes)).is_none(),
"{scopes:?} must not confine job reads"
);
}
assert!(job_read_run_confinement(None).is_none());
assert!(job_read_confinement(None).is_none());
}
#[test]
fn jobs_read_paths_confine_reads_and_reach_only_confined_routes() {
let scopes = |s: &[&str]| s.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let scoped = scopes(&["jobs:read:f/served/*,u/svc/report"]);
let confinement = job_read_confinement(Some(&scoped)).unwrap();
// Any kind of runnable at a named path.
assert!(confinement.admits("flows", "f/served/etl"));
assert!(confinement.admits("scripts", "u/svc/report"));
assert!(!confinement.admits("scripts", "f/other/etl"));
assert!(!confinement.admits("scripts", "f/served_not/etl"));
let lists = |s: &[&str], path: &str| job_list_path_filter(Some(&scopes(s))).allows(path);
assert!(lists(
&["jobs:read:f/served/*,u/svc/report"],
"f/served/etl"
));
assert!(!lists(
&["jobs:read:f/served/*,u/svc/report"],
"f/other/etl"
));
// A run scope adds the runnables it may start to the by-id reads, and nothing to
// the lists, not even a bare one that leaves the by-id reads unconfined.
let both = scopes(&["jobs:read:f/served/*", "jobs:run:scripts:f/other/etl"]);
let confinement = job_read_confinement(Some(&both)).unwrap();
assert!(confinement.admits("scripts", "f/other/etl"));
assert!(!confinement.admits("flows", "f/other/etl"));
for run in ["jobs:run:scripts:f/other/etl", "jobs:run"] {
assert!(
!lists(&["jobs:read:f/served/*", run], "f/other/etl"),
"{run}"
);
}
for unfiltered in [
&["jobs:read"][..],
&["jobs:read:f/served/*", "jobs:write"],
&["if_jobs:filter_tags:deno"],
] {
assert!(lists(unfiltered, "f/other/etl"), "{unfiltered:?}");
}
for unconfined in [
&["jobs:read:*"][..],
&["jobs:read:f/served/*", "jobs:read"],
&["jobs:read:f/served/*", "jobs:write"],
&["jobs:write:f/served/*"],
] {
assert!(
job_read_confinement(Some(&scopes(unconfined))).is_none(),
"{unconfined:?}"
);
}
let id = "0190f4c2-0000-7000-8000-000000000000";
for route in [
format!("jobs_u/get/{id}"),
format!("jobs_u/completed/get_result/{id}"),
format!("jobs_u/getupdate_sse/{id}"),
format!("jobs_u/flow/approval_info/{id}"),
"jobs/list".to_string(),
"jobs/completed/list".to_string(),
"jobs/queue/list".to_string(),
] {
let route = format!("/api/w/ws/{route}");
assert!(
check_route_access(&scoped, &route, "GET").is_ok(),
"{route}"
);
}
// Reads that aggregate over the workspace, resolve past the job they name, or
// mint a credential stay with a `jobs:read` that names no path.
for route in [
"jobs/list_filtered_uuids".to_string(),
"jobs/queue/list_filtered_uuids".to_string(),
"jobs/queue/count".to_string(),
"jobs/completed/count".to_string(),
"jobs/completed/count_jobs".to_string(),
"jobs/completed/export".to_string(),
"jobs/queue/export".to_string(),
"jobs/asset_dispatch_edges".to_string(),
format!("jobs/queue/scheduled_for/{id}"),
format!("jobs/result_by_id/{id}/a"),
format!("jobs/job_view_token/{id}"),
format!("jobs_u/get_root_job_id/{id}"),
] {
let route = format!("/api/w/ws/{route}");
assert!(
check_route_access(&scoped, &route, "GET").is_err(),
"{route}"
);
assert!(
check_route_access(&scopes(&["jobs:read"]), &route, "GET").is_ok(),
"{route}"
);
assert!(
check_route_access(&scopes(&["jobs:read:*"]), &route, "GET").is_ok(),
"{route}"
);
}
}
#[test]
@@ -1585,6 +1819,6 @@ mod tests {
}
// A cancel scope neither confines nor frees a run token's reads.
let run = scopes(&["jobs:run:flows:f/a/b", "jobs:cancel"]);
assert!(job_read_run_confinement(Some(&run)).is_some());
assert!(job_read_confinement(Some(&run)).is_some());
}
}
@@ -756,6 +756,13 @@ async fn test_compare_workspaces_trigger_and_schedule(db: Pool<Postgres>) -> any
)
.execute(&db)
.await?;
// The scheduler's missed-occurrence counters only ever move on the enabled side.
sqlx::query(
"UPDATE schedule SET late_run_streak = 1, missed_occurrences = 2, last_missed_at = NOW()
WHERE workspace_id = 'test-workspace' AND path = 'f/sch/runtime_only'",
)
.execute(&db)
.await?;
// ------ Schedule: config change (script_path) in fork. Should diff.
sqlx::query!(
@@ -2537,7 +2544,10 @@ async fn test_rename_records_the_vacated_path(db: Pool<Postgres>) -> anyhow::Res
.await?;
let status = resp.status();
let hash = resp.text().await?;
assert!(status.is_success(), "script deploy failed: {status} — {hash}");
assert!(
status.is_success(),
"script deploy failed: {status} — {hash}"
);
parent_hash = Some(hash.trim().trim_matches('"').to_string());
}
let mut vacated = None;
+28
View File
@@ -26,6 +26,34 @@ fn not_in_nullable(col: &str, quoted: &[String]) -> String {
)
}
/// Narrows a job list to the top-level runs of deployed scripts and flows at an `exact`
/// path, or at or under a `prefix` on the `/` boundary (`ScopePathFilter::allows`).
///
/// Top-level only because every `runnable_path` index on `v2_job` is partial on
/// `parent_job IS NULL`. Deployed kinds only because a preview's `runnable_path` is
/// whatever its caller sent.
pub fn and_where_root_run_at_paths(sqlb: &mut SqlBuilder, exact: &[String], prefix: &[String]) {
let mut clauses = Vec::new();
let equal: Vec<_> = exact.iter().chain(prefix).map(|p| quote(p)).collect();
if !equal.is_empty() {
clauses.push(format!("v2_job.runnable_path IN ({})", equal.join(", ")));
}
for p in prefix {
let pattern = format!("{}/%", escape_ilike_pattern(p));
clauses.push(format!("v2_job.runnable_path LIKE {}", quote(&pattern)));
}
if clauses.is_empty() {
sqlb.and_where("false");
return;
}
sqlb.and_where_is_null("v2_job.parent_job")
.and_where(
"v2_job.kind IN ('script', 'script_hub', 'unassigned_script', 'flow', \
'unassigned_flow', 'singlestepflow', 'unassigned_singlestepflow')",
)
.and_where(format!("({})", clauses.join(" OR ")));
}
pub fn filter_list_queue_query(
mut sqlb: SqlBuilder,
lq: &ListQueueQuery,
@@ -744,13 +744,15 @@ async fn is_premium(
#[derive(Serialize)]
struct BillableSeatsResponse {
/// Both omitted when the seats counted are another workspace's: a fork member need not be a
/// All three omitted when the seats counted are another workspace's: a fork member need not be a
/// member of the billing root, so the root's headcount is not theirs to read. The total is,
/// since it is the divisor of the quota their own executions draw on.
#[serde(skip_serializing_if = "Option::is_none")]
developers: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
operators: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
service_accounts: Option<i64>,
seats: i64,
}
@@ -780,6 +782,7 @@ async fn get_billable_seats(
Ok(Json(BillableSeatsResponse {
developers: own.then_some(counted.developers),
operators: own.then_some(counted.operators),
service_accounts: own.then_some(counted.service_accounts),
seats: counted.seats,
}))
}
@@ -14047,6 +14050,12 @@ const TRIGGER_COMPARE_IGNORE: &[&str] = &[
"last_server_ping",
"extra_perms",
"permissioned_as",
// Schedule counters the scheduler bumps as occurrences are missed. A fork's
// schedules are disabled so theirs stay at the defaults, and no deploy carries
// them across: compared, a parent schedule that ever ran late differs forever.
"late_run_streak",
"missed_occurrences",
"last_missed_at",
// Server-managed fields that the merge feature treats as workspace-local
// (regenerated by the deploy handler): GCP `subscription_id` is rewritten
// to `windmill_<workspace_id>_<path>` in `CreateUpdate` mode, and Azure's
+4
View File
@@ -5424,6 +5424,10 @@
"is_past_due": {
"type": "boolean"
},
"is_canceled": {
"type": "boolean",
"description": "the team plan was canceled this month; it stays premium until the month ends, capped at max_tolerated_executions"
},
"max_tolerated_executions": {
"type": "number"
}
+3
View File
@@ -5286,6 +5286,9 @@ paths:
type: string
is_past_due:
type: boolean
is_canceled:
type: boolean
description: the team plan was canceled this month; it stays premium until the month ends, capped at max_tolerated_executions
max_tolerated_executions:
type: number
required:
+41 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.823.1
version: 1.825.0
title: Windmill API
contact:
@@ -4492,6 +4492,12 @@ paths:
description: >-
Omitted when the seats counted are another workspace's, as they are for a
fork resolving to its billing root.
service_accounts:
type: integer
description: >-
Enabled service accounts, half a seat each like operators. Omitted when the
seats counted are another workspace's, as they are for a fork resolving to
its billing root.
seats:
type: integer
required:
@@ -4529,6 +4535,9 @@ paths:
type: string
is_past_due:
type: boolean
is_canceled:
type: boolean
description: the team plan was canceled this month; it stays premium until the month ends, capped at max_tolerated_executions
max_tolerated_executions:
type: number
required:
@@ -16527,6 +16536,31 @@ paths:
required:
- database_length
/w/{workspace}/jobs/oldest:
get:
summary: get the creation date of the oldest job
operationId: getOldestJob
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: all_workspaces
description: consider jobs from all workspaces (only valid if request come from the `admins` workspace)
in: query
schema:
type: boolean
responses:
"200":
description: creation date of the oldest job, absent when there is none
content:
application/json:
schema:
type: object
properties:
created_at:
type: string
format: date-time
/w/{workspace}/jobs/completed/count:
get:
summary: get completed count
@@ -28478,6 +28512,8 @@ components:
$ref: "../../openflow.openapi.yaml#/components/schemas/AiTransform"
ProviderConfig:
$ref: "../../openflow.openapi.yaml#/components/schemas/ProviderConfig"
ProviderTransform:
$ref: "../../openflow.openapi.yaml#/components/schemas/ProviderTransform"
MemoryConfig:
$ref: "../../openflow.openapi.yaml#/components/schemas/MemoryConfig"
FlowModuleValue:
@@ -28498,6 +28534,8 @@ components:
$ref: "../../openflow.openapi.yaml#/components/schemas/BranchAll"
AiAgent:
$ref: "../../openflow.openapi.yaml#/components/schemas/AiAgent"
AiDecision:
$ref: "../../openflow.openapi.yaml#/components/schemas/AiDecision"
Identity:
$ref: "../../openflow.openapi.yaml#/components/schemas/Identity"
FlowStatus:
@@ -29462,6 +29500,8 @@ components:
togetherai,
aws_bedrock,
customai,
typesafe,
cloudflare,
]
GitSyncObjectType:
+1 -1
View File
@@ -29,7 +29,7 @@ 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:<path>` reads its runs back by it (`require_job_within_run_scope`).
/// `jobs:run:agents:<path>` reads its runs back by it (`require_job_within_read_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.
+22 -4
View File
@@ -246,6 +246,12 @@ struct AIStandardResource {
/// Custom HTTP headers to include in AI requests
#[serde(default)]
headers: HashMap<String, String>,
/// The API token of a `cloudflare` resource, which names it `token` rather than `api_key`.
#[serde(default, deserialize_with = "empty_string_as_none")]
token: Option<String>,
/// The account of a `cloudflare` resource, which its Workers AI URL is built from.
#[serde(default, deserialize_with = "empty_string_as_none")]
account_id: Option<String>,
}
#[derive(Deserialize, Debug)]
@@ -293,10 +299,21 @@ async fn resolve_provider_credentials(
// Skip get_base_url for Bedrock - it uses SDK directly, not HTTP
let base_url = if matches!(provider, AIProvider::AWSBedrock) {
String::new()
} else if *provider == AIProvider::Cloudflare && resource.base_url.is_none() {
let account_id = if let Some(account_id) = resource.account_id {
Some(resolve_var(account_id, db, w_id, user_db.as_ref(), authed).await?)
} else {
None
};
windmill_ai::ai_providers::cloudflare_workers_ai_base_url(account_id.as_deref())?
} else {
provider.get_base_url(resource.base_url, db).await?
};
let api_key = if let Some(api_key) = resource.api_key {
let api_key = match provider {
AIProvider::Cloudflare => resource.api_key.or(resource.token),
_ => resource.api_key,
};
let api_key = if let Some(api_key) = api_key {
Some(resolve_var(api_key, db, w_id, user_db.as_ref(), authed).await?)
} else {
None
@@ -486,9 +503,10 @@ async fn assume_bedrock_role(
) -> Result<windmill_ai::ai_bedrock::AssumedRoleCredentials> {
#[cfg(all(feature = "enterprise", feature = "openidconnect", feature = "private"))]
{
use windmill_common::oidc_oss::{generate_id_token, WorkspaceClaim};
use windmill_common::oidc_oss::{generate_id_token, OidcCaller, WorkspaceClaim};
let id_token = generate_id_token(
OidcCaller::Bedrock,
Some(db),
WorkspaceClaim { workspace: w_id.to_string() },
windmill_ai::ai_bedrock::AWS_OIDC_AUDIENCE,
@@ -1238,8 +1256,8 @@ async fn proxy(
.get("X-Resource-Value")
.map(|v| {
let invalid = |e: String| Error::BadRequest(format!("Invalid X-Resource-Value: {e}"));
let decoded =
urlencoding::decode(v.to_str().unwrap_or("")).map_err(|e| invalid(e.to_string()))?;
let decoded = urlencoding::decode(v.to_str().unwrap_or(""))
.map_err(|e| invalid(e.to_string()))?;
let value = serde_json::from_str::<serde_json::Value>(&decoded)
.map_err(|e| invalid(e.to_string()))?;
check_scopes(&authed, || "resources:write".to_string())?;
+133 -35
View File
@@ -292,6 +292,7 @@ pub fn workspaced_service() -> Router {
)
.route("/run/dynamic_select", post(run_dynamic_select))
.route("/list", get(list_jobs))
.route("/oldest", get(get_oldest_job))
.route("/asset_dispatch_edges", get(list_asset_dispatch_edges))
.route(
"/list_selected_job_groups",
@@ -497,6 +498,28 @@ async fn get_result_by_id(
view_token.as_deref(),
)
.await?;
// The node is resolved up through the enclosing flows, so a read path that admitted
// `flow_id` as a step must also admit the run it is a step of. A path-scoped
// `jobs:read` does not reach this route; a `jobs:run` scope beside it does.
if windmill_api_auth::scopes::job_read_confinement(authed.scopes.as_deref())
.is_some_and(|c| c.has_read_paths())
{
let top = sqlx::query_scalar::<_, Uuid>(
"WITH RECURSIVE chain(id, parent_job) AS (
SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2
UNION ALL
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 id FROM chain WHERE parent_job IS NULL",
)
.bind(flow_id)
.bind(&w_id)
.fetch_optional(&db)
.await?
.ok_or_else(|| Error::NotFound(format!("Job {flow_id} not found")))?;
require_job_within_read_scope(&db, &authed, &w_id, &top).await?;
}
let res =
windmill_queue::get_result_by_id(db.clone(), w_id.clone(), flow_id, node_id, json_path)
@@ -1728,7 +1751,7 @@ async fn require_job_access(
job_id: &Uuid,
created_by: &str,
view_token: Option<&str>,
run_confined: bool,
read_confined: bool,
) -> error::Result<()> {
// Tag scope (`if_jobs:filter_tags:`) is an orthogonal hard restriction on a
// scoped token: it must never read a job outside its allowed tags, regardless of
@@ -1753,11 +1776,11 @@ async fn require_job_access(
}
}
// A path-scoped `jobs:run` token is likewise hard-restricted to the runnables it
// may start, ahead of every grant below — the token is handed out to run one thing,
// so it must not read jobs of anything else merely because its owner could.
if run_confined {
require_job_within_run_scope(db, authed, w_id, job_id).await?;
// A path-scoped `jobs:run` or `jobs:read` token is likewise hard-restricted to the
// runnables it names, ahead of every grant below — the token is handed out for one
// thing, so it must not read jobs of anything else merely because its owner could.
if read_confined {
require_job_within_read_scope(db, authed, w_id, job_id).await?;
}
// Fast path: you can always read a job you launched. This is also load-bearing
@@ -1906,20 +1929,35 @@ async fn require_job_access(
/// could start an app's inline-script component but not read the run back — those jobs
/// are `AppScript`/`Preview` kinds that no `jobs:run` scope can name.
///
/// No-op — and no query — for every caller whose job reads are not run-confined (see
/// `job_read_run_confinement`), which is all sessions, unscoped tokens and `jobs:read`
/// tokens.
async fn require_job_within_run_scope(
/// A `jobs:read:<paths>` scope confines the same way, by path alone: the job, or one of
/// its ancestors, must be a run of a deployed script, flow or agent at a named path.
/// Only those kinds count, because a preview's `runnable_path` is whatever its caller
/// sent and would otherwise let any preview pass for a named runnable.
///
/// No-op — and no query — for every caller whose job reads are not confined (see
/// `job_read_confinement`), which is all sessions, unscoped tokens and tokens whose
/// `jobs:read` names no path.
async fn require_job_within_read_scope(
db: &DB,
authed: &ApiAuthed,
w_id: &str,
job_id: &Uuid,
) -> error::Result<()> {
let Some(confinement) =
windmill_api_auth::scopes::job_read_run_confinement(authed.scopes.as_deref())
windmill_api_auth::scopes::job_read_confinement(authed.scopes.as_deref())
else {
return Ok(());
};
// A job whose token is restricted by `job_token_scopes` still reads its own flow run
// (step results, user state), which the route check lets through whatever the
// scopes. A read path names what else it may read, not a fence around its own run.
if let Some(own_job) = authed.job_id {
if confinement.has_read_paths()
&& windmill_api_auth::auth::job_in_same_flow_run(db, own_job, *job_id).await
{
return Ok(());
}
}
// `scope_kind` is the runnable kind a `jobs:run:<kind>:<path>` scope can name, or
// 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,
@@ -1967,13 +2005,7 @@ async fn require_job_within_run_scope(
let in_scope =
chain.iter().any(
|job| match (job.runnable_path.as_deref(), job.scope_kind.as_deref()) {
(Some(runnable_path), Some(kind))
if windmill_api_auth::scopes::run_confinement_admits(
&confinement,
kind,
runnable_path,
) =>
{
(Some(runnable_path), Some(kind)) if confinement.admits(kind, runnable_path) => {
true
}
_ => job.launched_by_app.as_deref().is_some_and(&runs_app),
@@ -1995,7 +2027,7 @@ async fn require_job_within_run_scope(
/// is within a scope on the flow. Only those kinds count, because a preview's
/// `runnable_path` is whatever its caller sent and would otherwise let any preview
/// impersonate an in-scope runnable. Agent runs are previews filed under the agent's
/// path, recognized the same way `require_job_within_run_scope` does.
/// path, recognized the same way `require_job_within_read_scope` does.
async fn filter_jobs_within_cancel_scope(
db: &DB,
authed: &ApiAuthed,
@@ -2269,7 +2301,7 @@ async fn require_job_cancel_access(
.fetch_optional(db)
.await?
.ok_or_else(|| Error::NotFound(format!("Job {job_id} not found")))?;
let run_confined = !windmill_api_auth::scopes::has_job_cancel_grant(authed.scopes.as_deref());
let read_confined = !windmill_api_auth::scopes::has_job_cancel_grant(authed.scopes.as_deref());
require_job_access(
db,
user_db,
@@ -2278,7 +2310,7 @@ async fn require_job_cancel_access(
job_id,
&created_by,
None,
run_confined,
read_confined,
)
.await?;
require_job_within_cancel_scope(db, authed, w_id, *job_id).await
@@ -2414,7 +2446,7 @@ async fn get_job(
// an approval link for a job must not let a scoped token read one outside the
// runnables it may start.
if let Some(authed) = opt_authed.as_ref() {
require_job_within_run_scope(&db, authed, &w_id, &id).await?;
require_job_within_read_scope(&db, authed, &w_id, &id).await?;
}
} else {
require_opt_authed_job_read_access(
@@ -3332,6 +3364,7 @@ fn flow_tree_entry_label(
"forloopflow" => " forloop",
"whileloopflow" => " whileloop",
"aiagent" => " ai-agent",
"aidecision" => " ai-decision",
_ => "",
};
@@ -4388,6 +4421,17 @@ struct ListableQueuedJob {
pub workspace_id: String,
}
/// Narrows a job list to what a path-scoped `jobs:read:<paths>` token may read: the
/// top-level runs of the runnables it names. A step is read by id, under its flow. No-op
/// for every caller whose lists are not path-filtered (see `job_list_path_filter`).
fn confine_list_to_read_scope(sqlb: &mut SqlBuilder, authed: &ApiAuthed) {
if let windmill_api_auth::ScopePathFilter::Restricted { exact, prefix } =
windmill_api_auth::scopes::job_list_path_filter(authed.scopes.as_deref())
{
and_where_root_run_at_paths(sqlb, &exact, &prefix);
}
}
async fn list_queue_jobs(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
@@ -4409,7 +4453,7 @@ async fn list_queue_jobs(
"null as args"
};
let sql = list_queue_jobs_query(
let mut sqlb = list_queue_jobs_query(
&w_id,
&lq,
&[
@@ -4439,8 +4483,9 @@ async fn list_queue_jobs(
pagination,
false,
get_scope_tags(&authed),
)
.sql()?;
);
confine_list_to_read_scope(&mut sqlb, &authed);
let sql = sqlb.sql()?;
let mut tx = user_db.begin(&authed).await?;
let jobs = sqlx::query_as::<_, ListableQueuedJob>(&sql)
.fetch_all(&mut *tx)
@@ -4727,6 +4772,41 @@ async fn count_completed_jobs(
))
}
#[derive(Deserialize)]
struct OldestJobQuery {
all_workspaces: Option<bool>,
}
#[derive(Serialize)]
struct OldestJob {
#[serde(skip_serializing_if = "Option::is_none")]
created_at: Option<chrono::DateTime<chrono::Utc>>,
}
/// Where the job history starts, whatever the caller may see of it: a client scanning the
/// runs backwards in time windows needs a bound that row-level visibility cannot make slow.
async fn get_oldest_job(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Query(q): Query<OldestJobQuery>,
) -> error::JsonResult<OldestJob> {
let created_at = if w_id == "admins" && q.all_workspaces.unwrap_or(false) {
// One index probe per workspace: a plain min() over the table has no index to walk.
sqlx::query_scalar(
"SELECT min(o.created_at) FROM workspace w CROSS JOIN LATERAL \
(SELECT min(created_at) AS created_at FROM v2_job WHERE workspace_id = w.id) o",
)
.fetch_one(&db)
.await?
} else {
sqlx::query_scalar("SELECT min(created_at) FROM v2_job WHERE workspace_id = $1")
.bind(&w_id)
.fetch_one(&db)
.await?
};
Ok(Json(OldestJob { created_at }))
}
lazy_static::lazy_static! {
/// 0 keeps the connection-wide statement_timeout.
static ref LIST_JOBS_STATEMENT_TIMEOUT_SECS: u64 =
@@ -4826,7 +4906,7 @@ async fn list_jobs(
}
let sqlc = if lq.running.is_none() {
Some(list_completed_jobs_query(
let mut sqlc = list_completed_jobs_query(
&w_id,
Some(per_page),
0,
@@ -4834,7 +4914,9 @@ async fn list_jobs(
cj_fields_ref,
true,
get_scope_tags(&authed),
))
);
confine_list_to_read_scope(&mut sqlc, &authed);
Some(sqlc)
} else {
None
};
@@ -4861,6 +4943,7 @@ async fn list_jobs(
true,
get_scope_tags(&authed),
);
confine_list_to_read_scope(&mut sqlq, &authed);
if let Some(sqlc) = sqlc {
format!("{} UNION ALL {}", &sqlq.subquery()?, &sqlc.subquery()?,)
@@ -5284,6 +5367,11 @@ async fn get_approval_info(
"Must be logged in or provide a valid approval token".to_string(),
));
}
// Nothing below goes through `require_job_read_access`, and an approval token is no
// way around a scoped token's confinement.
if let Some(authed) = opt_authed.as_ref() {
require_job_within_read_scope(&db, authed, &w_id, &job_id).await?;
}
// Fetch job info
#[derive(sqlx::FromRow)]
@@ -5516,8 +5604,17 @@ async fn get_approval_info(
// Possession of view rights over this approval is sufficient to mint a
// share-read-link token for the flow: it only grants read (no resume), and only to
// an authenticated workspace member, so it never widens what the approver can do.
let hmac = generate_view_token(&w_id, row.id, VIEW_TOKEN_DOMAIN, &db).await?;
let view_token = Some(format!("{}.{hmac}", row.id));
// Not for a token whose reads are confined: the link would outlive it and serve any
// member, which is why `job_view_token` is refused to it too.
let confined = opt_authed.as_ref().is_some_and(|authed| {
windmill_api_auth::scopes::job_read_confinement(authed.scopes.as_deref()).is_some()
});
let view_token = if confined {
None
} else {
let hmac = generate_view_token(&w_id, row.id, VIEW_TOKEN_DOMAIN, &db).await?;
Some(format!("{}.{hmac}", row.id))
};
Ok(Json(ApprovalInfo {
flow_id: row.id,
@@ -5979,7 +6076,7 @@ pub async fn get_suspended_job_flow(
// resume secret must not let a scoped token read a flow it may not run. Anonymous
// approvers are unaffected.
if let Some(authed) = authed.as_ref() {
require_job_within_run_scope(&db, authed, &w_id, &flow_id).await?;
require_job_within_read_scope(&db, authed, &w_id, &flow_id).await?;
}
let flow = GetQuery::new()
@@ -6163,7 +6260,7 @@ pub async fn get_flow_user_state(
// Reachable by a `jobs:run` token (it is one of the by-id routes a run needs), so
// apply the same run-scope confinement as the other single-job reads. RLS below
// still governs which jobs the owner's identity can see at all.
require_job_within_run_scope(&db, &authed, &w_id, &job_id).await?;
require_job_within_read_scope(&db, &authed, &w_id, &job_id).await?;
let mut tx = user_db.begin(&authed).await?;
let r = sqlx::query_scalar!(
r#"
@@ -11392,7 +11489,7 @@ async fn list_completed_jobs(
"null as args"
};
let sql = list_completed_jobs_query(
let mut sqlb = list_completed_jobs_query(
&w_id,
Some(per_page),
offset,
@@ -11432,8 +11529,9 @@ async fn list_completed_jobs(
],
false,
get_scope_tags(&authed),
)
.sql()?;
);
confine_list_to_read_scope(&mut sqlb, &authed);
let sql = sqlb.sql()?;
let mut tx = user_db.begin(&authed).await?;
let jobs = sqlx::query_as::<_, ListableCompletedJob>(&sql)
.fetch_all(&mut *tx)
@@ -11612,7 +11710,7 @@ async fn get_completed_job_result(
// confinement that gate carries — re-apply it, as `get_job` does for the
// approval token. Anonymous approval access is untouched.
if let Some(authed) = opt_authed.as_ref() {
require_job_within_run_scope(&db, authed, &w_id, &id).await?;
require_job_within_read_scope(&db, authed, &w_id, &id).await?;
}
} else {
require_opt_authed_job_read_access(
+13
View File
@@ -100,6 +100,19 @@ fn serve_path(path: &str, original_path: &str, query: Option<&str>) -> Response<
res = res.header("X-Robots-Tag", "noindex, nofollow");
}
// The raw-app preview shell evaluates whatever js is posted to it, so it is
// only ever served opaque-origin, with the deployed app wrapper's exact flags
// (`get_raw_app_data`). Matched on the embedded file, not the request path,
// so `//ui_builder/...` is covered. The editor loads a same-origin blob: copy.
if path == "ui_builder/app-preview.html" {
res = res.header(
header::CONTENT_SECURITY_POLICY,
"sandbox allow-scripts allow-forms allow-popups \
allow-popups-to-escape-sandbox allow-downloads allow-modals \
allow-top-navigation",
);
}
// Add Content-Security-Policy header for static assets when policy is set
if !CSP_POLICY.is_empty() {
if let Ok(header_value) = HeaderValue::try_from(CSP_POLICY.as_str()) {
+1 -1
View File
@@ -193,7 +193,7 @@ lazy_static! {
ScopeOption {
value: "jobs:read".to_string(),
label: "Read".to_string(),
requires_resource_path: false,
requires_resource_path: true,
},
ScopeOption {
value: "jobs:write".to_string(),
+107 -1
View File
@@ -307,12 +307,13 @@ impl AuthedClient {
workspace_id: &str,
file_key: &str,
storage: Option<String>,
max_bytes: Option<usize>,
) -> anyhow::Result<bytes::Bytes> {
let mut query = vec![("file_key", file_key.to_string())];
if let Some(storage) = storage {
query.push(("storage", storage));
}
let response = self
let mut response = self
.force_client
.as_ref()
.unwrap_or(&HTTP_CLIENT)
@@ -331,6 +332,39 @@ impl AuthedClient {
.context("Failed to send download_s3_file request")
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
if let Some(max_bytes) = max_bytes {
let size_error = || {
anyhow::anyhow!(
"S3 file '{file_key}' exceeds maximum supported download size of {max_bytes} bytes"
)
};
if response
.content_length()
.is_some_and(|len| len > max_bytes as u64)
{
return Err(size_error());
}
let status = response.status();
let mut bytes = Vec::new();
// Content-Length may be absent (including for decoded compressed responses).
while let Some(chunk) = response
.chunk()
.await
.context("Failed to read response bytes")?
{
if chunk.len() > max_bytes - bytes.len() {
return Err(size_error());
}
bytes.extend_from_slice(&chunk);
}
return if status == reqwest::StatusCode::OK {
Ok(bytes.into())
} else {
Err(anyhow::anyhow!(String::from_utf8_lossy(&bytes).into_owned()))
};
}
match response.status().as_u16() {
200u16 => Ok(response
.bytes()
@@ -341,6 +375,78 @@ impl AuthedClient {
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
async fn download_response(
response: &'static str,
max_bytes: Option<usize>,
) -> anyhow::Result<bytes::Bytes> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let client = AuthedClient::new(
format!("http://{}", listener.local_addr().unwrap()),
"test_ws".to_string(),
"test_token".to_string(),
Some(reqwest::Client::builder().no_proxy().build().unwrap()),
);
let server = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
let mut socket = BufReader::new(socket);
loop {
let mut line = String::new();
assert!(socket.read_line(&mut line).await.unwrap() > 0);
if line == "\r\n" {
break;
}
}
socket
.get_mut()
.write_all(response.as_bytes())
.await
.unwrap();
// Keep incomplete bodies open so rejection must happen before EOF.
std::future::pending::<()>().await;
});
let result = tokio::time::timeout(
Duration::from_secs(5),
client.download_s3_file("test_ws", "image.png", None, max_bytes),
)
.await;
server.abort();
result.expect("download waited for the oversized body to finish")
}
#[tokio::test]
async fn download_s3_file_rejects_oversized_body_before_eof() {
for response in [
"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n",
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nabcd\r\n1\r\ne\r\n",
"HTTP/1.1 500 Internal Server Error\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nerror\r\n",
] {
let error = download_response(response, Some(4)).await.unwrap_err();
assert!(error.to_string().contains(
"S3 file 'image.png' exceeds maximum supported download size of 4 bytes"
));
}
}
#[tokio::test]
async fn download_s3_file_accepts_body_at_limit() {
for response in [
"HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\nabcd",
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nabcd\r\n0\r\n\r\n",
] {
assert_eq!(
download_response(response, Some(4)).await.unwrap(),
&b"abcd"[..]
);
}
}
}
#[inline]
fn query_from_json_path(json_path: Option<String>) -> Vec<(&'static str, String)> {
json_path
+2
View File
@@ -357,6 +357,8 @@ fn check_module_value_is_composition_only(
}
}
}
// Runs no code: it sends its inputs to the decision model.
FlowModuleValue::AIDecision { tag, .. } => push_tag(tag),
}
Ok(())
}
+15
View File
@@ -79,6 +79,8 @@ pub struct JobClaim {
pub tag: String,
pub worker_group: Option<String>,
pub run_as_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub job_token_scopes: Option<Vec<String>>,
pub jti: String,
}
@@ -87,13 +89,26 @@ lazy_static::lazy_static! {
static ref PRIVATE_KEY: RwLock<Option<String>> = RwLock::new(None);
}
#[cfg(not(feature = "private"))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum OidcCaller {
JobMint,
S3Instance,
S3Workspace,
Vault,
Sqs,
Bedrock,
}
#[cfg(not(feature = "private"))]
pub async fn generate_id_token<T: AdditionalClaims>(
_caller: OidcCaller,
_db: Option<&DB>,
_claim: T,
_audience: &str,
_identifier: String,
_email: Option<String>,
_expires_in: Option<i64>,
) -> Result<WindmillIdToken> {
Err(Error::internal_err(
"Not implemented in Windmill's Open Source repository".to_string(),
+3
View File
@@ -76,6 +76,9 @@ pub fn add_root_flow_job_to_otlp(_queued_job: &QueuedJob, _success: bool) {}
#[cfg(not(feature = "private"))]
pub fn otel_incr_queue_push_count() {}
#[cfg(not(feature = "private"))]
pub fn otel_incr_oidc_signature_count(_caller: &'static str) {}
#[cfg(not(feature = "private"))]
pub fn otel_incr_queue_delete_count() {}
@@ -510,7 +510,7 @@ impl WorkspaceDependenciesPrefetched {
// external.get(0).map(|wd| dbg!(wd.content.clone())).or(Some(
// "
// module mymod
// go 1.26
// go 1.27
// require ()
// "
// .to_owned(),
+10 -5
View File
@@ -823,6 +823,7 @@ pub async fn count_workspace_forks(db: &crate::DB, root: &str) -> Result<i64> {
pub struct BillableSeats {
pub developers: i64,
pub operators: i64,
pub service_accounts: i64,
pub seats: i64,
}
@@ -1043,9 +1044,10 @@ pub async fn guest_app_admits<'c, E: sqlx::Executor<'c, Database = sqlx::Postgre
Ok(admits.unwrap_or(false))
}
/// Billable members of `w_id` and the seats they cost, as `ceil(developers + operators/2)`. Service
/// accounts cannot log in and do not take a seat; a disabled member is not billed either. In a
/// workspace that granted operators builder rights, every operator counts as a developer.
/// Billable members of `w_id` and the seats they cost, as
/// `ceil(developers + (operators + service_accounts)/2)`. A service account is half a seat whatever
/// its role; a disabled member is not billed. In a workspace that granted operators builder rights,
/// every operator counts as a developer.
///
/// The workspace is invoiced by a job outside this codebase that counts the same rows with its own
/// SQL. The two must be changed together: this rule disagreeing with that one is what bills a
@@ -1057,7 +1059,8 @@ pub async fn billable_seats(db: &crate::DB, w_id: &str) -> Result<BillableSeats>
let row = sqlx::query!(
r#"SELECT
COUNT(*) FILTER (WHERE NOT operator AND NOT disabled AND NOT is_service_account) AS "developers!",
COUNT(*) FILTER (WHERE operator AND NOT disabled AND NOT is_service_account) AS "operators!"
COUNT(*) FILTER (WHERE operator AND NOT disabled AND NOT is_service_account) AS "operators!",
COUNT(*) FILTER (WHERE is_service_account AND NOT disabled) AS "service_accounts!"
FROM usr WHERE workspace_id = $1"#,
w_id
)
@@ -1072,7 +1075,9 @@ pub async fn billable_seats(db: &crate::DB, w_id: &str) -> Result<BillableSeats>
Ok(BillableSeats {
developers,
operators,
seats: ((developers as f64) + 0.5 * (operators as f64)).ceil() as i64,
service_accounts: row.service_accounts,
seats: ((developers as f64) + 0.5 * ((operators + row.service_accounts) as f64)).ceil()
as i64,
})
}
@@ -101,21 +101,27 @@ async fn billing_workspace_survives_cycles(db: Pool<Postgres>) {
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn paid_seats_and_fork_count(db: Pool<Postgres>) {
insert_ws(&db, "seat-root", None, false).await;
// 2 developers + 2 operators counted -> ceil(2 + 0.5*2) = 3.
insert_member(&db, "seat-root", "dev1@w.dev", false, false, false).await;
insert_member(&db, "seat-root", "dev2@w.dev", false, false, false).await;
insert_member(&db, "seat-root", "op1@w.dev", true, false, false).await;
insert_member(&db, "seat-root", "op2@w.dev", true, false, false).await;
// These must NOT count towards seats. The service account is a non-operator, so counting it
// would inflate the developer tally the invoice line is written from, not the operator one.
insert_member(&db, "seat-root", "disabled@w.dev", false, true, false).await;
// A service account is half a seat even with a developer role, counted apart from operators.
insert_member(&db, "seat-root", "svc@w.dev", false, false, true).await;
// Not billed.
insert_member(&db, "seat-root", "disabled@w.dev", false, true, false).await;
insert_member(&db, "seat-root", "svc-disabled@w.dev", false, true, true).await;
assert_eq!(count_paid_seats(&db, "seat-root").await.unwrap(), 3);
// 2 developers + (2 operators + 1 service account)/2 -> ceil(3.5) = 4.
assert_eq!(count_paid_seats(&db, "seat-root").await.unwrap(), 4);
let breakdown = billable_seats(&db, "seat-root").await.unwrap();
assert_eq!(
(breakdown.developers, breakdown.operators, breakdown.seats),
(2, 2, 3)
(
breakdown.developers,
breakdown.operators,
breakdown.service_accounts,
breakdown.seats
),
(2, 2, 1, 4)
);
sqlx::query(
@@ -128,9 +134,14 @@ async fn paid_seats_and_fork_count(db: Pool<Postgres>) {
windmill_common::workspaces::invalidate_operator_rights_cache("seat-root");
let breakdown = billable_seats(&db, "seat-root").await.unwrap();
assert_eq!(
(breakdown.developers, breakdown.operators, breakdown.seats),
(4, 0, 4),
"builder rights bill every operator as a developer"
(
breakdown.developers,
breakdown.operators,
breakdown.service_accounts,
breakdown.seats
),
(4, 0, 1, 5),
"builder rights bill every operator as a developer, service accounts stay half a seat"
);
insert_ws(&db, "seat-fork1", Some("seat-root"), false).await;
+27 -4
View File
@@ -6261,9 +6261,20 @@ async fn push_inner<'c, 'd>(
if team_plan_status.max_tolerated_executions.is_none()
|| workspace_usage > team_plan_status.max_tolerated_executions.unwrap()
{
return Err(error::Error::QuotaExceeded(format!(
"Workspace {workspace_id} team plan is past due and isn't allowed to run any more jobs. Please fix your payment method in the workspace settings."
)));
// A canceled plan reuses the past-due cap for its last month. Only read
// here, on the refusal path, to word it.
let plan_canceled = sqlx::query_scalar::<_, bool>(
"SELECT COALESCE(plan = 'team_canceled', false) FROM workspace_settings WHERE workspace_id = $1",
)
.bind(&billing_w_id)
.fetch_optional(db)
.await?
.unwrap_or(false);
return Err(error::Error::QuotaExceeded(if plan_canceled {
format!("Workspace {workspace_id} team plan was canceled and has used the executions paid for this month. Jobs can run again on the free plan from the 1st, or right away by subscribing again in the workspace settings.")
} else {
format!("Workspace {workspace_id} team plan is past due and isn't allowed to run any more jobs. Please fix your payment method in the workspace settings.")
}));
}
} else {
if workspace_usage > MAX_FREE_EXECS
@@ -7512,6 +7523,18 @@ async fn push_inner<'c, 'd>(
concurrency_settings.concurrency_time_window_s,
)
};
// jsonb rejects the `\u0000` escape (22P05), which would fail the whole push, and args are
// not always what a caller typed: a flow step's are evaluated from the previous step's
// in-memory result, an agent tool's are written by the model.
let serialized_args = serde_json::value::to_raw_value(&args).map_err(|e| {
Error::internal_err(format!("Could not serialize args of job {job_id}: {e:#}"))
})?;
let sanitized_args = match strip_json_nul(serialized_args.get()) {
Cow::Owned(stripped) => RawValue::from_string(stripped).map_err(|e| {
Error::internal_err(format!("Could not sanitize args of job {job_id}: {e:#}"))
})?,
Cow::Borrowed(_) => serialized_args,
};
sqlx::query!(
"WITH inserted_job AS (
INSERT INTO v2_job (
@@ -7578,7 +7601,7 @@ async fn push_inner<'c, 'd>(
permissioned_as,
runnable_id,
runnable_path.clone(),
Json(args) as Json<PushArgs>,
Json(sanitized_args) as Json<Box<RawValue>>,
job_kind.clone() as JobKind,
trigger_path.flatten(),
language as Option<ScriptLang>,
@@ -136,7 +136,7 @@ impl PostgresSimpleClient {
/// Resolves the Postgres resource, validates that the configured publication and
/// replication slot still exist, and opens a fresh logical replication stream.
///
/// Returns `Error::BadConfig` when the publication or slot is missing, or the
/// Returns `Error::BadConfig` when the publication or slot is missing or invalidated, or the
/// data table is under roles (unrecoverable misconfigurations). Any other error is treated as transient
/// (connection refused, network interruption, ...) and is retried by the caller.
/// The resource is re-resolved on every call so credential rotations are picked
@@ -183,18 +183,33 @@ async fn connect_logical_replication_stream(
));
}
// `invalidation_reason` (PG 17+) and `conflicting` (PG 16) are read through jsonb so the
// query still runs on versions that lack those columns.
let replication_slot = client
.execute_query(&format!(
"SELECT slot_name FROM pg_replication_slots WHERE slot_name = {}",
r#"SELECT COALESCE(
j->>'invalidation_reason',
CASE WHEN j->>'wal_status' = 'lost' THEN 'wal_removed'
WHEN j->>'conflicting' = 'true' THEN 'conflict with recovery' END
) FROM pg_replication_slots s, to_jsonb(s) j WHERE slot_name = {}"#,
quote_literal(replication_slot_name)
))
.await
.map_err(to_anyhow)?;
if !replication_slot.row_exist() {
let Some(slot) = replication_slot.iter().find_map(|m| match m {
SimpleQueryMessage::Row(row) => Some(row),
_ => None,
}) else {
return Err(Error::BadConfig(
ERROR_REPLICATION_SLOT_NOT_EXISTS.to_string(),
));
};
if let Some(reason) = slot.get(0) {
return Err(Error::BadConfig(format!(
"The replication slot `{replication_slot_name}` associated with this trigger has been invalidated by PostgreSQL (reason: `{reason}`). Changes since the invalidation cannot be replayed. Drop the slot (Advanced tab, slots) and save the trigger again to recreate it, or select another slot."
)));
}
client
+14
View File
@@ -377,6 +377,8 @@ impl FlowValue {
| Flow { .. }
| FlowScript { .. }
| Identity) => cb(&s, &module.id)?,
// Runs no script of its own.
AIDecision { .. } => {}
ForloopFlow { modules, .. } | WhileloopFlow { modules, .. } => {
Self::traverse_leafs(modules.iter().collect(), cb)?
}
@@ -1138,6 +1140,13 @@ pub enum FlowModuleValue {
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
tool_inputs: HashMap<String, HashMap<String, InputTransform>>,
},
/// One call to a decision model (TypeSafe's Jev) answering the typed `questions` of its
/// `input_transforms` about a `state`. A flow branches on the answers with a `BranchOne`.
AIDecision {
input_transforms: HashMap<String, InputTransform>,
#[serde(skip_serializing_if = "is_none_or_empty")]
tag: Option<String>,
},
}
fn is_none_or_empty(expr: &Option<String>) -> bool {
@@ -1282,6 +1291,10 @@ impl<'de> Deserialize<'de> for FlowModuleValue {
agent: untagged.agent,
tool_inputs: untagged.tool_inputs.unwrap_or_default(),
}),
"aidecision" => Ok(FlowModuleValue::AIDecision {
input_transforms: untagged.input_transforms.unwrap_or_default(),
tag: untagged.tag,
}),
other => Err(serde::de::Error::unknown_variant(
other,
&[
@@ -1294,6 +1307,7 @@ impl<'de> Deserialize<'de> for FlowModuleValue {
"rawscript",
"identity",
"aiagent",
"aidecision",
],
)),
}
@@ -651,6 +651,7 @@ async fn summarize_prefix(
.query_builder
.parse_streaming_response(resp, StreamEventProcessor::new_silent().boxed_sink())
.await?
.without_nul()
};
match parsed {
+23 -4
View File
@@ -372,7 +372,8 @@ async fn enqueue_windmill_tool(
FlowModuleValue::Script { input_transforms, .. }
| FlowModuleValue::RawScript { input_transforms, .. }
| FlowModuleValue::FlowScript { input_transforms, .. }
| FlowModuleValue::AIAgent { input_transforms, .. } => input_transforms,
| FlowModuleValue::AIAgent { input_transforms, .. }
| FlowModuleValue::AIDecision { input_transforms, .. } => input_transforms,
_ => {
return Err(Error::internal_err(format!(
"Unsupported tool: {}",
@@ -473,13 +474,15 @@ async fn enqueue_windmill_tool(
let has_nested_agent_tools = sub_tools.iter().any(|t| {
matches!(
t.value,
windmill_common::flows::ToolValue::FlowModule(FlowModuleValue::AIAgent { .. })
windmill_common::flows::ToolValue::FlowModule(
FlowModuleValue::AIAgent { .. } | FlowModuleValue::AIDecision { .. }
)
)
});
if has_nested_agent_tools {
return Err(Error::internal_err(
"AI agent tools cannot be nested beyond 2 levels. The nested agent tool contains \
AIAgent sub-tools, which would exceed the maximum nesting depth.".to_string()
AI agent or AI decision sub-tools, which would exceed the maximum nesting depth.".to_string()
));
}
let path = format!("{}/tools/{}", ctx.job.runnable_path(), tool_module.id);
@@ -492,6 +495,18 @@ async fn enqueue_windmill_tool(
on_behalf_of: None,
}
}
// Runs as an AI agent job, whose handler finds this tool on the calling agent and answers
// it as a decision.
FlowModuleValue::AIDecision { tag, .. } => JobPayloadWithTag {
payload: JobPayload::AIAgent {
path: format!("{}/tools/{}", ctx.job.runnable_path(), tool_module.id),
},
tag: tag.filter(|t| !t.trim().is_empty()),
delete_after_use: tool_module.delete_after_use.unwrap_or(false),
delete_after_secs: None,
timeout: None,
on_behalf_of: None,
},
_ => {
return Err(Error::internal_err(format!(
"Unsupported tool: {}",
@@ -834,8 +849,12 @@ async fn execute_windmill_tools(
.result
.map(|value| value.0)
.unwrap_or_else(|| to_raw_value(&serde_json::Value::Null));
// A nested agent's or a decision's result is an envelope; the model sees `output`.
let is_agent = tool.module.as_ref().is_some_and(|module| {
matches!(module.get_value(), Ok(FlowModuleValue::AIAgent { .. }))
matches!(
module.get_value(),
Ok(FlowModuleValue::AIAgent { .. } | FlowModuleValue::AIDecision { .. })
)
});
let content = if is_agent && completed.success {
extract_ai_agent_output(&result).unwrap_or_else(|| result.get().to_string())
+1
View File
@@ -720,6 +720,7 @@ pub fn any_tool_needs_previous_result(tools: &[Tool]) -> bool {
FlowModuleValue::RawScript { input_transforms, .. } => input_transforms,
FlowModuleValue::FlowScript { input_transforms, .. } => input_transforms,
FlowModuleValue::AIAgent { input_transforms, .. } => input_transforms,
FlowModuleValue::AIDecision { input_transforms, .. } => input_transforms,
_ => return false,
};
+161 -8
View File
@@ -28,6 +28,7 @@ use windmill_mcp::McpClient;
use crate::ai::tools::McpClientStub as McpClient;
use windmill_ai::{
ai_providers::AIProvider,
decision::{decision_inputs, run_systemone, AIDecisionArgs},
image_handler::upload_image_to_s3,
model_context::model_context_window,
providers::{
@@ -67,7 +68,10 @@ use crate::{
build_args_map, get_root_job_id, resolve_job_timeout, transform_json_value,
OccupancyMetrics, StreamNotifier,
},
handle_child::{run_future_with_polling_update_job_poller_graceful, GracefulPollOutcome},
handle_child::{
run_future_with_polling_update_job_poller,
run_future_with_polling_update_job_poller_graceful, GracefulPollOutcome,
},
};
lazy_static::lazy_static! {
@@ -81,6 +85,20 @@ lazy_static::lazy_static! {
"required": ["user_message"],
"additionalProperties": false,
}));
// Text only: the model writes the state as it reads the conversation, and a schema offering a
// union of types is refused by some providers' strict tool schemas.
static ref AI_DECISION_TOOL_SCHEMA: Box<RawValue> = to_raw_value(&serde_json::json!({
"type": "object",
"properties": {
"state": {
"type": "string",
"description": "What the questions are asked about, with only what they need.",
},
},
"required": ["state"],
"additionalProperties": false,
}));
}
const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10;
@@ -498,7 +516,8 @@ fn overlay_tool_inputs(
FlowModuleValue::Script { input_transforms, .. }
| FlowModuleValue::RawScript { input_transforms, .. }
| FlowModuleValue::FlowScript { input_transforms, .. }
| FlowModuleValue::AIAgent { input_transforms, .. } => input_transforms,
| FlowModuleValue::AIAgent { input_transforms, .. }
| FlowModuleValue::AIDecision { input_transforms, .. } => input_transforms,
_ => continue,
};
for (key, transform) in overrides {
@@ -713,6 +732,22 @@ pub async fn handle_ai_agent_job(
let summary = module.summary.clone();
let module_value = module.get_value()?;
if let FlowModuleValue::AIDecision { .. } = module_value {
return handle_ai_decision(
conn,
db,
job,
&local_args,
direct_parent_job_kind == JobKind::AIAgent,
canceled_by,
mem_peak,
occupancy_metrics,
worker_name,
)
.await;
}
let FlowModuleValue::AIAgent {
tools: module_tools,
omit_output_from_conversation,
@@ -720,7 +755,7 @@ pub async fn handle_ai_agent_job(
tool_inputs,
input_transforms: step_input_transforms,
..
} = module.get_value()?
} = module_value
else {
return Err(Error::internal_err(
"AI agent module is not an AI agent".to_string(),
@@ -820,16 +855,18 @@ pub async fn handle_ai_agent_job(
keep_authored_memory_id(&mut args, &step_input_transforms);
// Nesting is capped at flow → agent → nested agent. When this job is itself a nested tool,
// a linked resource's tool set may still contain AIAgent tools (the editor can't constrain a
// shared resource); don't advertise them — invoking one would only fail the depth check as a
// third-level agent.
// a linked resource's tool set may still contain AIAgent or AIDecision tools (the editor can't
// constrain a shared resource); don't advertise them — invoking one would only fail the depth
// check as a third level.
let tools = if direct_parent_job_kind == JobKind::AIAgent {
tools
.into_iter()
.filter(|t| {
!matches!(
&t.value,
ToolValue::FlowModule(FlowModuleValue::AIAgent { .. })
ToolValue::FlowModule(
FlowModuleValue::AIAgent { .. } | FlowModuleValue::AIDecision { .. }
)
)
})
.collect()
@@ -1004,6 +1041,18 @@ pub async fn handle_ai_agent_job(
None,
)
}
FlowModuleValue::AIDecision { input_transforms, .. } => {
// The calling model supplies only the state: the questions are the tool.
let description = decision_tool_description(&input_transforms);
(
Some(
RawValue::from_string(AI_DECISION_TOOL_SCHEMA.get().to_string())
.expect("AI_DECISION_TOOL_SCHEMA should always be valid JSON"),
),
input_transforms,
description,
)
}
_ => {
return Err(Error::internal_err(format!(
"Unsupported tool: {}",
@@ -1242,6 +1291,12 @@ pub async fn run_agent(
job_completed_tx: crate::JobCompletedSender,
) -> error::Result<Box<RawValue>> {
let output_type = args.output_type.as_ref().unwrap_or(&OutputType::Text);
if args.provider.kind.is_decision_provider() {
return Err(Error::BadRequest(format!(
"{:?} serves decision models, which answer typed questions rather than messages: use an AI decision step",
args.provider.kind
)));
}
let credentials = args.provider.to_provider_credentials(db).await?;
let base_url = &credentials.base_url;
let api_key = credentials.api_key.as_deref().unwrap_or("");
@@ -1860,7 +1915,7 @@ pub async fn run_agent(
query_builder.parse_image_response(resp).await?
}
};
Ok(parsed)
Ok(parsed.without_nul())
}.await;
match attempt {
@@ -2379,6 +2434,104 @@ pub async fn run_agent(
}))
}
/// An AI decision, run as a flow step or as a tool of an agent: its questions answered about its
/// state in one call, with no loop, tools, memory or stream. It writes no conversation row: it is
/// not an agent step, so a chat flow posts its result like any other step's.
async fn handle_ai_decision(
conn: &Connection,
db: &DB,
job: &MiniPulledJob,
local_args: &HashMap<String, Box<RawValue>>,
as_tool: bool,
canceled_by: &mut Option<CanceledBy>,
mem_peak: &mut i32,
occupancy_metrics: &mut OccupancyMetrics,
worker_name: &str,
) -> error::Result<Box<RawValue>> {
let args = serde_json::from_str::<AIDecisionArgs>(&serde_json::to_string(local_args)?)?;
if !args.provider.kind.is_decision_provider() {
return Err(Error::BadRequest(format!(
"An AI decision runs on a TypeSafe or Cloudflare resource, not {:?}",
args.provider.kind
)));
}
let (state, questions) = decision_inputs(args.state.as_ref(), args.questions.as_ref())?;
let credentials = args.provider.to_provider_credentials(db).await?;
let timeout = resolve_job_timeout(conn, &job.workspace_id, job.id, job.timeout)
.await
.0;
// Under the job poller, so the job keeps its heartbeat and a cancel drops the request.
let result = run_future_with_polling_update_job_poller(
job.id,
job.timeout,
conn,
mem_peak,
canceled_by,
run_systemone(
&credentials,
args.provider.get_model(),
state,
questions,
timeout,
),
worker_name,
&job.workspace_id,
&mut Some(occupancy_metrics),
Box::pin(futures::stream::once(async { 0 })),
)
.await?;
windmill_common::feature_usage::log_feature_usage(
"ai_decision",
"run",
if as_tool { "tool" } else { "step" },
);
Ok(to_raw_value(&result))
}
/// What a decision tool tells the calling model it answers, read off its static questions. None
/// when they come from an expression, which only a run evaluates.
fn decision_tool_description(input_transforms: &HashMap<String, InputTransform>) -> Option<String> {
let InputTransform::Static { value } = input_transforms.get("questions")? else {
return None;
};
let questions = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(value.get())
.ok()
.filter(|questions| !questions.is_empty())?;
let described = questions
.iter()
.map(|(name, question)| {
let criteria = question.get("criteria");
let kind = match question.get("type").and_then(|t| t.as_str()) {
Some("choice") => {
let options = criteria
.and_then(|c| c.as_object())
.map(|c| c.keys().cloned().collect::<Vec<_>>().join(", "))
.unwrap_or_default();
format!("one of {options}")
}
Some("score") => {
let levels = criteria
.and_then(|c| c.as_array())
.map(|c| {
c.iter()
.filter_map(|l| l.as_str())
.collect::<Vec<_>>()
.join(" < ")
})
.unwrap_or_default();
format!("a score over {levels}")
}
_ => "yes or no".to_string(),
};
format!("{name} ({kind})")
})
.collect::<Vec<_>>()
.join("; ");
Some(format!(
"Answers these questions about the state it is given, with probabilities: {described}."
))
}
/// Whether the step asked for its answer as it is generated. Absence means on, matching the
/// schema's own default: a step that never wrote the key never had an opinion, and an answer
/// arriving as it is written is what people expect. Only an explicit `false` holds it back.
+167 -23
View File
@@ -1068,12 +1068,87 @@ pub async fn start_child_process(
Ok(child)
}
/// Which limit a resolved job timeout comes from.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TimeoutSource {
/// The timeout set on the script or the flow step.
Custom,
/// What is left of a wall clock that the phases of one job spend down together.
RemainingBudget,
/// The instance `job_default_timeout` setting.
InstanceDefault,
/// The instance ceiling (`TIMEOUT`), which no other timeout can exceed.
InstanceMax,
}
impl TimeoutSource {
/// How to name a limit of `secs` seconds in a message shown to the job's author.
pub fn describe(&self, secs: u64) -> String {
match self {
TimeoutSource::Custom => {
format!("the {secs}s custom timeout set on the script or flow step")
}
TimeoutSource::RemainingBudget => format!(
"the {secs}s left of the job's timeout, which its phases share"
),
TimeoutSource::InstanceDefault => format!(
"the {secs}s instance default timeout (instance setting 'Default timeout'); set a longer custom timeout on the script or flow step to raise it"
),
TimeoutSource::InstanceMax => format!(
"the {secs}s instance maximum job duration (TIMEOUT env variable of the worker), which no custom or default timeout can exceed"
),
}
}
}
lazy_static! {
static ref RUNNING_JOB_CUSTOM_TIMEOUTS: std::sync::Mutex<HashMap<Uuid, Option<i32>>> =
std::sync::Mutex::new(HashMap::new());
}
/// Makes a running job's custom timeout visible to the phases that are not handed it (dependency
/// resolution, install, build), for as long as the guard lives.
pub struct RunningJobCustomTimeout(Uuid);
impl RunningJobCustomTimeout {
pub fn register(job_id: Uuid, custom_timeout_secs: Option<i32>) -> Self {
if let Ok(mut m) = RUNNING_JOB_CUSTOM_TIMEOUTS.lock() {
m.insert(
job_id,
windmill_common::runnable_settings::none_if_non_positive(custom_timeout_secs),
);
}
Self(job_id)
}
}
impl Drop for RunningJobCustomTimeout {
fn drop(&mut self) {
if let Ok(mut m) = RUNNING_JOB_CUSTOM_TIMEOUTS.lock() {
m.remove(&self.0);
}
}
}
/// `None` when the job is not registered, `Some(None)` when it runs without a custom timeout.
fn registered_custom_timeout(job_id: Uuid) -> Option<Option<i32>> {
RUNNING_JOB_CUSTOM_TIMEOUTS
.lock()
.ok()
.and_then(|m| m.get(&job_id).copied())
}
/// Resolves the time limit of one phase of a job and where that limit comes from.
///
/// A phase called without a custom timeout runs under the instance default. A job's custom
/// timeout may only lengthen such a phase, never shorten it: a short run timeout must not kill
/// a dependency install that the instance default allows.
pub async fn resolve_job_timeout(
_conn: &Connection,
_w_id: &str,
_job_id: Uuid,
job_id: Uuid,
custom_timeout_secs: Option<i32>,
) -> (Duration, Option<String>, bool) {
) -> (Duration, Option<String>, TimeoutSource) {
let mut warn_msg: Option<String> = None;
#[cfg(feature = "cloud")]
let cloud_premium_workspace =
@@ -1091,6 +1166,7 @@ pub async fn resolve_job_timeout(
let global_max_timeout_duration =
Duration::from_secs(max_job_duration_secs(cloud_premium_workspace));
let max_secs = global_max_timeout_duration.as_secs();
// A `custom_timeout_secs <= 0` is not a 0-second limit but "unset": fall through to the
// default/global-max timeout instead of killing the job immediately.
@@ -1098,34 +1174,95 @@ pub async fn resolve_job_timeout(
Some(timeout_secs)
if Duration::from_secs(timeout_secs as u64) < global_max_timeout_duration =>
{
(Duration::from_secs(timeout_secs as u64), warn_msg, true)
// A phase handed anything but the job's own custom timeout was handed the rest of a
// shared wall clock, whatever limit that clock was started from.
let source =
if registered_custom_timeout(job_id).is_none_or(|t| t == Some(timeout_secs)) {
TimeoutSource::Custom
} else {
TimeoutSource::RemainingBudget
};
(Duration::from_secs(timeout_secs as u64), warn_msg, source)
}
Some(timeout_secs) => {
warn_msg = Some(format!("WARNING: Custom job timeout of {timeout_secs} seconds was greater than the maximum timeout. It will be ignored and the max timeout will be used instead"));
warn_msg = Some(format!("WARNING: Custom job timeout of {timeout_secs} seconds was greater than the instance maximum job duration of {max_secs} seconds (TIMEOUT env variable of the worker). It will be ignored and the max timeout will be used instead"));
tracing::warn!(warn_msg);
(global_max_timeout_duration, warn_msg, false)
(
global_max_timeout_duration,
warn_msg,
TimeoutSource::InstanceMax,
)
}
None => {
// fallback to default timeout or max if not set
let default_timeout = match JOB_DEFAULT_TIMEOUT.read().await.clone() {
None => global_max_timeout_duration,
let (default_timeout, source) = match JOB_DEFAULT_TIMEOUT.read().await.clone() {
None => (global_max_timeout_duration, TimeoutSource::InstanceMax),
Some(default_timeout_secs)
if Duration::from_secs(default_timeout_secs as u64)
< global_max_timeout_duration =>
{
Duration::from_secs(default_timeout_secs as u64)
(
Duration::from_secs(default_timeout_secs as u64),
TimeoutSource::InstanceDefault,
)
}
Some(default_timeout_secs) => {
warn_msg = Some(format!("WARNING: Default job timeout of {default_timeout_secs} seconds was greater than the maximum timeout. It will be ignored and the global max timeout will be used instead"));
warn_msg = Some(format!("WARNING: Default job timeout of {default_timeout_secs} seconds was greater than the instance maximum job duration of {max_secs} seconds (TIMEOUT env variable of the worker). It will be ignored and the max timeout will be used instead"));
tracing::warn!(warn_msg);
global_max_timeout_duration
(global_max_timeout_duration, TimeoutSource::InstanceMax)
}
};
(default_timeout, warn_msg, false)
let job_custom_timeout = registered_custom_timeout(job_id)
.flatten()
.map(|secs| Duration::from_secs(secs as u64));
let (timeout, source) = lengthen_with_job_custom_timeout(
default_timeout,
source,
job_custom_timeout,
global_max_timeout_duration,
);
(timeout, warn_msg, source)
}
}
}
fn lengthen_with_job_custom_timeout(
phase_timeout: Duration,
phase_source: TimeoutSource,
job_custom_timeout: Option<Duration>,
max_timeout: Duration,
) -> (Duration, TimeoutSource) {
match job_custom_timeout {
Some(custom) if custom > phase_timeout && custom < max_timeout => {
(custom, TimeoutSource::Custom)
}
Some(custom) if custom > phase_timeout => (max_timeout, TimeoutSource::InstanceMax),
_ => (phase_timeout, phase_source),
}
}
#[cfg(test)]
mod job_timeout_tests {
use super::{lengthen_with_job_custom_timeout, TimeoutSource};
use std::time::Duration;
// A phase that is not handed the job's custom timeout (dependency install, build) runs under
// the instance default. The custom timeout may extend it up to the instance max, but a
// shorter one must leave it alone.
#[test]
fn job_custom_timeout_only_lengthens_a_setup_phase() {
let s = Duration::from_secs;
let default = (s(120), TimeoutSource::InstanceDefault);
let resolve = |custom: Option<u64>| {
lengthen_with_job_custom_timeout(default.0, default.1, custom.map(s), s(600))
};
assert_eq!(resolve(None), default);
assert_eq!(resolve(Some(240)), (s(240), TimeoutSource::Custom));
assert_eq!(resolve(Some(30)), default);
assert_eq!(resolve(Some(900)), (s(600), TimeoutSource::InstanceMax));
}
}
/// Compute the nsjail timeout (in seconds) with a 15s buffer so handle_child fires first.
pub async fn resolve_nsjail_timeout(
conn: &Connection,
@@ -1517,6 +1654,10 @@ async fn hash_args(
#[allow(unused)] ignore_s3_path: bool,
) {
if let Some(Json(hm)) = v {
// Resolving an AWS OIDC storage mints a token for the job, so each storage is
// resolved once however many arguments point into it.
#[cfg(feature = "parquet")]
let mut storages: HashMap<Option<String>, Option<ObjectStoreResource>> = HashMap::new();
for k in hm.keys().sorted() {
hasher.update(k.as_bytes());
let arg_value = hm.get(k).unwrap();
@@ -1524,18 +1665,21 @@ async fn hash_args(
#[cfg(feature = "parquet")]
let etag = match serde_json::from_str::<S3Object>(arg_value.get()).ok() {
Some(s3_object) => {
let s3_resource = get_workspace_s3_resource_path(
db,
client,
workspace_id,
s3_object.storage.as_ref(),
job_id,
)
.await
.ok()
.flatten();
match s3_resource {
Some(s3_resource) => get_etag_or_empty(&s3_resource, s3_object).await,
if !storages.contains_key(&s3_object.storage) {
let s3_resource = get_workspace_s3_resource_path(
db,
client,
workspace_id,
s3_object.storage.as_ref(),
job_id,
)
.await
.ok()
.flatten();
storages.insert(s3_object.storage.clone(), s3_resource);
}
match storages.get(&s3_object.storage).and_then(Option::as_ref) {
Some(s3_resource) => get_etag_or_empty(s3_resource, s3_object).await,
None => None,
}
}
+5 -5
View File
@@ -779,6 +779,7 @@ pub(crate) async fn dbt_dep(
// A dependency job carries no per-job timeout of its own, but its phases
// still share one wall clock rather than each getting the instance-wide
// one.
let _custom_timeout = crate::common::RunningJobCustomTimeout::register(*job_id, None);
let deadline = JobDeadline::start(&conn, w_id, *job_id, None).await;
let mut ctx = JobCtx { mem_peak, canceled_by, occupancy_metrics, worker_name, deadline };
let prepared = prepare_project(
@@ -3560,11 +3561,10 @@ async fn attach_column_index(
.iter()
.map(|n| n.unique_id.as_str())
.collect();
let index =
crate::dbt_column_index::collect(
p, descriptor, inv, command, ctx, job_id, w_id, conn, &kept,
)
.await?;
let index = crate::dbt_column_index::collect(
p, descriptor, inv, command, ctx, job_id, w_id, conn, &kept,
)
.await?;
drop(kept);
let Some(index) = index else {
return Ok(());
+18 -17
View File
@@ -49,7 +49,7 @@ use futures::{
stream, StreamExt,
};
use crate::common::{resolve_job_timeout, OccupancyMetrics, StreamNotifier};
use crate::common::{resolve_job_timeout, OccupancyMetrics, StreamNotifier, TimeoutSource};
use crate::job_logger::{append_job_logs, append_result_stream, append_with_limit, strip_nul};
use crate::job_logger_oss::process_streaming_log_lines;
use crate::worker_utils::{ping_job_status, update_worker_ping_from_job};
@@ -181,7 +181,7 @@ pub async fn handle_child(
enum KillReason {
TooManyLogs,
Timeout { is_job_specific: bool },
Timeout { phase: String, source: TimeoutSource, secs: u64 },
Cancelled(Option<CanceledBy>),
AlreadyCompleted,
}
@@ -190,11 +190,9 @@ pub async fn handle_child(
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
KillReason::TooManyLogs => f.write_str("too many logs (max size: 2MB)"),
KillReason::Timeout { is_job_specific } => f.write_str(if *is_job_specific {
"timeout after exceeding job-specific duration limit"
} else {
"timeout after exceeding instance-wide job duration limit"
}),
KillReason::Timeout { phase, source, secs } => {
write!(f, "timeout: `{phase}` exceeded {}", source.describe(*secs))
}
KillReason::Cancelled(canceled_by) => {
let mut reason = "cancelled".to_string();
if let Some(canceled_by) = canceled_by {
@@ -212,7 +210,7 @@ pub async fn handle_child(
}
}
let (timeout_duration, timeout_warn_msg, is_job_specific) =
let (timeout_duration, timeout_warn_msg, timeout_source) =
resolve_job_timeout(&conn, w_id, job_id, custom_timeout).await;
if let Some(msg) = timeout_warn_msg {
append_logs(&job_id, w_id, msg.as_str(), conn).await;
@@ -224,7 +222,11 @@ pub async fn handle_child(
biased;
result = Box::into_pin(child.wait()) => return result.map(Ok),
Ok(()) = too_many_logs.changed() => KillReason::TooManyLogs,
_ = sleep(timeout_duration) => KillReason::Timeout { is_job_specific },
_ = sleep(timeout_duration) => KillReason::Timeout {
phase: child_name.to_string(),
source: timeout_source,
secs: timeout_duration.as_secs(),
},
ex = update_job, if job_id != Uuid::nil() => match ex {
UpdateJobPollingExit::Done(canceled_by) => KillReason::Cancelled(canceled_by),
UpdateJobPollingExit::AlreadyCompleted => KillReason::AlreadyCompleted,
@@ -233,17 +235,16 @@ pub async fn handle_child(
tx.send(()).expect("rx should never be dropped");
drop(tx);
let timeout_reason = format!(
"`{child_name}` exceeded {}",
timeout_source.describe(timeout_duration.as_secs())
);
let set_reason = async {
if matches!(kill_reason, KillReason::Timeout { .. }) {
match conn {
Connection::Sql(db) => {
if let Err(err) = set_job_cancelled_query(
job_id,
db,
"timeout",
&format!("duration > {}", timeout_duration.as_secs()),
)
.await
if let Err(err) =
set_job_cancelled_query(job_id, db, "timeout", &timeout_reason).await
{
tracing::error!(%job_id, %err, "error setting cancelation reason for job {job_id}: {err}");
}
@@ -255,7 +256,7 @@ pub async fn handle_child(
None,
&JobCancelled {
canceled_by: "timeout".to_string(),
reason: format!("duration > {}", timeout_duration.as_secs()),
reason: timeout_reason.clone(),
},
)
.await
+1 -1
View File
@@ -56,7 +56,7 @@ pub async fn fetch_s3object_as_json_text(
crate::worker_utils::JobPingHeartbeat::start(conn, job_id, "s3object materialization");
let bytes = client
.download_s3_file(workspace_id, &obj.s3, obj.storage.clone())
.download_s3_file(workspace_id, &obj.s3, obj.storage.clone(), None)
.await
.with_context(|| format!("Failed to download S3 object `{}`", obj.s3))?;
+1
View File
@@ -5401,6 +5401,7 @@ async fn handle_code_execution_job(
precomputed_agent_info: Option<PrecomputedAgentInfo>,
has_stream: &mut bool,
) -> error::Result<Box<RawValue>> {
let _custom_timeout = crate::common::RunningJobCustomTimeout::register(job.id, job.timeout);
let script_hash = || {
job.runnable_id
.ok_or_else(|| Error::internal_err("expected script hash"))
+4 -2
View File
@@ -4184,7 +4184,8 @@ async fn push_next_flow_job(
| FlowModuleValue::RawScript { input_transforms, .. }
| FlowModuleValue::FlowScript { input_transforms, .. }
| FlowModuleValue::Flow { input_transforms, .. }
| FlowModuleValue::AIAgent { input_transforms, .. },
| FlowModuleValue::AIAgent { input_transforms, .. }
| FlowModuleValue::AIDecision { input_transforms, .. },
) => {
let ctx = get_transform_context(&flow_job, &previous_id, &status);
transform_context = Some(ctx);
@@ -5488,7 +5489,8 @@ async fn compute_next_flow_transform(
NextStatus::NextStep,
))
}
FlowModuleValue::AIAgent { tag, .. } => {
// A decision runs as an AI agent job, whose handler answers it as a decision.
FlowModuleValue::AIAgent { tag, .. } | FlowModuleValue::AIDecision { tag, .. } => {
let path = get_path(flow_job, status, module);
let payload = JobPayload::AIAgent { path };
Ok(NextFlowTransform::Continue(
+1 -1
View File
@@ -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.823.1";
export const VERSION = "v1.825.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-chat",
"version": "1.823.1",
"version": "1.825.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-chat",
"version": "1.823.1",
"version": "1.825.0",
"license": "Apache-2.0",
"devDependencies": {
"@ai-sdk/react": "^4.0.102",
+1 -1
View File
@@ -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.823.1",
"version": "1.825.0",
"author": "Ruben Fiszel",
"license": "Apache-2.0",
"homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme",
+1 -1
View File
@@ -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.823.1";
export const VERSION = "1.825.0";
+86 -1
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -44,9 +44,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN set -eux; \
arch="$(dpkg --print-architecture)"; arch="${arch##*-}"; \
case "$arch" in \
'amd64') targz='go1.26.0.linux-amd64.tar.gz' ;; \
'arm64') targz='go1.26.0.linux-arm64.tar.gz' ;; \
'armhf') targz='go1.26.0.linux-armv6l.tar.gz' ;; \
'amd64') targz='go1.27.1.linux-amd64.tar.gz' ;; \
'arm64') targz='go1.27.1.linux-arm64.tar.gz' ;; \
'armhf') targz='go1.27.1.linux-armv6l.tar.gz' ;; \
*) echo >&2 "error: unsupported architecture '$arch'"; exit 1 ;; \
esac; \
wget "https://golang.org/dl/$targz" -nv && tar -C /usr/local -xzf "$targz" && rm "$targz"
+1 -1
View File
@@ -134,7 +134,7 @@ fi
# Setup Monaco temp directory for LSP
mkdir -p /tmp/monaco
if [ ! -f /tmp/monaco/go.mod ]; then
echo -e "module mymod\ngo 1.26" > /tmp/monaco/go.mod
echo -e "module mymod\ngo 1.27" > /tmp/monaco/go.mod
fi
echo "[entrypoint] Starting Windmill Extra Services"
+4 -1
View File
@@ -71,7 +71,10 @@ Symbols, not line numbers, are cited: they drift less.
unscoped token for every job. `$var:`/`$res:` args are resolved through the API with the job's
own token, so they need read scopes. A deploy that omits the setting keeps the deployed value
and `null` clears it, so an unaware client cannot drop a restriction; setting one is refused
until every worker supports it, since an older worker mints without it. The setting belongs to
until every worker supports it, since an older worker mints without it. The OIDC
`job_token_scopes` claim is the presented token's scopes, not the runnable's setting, so a
verifier sees what the job could reach and a token minted without the restriction shows as
unrestricted. The setting belongs to
the deployed version: an older script hash run by hash runs with that version's setting (a
restricted caller still caps it). Write scopes on scripts, flows, schedules or triggers let a
job escape its restriction.
+2 -2
View File
@@ -4,8 +4,8 @@
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 61 registered actions across twenty features (`ai_session`, `ai_chat`,
`ai_fix`, `ai_agent`, `ai_agent_eval`, `app_sandbox`, `datatable`, `db_manager`, `flow_editor`,
It currently carries 62 registered actions across twenty-one features (`ai_session`, `ai_chat`,
`ai_fix`, `ai_agent`, `ai_decision`, `ai_agent_eval`, `app_sandbox`, `datatable`, `db_manager`, `flow_editor`,
`flow_run`, `flow_step`, `home`, `run_form`, `debugger`, `trigger`, `command_script`,
`hub_script`, `usage_meter`, `sso_groups_claim`, `cloud_trial_offer`). Nearly all of the
product is uninstrumented, so new user-facing work is the opportunity to change that.
+28 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/components",
"version": "1.823.1",
"version": "1.825.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/components",
"version": "1.823.1",
"version": "1.825.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@@ -20,6 +20,7 @@
"@codingame/monaco-vscode-standalone-languages": "=25.0.0",
"@codingame/monaco-vscode-standalone-typescript-language-features": "=25.0.0",
"@floating-ui/core": "^1.3.1",
"@jitl/quickjs-wasmfile-release-sync": "^0.32.0",
"@json2csv/plainjs": "^7.0.6",
"@leeoniya/ufuzzy": "^1.0.8",
"@melt-ui/svelte": "^0.86.2",
@@ -70,6 +71,7 @@
"p-limit": "^6.1.0",
"panzoom": "^9.4.3",
"pdfjs-dist": "4.8.69",
"quickjs-emscripten-core": "^0.32.0",
"quicktype-core": "^23.2.6",
"quill": "^2.0.3",
"rehype-github-alerts": "^3.0.0",
@@ -1431,6 +1433,21 @@
"node": ">=18.0.0"
}
},
"node_modules/@jitl/quickjs-ffi-types": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz",
"integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==",
"license": "MIT"
},
"node_modules/@jitl/quickjs-wasmfile-release-sync": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.32.0.tgz",
"integrity": "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.32.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -11432,6 +11449,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/quickjs-emscripten-core": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz",
"integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.32.0"
}
},
"node_modules/quickselect": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.823.1",
"version": "1.825.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",
@@ -86,6 +86,7 @@
"@codingame/monaco-vscode-standalone-languages": "=25.0.0",
"@codingame/monaco-vscode-standalone-typescript-language-features": "=25.0.0",
"@floating-ui/core": "^1.3.1",
"@jitl/quickjs-wasmfile-release-sync": "^0.32.0",
"@json2csv/plainjs": "^7.0.6",
"@leeoniya/ufuzzy": "^1.0.8",
"@melt-ui/svelte": "^0.86.2",
@@ -136,6 +137,7 @@
"p-limit": "^6.1.0",
"panzoom": "^9.4.3",
"pdfjs-dist": "4.8.69",
"quickjs-emscripten-core": "^0.32.0",
"quicktype-core": "^23.2.6",
"quill": "^2.0.3",
"rehype-github-alerts": "^3.0.0",
+5 -1
View File
@@ -339,8 +339,12 @@ class Target {
client.destroy()
upstream.destroy()
}
// An upstream close must not destroy the client: `destroy` drops bytes still queued
// toward a slow client, truncating the response body. `pipe` already ends the client
// once those bytes are flushed.
upstream.on('error', teardown)
upstream.on('close', teardown)
client.on('error', teardown)
upstream.on('close', () => this.liveSockets.delete(client))
client.on('close', teardown)
},
(err) => {
@@ -1,7 +1,12 @@
<script lang="ts">
import { type Snippet } from 'svelte'
import { type Snippet, untrack } from 'svelte'
import Select from './select/Select.svelte'
import { fetchAvailableModels, AI_PROVIDERS } from './copilot/lib'
import {
fetchAvailableModels,
AI_PROVIDERS,
DECISION_AI_PROVIDERS,
aiProviderDetails
} from './copilot/lib'
import type { AIProvider, ProviderConfig } from '$lib/gen'
import ResourcePicker from './ResourcePicker.svelte'
import Toggle from './Toggle.svelte'
@@ -19,13 +24,17 @@
* other than the one being navigated. Resources and the models read off them are per
* workspace, so without it this offers what the wrong one holds. */
workspace?: string | undefined
/** Offer the decision providers (TypeSafe, Cloudflare) in place of the chat ones, for an AI
* decision. */
decision?: boolean
}
let {
value: _uncheckedValue = $bindable(),
disabled = false,
actions,
workspace = undefined
workspace = undefined,
decision = false
}: Props = $props()
let effectiveWorkspace = $derived(workspace ?? $operatingWorkspace ?? '')
@@ -44,12 +53,15 @@
let modelsCache = new Map<string, string[]>()
// The resource picker offers every provider type at once and the pick is what names the kind.
// One string for the component's life: it is what the picker queries with.
const providerResourceTypes = Object.keys(AI_PROVIDERS).join(',')
// One list for the component's life: it is what the picker queries with.
const offeredProviders = Object.keys(
untrack(() => decision) ? DECISION_AI_PROVIDERS : AI_PROVIDERS
)
const providerResourceTypes = offeredProviders.join(',')
if (!_uncheckedValue) {
_uncheckedValue = {
kind: 'openai',
kind: untrack(() => decision) ? 'typesafe' : 'openai',
resource: '',
model: ''
}
@@ -101,7 +113,7 @@
return
}
// Fall back to default models for this provider
const defaultModels = AI_PROVIDERS[provider]?.defaultModels || []
const defaultModels = aiProviderDetails(provider).defaultModels
availableModels = defaultModels
} finally {
if (!signal?.aborted) {
@@ -118,7 +130,7 @@
function onResourcePicked(_path: string | undefined, type: string | undefined) {
// An empty type is the placeholder the picker keeps for a saved path it could not find. It
// says nothing about the provider, so the kind stands.
if (!value || !type || !(type in AI_PROVIDERS)) {
if (!value || !type || !offeredProviders.includes(type)) {
return
}
if (value.kind === type) {
@@ -166,7 +178,7 @@
if (provider && resourcePath) {
loadModels(abortController.signal)
} else {
const defaultModels = provider ? AI_PROVIDERS[provider]?.defaultModels || [] : []
const defaultModels = provider ? aiProviderDetails(provider).defaultModels : []
availableModels = defaultModels
loading = false
}
@@ -226,7 +238,7 @@
/>
</div>
{#if value?.model}
{#if value?.model && !decision}
<div class="flex flex-col gap-1">
<span class="text-xs font-normal text-secondary">Reasoning effort</span>
<AIReasoningEffortPicker
@@ -237,21 +249,24 @@
</div>
{/if}
<div class="flex justify-end">
<Toggle
disabled={disabled || !value?.kind || !value?.resource || !value?.model}
bind:checked={useAsDefault}
options={{ right: 'Use as personal default for other new agents' }}
size="xs"
on:change={(e) => {
if (!e.detail) {
removeConfig()
} else {
saveConfig(value)
}
}}
/>
</div>
<!-- The default seeds new agents, which a decision provider cannot run. -->
{#if !decision}
<div class="flex justify-end">
<Toggle
disabled={disabled || !value?.kind || !value?.resource || !value?.model}
bind:checked={useAsDefault}
options={{ right: 'Use as personal default for other new agents' }}
size="xs"
on:change={(e) => {
if (!e.detail) {
removeConfig()
} else {
saveConfig(value)
}
}}
/>
</div>
{/if}
{@render actions?.()}
</div>
@@ -0,0 +1,323 @@
<script lang="ts">
import { untrack } from 'svelte'
import { Plus, Trash2, X } from 'lucide-svelte'
import { Button } from './common'
import TextInput from './text_input/TextInput.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import Toggle from './Toggle.svelte'
import {
branchableQuestions,
emptyQuestion,
optionNameError,
questionNameError,
questionsFitRows,
questionsToRows,
rowsToQuestions,
type DecisionQuestionType,
type QuestionRow
} from './flows/aiDecisionQuestions'
import { getAiDecisionStep } from './flows/aiDecisionBranching'
import AiDecisionQuestionRouting from './flows/content/AiDecisionQuestionRouting.svelte'
interface Props {
value: any
disabled?: boolean
onModeChange?: (mode: 'form' | 'json') => void
}
let { value = $bindable(), disabled = false, onModeChange }: Props = $props()
const decisionStep = getAiDecisionStep()
// Whether the cards can hold the value: TypeSafe also takes structured instructions and
// descriptions, which the cards would flatten to text, so such questions stay in JSON.
let fitsRows = $state(questionsFitRows(untrack(() => value)))
let mode: 'form' | 'json' = $state(untrack(() => fitsRows) ? 'form' : 'json')
$effect(() => onModeChange?.(mode))
let jsonCode = $state(
JSON.stringify(
untrack(() => value),
null,
2
)
)
// The code editor reads `code` only when it is created, so a replaced value recreates it.
let jsonEditorKey = $state(0)
let rows: QuestionRow[] = $state(questionsToRows(untrack(() => value)))
// What the rows last wrote or were read from. Rows hold drafts the value leaves out (an unnamed
// question), so only an edit writes; opening the editor leaves the stored value untouched.
let synced = JSON.stringify(rowsToQuestions(untrack(() => rows)))
let written = JSON.stringify(untrack(() => value) ?? {})
function readRows() {
rows = questionsToRows(value)
synced = JSON.stringify(rowsToQuestions(rows))
}
function readJson() {
jsonCode = JSON.stringify(value, null, 2)
jsonEditorKey++
}
$effect(() => {
const questions = rowsToQuestions(rows)
const json = JSON.stringify(questions)
untrack(() => {
if (mode === 'form' && json !== synced) {
synced = json
written = json
value = questions
}
})
})
// A value replaced from elsewhere (an undo, the AI chat) is read into both views, and moves the
// editor to JSON when the cards cannot hold it.
$effect(() => {
const json = JSON.stringify(value ?? {})
untrack(() => {
if (json !== written) {
written = json
fitsRows = questionsFitRows(value)
if (!fitsRows) mode = 'json'
readJson()
readRows()
}
})
})
function switchTo(next: 'form' | 'json') {
if (next === mode || (next === 'form' && !fitsRows)) return
if (next === 'form') readRows()
else readJson()
mode = next
}
function addQuestion() {
let i = rows.length + 1
while (rows.some((r) => r.name === `question_${i}`)) i++
rows.push(emptyQuestion(`question_${i}`))
}
const TYPES: { value: DecisionQuestionType; label: string }[] = [
{ value: 'choice', label: 'Choice' },
{ value: 'score', label: 'Score' },
{ value: 'noul', label: 'Yes/no' }
]
</script>
{#snippet label(text: string)}
<span class="text-xs font-semibold text-emphasis">{text}</span>
{/snippet}
{#snippet error(text: string | undefined)}
{#if text}
<span class="text-2xs text-red-600 dark:text-red-400">{text}</span>
{/if}
{/snippet}
<div class="flex flex-col gap-2 w-full">
<div class="flex justify-end">
<Toggle
size="xs"
lightMode
disabled={disabled || !fitsRows}
checked={mode === 'json'}
options={{
right: 'JSON',
rightTooltip: fitsRows
? 'Edit the questions as JSON'
: 'These questions use structured instructions or descriptions, which only JSON can show'
}}
on:change={(e) => switchTo(e.detail ? 'json' : 'form')}
/>
</div>
{#if mode === 'json'}
<div class="w-full">
{#key jsonEditorKey}
{#await import('./JsonEditor.svelte') then Module}
<Module.default
bind:code={jsonCode}
{disabled}
on:changeValue={(e) => {
written = JSON.stringify(e.detail ?? {})
fitsRows = questionsFitRows(e.detail)
value = e.detail
}}
/>
{/await}
{/key}
</div>
{#if decisionStep?.id}
{@const id = decisionStep.id}
{#each branchableQuestions(value) as q (q.name)}
<AiDecisionQuestionRouting decisionId={id} question={q.name} named />
{/each}
{/if}
{:else}
<div class="flex flex-col gap-3 w-full">
{#each rows as row, i (i)}
<div class="flex flex-col gap-3 rounded-md bg-surface-tertiary p-3 shadow-sm">
<div class="flex items-start gap-2">
<div class="flex flex-col gap-1 grow">
{@render label('Name')}
<TextInput
size="sm"
bind:value={row.name}
error={questionNameError(rows, i)}
inputProps={{ disabled, placeholder: 'intent' }}
/>
{@render error(questionNameError(rows, i))}
</div>
<div class="flex flex-col gap-1">
{@render label('Answer')}
<ToggleButtonGroup
noWFull
{disabled}
selected={row.type}
onSelected={(v) => (row.type = v as DecisionQuestionType)}
>
{#snippet children({ item })}
{#each TYPES as t (t.value)}
<ToggleButton value={t.value} label={t.label} {item} small />
{/each}
{/snippet}
</ToggleButtonGroup>
</div>
<div class="pt-5">
<Button
variant="subtle"
unifiedSize="sm"
destructive
iconOnly
{disabled}
startIcon={{ icon: Trash2 }}
title="Delete question"
onClick={() => rows.splice(i, 1)}
/>
</div>
</div>
<div class="flex flex-col gap-1">
{@render label('Question')}
<TextInput
size="sm"
underlyingInputEl="textarea"
bind:value={row.instructions}
autosizeParams={{ minHeight: 0 }}
inputProps={{ disabled, placeholder: 'What does the customer want?', rows: 1 }}
/>
</div>
{#if row.type === 'choice'}
<div class="flex flex-col gap-1">
{@render label('Options')}
<span class="text-xs text-secondary"
>What each option means helps the model choose</span
>
{#each row.options as option, j (j)}
<div class="flex gap-2 items-start">
<div class="w-1/3 shrink-0 flex flex-col gap-1">
<TextInput
size="sm"
bind:value={option.name}
error={optionNameError(row, j)}
inputProps={{ disabled, placeholder: 'refund' }}
/>
{@render error(optionNameError(row, j))}
</div>
<TextInput
size="sm"
bind:value={option.description}
inputProps={{ disabled, placeholder: 'What this option means' }}
/>
<Button
variant="subtle"
unifiedSize="sm"
iconOnly
disabled={disabled || row.options.length <= 2}
startIcon={{ icon: X }}
title="Remove option"
onClick={() => row.options.splice(j, 1)}
/>
</div>
{/each}
<Button
variant="subtle"
unifiedSize="sm"
wrapperClasses="self-start"
{disabled}
startIcon={{ icon: Plus }}
onClick={() => row.options.push({ name: '', description: '' })}
>
Add option
</Button>
</div>
{:else if row.type === 'score'}
<div class="flex flex-col gap-1">
{@render label('Levels')}
<span class="text-xs text-secondary">Lowest first</span>
{#each row.levels as _, j (j)}
<div class="flex gap-2 items-center">
<span class="text-xs text-secondary w-4 text-right">{j}</span>
<TextInput
size="sm"
bind:value={row.levels[j]}
inputProps={{ disabled, placeholder: j === 0 ? 'Can wait' : 'Right now' }}
/>
<Button
variant="subtle"
unifiedSize="sm"
iconOnly
disabled={disabled || row.levels.length <= 2}
startIcon={{ icon: X }}
title="Remove level"
onClick={() => row.levels.splice(j, 1)}
/>
</div>
{/each}
<Button
variant="subtle"
unifiedSize="sm"
wrapperClasses="self-start"
disabled={disabled || row.levels.length >= 10}
startIcon={{ icon: Plus }}
onClick={() => row.levels.push('')}
>
Add level
</Button>
</div>
{:else}
<div class="grid grid-cols-2 gap-2">
<div class="flex flex-col gap-1">
{@render label('Yes means')}
<TextInput size="sm" bind:value={row.yes} inputProps={{ disabled }} />
<span class="text-2xs text-hint">Optional</span>
</div>
<div class="flex flex-col gap-1">
{@render label('No means')}
<TextInput size="sm" bind:value={row.no} inputProps={{ disabled }} />
<span class="text-2xs text-hint">Optional</span>
</div>
</div>
{/if}
{#if decisionStep?.id && row.type !== 'score' && row.name.trim()}
<AiDecisionQuestionRouting decisionId={decisionStep.id} question={row.name.trim()} />
{/if}
</div>
{/each}
<Button
variant="default"
unifiedSize="sm"
wrapperClasses="self-start"
{disabled}
startIcon={{ icon: Plus }}
onClick={addQuestion}
>
Add question
</Button>
</div>
{/if}
</div>
+20 -2
View File
@@ -8,6 +8,7 @@
debounce,
emptySchema,
emptyString,
escapeHtml,
getSchemaFromProperties,
type DynamicInput as DynamicInputTypes
} from '$lib/utils'
@@ -43,6 +44,7 @@
import { base } from '$lib/base'
import { getJsonSchemaFromResource } from './schema/jsonSchemaResource.svelte'
import AIProviderPicker from './AIProviderPicker.svelte'
import AiDecisionQuestionsEditor from './AiDecisionQuestionsEditor.svelte'
import TextInput from './text_input/TextInput.svelte'
import FileInput from './common/fileInput/FileInput.svelte'
import { randomUUID } from '$lib/utils/uuid'
@@ -257,6 +259,7 @@
// never reaches `value`, so without this the field would stay valid on its last parsed value.
let jsonError: string = $state('')
let isListJson = $state(false)
let questionsInJson = $state(false)
let hasIsListJsonChanged = $state(false)
let el: HTMLTextAreaElement | undefined = $state(undefined)
@@ -698,7 +701,9 @@
{#if description}
<div class={twMerge('text-xs text-secondary', css?.description?.class)}>
<pre class="font-main whitespace-normal">{description}</pre>
<pre class="font-main whitespace-normal"
>{description}{#if inputCat == 'ai-decision-questions' && questionsInJson}{': { <name>: { type: choice | score | noul, instructions, criteria } }'}{/if}</pre
>
</div>
{/if}
@@ -1382,6 +1387,7 @@
dispatch('blur')
}}
code={rawValue}
placeholder={placeholder && escapeHtml(placeholder)}
bind:error={jsonError}
on:changeValue={(e) => {
setNewValueFromCode(e.detail)
@@ -1484,7 +1490,19 @@
{showSchemaExplorer}
/>
{:else if inputCat == 'ai-provider'}
<AIProviderPicker bind:value {disabled} {actions} {workspace} />
<AIProviderPicker
bind:value
{disabled}
{actions}
{workspace}
decision={format === 'ai-decision-provider'}
/>
{:else if inputCat == 'ai-decision-questions'}
<AiDecisionQuestionsEditor
bind:value
{disabled}
onModeChange={(mode) => (questionsInJson = mode === 'json')}
/>
{:else if inputCat == 'email'}
<input
{autofocus}
@@ -96,7 +96,8 @@
'apify',
'docusign',
'salesforce',
'outreach'
'outreach',
'notion'
]
// Providers whose registry entry (`backend/oauth_connect.json`) carries a
// `sandbox` URL block. Each one gets a sibling `<name>_sandbox` dropdown
@@ -6,6 +6,8 @@
loaded: number
total: number
itemsLabel: string
/** Replaces the default "Loading … of …" text, for progress that is not a count of items. */
label?: string
batchSize?: number | null
/** Largest batch the caller can fetch in one request, when that is below the page size. */
batchSizeCap?: number
@@ -17,6 +19,7 @@
loaded,
total,
itemsLabel,
label,
batchSize = null,
batchSizeCap,
onBatchSizeChange,
@@ -30,7 +33,12 @@
</script>
<div class="flex items-center gap-3 text-xs text-secondary">
<span class="whitespace-nowrap shrink-0">Loading {itemsLabel}: {loaded} of {total}...</span>
{#if label}
<!-- A custom label has no bounded length: it gives way before the bar and Stop do. -->
<span class="min-w-0 truncate" title={label}>{label}</span>
{:else}
<span class="whitespace-nowrap shrink-0">Loading {itemsLabel}: {loaded} of {total}...</span>
{/if}
<div class="flex-1 min-w-8 bg-surface-hover rounded-full h-1.5">
<div
class="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
@@ -216,6 +216,11 @@
<h3 class="mb-1 font-semibold mt-2 text-xs text-emphasis">Step inputs</h3>
<InputTransformsViewer inputTransforms={stepDetail?.value?.input_transforms ?? {}} />
</div>
{:else if stepDetail.value.type == 'aidecision'}
<div class="text-xs">
<h3 class="mb-1 font-semibold mt-2 text-xs text-emphasis">Step inputs</h3>
<InputTransformsViewer inputTransforms={stepDetail.value.input_transforms ?? {}} />
</div>
{:else if stepDetail.value.type == 'forloopflow'}
<div>
<p class="font-medium text-secondary pb-2"> Iterator expression: </p>
@@ -54,6 +54,8 @@
return 'Workspace script'
case 'aiagent':
return 'AI Agent'
case 'aidecision':
return 'AI decision'
default:
return stepDetail.id
}
@@ -12,7 +12,7 @@
import type { Schema } from '$lib/common'
import type { InputCat, DynamicInput as DynamicInputTypes } from '$lib/utils'
import { createEventDispatcher, getContext, onDestroy, untrack, type Snippet } from 'svelte'
import { computeShow } from '$lib/utils'
import { computeShow, quickjsSettled } from '$lib/utils/quickjsEval.svelte'
import ArgInput from './ArgInput.svelte'
import FieldHeader from './FieldHeader.svelte'
@@ -479,8 +479,9 @@
contextArgs[key] = otherArgValue
})
const shouldShow = computeShow(argName, schemaProperty.showExpr, contextArgs)
if (shouldShow || hasJavascript) {
const shouldShow = hasJavascript || computeShow(schemaProperty.showExpr, contextArgs)
if (shouldShow === undefined) return
if (shouldShow) {
hidden = false
} else if (!hidden) {
hidden = true
@@ -596,6 +597,7 @@
JSON.stringify(schema)
JSON.stringify(arg)
JSON.stringify(otherArgs)
quickjsSettled()
untrack(() => handleFieldVisibility(schema, arg, otherArgs))
})
@@ -19,6 +19,7 @@
class?: string | undefined
disabled?: boolean
fixedOverflowWidgets?: boolean
placeholder?: string
}
let {
@@ -30,7 +31,8 @@
loadAsync = false,
class: clazz = undefined,
disabled = false,
fixedOverflowWidgets = true
fixedOverflowWidgets = true,
placeholder = undefined
}: Props = $props()
let tooBig = $derived(code && code?.length > 1000000)
@@ -101,6 +103,7 @@
class={clazz}
{disabled}
{fixedOverflowWidgets}
{placeholder}
renderLineHighlight="none"
/>
</div>
@@ -1,4 +1,5 @@
<script lang="ts">
import { setAiDecisionStep } from './flows/aiDecisionBranching'
import type { Schema } from '$lib/common'
import { type FlowModule, type Job } from '$lib/gen'
import { CornerDownLeft, Loader2 } from 'lucide-svelte'
@@ -43,6 +44,9 @@
runInputKeys = undefined
}: Props = $props()
// The test form takes sample questions, which are not the step's to branch on.
setAiDecisionStep({ id: undefined })
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
let moduleTest: ModuleTest | undefined = $state()
@@ -8,6 +8,7 @@
import { getContext, untrack } from 'svelte'
import type { FlowEditorContext } from './flows/types'
import { evalValue } from './flows/utils.svelte'
import { quickjsReady, quickjsSettled } from '$lib/utils/quickjsEval.svelte'
import { memoryPropertyFor } from './flows/flowInfers'
import type { FlowModule } from '$lib/gen'
import type { PickableProperties } from './flows/previousResults'
@@ -155,9 +156,12 @@
loadResourceTypes()
let initialized = $state(false)
quickjsReady()
// Inputs mounted before the engine can evaluate fill in their defaults, which then read as
// manual edits and win over the evaluated values.
$effect.pre(() => {
if (!initialized) {
if (!initialized && quickjsSettled()) {
if (stepsInputArgs) {
stepsInputArgs?.updateStepArgs(mod.id, flowStateStore.val, flowStore?.val, previewArgs?.val)
initialized = true
+26 -1
View File
@@ -2,6 +2,7 @@
import {
ScriptService,
type AiAgent,
type AiDecision,
type FlowModule,
type InputTransform,
type Job
@@ -21,6 +22,7 @@
import { AGENT_FLOW_LOCAL_KEYS } from './flows/agentResourceUtils'
import { agentTestInputTransforms } from './flows/agentFormFields'
import { sendUserToast } from '$lib/toast'
import { quickjsReady } from '$lib/utils/quickjsEval.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
@@ -67,7 +69,8 @@
// that follow each flow edit stay quiet, so without this a failing expression is silently
// `undefined` in what the run is built from. Manually edited args are preserved across the
// refresh by `initializeFromSchema`.
export function runTestWithStepArgs() {
export async function runTestWithStepArgs() {
await quickjsReady()
stepsInputArgs?.updateStepArgs(
mod.id,
flowStateStore.val,
@@ -212,6 +215,28 @@
callbacks,
previewBase
)
} else if (val.type == 'aidecision') {
const { schema } = await loadSchemaFromModule(mod, opWs)
const inputTransforms = Object.fromEntries(
Object.keys(args).map((key) => [key, { expr: `flow_input.${key}`, type: 'javascript' }])
) as AiDecision['input_transforms']
await jobLoader?.runFlowPreview(
args,
{
value: {
modules: [
{
id: mod.id,
value: { type: 'aidecision', input_transforms: inputTransforms, tag: val.tag }
}
]
},
summary: '',
schema
},
callbacks,
previewBase
)
} else {
throw Error('Not supported module type')
}
+3 -2
View File
@@ -152,8 +152,8 @@
* form is embedded in a page that is not the runnable's own — an AI session preview
* tab — since there the fragment would land on an unrelated URL. */
syncArgsToUrl?: boolean
/** Controls beside the Run button of a form that cannot schedule, in the row a
* schedulable one gives its Advanced options. */
/** Controls beside the Run button: left of Advanced on a schedulable form, in
* Advanced's place on one that cannot schedule. */
actions?: import('svelte').Snippet
}
@@ -405,6 +405,7 @@
{/snippet}
</Popover>
</div>
{@render actions?.()}
</div>
{@render unparseableError()}
{#if overrideTag}
+354 -234
View File
@@ -10,6 +10,7 @@
} from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { displayDate } from '$lib/utils'
import {
userStore,
workspaceStore,
@@ -52,10 +53,11 @@
import BatchLoadProgress from '$lib/components/BatchLoadProgress.svelte'
import { pluralize, MAX_RESOLUTION_BATCH, MAX_RESOLUTION_NOTE_LEN } from '$lib/utils'
import type { BatchReRunOptions } from '$lib/components/runs/BatchReRunOptionsPane.svelte'
import { untrack } from 'svelte'
import { untrack, type Snippet } from 'svelte'
import { page } from '$app/state'
import Select from '$lib/components/select/Select.svelte'
import AnimatedPane from '$lib/components/splitPanes/AnimatedPane.svelte'
import PagedContent from '$lib/components/common/modal/PagedContent.svelte'
import { StaleWhileLoading, useLocalStorageValue } from '$lib/svelte5Utils.svelte'
import {
Calendar,
@@ -64,7 +66,9 @@
CirclePlay,
Clock,
Hourglass,
TriangleAlertIcon
TriangleAlertIcon,
ArrowLeft,
Loader2
} from 'lucide-svelte'
import DropdownV2 from './DropdownV2.svelte'
import TimeframeSelect, {
@@ -191,6 +195,7 @@
}))
let batchProgress = $derived(jobsLoader.batchProgress)
let currentBatchSize = $derived(jobsLoader.currentBatchSize)
let scanProgress = $derived(jobsLoader.scanProgress)
let lastFetchWentToEnd = $derived(jobsLoader.lastFetchWentToEnd)
let queue_count = $derived(jobsLoader.queue_count)
let suspended_count = $derived(jobsLoader.suspended_count)
@@ -207,6 +212,7 @@
function reset() {
_timeframe.val = { ...runsTimeframes[0] }
selectedIds = []
openedRunId = undefined
delete filters.val.schedule_path
selectedWorkspace = undefined
jobsLoader?.loadJobs(true)
@@ -497,6 +503,9 @@
})
selectedIds = []
// Opened from the table's "Run again", the options are done; left open, the next selection
// of one run would show them again. The re-run selection mode keeps them for its next batch.
if (!manualSelectionMode) batchRerunOptionsIsOpen = false
jobsLoader?.loadJobs(true, true)
}
@@ -580,6 +589,27 @@
)
let manualSelectionMode: undefined | 'cancel' | 'rerun' | 'resolve' = $state()
// Below 1024px (Tailwind `lg`) the side pane leaves the table too little room, so what it would
// show becomes a page pushed over the list. Selection modes keep the split: their rows are
// picked in the list while the pane holds the action.
const pagedLayout = $derived(innerWidth < 1024 && !manualSelectionMode)
// Set only by a plain row click, a chart point or the arrow keys, never by a modifier click:
// a Ctrl/Cmd/Shift-click starting a multi-selection selects one run first, and opening it would
// hide the list before the second click.
let openedRunId: string | undefined = $state()
const runPageOpen = $derived(
// A confirmed re-run clears the selection but leaves the options flag set.
(batchRerunOptionsIsOpen && selectedIds.length > 0) ||
(selectedIds.length === 1 && selectedIds[0] === openedRunId)
)
// Clears the selection too: a re-run selection of one job would otherwise land on that job.
function backToList() {
batchRerunOptionsIsOpen = false
selectedIds = []
openedRunId = undefined
}
let resolutionNote = $state('')
const phone = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR)
</script>
@@ -931,6 +961,7 @@
_timeframe.val = buildManualTimeframe(zoom.min.toISOString(), zoom.max.toISOString())
}}
onPointClicked={(ids) => {
openedRunId = ids.length === 1 ? ids[0] : undefined
runsTable?.scrollToRun(ids)
}}
/>
@@ -948,242 +979,331 @@
</div>
<div class="grow min-h-0 bottom-splitpane-wrapper">
<Splitpanes>
<Pane minSize={40}>
<div class="h-full flex">
<div class="flex flex-col flex-1 m-4 mt-2 mr-2">
{#if batchProgress}
<div class="px-1 pb-2">
<BatchLoadProgress
loaded={batchProgress.loaded}
total={batchProgress.total}
itemsLabel="jobs"
batchSize={currentBatchSize}
batchSizeCap={1000}
onBatchSizeChange={(v) => jobsLoader.restreamWithBatchSize(v)}
onStop={() => jobsLoader.stopBatchLoading()}
/>
</div>
{/if}
<!-- Runs table. Add overflow-hidden because scroll is handled inside the runs table based on this wrapper height -->
<div class="grow min-h-0 overflow-y-hidden overflow-x-auto">
{#if jobs}
<RunsTable
{jobs}
externalJobs={externalJobs ?? []}
omittedObscuredJobs={extendedJobs?.omitted_obscured_jobs ?? false}
showExternalJobs={graph !== 'RunChart'}
activeLabel={filters.val.label}
{lastFetchWentToEnd}
loadingExtra={jobsLoader.loadingExtra}
bind:selectedIds
bind:selectedWorkspace
on:loadExtra={loadExtra}
on:filterByPath={filterByPath}
on:filterByUser={filterByUser}
on:filterByFolder={filterByFolder}
on:filterByLabel={filterByLabel}
on:filterByConcurrencyKey={filterByConcurrencyKey}
on:filterByTag={filterByTag}
on:filterBySchedule={filterBySchedule}
on:filterByWorker={filterByWorker}
bind:this={runsTable}
perPage={perPage.val}
bind:batchRerunOptionsIsOpen
onCancelJobs={onCancelSelectedJobs}
onSetJobsResolution={setJobsResolution}
{manualSelectionMode}
></RunsTable>
{:else}
<div class="gap-1 flex flex-col">
{#each new Array(8) as _}
<Skeleton layout={[[3]]} />
{/each}
</div>
{/if}
</div>
<div
class="bg-surface-tertiary border rounded-b-md flex text-xs px-2 py-1 items-center gap-4"
>
{#if !manualSelectionMode}
<DropdownV2
btnText="Batch actions"
size="xs"
items={[
{
displayName: 'Cancel jobs',
action: () => ((manualSelectionMode = 'cancel'), (selectedIds = []))
},
{
displayName: 'Re-run jobs',
action: () => {
manualSelectionMode = 'rerun'
selectedIds = []
batchRerunOptionsIsOpen = true
}
},
...(!$userStore?.operator
? [
{
// Operators are rejected by the endpoint, so offering it would only 403.
displayName: 'Resolve failed jobs',
action: () => (
(manualSelectionMode = 'resolve'),
(selectedIds = []),
(resolutionNote = '')
)
}
]
: []),
{
displayName: 'Cancel all jobs matching filters',
action: () => onCancelAllJobsMatchingFilters()
},
{
displayName: 'Re-run all jobs matching filters',
action: () => onRerunAllJobsMatchingFilters()
}
]}
/>
{:else}
<Button
size="xs"
destructive
onClick={() => {
manualSelectionMode = undefined
batchRerunOptionsIsOpen = false
}}
>
Exit selection mode
</Button>
{/if}
<div class="flex-1"></div>
<Toggle
size="xs"
color="nord"
bind:checked={autoRefresh.val}
options={{ right: 'Auto-refresh' }}
textClass="whitespace-nowrap"
/>
<Select
class="w-24"
bind:value={
() => perPage.val,
(newPerPage) => {
perPage.val = newPerPage
if (newPerPage > (jobs?.length ?? 1000)) loadExtra()
}
}
onCreateItem={(v) => (perPage.val = parseInt(v))}
items={[
{ value: 25, label: '25' },
{ value: 100, label: '100' },
{ value: 1000, label: '1000' },
{ value: 10000, label: '10000' }
]}
transformInputSelectedText={(_, v) => `${v} / page`}
/>
</div>
</div>
</div>
</Pane>
<AnimatedPane
size={40}
minSize={15}
class="flex flex-col"
opened={selectedIds.length > 0 || !!manualSelectionMode}
>
<div class="mt-12 overflow-y-auto pr-4 ml-2 relative flex-1">
{#if manualSelectionMode === 'cancel'}
<div
class="rounded-md bg-surface-tertiary border absolute inset-0 mb-4 flex flex-col items-center justify-center"
>
<Button
destructive
variant="accent"
disabled={!selectedIds.length}
onClick={() => onCancelSelectedJobs(selectedIds)}
>
Cancel {selectedIds.length} jobs
</Button>
</div>
{:else if manualSelectionMode === 'resolve'}
<div
class="rounded-md bg-surface-tertiary border absolute inset-0 mb-4 flex flex-col items-center justify-center gap-3 p-4"
>
<p class="text-xs text-secondary text-center max-w-xs">
Resolving keeps the run a failure but stops it showing as one in the runs list.
</p>
<TextInput
bind:value={resolutionNote}
inputProps={{
placeholder: $enterpriseLicense
? 'Why is this handled? (optional)'
: 'Notes and attribution require ee',
disabled: !$enterpriseLicense
}}
size="sm"
/>
<div class="flex flex-row gap-2">
<Button
variant="accent"
disabled={!selectedIds.length}
onClick={() => setJobsResolution(selectedIds, true, resolutionNote)}
>
Mark {selectedIds.length} resolved
</Button>
<Button
variant="default"
disabled={!selectedIds.length}
onClick={() => setJobsResolution(selectedIds, false)}
>
Unresolve {selectedIds.length}
</Button>
</div>
</div>
{:else if batchRerunOptionsIsOpen}
{#await import('$lib/components/runs/BatchReRunOptionsPane.svelte') then BatchReRunOptionsPane}
<BatchReRunOptionsPane.default
{selectedIds}
onCancel={() => (
(batchRerunOptionsIsOpen = false),
(manualSelectionMode = undefined)
)}
onConfirm={async (options) => {
await onReRunSelectedJobs(options)
}}
/>
{/await}
{:else if selectedIds.length === 1}
{#if selectedIds[0] === '-'}
<div class="p-4">There is no information available for this job</div>
{:else}
<!-- Dynamic, like the batch re-run pane: both only show once jobs are selected
and each statically reaches hundreds of modules (flow graph, monaco). -->
{#await import('$lib/components/runs/JobRunsPreview.svelte') then JobRunsPreview}
<JobRunsPreview.default
id={selectedIds[0]}
workspace={selectedWorkspace}
on:filterByConcurrencyKey={filterByConcurrencyKey}
on:filterByWorker={filterByWorker}
onResolutionChanged={() => jobsLoader?.loadJobs(true, true)}
/>
{/await}
{/if}
{:else if selectedIds.length > 1}
<div
class="rounded-md bg-surface-tertiary border absolute inset-0 mb-4 flex items-center justify-center"
>
<div class="text-xs m-4"> {selectedIds.length} jobs selected</div>
</div>
{/if}
</div>
</AnimatedPane>
</Splitpanes>
{#if pagedLayout}
<!-- No `onNavigate`: its arrow keys are read at `window`, where they would take
ArrowRight from the table's horizontal scroll and ArrowLeft from a modal or drawer
opened over the run. -->
<PagedContent
class="h-full"
current={runPageOpen ? 'run' : 'list'}
pages={[
{ key: 'list', content: runsList },
{ key: 'run', content: runPage, placeholder: runPagePlaceholder }
]}
/>
{:else}
<Splitpanes>
<Pane minSize={40}>
{@render runsList()}
</Pane>
<AnimatedPane
size={40}
minSize={15}
class="flex flex-col"
opened={selectedIds.length > 0 || !!manualSelectionMode}
>
{@render sidePanel()}
</AnimatedPane>
</Splitpanes>
{/if}
</div>
</div>
{/if}
{#snippet runsList()}
<div class="h-full flex">
<div class="flex flex-col flex-1 min-w-0 m-4 mt-2 mr-2">
{#if scanProgress}
<div class="px-1 pb-2 flex items-center gap-2">
<Loader2 size={14} class="animate-spin shrink-0 text-accent" />
<div class="flex-1 min-w-0">
<BatchLoadProgress
loaded={Math.round(scanProgress.fraction * 100)}
total={100}
itemsLabel="runs"
label={`Slow search: runs since ${displayDate(scanProgress.scannedTo)} searched, ${jobs?.length ?? 0} found`}
onStop={() => jobsLoader.stopBatchLoading()}
/>
</div>
</div>
{/if}
{#if batchProgress}
<div class="px-1 pb-2">
<BatchLoadProgress
loaded={batchProgress.loaded}
total={batchProgress.total}
itemsLabel="jobs"
batchSize={currentBatchSize}
batchSizeCap={1000}
onBatchSizeChange={(v) => jobsLoader.restreamWithBatchSize(v)}
onStop={() => jobsLoader.stopBatchLoading()}
/>
</div>
{/if}
<!-- Runs table. Add overflow-hidden because scroll is handled inside the runs table based on this wrapper height -->
<div class="grow min-h-0 overflow-y-hidden overflow-x-auto">
{#if jobs}
<RunsTable
{jobs}
externalJobs={externalJobs ?? []}
omittedObscuredJobs={extendedJobs?.omitted_obscured_jobs ?? false}
showExternalJobs={graph !== 'RunChart'}
activeLabel={filters.val.label}
{lastFetchWentToEnd}
loadingExtra={jobsLoader.loadingExtra}
bind:selectedIds
bind:selectedWorkspace
on:select={() => (openedRunId = selectedIds.length === 1 ? selectedIds[0] : undefined)}
on:loadExtra={loadExtra}
on:filterByPath={filterByPath}
on:filterByUser={filterByUser}
on:filterByFolder={filterByFolder}
on:filterByLabel={filterByLabel}
on:filterByConcurrencyKey={filterByConcurrencyKey}
on:filterByTag={filterByTag}
on:filterBySchedule={filterBySchedule}
on:filterByWorker={filterByWorker}
bind:this={runsTable}
perPage={perPage.val}
bind:batchRerunOptionsIsOpen
onCancelJobs={onCancelSelectedJobs}
onSetJobsResolution={setJobsResolution}
{manualSelectionMode}
></RunsTable>
{:else}
<div class="gap-1 flex flex-col">
{#each new Array(8) as _}
<Skeleton layout={[[3]]} />
{/each}
</div>
{/if}
</div>
<div
class="bg-surface-tertiary border rounded-b-md flex text-xs px-2 py-1 items-center gap-4"
>
{#if !manualSelectionMode}
<DropdownV2
btnText="Batch actions"
size="xs"
items={[
{
displayName: 'Cancel jobs',
action: () => ((manualSelectionMode = 'cancel'), (selectedIds = []))
},
{
displayName: 'Re-run jobs',
action: () => {
manualSelectionMode = 'rerun'
selectedIds = []
batchRerunOptionsIsOpen = true
}
},
...(!$userStore?.operator
? [
{
// Operators are rejected by the endpoint, so offering it would only 403.
displayName: 'Resolve failed jobs',
action: () => (
(manualSelectionMode = 'resolve'),
(selectedIds = []),
(resolutionNote = '')
)
}
]
: []),
{
displayName: 'Cancel all jobs matching filters',
action: () => onCancelAllJobsMatchingFilters()
},
{
displayName: 'Re-run all jobs matching filters',
action: () => onRerunAllJobsMatchingFilters()
}
]}
/>
{:else}
<Button
size="xs"
destructive
onClick={() => {
manualSelectionMode = undefined
batchRerunOptionsIsOpen = false
}}
>
Exit selection mode
</Button>
{/if}
<div class="flex-1"></div>
<Toggle
size="xs"
color="nord"
bind:checked={autoRefresh.val}
options={{ right: 'Auto-refresh' }}
textClass="whitespace-nowrap"
/>
<Select
class="w-24"
bind:value={
() => perPage.val,
(newPerPage) => {
perPage.val = newPerPage
if (newPerPage > (jobs?.length ?? 1000)) loadExtra()
}
}
onCreateItem={(v) => (perPage.val = parseInt(v))}
items={[
{ value: 25, label: '25' },
{ value: 100, label: '100' },
{ value: 1000, label: '1000' },
{ value: 10000, label: '10000' }
]}
transformInputSelectedText={(_, v) => `${v} / page`}
/>
</div>
</div>
</div>
{/snippet}
{#snippet sidePanel()}
<div class="mt-12 overflow-y-auto pr-4 ml-2 relative flex-1">
{#if manualSelectionMode === 'cancel'}
<div
class="rounded-md bg-surface-tertiary border absolute inset-0 mb-4 flex flex-col items-center justify-center"
>
<Button
destructive
variant="accent"
disabled={!selectedIds.length}
onClick={() => onCancelSelectedJobs(selectedIds)}
>
Cancel {selectedIds.length} jobs
</Button>
</div>
{:else if manualSelectionMode === 'resolve'}
<div
class="rounded-md bg-surface-tertiary border absolute inset-0 mb-4 flex flex-col items-center justify-center gap-3 p-4"
>
<p class="text-xs text-secondary text-center max-w-xs">
Resolving keeps the run a failure but stops it showing as one in the runs list.
</p>
<TextInput
bind:value={resolutionNote}
inputProps={{
placeholder: $enterpriseLicense
? 'Why is this handled? (optional)'
: 'Notes and attribution require ee',
disabled: !$enterpriseLicense
}}
size="sm"
/>
<div class="flex flex-row gap-2">
<Button
variant="accent"
disabled={!selectedIds.length}
onClick={() => setJobsResolution(selectedIds, true, resolutionNote)}
>
Mark {selectedIds.length} resolved
</Button>
<Button
variant="default"
disabled={!selectedIds.length}
onClick={() => setJobsResolution(selectedIds, false)}
>
Unresolve {selectedIds.length}
</Button>
</div>
</div>
{:else if batchRerunOptionsIsOpen}
{@render batchRerunOptions()}
{:else if selectedIds.length === 1}
{#if selectedIds[0] === '-'}
<div class="p-4">There is no information available for this job</div>
{:else}
{@render jobPreview()}
{/if}
{:else if selectedIds.length > 1}
<div
class="rounded-md bg-surface-tertiary border absolute inset-0 mb-4 flex items-center justify-center"
>
<div class="text-xs m-4"> {selectedIds.length} jobs selected</div>
</div>
{/if}
</div>
{/snippet}
{#snippet runPage()}
{@render runPageFrame(runPageBody)}
{/snippet}
{#snippet runPagePlaceholder()}
{@render runPageFrame(runPageSkeleton)}
{/snippet}
{#snippet runPageFrame(body: Snippet)}
<!-- Margins and header row match the runs table's column header, so the back row sits where
the column labels were. -->
<div class="h-full flex flex-col min-h-0 p-4 pt-2 pr-2">
<div class="flex min-h-6 my-2 items-end">
<Button
unifiedSize="xs"
variant="subtle"
startIcon={{ icon: ArrowLeft }}
btnClasses="-mb-1 gap-1 text-xs font-semibold leading-3"
onclick={backToList}
>
Runs list
</Button>
</div>
<div class="grow min-h-0 overflow-y-auto">
{@render body()}
</div>
</div>
{/snippet}
{#snippet runPageBody()}
{#if batchRerunOptionsIsOpen}
{@render batchRerunOptions()}
{:else if selectedIds[0] === '-'}
<div class="p-4">There is no information available for this job</div>
{:else if selectedIds.length === 1}
{@render jobPreview(runPageSkeleton)}
{/if}
{/snippet}
<!-- The shape of a run: its header card, then inputs and result. `mounted` skips the skeleton's
fade-in, which outlasts the slide it is drawn for. -->
{#snippet runPageSkeleton()}
<Skeleton mounted layout={[[4.5], 1.5, [1], 0.5, [3], 1.5, [1], 0.5, [8]]} />
{/snippet}
{#snippet batchRerunOptions()}
{#await import('$lib/components/runs/BatchReRunOptionsPane.svelte') then BatchReRunOptionsPane}
<BatchReRunOptionsPane.default
{selectedIds}
onCancel={() => ((batchRerunOptionsIsOpen = false), (manualSelectionMode = undefined))}
onConfirm={async (options) => {
await onReRunSelectedJobs(options)
}}
/>
{/await}
{/snippet}
{#snippet jobPreview(pending?: Snippet)}
<!-- Dynamic, like the batch re-run pane: both only show once jobs are selected
and each statically reaches hundreds of modules (flow graph, monaco). -->
{#await import('$lib/components/runs/JobRunsPreview.svelte')}
{@render pending?.()}
{:then JobRunsPreview}
<JobRunsPreview.default
id={selectedIds[0]}
workspace={selectedWorkspace}
on:filterByConcurrencyKey={filterByConcurrencyKey}
on:filterByWorker={filterByWorker}
onResolutionChanged={() => jobsLoader?.loadJobs(true, true)}
/>
{/await}
{/snippet}
<style>
:global(.bottom-splitpane-wrapper .splitpanes__splitter) {
background-color: transparent !important;
@@ -4,7 +4,8 @@
const bubble = createBubbler()
import type { Schema } from '$lib/common'
import { VariableService } from '$lib/gen'
import { allTrue, computeShow, type DynamicInput } from '$lib/utils'
import { allTrue, type DynamicInput } from '$lib/utils'
import { computeShow, quickjsSettled } from '$lib/utils/quickjsEval.svelte'
import { Button } from './common'
import ItemPicker from './ItemPicker.svelte'
import VariableEditor from './VariableEditor.svelte'
@@ -232,7 +233,9 @@
continue
}
if (prop?.showExpr) {
if (computeShow(x.value, prop.showExpr, args)) {
const show = computeShow(prop.showExpr, args)
if (show === undefined) continue
if (show) {
hidden[x.value] = false
} else if (!hidden[x.value]) {
hidden[x.value] = true
@@ -277,6 +280,7 @@
for (const key in args) {
args[key]
}
quickjsSettled()
}
untrack(() => handleHiddenFields(schema, args ?? {}))
})
+82 -72
View File
@@ -81,6 +81,7 @@
import type { ScriptEditorWhitelabelCustomUi } from './custom_ui'
import Tabs from './common/tabs/Tabs.svelte'
import Tab from './common/tabs/Tab.svelte'
import DraggableTabs, { type TabItem } from './common/tabs/DraggableTabs.svelte'
import { slide } from 'svelte/transition'
import CaptureTable from '$lib/components/triggers/CaptureTable.svelte'
import CaptureButton from './triggers/CaptureButton.svelte'
@@ -407,6 +408,30 @@
let supportsModules = $derived((lang === 'bun' || lang === 'python3') && isWacV2)
let mainFileName = $derived('script.' + langToExt(scriptLangToEditorLang(lang)))
// A module path always ends in a file extension, so this id can't collide with one.
const MAIN_FILE_TAB_ID = '__main__'
// Tab order is view state only: `modules` is stored as JSONB, which re-sorts its keys on
// deploy, so an order written into it would not survive.
let moduleTabOrder: string[] = $state([])
let fileTabs = $derived.by<TabItem[]>(() => {
const paths = Object.keys(modules ?? {})
const rank = new Map(paths.map((p, i) => [p, i + moduleTabOrder.length]))
moduleTabOrder.forEach((p, i) => rank.has(p) && rank.set(p, i))
return [
{ id: MAIN_FILE_TAB_ID, label: mainFileName, closable: false, pinned: 'left' },
// Not closable through the strip: closing deletes the module's code, so it stays on
// the explicit × rather than Delete/Backspace and middle-click.
...paths
.sort((a, b) => rank.get(a)! - rank.get(b)!)
.map((modulePath) => ({
id: modulePath,
label: modulePath,
title: modulePath,
closable: false
}))
]
})
let modulePathInput = $state('')
let showAddModulePopover = $state(false)
let modulePathInputEl: HTMLInputElement | undefined = $state(undefined)
@@ -561,6 +586,7 @@
delete modules[modulePath]
delete moduleTestState[modulePath]
modules = { ...modules }
moduleTabOrder = moduleTabOrder.filter((p) => p !== modulePath)
}
function validateRenameModulePath(newPath: string, oldPath: string): string {
@@ -591,6 +617,11 @@
return 'path' in canonical && canonical.path === oldPath
}
function prefillRename(modulePath: string) {
renameModuleInput = modulePath
renameModuleError = ''
}
function renameModule(oldPath: string) {
if (!renameModuleInput.trim()) return
const canonical = canonicalModulePath(renameModuleInput)
@@ -611,6 +642,7 @@
delete modules[oldPath]
modules[newPath] = { ...mod, language: newLang ?? mod.language }
modules = { ...modules }
moduleTabOrder = moduleTabOrder.map((p) => (p === oldPath ? newPath : p))
if (moduleTestState[oldPath]) {
moduleTestState[newPath] = moduleTestState[oldPath]
delete moduleTestState[oldPath]
@@ -2563,93 +2595,71 @@
{#snippet editorContent()}
<div class="h-full !overflow-visible bg-surface dark:bg-surface-secondary relative flex flex-col">
{#if supportsModules}
<div
class="flex items-center border-b border-tertiary/30 bg-surface-secondary px-1 gap-0.5 text-xs overflow-x-auto shrink-0"
<DraggableTabs
tabs={fileTabs}
activeId={activeModuleTab ?? MAIN_FILE_TAB_ID}
onSelect={(id) => (id === MAIN_FILE_TAB_ID ? switchToMain() : switchToModule(id))}
onReorder={(next) => (moduleTabOrder = next.map((t) => t.id))}
class="shrink-0 border-b border-light bg-surface-secondary/50"
>
<button
class="px-2 py-1 rounded-t {activeModuleTab === null
? 'bg-surface font-semibold border-b-2 border-blue-500'
: 'hover:bg-surface-hover'}"
onclick={() => switchToMain()}
>
{mainFileName}
</button>
{#each Object.keys(modules ?? {}) as modulePath}
<div
class="group rounded-t flex items-center {activeModuleTab === modulePath
? 'bg-surface font-semibold border-b-2 border-blue-500'
: 'hover:bg-surface-hover'}"
>
<button class="pl-2 py-1 flex items-center" onclick={() => switchToModule(modulePath)}>
{modulePath}
</button>
<div class="flex items-center pr-1 w-[32px] justify-end">
{#snippet tabAccessory(tab)}
{#if tab.id !== MAIN_FILE_TAB_ID}
<!-- Stops the trigger's click, mouse or Enter/Space alike, from also selecting the tab. -->
<span role="presentation" class="inline-flex" onclick={(e) => e.stopPropagation()}>
<Popover
placement="bottom-start"
openFocus={renameModuleInputEl}
contentClasses="p-3 w-72"
class="inline-flex"
triggerAttrs={{
'aria-label': `Rename ${tab.id}`,
onpointerdown: () => prefillRename(tab.id),
onkeydown: (e: KeyboardEvent) =>
(e.key === 'Enter' || e.key === ' ') && prefillRename(tab.id)
}}
>
{#snippet trigger()}
<span
class="opacity-0 group-hover:opacity-100 hover:text-blue-500 transition-opacity"
role="button"
tabindex="0"
onclick={(e) => {
e.stopPropagation()
renameModuleInput = modulePath
renameModuleError = ''
}}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.stopPropagation()
renameModuleInput = modulePath
renameModuleError = ''
}
}}
class="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 rounded hover:bg-surface-hover w-4 h-4 inline-flex items-center justify-center"
>
<Pencil size={12} />
<Pencil size={10} />
</span>
{/snippet}
{#snippet content({ close })}
{@render renameModuleForm(modulePath, close)}
{@render renameModuleForm(tab.id, close)}
{/snippet}
</Popover>
<span
class="opacity-0 group-hover:opacity-100 hover:text-red-500 transition-opacity"
role="button"
tabindex="0"
onclick={(e) => {
e.stopPropagation()
removeModule(modulePath)
}}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.stopPropagation()
removeModule(modulePath)
}
}}
>
<X size={12} />
</span>
</div>
</div>
{/each}
<Popover
placement="bottom-start"
bind:isOpen={showAddModulePopover}
openFocus={modulePathInputEl}
contentClasses="p-3 w-72"
>
{#snippet trigger()}
<span class="px-2 py-1 rounded-t hover:bg-surface-hover inline-flex items-center">
<Plus size={12} />
</span>
{/snippet}
{#snippet content({ close })}
{@render addModuleForm(close)}
{/snippet}
</Popover>
</div>
<Button
variant="subtle"
unifiedSize="2xs"
iconOnly
startIcon={{ icon: X }}
wrapperClasses="opacity-0 group-hover:opacity-100 focus-within:opacity-100"
aria-label={`Delete ${tab.id}`}
title={`Delete ${tab.id}`}
onClick={() => removeModule(tab.id)}
/>
{/if}
{/snippet}
{#snippet afterTabs()}
<Popover
placement="bottom-start"
bind:isOpen={showAddModulePopover}
openFocus={modulePathInputEl}
contentClasses="p-3 w-72"
class="ml-0.5 h-6 w-6 rounded-md inline-flex items-center justify-center text-hint hover:text-secondary hover:bg-surface-hover focus:outline-none focus-visible:ring-1 focus-visible:ring-border-selected"
triggerAttrs={{ 'aria-label': 'Add module' }}
>
{#snippet trigger()}
<Plus size={12} />
{/snippet}
{#snippet content({ close })}
{@render addModuleForm(close)}
{/snippet}
</Popover>
{/snippet}
</DraggableTabs>
{/if}
<div class="relative flex-1 min-h-0 min-w-0 !overflow-visible">
<div class="absolute bg-surface top-2 right-4 z-10 flex flex-row gap-2">
@@ -154,7 +154,7 @@ numpy>=1.24.0
// go: `module mymod
// go 1.26
// go 1.27
// require (
// rsc.io/quote v1.5.2
@@ -124,6 +124,8 @@
}
let scheduleEditor: ScheduleEditor | undefined = $state(undefined)
let flowHistory: FlowHistory | undefined = $state(undefined)
let hideForOperator = $derived($userStore?.operator && !$operatorBuilderFlows)
</script>
{#if menuOpen}
@@ -191,25 +193,24 @@
{/snippet}
{#snippet actions()}
<span class="hidden md:inline-flex gap-x-1">
{#if !$userStore?.operator}
{#if showEditButton && flow.canWrite && !flow.archived}
<div>
<Button
variant="subtle"
wrapperClasses="w-20"
unifiedSize="md"
startIcon={{ icon: Pen }}
href="{base}/flows/edit/{flow.path}"
aiId={`edit-flow-button-${flow.summary?.length > 0 ? flow.summary : flow.path}`}
aiDescription={`Edits the flow ${flow.summary?.length > 0 ? flow.summary : flow.path}`}
>
Edit
</Button>
</div>
{/if}
{#if !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !flow.canWrite)}
<EditInForkButton itemType="flow" path={flow.path} />
{/if}
{#if !hideForOperator && showEditButton && flow.canWrite && !flow.archived}
<div>
<Button
variant="subtle"
wrapperClasses="w-20"
unifiedSize="md"
startIcon={{ icon: Pen }}
href="{base}/flows/edit/{flow.path}"
aiId={`edit-flow-button-${flow.summary?.length > 0 ? flow.summary : flow.path}`}
aiDescription={`Edits the flow ${flow.summary?.length > 0 ? flow.summary : flow.path}`}
>
Edit
</Button>
</div>
{/if}
<!-- Hidden from every operator, builder rights included. -->
{#if !$userStore?.operator && !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) && (!showEditButton || !flow.canWrite)}
<EditInForkButton itemType="flow" path={flow.path} />
{/if}
</span>
@@ -220,7 +221,6 @@
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),
@@ -202,6 +202,8 @@
// would fire, so the tab must claim its keys first via stopPropagation.
function handleKeydown(e: KeyboardEvent, tab: TabItem) {
if (e.target !== e.currentTarget) return // let nested controls (close ×) act
// A modified key is the host page's shortcut (e.g. Cmd+Enter to run), not a tab action.
if (e.ctrlKey || e.metaKey || e.altKey) return
if (e.key === 'Delete' || e.key === 'Backspace') {
if (tab.closable !== false) {
e.preventDefault()
@@ -226,6 +228,19 @@
}
}
// svelte-dnd-action claims every Enter/Space on a focused tab or on its (also focusable)
// item wrapper to start a keyboard drag, and stops it there — so a modified one, the host
// page's shortcut, is stopped first and re-sent from the strip, above the dnd zone. The
// target test mirrors dnd's own skip, leaving nested buttons and inputs alone.
function forwardModifiedKey(e: KeyboardEvent) {
if (!(e.ctrlKey || e.metaKey || e.altKey) || (e.key !== 'Enter' && e.key !== ' ')) return
const target = e.target as HTMLElement & { disabled?: boolean; href?: string }
if (target === stripEl || target.disabled !== undefined || target.href) return
if (target.isContentEditable) return
e.stopPropagation()
stripEl?.dispatchEvent(new KeyboardEvent(e.type, e))
}
function handleAuxClick(e: MouseEvent, tab: TabItem) {
if (e.button === 1 && tab.closable !== false) {
e.preventDefault()
@@ -274,7 +289,12 @@
</div>
{/snippet}
<div bind:this={stripEl} class={twMerge('flex items-center bg-surface min-h-8', c)}>
<div
bind:this={stripEl}
class={twMerge('flex items-center bg-surface min-h-8', c)}
onkeydowncapture={forwardModifiedKey}
role="presentation"
>
<!-- The tabs centre in the full strip and the bar overlays the air under them,
flush with the strip's bottom edge — it takes no height of its own, so the
strip never resizes and the tabs sit at the same place whether or not they
@@ -8,10 +8,13 @@
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
import { copilotInfo } from '$lib/aiStore'
import { logFeatureUsage } from '$lib/utils/featureUsage'
import { pageHref } from '$lib/components/sessions/previewPaths'
interface Props {
/** Unset for a viewer who cannot edit the item: the card then offers no way to the editor. */
onEditInstructions: (() => void) | undefined
/** Empty renders the AI button alone, sized for the run form's action row, which
* is where the host places it; a prompt makes this a card of its own. */
instructions: string
runnableType: 'script' | 'flow'
path: string | undefined
@@ -37,72 +40,52 @@
aiChatManager.askAi(`Analyze the ${runnableType} form on this page and fill the inputs for me`)
}
// A session cannot reach this page's form (the preview is a separate editor,
// and form filling drives the DOM through NAVIGATOR mode), so the hand-off
// asks it to run the item instead. Naming the DEPLOYED version matters: the
// test_run_* tools prefer drafts, which is not what this page runs.
// The session's preview opens on this same deployed page (`/get/`), not the item's
// editor: someone on a run page came to run the item, not to change it.
const sessionSource = $derived(
path
? {
target: { kind: runnableType, path } as const,
page: () => pageHref(`/${runnableType}s/get/${path}`),
workspaceId: $operatingWorkspace ?? undefined,
beforeOpen: logAsked,
seedPrompt:
`Run the deployed ${runnableType} \`${path}\` for me. Pick sensible inputs, ` +
`tell me what you chose, then run it.` +
(instructions ? `\n\nHow to choose the inputs:\n${instructions}` : '')
beforeOpen: logAsked
}
: undefined
)
</script>
{#if !$copilotInfo.workspaceDisabled}
{#snippet aiButton(unifiedSize: 'sm' | 'md')}
<!-- The fallback is a plain Button rather than AskAiButton, whose own session branch
would fire here too and open an empty session. -->
<OpenInSessionButton source={sessionSource} btnProps={{ unifiedSize }}>
{#snippet fallback()}
<Button
{unifiedSize}
startIcon={{ icon: WandSparkles }}
btnClasses={AIBtnClasses('default')}
onclick={fillFormWithAI}
>
Fill with AI
</Button>
{/snippet}
</OpenInSessionButton>
{/snippet}
{#if $copilotInfo.workspaceDisabled}
<!-- The workspace hid the assistant. -->
{:else if instructions}
<div class="my-2 flex flex-col gap-1">
<div class="flex flex-row gap-2 justify-between items-center">
{#if instructions}
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: expanded ? ChevronDown : ChevronRight }}
onclick={() => (expanded = !expanded)}
>
Additional prompt for AI
</Button>
{:else if onEditInstructions}
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: Pencil }}
onclick={onEditInstructions}
>
Add a prompt for AI
</Button>
{:else}
<span></span>
{/if}
<!-- Each button names its own action because the two branches do different things:
the hand-off runs the item, the legacy path fills the form. A plain Button rather
than AskAiButton, whose own session branch would fire here too and open an empty
session. -->
<OpenInSessionButton
source={sessionSource}
label="Run in AI session"
tooltip="Open an AI session that picks inputs and runs this"
btnProps={{ iconOnly: false, unifiedSize: 'sm', startIcon: { icon: WandSparkles } }}
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: expanded ? ChevronDown : ChevronRight }}
onclick={() => (expanded = !expanded)}
>
{#snippet fallback()}
<Button
unifiedSize="sm"
startIcon={{ icon: WandSparkles }}
btnClasses={AIBtnClasses('default')}
onclick={fillFormWithAI}
>
Fill with AI
</Button>
{/snippet}
</OpenInSessionButton>
AI prompt
</Button>
{@render aiButton('sm')}
</div>
{#if instructions && expanded}
{#if expanded}
<div
transition:slide={{ duration: 120 }}
class="flex flex-row gap-2 items-start justify-between p-2 bg-surface-secondary rounded-md"
@@ -120,4 +103,6 @@
</div>
{/if}
</div>
{:else}
{@render aiButton('md')}
{/if}
@@ -13,7 +13,7 @@
{#if !$copilotInfo.workspaceDisabled}
<Label
label="Additional prompt for AI"
label="AI prompt"
tooltip="An AI choosing this {type}'s inputs already reads its description and each field's description. Anything written here is given to it as extra guidance: mention specific fields and how they interact."
>
<TextInput
@@ -24,6 +24,7 @@
const diffRows = $derived(diffLines ?? toolDiffLines(diff, streaming))
const visibleRows = $derived(visibleToolDiffRows(diffRows, expansions))
const language = $derived(toolCodeDiffLanguage(diff.lang))
const beforeLanguage = $derived(toolCodeDiffLanguage(diff.beforeLang ?? diff.lang))
const lastLines = $derived.by(() => {
let old = 0
let current = 0
@@ -38,10 +39,12 @@
`max(0.875rem, ${String(Math.max(lastLines.old, lastLines.current)).length}ch)`
)
const highlighted = $derived.by(() => {
if (!hljs.getLanguage(language.name)) hljs.registerLanguage(language.name, language.register)
for (const { name, register } of [language, beforeLanguage]) {
if (!hljs.getLanguage(name)) hljs.registerLanguage(name, register)
}
return {
original: highlightedSourceLines(diff.before, language.name, lastLines.old),
original: highlightedSourceLines(diff.before, beforeLanguage.name, lastLines.old),
modified: highlightedSourceLines(diff.after, language.name, lastLines.current)
}
})
@@ -82,50 +85,54 @@
class="tool-code-diff max-h-[400px] overflow-auto bg-surface-tertiary text-xs leading-[18px] text-primary"
style="--line-number: {lineNumberWidth}"
>
{#each visibleRows as row, index ('key' in row ? `${row.kind}:${row.key}` : `line:${row.oldLine}:${row.newLine}:${index}`)}
{#if row.kind === 'collapsed' || row.kind === 'omitted'}
<Button
unifiedSize="2xs"
variant="subtle"
btnClasses="grid min-h-7 w-full grid-cols-[var(--line-number)_calc(2.0625rem_+_var(--line-number))_minmax(0,1fr)] items-center gap-0 rounded-none bg-surface-secondary px-0 py-1 text-left text-xs font-normal leading-[18px] text-hint hover:bg-surface-hover"
onclick={() => expand(row.key)}
>
<span class="text-right">...</span>
<span class="grid grid-cols-[1.125rem_var(--line-number)_0.9375rem]">
<span></span><span class="text-right">...</span><span>...</span>
</span>
<span>{row.count} {row.kind === 'collapsed' ? 'unchanged lines' : 'more lines'}</span>
</Button>
{:else}
<div
class="diff-line-{row.kind} grid grid-cols-[var(--line-number)_calc(2.0625rem_+_var(--line-number))_minmax(0,1fr)]"
>
<span class="text-right text-hint">{row.oldLine ?? ''}</span>
<span class="grid grid-cols-[1.125rem_var(--line-number)_0.9375rem] text-hint">
<span></span><span class="text-right">{row.newLine ?? ''}</span><span
>{row.kind === 'added' ? '+' : row.kind === 'removed' ? '-' : ''}</span
>
</span>
<span
class="relative min-w-0 whitespace-pre {row.kind === 'added'
? 'bg-green-500/20'
: row.kind === 'removed'
? 'bg-red-500/20'
: ''}"
<!-- Rows take this wrapper's width, so the longest line sets it: sized to the scroller
instead, a row's background stops at the visible edge when a line overflows. -->
<div class="w-max min-w-full">
{#each visibleRows as row, index ('key' in row ? `${row.kind}:${row.key}` : `line:${row.oldLine}:${row.newLine}:${index}`)}
{#if row.kind === 'collapsed' || row.kind === 'omitted'}
<Button
unifiedSize="2xs"
variant="subtle"
btnClasses="grid min-h-7 w-full grid-cols-[var(--line-number)_calc(2.0625rem_+_var(--line-number))_minmax(0,1fr)] items-center gap-0 rounded-none bg-surface-secondary px-0 py-1 text-left text-xs font-normal leading-[18px] text-hint hover:bg-surface-hover"
onclick={() => expand(row.key)}
>
{#each row.changedRanges ?? [] as range}
<span
class="pointer-events-none absolute top-0 z-0 h-[18px] {row.kind === 'added'
? 'bg-green-500/25'
: 'bg-red-500/20'}"
style={rangeStyle(row.content, range)}
></span>
{/each}
<span class="relative z-[1]">{@html highlightedLine(row) || '&nbsp;'}</span>
</span>
</div>
{/if}
{/each}
<span class="text-right">...</span>
<span class="grid grid-cols-[1.125rem_var(--line-number)_0.9375rem]">
<span></span><span class="text-right">...</span><span>...</span>
</span>
<span>{row.count} {row.kind === 'collapsed' ? 'unchanged lines' : 'more lines'}</span>
</Button>
{:else}
<div
class="diff-line-{row.kind} grid grid-cols-[var(--line-number)_calc(2.0625rem_+_var(--line-number))_minmax(0,1fr)]"
>
<span class="text-right text-hint">{row.oldLine ?? ''}</span>
<span class="grid grid-cols-[1.125rem_var(--line-number)_0.9375rem] text-hint">
<span></span><span class="text-right">{row.newLine ?? ''}</span><span
>{row.kind === 'added' ? '+' : row.kind === 'removed' ? '-' : ''}</span
>
</span>
<span
class="relative min-w-0 whitespace-pre {row.kind === 'added'
? 'bg-green-500/20'
: row.kind === 'removed'
? 'bg-red-500/20'
: ''}"
>
{#each row.changedRanges ?? [] as range}
<span
class="pointer-events-none absolute top-0 z-0 h-[18px] {row.kind === 'added'
? 'bg-green-500/25'
: 'bg-red-500/20'}"
style={rangeStyle(row.content, range)}
></span>
{/each}
<span class="relative z-[1]">{@html highlightedLine(row) || '&nbsp;'}</span>
</span>
</div>
{/if}
{/each}
</div>
</div>
<style>
@@ -4,7 +4,7 @@
import ToolCodeDiffView from './ToolCodeDiffView.svelte'
import ToolPreviewCard from './ToolPreviewCard.svelte'
import {
argumentsCarryWholeFile,
diffIsWholeFile,
diffLineCounts,
toolCodeDiff,
toolDiffLineCounts,
@@ -13,9 +13,10 @@
interface Props {
message: ToolDisplayMessage
hidePreviewChip?: boolean
}
let { message }: Props = $props()
let { message, hidePreviewChip = false }: Props = $props()
const diff = $derived(toolCodeDiff(message))
const isStreaming = $derived(Boolean(message.isStreamingArguments))
@@ -25,16 +26,17 @@
// Keyed by call id: a bare flag would carry the expansion onto the next message that
// reuses this instance. An active or failed call opens by itself, and remains open
// when it settles so the new diff does not disappear under the user. A call whose arguments
// are a whole file (`write_script`, full-code `edit_code`) opens only on failure. The key
// also changes on failure, so a collapse made while running cannot hide the error.
// when it settles so the new diff does not disappear under the user. A call whose diff is a
// whole file (`write_script`, full-code `edit_code`, raw app writes and deletes) opens only
// on failure. The key also changes on failure, so a collapse made while running cannot hide
// the error.
const toggleKey = $derived(`${message.tool_call_id}:${message.error ? 'error' : ''}`)
let toggled = $state<{ id: string; open: boolean } | undefined>(undefined)
const opensByDefault = $derived(
Boolean(
message.error ||
(hasDiff &&
!argumentsCarryWholeFile(message) &&
!diffIsWholeFile(message) &&
(message.isLoading || message.isQueued || message.isStreamingArguments))
)
)
@@ -43,9 +45,7 @@
toggled = { id: toggleKey, open: true }
}
})
const expanded = $derived(
hasBody && (toggled?.id === toggleKey ? toggled.open : opensByDefault)
)
const expanded = $derived(hasBody && (toggled?.id === toggleKey ? toggled.open : opensByDefault))
const lines = $derived(diff && expanded ? toolDiffLines(diff, isStreaming) : undefined)
const counts = $derived(
diff && expanded
@@ -57,7 +57,7 @@
const isRunning = $derived(Boolean(message.isLoading && !message.needsConfirmation))
const showPreviewChip = $derived(
Boolean(message.previewCard && !message.isLoading && !message.error)
Boolean(!hidePreviewChip && message.previewCard && !message.isLoading && !message.error)
)
</script>
@@ -114,9 +114,13 @@
// otherwise the error set on its status would be invisible.
const detailsAvailable = $derived(message.showDetails === true || message.error !== undefined)
// Queued and running calls stay closed: their label already says what they are doing, and
// opening them would only show empty logs and result. A call opens while its arguments
// stream, while it waits on the user, and once it has an error or details to keep in view.
let isExpanded = $derived(
Boolean(
(detailsAvailable && (!isSuccessful || !autoCollapseDetails)) ||
(detailsAvailable &&
(message.error !== undefined || (isSuccessful && !autoCollapseDetails))) ||
(message.isStreamingArguments && hasParameters) ||
(message.isLoading && message.needsConfirmation)
)
@@ -137,7 +141,7 @@
// A call that inspected a run rather than starting one gets the same card, bound to
// the job it named — what happened in a run reads the same either way.
const isRunCard = $derived(Boolean(message.runForm || message.inspectedRun))
const isDiffCard = $derived(Boolean(message.codeDiff) || hasToolCodeDiff(message.toolName))
const isDiffCard = $derived(hasToolCodeDiff(message))
// The preview chip sits on the header row (to the right of the tool-call text);
// shown once the tool settled, never while loading/erroring/awaiting confirmation.
@@ -193,7 +197,7 @@
{:else if isRunCard}
<RunScriptCard {message} />
{:else if isDiffCard}
<ToolDiffCard {message} />
<ToolDiffCard {message} {hidePreviewChip} />
{:else if planState}
<!-- Same lean shape as a tool call below: a header row that collapses into the
transcript, with everything else in one box under it. -->
@@ -0,0 +1,25 @@
// Kept apart from toolCodeDiff.ts, which imports Monaco's diff engine: the chat core runs in
// the app shell and must not statically reach Monaco.
const LANGUAGE_BY_APP_FILE_EXTENSION: Record<string, string> = {
ts: 'typescript',
tsx: 'typescript',
mts: 'typescript',
js: 'javascript',
jsx: 'javascript',
mjs: 'javascript',
py: 'python',
css: 'css',
html: 'xml',
svelte: 'xml',
vue: 'xml',
json: 'json',
yaml: 'yaml',
yml: 'yaml'
}
// A raw app's backend runnables are named `backend/<key>/main.ts|py`, so their extension
// picks the language like any frontend file's.
export function appFileEditorLang(filePath: string | undefined): string {
const extension = filePath?.split('/').pop()?.split('.').slice(1).pop()?.toLowerCase()
return (extension && LANGUAGE_BY_APP_FILE_EXTENSION[extension]) ?? 'plaintext'
}
@@ -344,6 +344,7 @@ export function getDatatableTools(): SessionTool<{}>[] {
def: getExecDatatableSqlToolDef(),
requiresConfirmation: true,
confirmationMessage: 'Execute SQL on datatable',
queuedLabel: (args) => `Execute SQL on "${args?.datatable_name ?? 'datatable'}"`,
showDetails: true,
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
const parsedArgs = getExecDatatableSqlSchema().parse(args)
@@ -1,5 +1,10 @@
import { ResourceService, WorkspaceService, type AIConfig, type AIProvider } from '$lib/gen'
import { AI_PROVIDERS, fetchAvailableModels } from '../../lib'
import {
AI_PROVIDERS,
DECISION_AI_PROVIDERS,
aiProviderDetails,
fetchAvailableModels
} from '../../lib'
import {
collectAiAgentProviderRefs,
isModelId,
@@ -10,8 +15,11 @@ import {
} from './aiAgentProviders'
// Read lazily: evaluating this at module scope makes `AI_PROVIDERS` a load-time requirement
// for everything that reaches this module, the whole global chat included.
const aiResourceTypes = () => Object.keys(AI_PROVIDERS) as AIProvider[]
// for everything that reaches this module, the whole global chat included. Decision kinds are
// included so an AI decision step's resource validates; `validateAiAgentProviders` keeps each kind
// to its step type.
const aiResourceTypes = () =>
[...Object.keys(AI_PROVIDERS), ...Object.keys(DECISION_AI_PROVIDERS)] as AIProvider[]
/** Kinds whose model listing `fetchAvailableModels` narrows before returning it: OpenAI and Azure
* OpenAI keep only `gpt-`/`o`/`codex` ids (so a fine-tune never appears), Bedrock keeps text
@@ -27,8 +35,14 @@ const FILTERED_MODEL_LISTING_KINDS: ReadonlySet<string> = new Set([
/** Kinds whose endpoint serves ids its listing does not contain, so an unlisted id is never
* grounds for calling it wrong. OpenRouter appends routing suffixes — `:online`, `:nitro`,
* `:floor` — to any listed model without listing the combinations, and it carries no `base_url`
* of its own (the backend supplies it), so `customEndpoint` does not cover it. */
const ALIASING_MODEL_LISTING_KINDS: ReadonlySet<string> = new Set(['openrouter'])
* of its own (the backend supplies it), so `customEndpoint` does not cover it. The decision
* providers' lists are the ids Windmill knows, not listings: TypeSafe also serves `jev-preview` and
* every pinned version. */
const ALIASING_MODEL_LISTING_KINDS: ReadonlySet<string> = new Set([
'openrouter',
'typesafe',
'cloudflare'
])
/** Each resource costs one model listing call against the provider. A workspace with a
* long tail of AI resources would otherwise stall every flow write. */
@@ -260,8 +274,6 @@ async function loadOption(
// Without a live listing, a model id cannot be ruled out: offer the configured
// models (then the curated defaults) as a hint, but leave the check off.
const fallback =
configuredModels.length > 0
? configuredModels
: (AI_PROVIDERS[candidate.kind]?.defaultModels ?? [])
configuredModels.length > 0 ? configuredModels : aiProviderDetails(candidate.kind).defaultModels
return { ...base, models: sanitizeModelListing(fallback, false), modelsAreLive: false }
}
@@ -67,7 +67,13 @@ describe('validateAiAgentProviders', () => {
expect(() =>
validateAiAgentProviders(anthropicProvider('claude-something-new'), {
...COMPLETE,
options: [{ ...ANTHROPIC, modelsAreLive: false, models: { ids: ['claude-sonnet-5'], complete: false } }]
options: [
{
...ANTHROPIC,
modelsAreLive: false,
models: { ids: ['claude-sonnet-5'], complete: false }
}
]
})
).not.toThrow()
})
@@ -107,6 +113,35 @@ describe('validateAiAgentProviders', () => {
).not.toThrow()
expect(warnings).toHaveLength(1)
})
it('keeps decision providers to AI decision steps', () => {
const typesafe = {
type: 'static',
value: { kind: 'typesafe', resource: '$res:u/admin/typesafe', model: 'jev-latest' }
}
const anthropic = {
type: 'static',
value: { kind: 'anthropic', resource: '$res:u/admin/anthropic', model: 'claude-sonnet-5' }
}
const decision = (provider: unknown) => [
{ id: 'd', value: { type: 'aidecision', input_transforms: { provider } } }
]
expect(() => validateAiAgentProviders(decision(anthropic), undefined)).toThrow(
/Step "d".*must be "typesafe" or "cloudflare"/
)
expect(() => validateAiAgentProviders(agentStep(typesafe), undefined)).toThrow(
/Step "agent".*use an "aidecision" step/
)
const cloudflare = {
type: 'static',
value: { kind: 'cloudflare', resource: '$res:u/admin/cloudflare', model: 'clef' }
}
expect(() => validateAiAgentProviders(decision(cloudflare), undefined)).not.toThrow()
expect(() => validateAiAgentProviders(agentStep(cloudflare), undefined)).toThrow(
/Step "agent".*use an "aidecision" step/
)
})
})
describe('formatAiAgentProvidersPrompt', () => {
@@ -120,10 +155,12 @@ describe('formatAiAgentProvidersPrompt', () => {
expect(
formatAiAgentProvidersPrompt({ ...COMPLETE, defaultModel }, { canAskUser: true })
).toContain('the workspace default')
expect(formatAiAgentProvidersPrompt({ ...AMBIGUOUS, defaultModel }, { canAskUser: true })).toContain(
expect(
formatAiAgentProvidersPrompt({ ...AMBIGUOUS, defaultModel }, { canAskUser: true })
).toContain('askUserQuestion')
expect(formatAiAgentProvidersPrompt(COMPLETE, { canAskUser: true })).toContain(
'askUserQuestion'
)
expect(formatAiAgentProvidersPrompt(COMPLETE, { canAskUser: true })).toContain('askUserQuestion')
})
it('never names askUserQuestion for a chat that does not have the tool', () => {
@@ -135,9 +172,10 @@ describe('formatAiAgentProvidersPrompt', () => {
describe('collectAiAgentProviderRefs', () => {
it('needs the catalog only for a step that states its own static provider', () => {
expect(
collectAiAgentProviderRefs(anthropicProvider('claude-sonnet-5'))
).toEqual({ needsCatalog: true, resourceRefs: ['$res:u/admin/anthropic'] })
expect(collectAiAgentProviderRefs(anthropicProvider('claude-sonnet-5'))).toEqual({
needsCatalog: true,
resourceRefs: ['$res:u/admin/anthropic']
})
expect(
collectAiAgentProviderRefs([{ id: 'a', value: { type: 'aiagent', agent: 'u/admin/saved' } }])
).toEqual({ needsCatalog: false, resourceRefs: [] })
@@ -159,7 +197,10 @@ describe('an authoritative empty catalog', () => {
)
// Not knowing the resources is not the same as knowing there are none.
expect(
formatAiAgentProvidersPrompt({ options: [], resourcesAreComplete: false }, { canAskUser: true })
formatAiAgentProvidersPrompt(
{ options: [], resourcesAreComplete: false },
{ canAskUser: true }
)
).toBe('')
})
@@ -29,6 +29,9 @@ export type AiAgentProviderCatalog = {
defaultModel?: { kind: AIProvider; model: string }
}
/** The providers an AI decision step runs on, and the kinds an AI agent step cannot. */
const DECISION_PROVIDER_KINDS: readonly string[] = ['typesafe', 'cloudflare']
/** A model id as every provider writes one: `claude-sonnet-5`, `meta-llama/Llama-3.3-70B`,
* `anthropic.claude-haiku-4-5-20251001-v1:0`, `ft:gpt-4o:acme::abc`. Anything else is not
* rendered: a resource may point at a gateway someone else controls, and its listing lands in a
@@ -146,11 +149,14 @@ This workspace has none, so an AI agent step has no model to run on. ${
const truncationLine = catalog.resourcesAreComplete
? ''
: '\nThis list is incomplete: the workspace has AI provider resources that are not shown.'
const decisionLine = catalog.options.some((o) => DECISION_PROVIDER_KINDS.includes(o.kind))
? `\nA \`typesafe\` or \`cloudflare\` resource serves AI decision steps only, and AI decision steps run on nothing else.`
: ''
return `## AI provider resources in this workspace
An AI agent step's \`model\` must be one of the ids listed below for the resource it references — never a model id from memory, which the endpoint would reject at run time.
${catalog.options.map(describeOption).join('\n')}${truncationLine}
${catalog.options.map(describeOption).join('\n')}${truncationLine}${decisionLine}
${choiceLine}`
}
@@ -176,7 +182,9 @@ export function selectAiAgentProviderCandidates(
if (!configuredPaths.has(candidate.resourcePath)) return 2
return candidate.kind === defaultProviderKind ? 0 : 1
}
return candidates.sort((a, b) => rank(a) - rank(b) || a.resourcePath.localeCompare(b.resourcePath))
return candidates.sort(
(a, b) => rank(a) - rank(b) || a.resourcePath.localeCompare(b.resourcePath)
)
}
/** What a set of modules needs from the catalog: `needsCatalog` is false when no AI agent step
@@ -224,7 +232,8 @@ function blocking(message: string): ProviderIssue {
function checkProviderValue(
value: unknown,
catalog: AiAgentProviderCatalog
catalog: AiAgentProviderCatalog,
stepType: string
): ProviderIssue | undefined {
if (typeof value === 'string') {
return blocking(
@@ -238,6 +247,16 @@ function checkProviderValue(
if (typeof kind !== 'string' || kind === '') {
return blocking(`provider.kind is missing. Expected ${PROVIDER_SHAPE}`)
}
if (stepType === 'aidecision' && !DECISION_PROVIDER_KINDS.includes(kind)) {
return blocking(
`an AI decision step runs on a decision model: provider.kind must be "typesafe" or "cloudflare", not "${kind}"`
)
}
if (stepType !== 'aidecision' && DECISION_PROVIDER_KINDS.includes(kind)) {
return blocking(
`"${kind}" serves decision models, which answer typed questions rather than messages: use an "aidecision" step rather than an AI agent`
)
}
if (typeof resource !== 'string' || !resource.startsWith('$res:')) {
return blocking(
`provider.resource must be a "$res:<resource path>" reference. Expected ${PROVIDER_SHAPE}`
@@ -277,9 +296,9 @@ function checkProviderValue(
}
/**
* Reject AI agent steps whose provider config would fail at run time: a malformed provider, a
* resource that is not an AI provider resource of the workspace, or a model the endpoint's own
* listing rules out.
* Reject AI agent and AI decision steps whose provider config would fail at run time: a malformed
* provider, a kind the step cannot run on, a resource that is not an AI provider resource of the
* workspace, or a model the endpoint's own listing rules out.
*
* Everything the catalog could not establish is reported through `warnings` instead of blocking,
* so an incomplete catalog never rejects a provider that would have worked.
@@ -297,7 +316,7 @@ export function validateAiAgentProviders(
// A missing provider is reported by collectProviderlessAgentIds, and a javascript
// transform resolves at run time with no value to check here.
if (!transform || transform.type !== 'static') return
const issue = checkProviderValue(transform.value, known)
const issue = checkProviderValue(transform.value, known, value.type)
if (!issue) return
if (issue.blocking) {
errors.push(`Step "${mod.id}": ${issue.message}`)

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