mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f7ee6c4e5 | ||
|
|
fec4008696 | ||
|
|
bd32c5f951 | ||
|
|
4e91f83b8f | ||
|
|
bd05bcadde | ||
|
|
664edcdfb7 | ||
|
|
9dbce4a8c4 | ||
|
|
e1df6b45e9 | ||
|
|
24eedef918 | ||
|
|
ab11c7747a | ||
|
|
8bc2295b94 | ||
|
|
20719b4731 | ||
|
|
f8467f38c8 | ||
|
|
25172bdc28 | ||
|
|
fa090f3081 | ||
|
|
dfeed9c5c2 | ||
|
|
52960ca30a | ||
|
|
302ce58e98 | ||
|
|
4e25954722 | ||
|
|
81b5736106 | ||
|
|
6a334e9a07 | ||
|
|
85555542bf | ||
|
|
e3a3dbb89c | ||
|
|
8f95402850 | ||
|
|
69b3141e03 | ||
|
|
b7bc9b44b4 | ||
|
|
e1819313e1 | ||
|
|
d48d61cc79 | ||
|
|
1c05604e4c | ||
|
|
f414ffc484 | ||
|
|
7f589a8c7d | ||
|
|
6637e00375 | ||
|
|
bf99283c33 | ||
|
|
90f494975d | ||
|
|
5e909b2b4f | ||
|
|
d870edc959 | ||
|
|
e5286f4607 | ||
|
|
4f3a1e3109 | ||
|
|
33bf01b627 | ||
|
|
d666e8431c | ||
|
|
110bef0a6e | ||
|
|
c5092069cb | ||
|
|
dd19e52a84 | ||
|
|
4d0f2c26a1 | ||
|
|
d243e0cde8 | ||
|
|
818cb31fbc | ||
|
|
2ec1863340 | ||
|
|
7a7d246a6e | ||
|
|
b348119ab9 | ||
|
|
79c5b7b8b7 | ||
|
|
7ebb08133c | ||
|
|
d0f23cc523 | ||
|
|
17cf538a2d | ||
|
|
a305a74e73 | ||
|
|
c6346aabe0 | ||
|
|
cab0000f3a | ||
|
|
0bb77a9bd6 | ||
|
|
07a4cb6872 | ||
|
|
110384580e | ||
|
|
01e21c7f91 | ||
|
|
411ca47ffd | ||
|
|
cd65de4928 | ||
|
|
b972fabab4 | ||
|
|
9da13d0180 | ||
|
|
ac3c155541 | ||
|
|
07d3ffbf34 | ||
|
|
9c6cd8c852 | ||
|
|
1abfe9de39 | ||
|
|
f8ba0840d7 | ||
|
|
0a5f8dcd48 | ||
|
|
e3a914fd48 | ||
|
|
3cd0eac8c1 | ||
|
|
36b316d9e8 | ||
|
|
9f79a86a68 | ||
|
|
1e89aff2d6 | ||
|
|
20ecd904e7 | ||
|
|
03e8bc8c14 |
@@ -55,7 +55,10 @@
|
||||
"Read(**/*.pem)",
|
||||
"Read(**/*.key)",
|
||||
"Read(**/credentials.json)",
|
||||
"Read(**/*secret*)",
|
||||
"Read(**/.secret*)",
|
||||
"Read(**/.secrets*)",
|
||||
"Read(**/*.secret)",
|
||||
"Read(**/*.secrets)",
|
||||
"Edit(.env)",
|
||||
"Edit(.env.*)",
|
||||
"Edit(**/.env)",
|
||||
|
||||
@@ -145,6 +145,14 @@ jobs:
|
||||
RUST_LOG_STYLE: never
|
||||
CARGO_NET_GIT_FETCH_WITH_CLI: true
|
||||
CARGO_BUILD_JOBS: 12
|
||||
# backend/Cargo.toml sets split-debuginfo = "unpacked", which on
|
||||
# windows-msvc is coerced to "packed": every test-binary link spawns
|
||||
# the mspdbsrv.exe PDB type server and writes a large .pdb. With 12
|
||||
# parallel link jobs this races the type-server cap (LNK1318 "LIMIT
|
||||
# (12)") and exhausts the runner disk (LNK1180). CI needs no debug
|
||||
# info, so disable PDB generation for the dev/test profiles here.
|
||||
CARGO_PROFILE_DEV_SPLIT_DEBUGINFO: "off"
|
||||
CARGO_PROFILE_TEST_SPLIT_DEBUGINFO: "off"
|
||||
# Tests' poll-time stack frames (deep nested async fn chains in
|
||||
# debug builds) reach ~1.8MB. 4MB gives ~2x headroom against flaky
|
||||
# overflows under parallel-test contention.
|
||||
|
||||
@@ -20,6 +20,8 @@ on:
|
||||
type: string
|
||||
default: ''
|
||||
secrets:
|
||||
OPENAI_API_KEY:
|
||||
required: false
|
||||
CODEX_AUTH_JSON:
|
||||
required: false
|
||||
WINDMILL_EE_PRIVATE_ACCESS:
|
||||
@@ -60,13 +62,18 @@ jobs:
|
||||
- name: Check Codex configuration
|
||||
id: codex_config
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }}
|
||||
run: |
|
||||
if [ -n "$CODEX_AUTH_JSON" ]; then
|
||||
if [ -n "$OPENAI_API_KEY" ]; then
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
echo "auth_mode=api_key" >> "$GITHUB_OUTPUT"
|
||||
elif [ -n "$CODEX_AUTH_JSON" ]; then
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
echo "auth_mode=oauth_json" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
echo "CODEX_AUTH_JSON is not configured; skipping Codex review."
|
||||
echo "Codex auth is not configured; set OPENAI_API_KEY or CODEX_AUTH_JSON to enable Codex review."
|
||||
fi
|
||||
|
||||
- name: Resolve PR metadata
|
||||
@@ -169,9 +176,10 @@ jobs:
|
||||
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
run: npm install --global @openai/codex@0.128.0
|
||||
|
||||
- name: Configure file-backed Codex auth
|
||||
- name: Configure Codex auth
|
||||
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }}
|
||||
run: |
|
||||
CODEX_HOME="$HOME/.codex"
|
||||
@@ -181,9 +189,13 @@ jobs:
|
||||
cat > "$CODEX_HOME/config.toml" <<'EOF'
|
||||
cli_auth_credentials_store = "file"
|
||||
EOF
|
||||
printf '%s' "$CODEX_AUTH_JSON" > "$CODEX_HOME/auth.json"
|
||||
chmod 600 "$CODEX_HOME/auth.json"
|
||||
node -e 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))' "$CODEX_HOME/auth.json"
|
||||
if [ -n "$OPENAI_API_KEY" ]; then
|
||||
printf '%s' "$OPENAI_API_KEY" | codex login --with-api-key
|
||||
else
|
||||
printf '%s' "$CODEX_AUTH_JSON" > "$CODEX_HOME/auth.json"
|
||||
chmod 600 "$CODEX_HOME/auth.json"
|
||||
node -e 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))' "$CODEX_HOME/auth.json"
|
||||
fi
|
||||
|
||||
- name: Pre-fetch base and head refs for the PR
|
||||
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
|
||||
|
||||
@@ -100,6 +100,7 @@ jobs:
|
||||
extra_prompt: ${{ needs.parse.outputs.extra_prompt }}
|
||||
triggered_by: ${{ github.event.comment.user.login }}
|
||||
secrets:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }}
|
||||
WINDMILL_EE_PRIVATE_ACCESS: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
name: Publish CLI docs repo
|
||||
|
||||
# Regenerates the windmill-cli-docs repo (consumed by context7) from the
|
||||
# canonical sources in this repo on every Windmill release.
|
||||
#
|
||||
# Required secret:
|
||||
# CLI_DOCS_DEPLOY_KEY — ed25519 private key whose public half is registered
|
||||
# as a write-access deploy key on
|
||||
# windmill-labs/windmill-cli-docs.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
# Serialize pushes to windmill-cli-docs so two release tags landing close
|
||||
# together (e.g. a release-please bump + a hotfix) can't race to force-push
|
||||
# the docs repo.
|
||||
concurrency:
|
||||
group: publish-cli-docs
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout windmill (source of truth)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: windmill
|
||||
|
||||
- name: Checkout windmill-cli-docs (publish target)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: windmill-labs/windmill-cli-docs
|
||||
path: windmill-cli-docs
|
||||
ssh-key: ${{ secrets.CLI_DOCS_DEPLOY_KEY }}
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install pyyaml
|
||||
|
||||
- name: Regenerate docs
|
||||
run: |
|
||||
python3 windmill/system_prompts/generate.py \
|
||||
--context7-dir "$GITHUB_WORKSPACE/windmill-cli-docs"
|
||||
|
||||
- name: Commit and push if changed
|
||||
working-directory: windmill-cli-docs
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
REF_TYPE: ${{ github.ref_type }}
|
||||
run: |
|
||||
git config user.name "windmill-bot"
|
||||
git config user.email "bot@windmill.dev"
|
||||
git add -A
|
||||
if git diff --cached --quiet; then
|
||||
echo "No doc changes for ${REF_NAME}."
|
||||
committed=false
|
||||
else
|
||||
committed=true
|
||||
if [ "${REF_TYPE}" = "tag" ]; then
|
||||
git commit -m "chore: sync from windmill ${REF_NAME}"
|
||||
else
|
||||
git commit -m "chore: sync from windmill (manual dispatch from ${REF_NAME})"
|
||||
fi
|
||||
git push origin HEAD
|
||||
fi
|
||||
# Always mirror the version tag on tag pushes, even when content
|
||||
# didn't change — downstream consumers tie snapshots to releases by
|
||||
# tag, and skipping it would leave the docs repo without a tag for
|
||||
# the new Windmill release.
|
||||
# workflow_dispatch from a non-tag ref skips this so we don't
|
||||
# create a junk tag named after a branch.
|
||||
if [ "${REF_TYPE}" = "tag" ]; then
|
||||
git tag -f "${REF_NAME}"
|
||||
git push origin "${REF_NAME}" --force
|
||||
echo "Mirrored tag ${REF_NAME} to windmill-cli-docs (content changed: ${committed})."
|
||||
fi
|
||||
@@ -20,6 +20,7 @@ rust-client/Cargo.toml
|
||||
|
||||
# Worktree-specific Claude Code settings (generated by scripts/worktree-env)
|
||||
.claude/settings.local.json
|
||||
.claude/worktrees/
|
||||
|
||||
# Symlinked cache directories (for git worktrees)
|
||||
backend/target
|
||||
|
||||
+126
@@ -1,5 +1,131 @@
|
||||
# Changelog
|
||||
|
||||
## [1.703.3](https://github.com/windmill-labs/windmill/compare/v1.703.2...v1.703.3) (2026-05-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* constrain unauthenticated get_public_resource to app_theme resources ([#9203](https://github.com/windmill-labs/windmill/issues/9203)) ([24eedef](https://github.com/windmill-labs/windmill/commit/24eedef918376d9d401335b6fada577916f8cc0e))
|
||||
* enforce folder ACL on flow run-by-version routes ([#9202](https://github.com/windmill-labs/windmill/issues/9202)) ([ab11c77](https://github.com/windmill-labs/windmill/commit/ab11c7747a9076e8121fcea6eafb8e88079ac987))
|
||||
* enforce jobs:run scope on job preview and inline endpoints ([#9198](https://github.com/windmill-labs/windmill/issues/9198)) ([664edcd](https://github.com/windmill-labs/windmill/commit/664edcdfb746f6c8513e2b487383b5d9ab9f5434))
|
||||
* **mcp:** validate oauth dynamic client registration redirect_uris ([#9197](https://github.com/windmill-labs/windmill/issues/9197)) ([8bc2295](https://github.com/windmill-labs/windmill/commit/8bc2295b94df159a7c8630cdbe02953b8b7c13a1))
|
||||
* validate entrypoint override to prevent worker code injection (GHSA-wxjq-w5pj-jqhx) ([#9204](https://github.com/windmill-labs/windmill/issues/9204)) ([bd05bca](https://github.com/windmill-labs/windmill/commit/bd05bcadde06b65fc4b732f576d89aae908b5a3f))
|
||||
|
||||
## [1.703.2](https://github.com/windmill-labs/windmill/compare/v1.703.1...v1.703.2) (2026-05-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* prevent cross-tenant DNS poisoning via writable /etc in nsjail ([#9194](https://github.com/windmill-labs/windmill/issues/9194)) ([f8467f3](https://github.com/windmill-labs/windmill/commit/f8467f38c8a053117ce62f96684cfb15ef792f08))
|
||||
|
||||
## [1.703.1](https://github.com/windmill-labs/windmill/compare/v1.703.0...v1.703.1) (2026-05-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* actionable error when a custom_path is taken by an app in another workspace ([#9190](https://github.com/windmill-labs/windmill/issues/9190)) ([dfeed9c](https://github.com/windmill-labs/windmill/commit/dfeed9c5c2e39bf3e10eea4f69ea140ee9e7832f))
|
||||
* atomic bundle cache writes to prevent parallel cold-load race ([#9186](https://github.com/windmill-labs/windmill/issues/9186)) ([81b5736](https://github.com/windmill-labs/windmill/commit/81b573610692b386e4861ef989fa7698b53fc861))
|
||||
* detect S3 assets passed as SDK object arg in ts parser ([#9181](https://github.com/windmill-labs/windmill/issues/9181)) ([6a334e9](https://github.com/windmill-labs/windmill/commit/6a334e9a07a7d0cffabde48be75263b0844d586c))
|
||||
* don't show ALLOW_PRIVATE_AI_BASE_URLS hint for malformed AI base URLs ([#9188](https://github.com/windmill-labs/windmill/issues/9188)) ([4e25954](https://github.com/windmill-labs/windmill/commit/4e259547225e13e5b51a166a84cdbbbfa35c3264))
|
||||
* reset parent_hash in auto_parent when all versions at path are archived ([#9172](https://github.com/windmill-labs/windmill/issues/9172)) ([52960ca](https://github.com/windmill-labs/windmill/commit/52960ca30ab9c019186a28b3ab054a1dfe72f451))
|
||||
|
||||
## [1.703.0](https://github.com/windmill-labs/windmill/compare/v1.702.1...v1.703.0) (2026-05-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **otel-tracing-proxy:** configurable tracing MITM NO_PROXY hosts ([#9169](https://github.com/windmill-labs/windmill/issues/9169)) ([d48d61c](https://github.com/windmill-labs/windmill/commit/d48d61cc79114f0b36736306d4015789be10c1f4))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* aggregate wait time should target the true root job, not flow_innermost_root_job ([#9177](https://github.com/windmill-labs/windmill/issues/9177)) ([e181931](https://github.com/windmill-labs/windmill/commit/e1819313e15766007c959497a84fae5f5c78a46b))
|
||||
* apply pip_local_dependencies filtering to deployed scripts with populated lockfiles ([#9178](https://github.com/windmill-labs/windmill/issues/9178)) ([69b3141](https://github.com/windmill-labs/windmill/commit/69b3141e0370b95f2e13987503480d341608dbdf))
|
||||
* never mark failure/trigger/approval scripts as auto_kind=lib ([#9168](https://github.com/windmill-labs/windmill/issues/9168)) ([f414ffc](https://github.com/windmill-labs/windmill/commit/f414ffc4849cf4b92fcd5ca9611ecd246e59a7bd))
|
||||
|
||||
## [1.702.1](https://github.com/windmill-labs/windmill/compare/v1.702.0...v1.702.1) (2026-05-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **nativets:** pass tracing-enabled OtelConfig to deno_telemetry::init ([#9163](https://github.com/windmill-labs/windmill/issues/9163)) ([bf99283](https://github.com/windmill-labs/windmill/commit/bf99283c3333bcdbc7679f4aea04ba29e41a48a5))
|
||||
|
||||
## [1.702.0](https://github.com/windmill-labs/windmill/compare/v1.701.0...v1.702.0) (2026-05-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **git-sync:** sync extra_perms for flows/scripts/apps ([#9162](https://github.com/windmill-labs/windmill/issues/9162)) ([5e909b2](https://github.com/windmill-labs/windmill/commit/5e909b2b4f2819f19deaf06d9e78e6458b324683))
|
||||
* include service accounts in instance settings users list ([#9157](https://github.com/windmill-labs/windmill/issues/9157)) ([e5286f4](https://github.com/windmill-labs/windmill/commit/e5286f46074cf2893e6ccd26175f929f16011c8f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **mcp:** sanitize and enrich nested resource schemas ([#9158](https://github.com/windmill-labs/windmill/issues/9158)) ([d870edc](https://github.com/windmill-labs/windmill/commit/d870edc959481a06c894b4eda5e2be1a0269d7d0))
|
||||
|
||||
## [1.701.0](https://github.com/windmill-labs/windmill/compare/v1.700.2...v1.701.0) (2026-05-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **frontend:** unified EditorHeader with file picker for flow/script/app editors ([#9047](https://github.com/windmill-labs/windmill/issues/9047)) ([d0f23cc](https://github.com/windmill-labs/windmill/commit/d0f23cc5238b025208c61e983701894de28536d5))
|
||||
* read-only flag on API tokens ([#9144](https://github.com/windmill-labs/windmill/issues/9144)) ([d666e84](https://github.com/windmill-labs/windmill/commit/d666e8431cdbf14d9373d9ef625b5aafc50ac50a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* align script path existence check with deploy logic; hide Delete for non-admin ([#9152](https://github.com/windmill-labs/windmill/issues/9152)) ([c509206](https://github.com/windmill-labs/windmill/commit/c5092069cbeda2c4c18bea80dd629c7c087b30bf))
|
||||
* Allow devops role to use all_workspaces runs filter in admins workspace ([#9153](https://github.com/windmill-labs/windmill/issues/9153)) ([110bef0](https://github.com/windmill-labs/windmill/commit/110bef0a6e76615c7b371c5c0f5bc1f4e7a73a64))
|
||||
* **bun:** pass --preserve-symlinks on unbundled execution ([#9147](https://github.com/windmill-labs/windmill/issues/9147)) ([4d0f2c2](https://github.com/windmill-labs/windmill/commit/4d0f2c26a116a0f8a89a64231dc824eabda0a8c3))
|
||||
* **cli:** prevent !inline-corruption in flow push/pull ([#9142](https://github.com/windmill-labs/windmill/issues/9142)) ([79c5b7b](https://github.com/windmill-labs/windmill/commit/79c5b7b8b7676b0a06fa6480dd04b7105d39d250))
|
||||
* **operator:** refresh IAM RDS / Entra ID tokens in operator process ([#9141](https://github.com/windmill-labs/windmill/issues/9141)) ([7ebb081](https://github.com/windmill-labs/windmill/commit/7ebb08133cd4027bc00bacc4a0fc5865cd5709ec))
|
||||
* **python:** preserve strings containing Infinity/NaN in result JSON ([#9149](https://github.com/windmill-labs/windmill/issues/9149)) ([33bf01b](https://github.com/windmill-labs/windmill/commit/33bf01b627c8ea430c03dfc27a97a8f2d770582f))
|
||||
* scope promotion-mode debounce key per repo ([#9145](https://github.com/windmill-labs/windmill/issues/9145)) ([2ec1863](https://github.com/windmill-labs/windmill/commit/2ec1863340e759bba3408dbc4f41b16912b959ea))
|
||||
* send flow push-loop ping outside transaction so zombie monitor sees it ([#9136](https://github.com/windmill-labs/windmill/issues/9136)) ([818cb31](https://github.com/windmill-labs/windmill/commit/818cb31fbc731fa5c70ddf5942bb37bc4bc56e4d))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **dynselect:** only retrigger when helper args actually change ([#9148](https://github.com/windmill-labs/windmill/issues/9148)) ([dd19e52](https://github.com/windmill-labs/windmill/commit/dd19e52a84fb9a9f48e3ad061b084841c2ee7464))
|
||||
|
||||
## [1.700.2](https://github.com/windmill-labs/windmill/compare/v1.700.1...v1.700.2) (2026-05-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* preserve explicit nulls for typed fields in bulk instance config ([#9123](https://github.com/windmill-labs/windmill/issues/9123)) ([cab0000](https://github.com/windmill-labs/windmill/commit/cab0000f3a5e9a0b201a85da1a01b1f82df8a316))
|
||||
* preserve negative integers in Bedrock tool schema conversion ([#9116](https://github.com/windmill-labs/windmill/issues/9116)) ([01e21c7](https://github.com/windmill-labs/windmill/commit/01e21c7f913eaf7dffc3d6a31501418ff2104c8b))
|
||||
|
||||
## [1.700.1](https://github.com/windmill-labs/windmill/compare/v1.700.0...v1.700.1) (2026-05-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* CE build broken by enterprise-gated compute_instance_hash ([#9113](https://github.com/windmill-labs/windmill/issues/9113)) ([cd65de4](https://github.com/windmill-labs/windmill/commit/cd65de49285ff60abdd94c883180ded65609f382))
|
||||
|
||||
## [1.700.0](https://github.com/windmill-labs/windmill/compare/v1.699.0...v1.700.0) (2026-05-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **cli:** auto-infer args for `wmill app push` ([#9091](https://github.com/windmill-labs/windmill/issues/9091)) ([43b1800](https://github.com/windmill-labs/windmill/commit/43b18006f32fd5db54bbf8ae7ff0e0b314a517e5))
|
||||
* **forks:** prompt to delete forked children when deleting a fork ([#9097](https://github.com/windmill-labs/windmill/issues/9097)) ([e43a958](https://github.com/windmill-labs/windmill/commit/e43a958c5c6ae01a1fbecf3db63c6541a245be62))
|
||||
* **operators:** allow operators to access assets page ([#9095](https://github.com/windmill-labs/windmill/issues/9095)) ([20ecd90](https://github.com/windmill-labs/windmill/commit/20ecd904e7060c3cf90f2605740bb349b2a3e6ed))
|
||||
* **vault:** configurable JWT auth mount path and setup-doc fixes ([#9100](https://github.com/windmill-labs/windmill/issues/9100)) ([f8ba084](https://github.com/windmill-labs/windmill/commit/f8ba0840d74572c880cf458938365b3ec808c6fb))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add Input, Result, Trigger to reserved flow step IDs ([#9109](https://github.com/windmill-labs/windmill/issues/9109)) ([9f79a86](https://github.com/windmill-labs/windmill/commit/9f79a86a686708f66ccc512d4f132cb9a00397a7)), closes [#7139](https://github.com/windmill-labs/windmill/issues/7139)
|
||||
* **frontend:** mark Path dirty when folder picker changes selection ([#9096](https://github.com/windmill-labs/windmill/issues/9096)) ([23bb1b5](https://github.com/windmill-labs/windmill/commit/23bb1b541e78846d5978153fd8d9bb4f01cec72b))
|
||||
* mask oauth client secret in instance settings ([#9112](https://github.com/windmill-labs/windmill/issues/9112)) ([ac3c155](https://github.com/windmill-labs/windmill/commit/ac3c155541eb5ca20d65c38ad13dca6c10a572c9))
|
||||
* populate raw_code for flowscript and appscript runs ([#9104](https://github.com/windmill-labs/windmill/issues/9104)) ([05172ac](https://github.com/windmill-labs/windmill/commit/05172ac3bdfc3472da5e9d8a825cdd479ba9e375))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* lazy-load script editor history and hit partial index ([#9107](https://github.com/windmill-labs/windmill/issues/9107)) ([03e8bc8](https://github.com/windmill-labs/windmill/commit/03e8bc8c14258355d7d695333c1588807fbf8cd6))
|
||||
|
||||
## [1.699.0](https://github.com/windmill-labs/windmill/compare/v1.698.0...v1.699.0) (2026-05-08)
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ This folder contains black-box benchmark cases for:
|
||||
- `app`
|
||||
- `script`
|
||||
- `cli`
|
||||
- `global`
|
||||
|
||||
The goal is to test the current production prompts and guidance with realistic user requests, not to test one exact implementation shape.
|
||||
|
||||
@@ -75,6 +76,16 @@ Still, avoid benchmark phrasing. The prompt should read like a repo task, not a
|
||||
|
||||
When relevant, ask the assistant to tell the user which `wmill` commands to run next. That is part of the benchmarked behavior.
|
||||
|
||||
## Global-specific rules
|
||||
|
||||
Global prompts should exercise workspace-level drafting behavior:
|
||||
|
||||
- inspecting existing scripts, flows, apps, schedules, triggers, resources, and variables when relevant
|
||||
- writing AI drafts rather than saving or deploying by default
|
||||
- producing coherent multi-artifact changes when the request crosses artifact boundaries
|
||||
|
||||
Keep deterministic validation focused on the draft contract: required draft type/path, required content snippets, forbidden draft paths, and forbidden mutating tools such as deploy/delete unless the case explicitly asks for them.
|
||||
|
||||
## Deterministic validation
|
||||
|
||||
Use deterministic validation only for hard failures such as:
|
||||
|
||||
+21
-13
@@ -1,11 +1,12 @@
|
||||
# AI Evals
|
||||
|
||||
Small benchmark runner for the four Windmill AI generation modes:
|
||||
Small benchmark runner for the Windmill AI generation modes:
|
||||
|
||||
- `cli`
|
||||
- `flow`
|
||||
- `script`
|
||||
- `app`
|
||||
- `global`
|
||||
|
||||
The benchmark always tests the current production prompts, tools, and guidance in this checkout.
|
||||
|
||||
@@ -55,8 +56,9 @@ bun run cli -- run flow flow-test4-order-processing-loop --model opus
|
||||
bun run cli -- run flow flow-test0-sum-two-numbers --models haiku,opus,4o
|
||||
bun run cli -- run flow flow-test0-sum-two-numbers --runs 3 --verbose
|
||||
bun run cli -- run flow --record
|
||||
GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-pro --transport proxy
|
||||
GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-pro
|
||||
WMILL_AI_EVAL_BACKEND_URL=http://127.0.0.1:8000 bun run cli -- run flow --backend-validation preview
|
||||
bun run cli -- run global global-test1-script-create
|
||||
bun run cli -- run cli bun-hello-script
|
||||
```
|
||||
|
||||
@@ -72,7 +74,6 @@ Public CLI surface:
|
||||
- `--output <path>`: custom result JSON path
|
||||
- `--model <alias>`: choose the model under test
|
||||
- `--models <a,b,c>`: run the same cases sequentially against several model aliases
|
||||
- `--transport <mode>`: frontend request transport (`direct` by default, `proxy` to exercise `/api/w/{workspace}/ai/proxy`)
|
||||
- `--verbose`: stream assistant output for frontend runs
|
||||
- `--record`: append a compact tracked summary line to `ai_evals/history/<mode>.jsonl` for full-suite runs only
|
||||
- `--backend-validation <mode>`: optional backend smoke validation (`off` or `preview`) for `script` and `flow` evals
|
||||
@@ -95,7 +96,7 @@ Today:
|
||||
Notes:
|
||||
|
||||
- the command also prints accepted alias spellings such as `gpt-4o`, `claude-opus-4.6`, and `claude-haiku-4.5`
|
||||
- frontend modes (`flow`, `script`, `app`) can use Anthropic, OpenAI, and Gemini-backed aliases
|
||||
- frontend modes (`flow`, `script`, `app`, `global`) can use Anthropic, OpenAI, and Gemini-backed aliases
|
||||
- `cli` mode always uses the Anthropic agent SDK, so only Anthropic aliases are valid there
|
||||
- the judge model is separate and currently defaults to `claude-sonnet-4-6`
|
||||
|
||||
@@ -134,6 +135,13 @@ For `app` mode, `validate` can express narrow hard requirements such as:
|
||||
- minimum datatable / datatable-table counts
|
||||
- specific required datatable tables
|
||||
|
||||
For `global` mode, `validate` can express draft-level requirements such as:
|
||||
|
||||
- required draft type/path/language
|
||||
- required or forbidden snippets in draft values
|
||||
- required or forbidden draft counts
|
||||
- forbidden draft paths
|
||||
|
||||
App fixtures can also include an optional `datatables.json` file at the fixture root.
|
||||
|
||||
For `flow` mode, an `initial` fixture can also include a benchmark workspace catalog of
|
||||
@@ -145,26 +153,23 @@ If `--backend-validation preview` is enabled:
|
||||
- `script` evals run a real backend script preview in an isolated temp workspace
|
||||
- `flow` evals run a real backend flow preview only for cases that define `runtime.backendPreview`
|
||||
- `flow` cases with `initial.workspace` fixtures seed those scripts and flows into the preview workspace before preview
|
||||
- when `WMILL_AI_EVAL_BACKEND_WORKSPACE` is set, `ai_evals` treats that workspace as a dedicated test workspace, clears managed eval assets under `f/evals/*` before each preview run, and then reseeds the current case fixtures
|
||||
- when `WMILL_AI_EVAL_BACKEND_WORKSPACE` is set, `ai_evals` creates or reuses that workspace as a dedicated test workspace, clears managed eval assets under `f/evals/*` before each preview run, and then reseeds the current case fixtures
|
||||
|
||||
Supported backend validation env vars:
|
||||
Supported backend env vars:
|
||||
|
||||
- `WMILL_AI_EVAL_BACKEND_VALIDATION=preview`
|
||||
- `WMILL_AI_EVAL_BACKEND_URL=http://127.0.0.1:8000`
|
||||
- `WMILL_AI_EVAL_BACKEND_EMAIL=admin@windmill.dev`
|
||||
- `WMILL_AI_EVAL_BACKEND_PASSWORD=changeme`
|
||||
- `WMILL_AI_EVAL_BACKEND_WORKSPACE=integration-tests` to reuse an existing workspace on CE installs with low workspace limits
|
||||
- `WMILL_AI_EVAL_KEEP_WORKSPACES=1`
|
||||
- `WMILL_AI_EVAL_WORKSPACE_PREFIX=ai-evals`
|
||||
|
||||
Frontend proxy transport uses the same backend auth/workspace env vars.
|
||||
Frontend modes require a reachable Windmill backend and send model requests through the workspace AI proxy at `/api/w/{workspace}/ai/proxy`. At startup, `ai_evals` checks the resolved backend URL and fails early with setup guidance if the backend cannot be reached or login fails.
|
||||
|
||||
When `--transport proxy` is set:
|
||||
For frontend modes:
|
||||
|
||||
- `ai_evals` creates or reuses a backend workspace
|
||||
- `ai_evals` creates a temporary backend workspace, or creates/reuses `WMILL_AI_EVAL_BACKEND_WORKSPACE` when it is set
|
||||
- it upserts a provider resource under `f/evals/ai/<provider>`
|
||||
- frontend requests go through `/api/w/{workspace}/ai/proxy`
|
||||
- result JSON and history records include `transport` so direct vs proxy runs stay distinguishable
|
||||
|
||||
## Results And Artifacts
|
||||
|
||||
@@ -178,11 +183,12 @@ If `--record` is used, the CLI also appends one compact JSON line to:
|
||||
- `ai_evals/history/flow.jsonl`
|
||||
- `ai_evals/history/script.jsonl`
|
||||
- `ai_evals/history/app.jsonl`
|
||||
- `ai_evals/history/global.jsonl`
|
||||
- `ai_evals/history/cli.jsonl`
|
||||
|
||||
Each recorded line contains:
|
||||
|
||||
- run metadata (`createdAt`, `gitSha`, `mode`, `runModel`, `transport`, `judgeModel`)
|
||||
- run metadata (`createdAt`, `gitSha`, `mode`, `runModel`, `judgeModel`)
|
||||
- suite totals (`caseCount`, `attemptCount`, `passedAttempts`, `passRate`, `averageDurationMs`, `averageJudgeScore`)
|
||||
- average token usage (`averageTokenUsagePerAttempt`)
|
||||
- per-case metrics under `cases[]` (`averageDurationMs`, `averageJudgeScore`, `averageTokenUsagePerAttempt`, pass rate)
|
||||
@@ -198,6 +204,7 @@ Typical artifacts by mode:
|
||||
- `flow`: `flow.json`
|
||||
- `script`: `script.json` plus the generated script file
|
||||
- `app`: `app.json` plus frontend/backend files
|
||||
- `global`: `global-drafts.json`
|
||||
- `cli`: `assistant-output.txt`, `trace.json`, `wmill-invocations.jsonl`, plus generated workspace files
|
||||
- backend-validated attempts also include `backend-preview.json`
|
||||
|
||||
@@ -213,6 +220,7 @@ Typical artifacts by mode:
|
||||
## Notes
|
||||
|
||||
- Frontend modes reuse the production frontend chat code through the Vitest bridge.
|
||||
- Global mode evaluates the production global AI tools and validates the resulting AI draft store.
|
||||
- CLI mode creates an isolated workspace, writes the current checkout guidance into it, and benchmarks the real skills / `AGENTS.md` flow.
|
||||
- CLI mode now also records a structured trace of invoked skills, tool calls, proposed `wmill` commands, and any attempted `wmill` executions.
|
||||
- Frontend progress streams live while the benchmark is running.
|
||||
|
||||
@@ -210,8 +210,6 @@ function buildSettings(
|
||||
baseUrl: 'http://backend.test/default',
|
||||
email: 'admin@windmill.dev',
|
||||
password: 'changeme',
|
||||
keepWorkspaces: true,
|
||||
workspacePrefix: 'ai-evals',
|
||||
pollIntervalMs: 1,
|
||||
maxWaitMs: 50,
|
||||
...overrides
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface CompletedPreviewJob {
|
||||
const tokenCache = new Map<string, Promise<string>>()
|
||||
const sharedWorkspaceQueue = new Map<string, Promise<void>>()
|
||||
const managedSharedWorkspacePrefixes = ['f/evals/']
|
||||
const DEFAULT_WORKSPACE_PREFIX = 'ai-evals'
|
||||
|
||||
export class BackendPreviewClient {
|
||||
constructor(private readonly settings: BackendValidationSettings) {}
|
||||
@@ -35,7 +36,7 @@ export class BackendPreviewClient {
|
||||
): Promise<T> {
|
||||
const workspaceId =
|
||||
this.settings.workspaceOverride ??
|
||||
buildWorkspaceId(this.settings.workspacePrefix, caseId, attempt)
|
||||
buildWorkspaceId(caseId, attempt)
|
||||
|
||||
const run = async () => {
|
||||
await this.ensureWorkspace(workspaceId)
|
||||
@@ -46,7 +47,7 @@ export class BackendPreviewClient {
|
||||
try {
|
||||
return await body(workspaceId)
|
||||
} finally {
|
||||
if (!this.settings.keepWorkspaces && !this.settings.workspaceOverride) {
|
||||
if (!this.settings.workspaceOverride) {
|
||||
await this.deleteWorkspace(workspaceId).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
@@ -440,14 +441,14 @@ async function withSharedWorkspaceLock<T>(workspaceId: string, body: () => Promi
|
||||
}
|
||||
}
|
||||
|
||||
function buildWorkspaceId(prefix: string, caseId: string, attempt: number): string {
|
||||
function buildWorkspaceId(caseId: string, attempt: number): string {
|
||||
const caseSlug = caseId
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 30)
|
||||
const suffix = randomUUID().slice(0, 8)
|
||||
return `${prefix}-${caseSlug || 'case'}-a${attempt}-${suffix}`
|
||||
return `${DEFAULT_WORKSPACE_PREFIX}-${caseSlug || 'case'}-a${attempt}-${suffix}`
|
||||
}
|
||||
|
||||
function extractFolderName(path: string): string | null {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { loadSelectedCases } from "../../core/cases";
|
||||
import { resolveBackendValidationSettings } from "../../core/backendValidation";
|
||||
import { resolveFrontendEvalTransportSettings } from "../../core/frontendTransport";
|
||||
import {
|
||||
formatRunModelLabel,
|
||||
getFrontendEvalModel,
|
||||
@@ -9,13 +8,15 @@ import {
|
||||
import { buildRunResult } from "../../core/results";
|
||||
import { runSuite } from "../../core/runSuite";
|
||||
import type { BenchmarkRunResult, ModeRunner } from "../../core/types";
|
||||
import { resolveWindmillBackendSettings } from "../../core/windmillBackendSettings";
|
||||
import { emitFrontendBenchmarkProgress } from "./progress";
|
||||
import { createAppModeRunner } from "../../modes/app";
|
||||
import { createFlowModeRunner } from "../../modes/flow";
|
||||
import { createGlobalModeRunner } from "../../modes/global";
|
||||
import { createScriptModeRunner } from "../../modes/script";
|
||||
import { DEFAULT_JUDGE_MODEL } from "../../core/judge";
|
||||
|
||||
export type FrontendBenchmarkMode = "flow" | "app" | "script";
|
||||
export type FrontendBenchmarkMode = "flow" | "app" | "script" | "global";
|
||||
|
||||
export async function runFrontendBenchmarkFromEnv(): Promise<BenchmarkRunResult> {
|
||||
const mode = parseMode(process.env.WMILL_FRONTEND_AI_EVAL_MODE);
|
||||
@@ -36,17 +37,14 @@ export async function runFrontendBenchmarkFromEnv(): Promise<BenchmarkRunResult>
|
||||
evalMode: mode,
|
||||
requestedMode: process.env.WMILL_FRONTEND_AI_EVAL_BACKEND_VALIDATION,
|
||||
});
|
||||
const transportSettings = resolveFrontendEvalTransportSettings({
|
||||
evalMode: mode,
|
||||
requestedTransport: process.env.WMILL_FRONTEND_AI_EVAL_TRANSPORT,
|
||||
});
|
||||
const backendSettings = resolveWindmillBackendSettings();
|
||||
|
||||
const selectedCases = await loadSelectedCases(mode, caseIds);
|
||||
const modeRunner = getModeRunner(
|
||||
mode,
|
||||
getFrontendEvalModel(model),
|
||||
backendValidation,
|
||||
transportSettings,
|
||||
backendSettings,
|
||||
);
|
||||
const runModel = formatRunModelLabel(mode, model);
|
||||
const caseResults = await runSuite({
|
||||
@@ -66,7 +64,6 @@ export async function runFrontendBenchmarkFromEnv(): Promise<BenchmarkRunResult>
|
||||
mode,
|
||||
runs,
|
||||
runModel,
|
||||
transport: transportSettings.transport,
|
||||
judgeModel: DEFAULT_JUDGE_MODEL,
|
||||
caseResults,
|
||||
});
|
||||
@@ -76,24 +73,26 @@ function getModeRunner(
|
||||
mode: FrontendBenchmarkMode,
|
||||
model: ReturnType<typeof getFrontendEvalModel>,
|
||||
backendValidation: ReturnType<typeof resolveBackendValidationSettings>,
|
||||
transportSettings: ReturnType<typeof resolveFrontendEvalTransportSettings>,
|
||||
backendSettings: ReturnType<typeof resolveWindmillBackendSettings>,
|
||||
): ModeRunner<any, any, any> {
|
||||
switch (mode) {
|
||||
case "flow":
|
||||
return createFlowModeRunner(model, backendValidation, transportSettings);
|
||||
return createFlowModeRunner(model, backendValidation, backendSettings);
|
||||
case "app":
|
||||
return createAppModeRunner(model, transportSettings);
|
||||
return createAppModeRunner(model, backendSettings);
|
||||
case "script":
|
||||
return createScriptModeRunner(
|
||||
model,
|
||||
backendValidation,
|
||||
transportSettings,
|
||||
backendSettings,
|
||||
);
|
||||
case "global":
|
||||
return createGlobalModeRunner(model, backendSettings);
|
||||
}
|
||||
}
|
||||
|
||||
function parseMode(value: string | undefined): FrontendBenchmarkMode {
|
||||
if (value === "flow" || value === "app" || value === "script") {
|
||||
if (value === "flow" || value === "app" || value === "script" || value === "global") {
|
||||
return value;
|
||||
}
|
||||
throw new Error(`Unsupported frontend benchmark mode: ${String(value)}`);
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
prepareAppUserMessage,
|
||||
} from "../../../../../frontend/src/lib/components/copilot/chat/app/core";
|
||||
import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
|
||||
import { createAppFileHelpers } from "./fileHelpers";
|
||||
import { createAppFileHelpers, type AppEvalChatHelpers } from "./fileHelpers";
|
||||
import { runEval } from "../shared";
|
||||
import type { AIProvider } from "$lib/gen/types.gen";
|
||||
import type {
|
||||
@@ -22,7 +22,6 @@ import type {
|
||||
} from "../../../../core/types";
|
||||
import type { TokenUsage } from "../shared/types";
|
||||
import type { AppFilesState } from "../../../../core/validators";
|
||||
import type { FrontendEvalTransport } from "../../../../core/frontendTransport";
|
||||
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
|
||||
import {
|
||||
createAppBackendRunnableContextElement,
|
||||
@@ -49,8 +48,7 @@ export interface AppEvalOptions {
|
||||
model?: string;
|
||||
maxIterations?: number;
|
||||
provider?: AIProvider;
|
||||
transport?: FrontendEvalTransport;
|
||||
backend?: WindmillBackendSettings;
|
||||
backend: WindmillBackendSettings;
|
||||
workspaceRoot?: string;
|
||||
runContext?: ModeRunContext;
|
||||
}
|
||||
@@ -58,7 +56,7 @@ export interface AppEvalOptions {
|
||||
export async function runAppEval(
|
||||
userPrompt: string,
|
||||
apiKey: string,
|
||||
options?: AppEvalOptions,
|
||||
options: AppEvalOptions,
|
||||
): Promise<AppEvalResult> {
|
||||
const workspaceRoot =
|
||||
options?.workspaceRoot ??
|
||||
@@ -101,10 +99,9 @@ export async function runAppEval(
|
||||
model,
|
||||
workspace: workspaceRoot,
|
||||
provider: options?.provider,
|
||||
transport: options?.transport,
|
||||
backend: options?.backend,
|
||||
proxyCaseId: options?.runContext?.caseId,
|
||||
proxyAttempt: options?.runContext?.attempt,
|
||||
backend: options.backend,
|
||||
caseId: options?.runContext?.caseId,
|
||||
attempt: options?.runContext?.attempt,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -124,7 +121,7 @@ export async function runAppEval(
|
||||
|
||||
async function buildAdditionalContext(
|
||||
appContext: EvalCaseRuntimeAppContextSpec | undefined,
|
||||
helpers: AppAIChatHelpers,
|
||||
helpers: AppEvalChatHelpers,
|
||||
): Promise<ContextElement[]> {
|
||||
const entries = appContext?.additional ?? [];
|
||||
if (entries.length === 0) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mkdir, rm, writeFile } from 'fs/promises'
|
||||
import { dirname, join } from 'path'
|
||||
import type {
|
||||
AppAIChatHelpers,
|
||||
AppDatatableMetadata,
|
||||
AppFiles,
|
||||
BackendRunnable,
|
||||
DataTableSchema,
|
||||
@@ -10,6 +11,10 @@ import type {
|
||||
} from '../../../../../frontend/src/lib/components/copilot/chat/app/core'
|
||||
import { buildAppWmillTypes, collectAppDiagnostics } from '../../../../core/appDiagnostics'
|
||||
|
||||
export interface AppEvalChatHelpers extends AppAIChatHelpers {
|
||||
getDatatables: () => Promise<DataTableSchema[]>
|
||||
}
|
||||
|
||||
async function writeFrontendFile(
|
||||
workspaceRoot: string | undefined,
|
||||
path: string,
|
||||
@@ -92,7 +97,7 @@ export async function createAppFileHelpers(
|
||||
initialDatatables: DataTableSchema[] = [],
|
||||
workspaceRoot?: string
|
||||
): Promise<{
|
||||
helpers: AppAIChatHelpers
|
||||
helpers: AppEvalChatHelpers
|
||||
getFiles: () => AppFiles
|
||||
getEvalState: () => {
|
||||
frontend: Record<string, string>
|
||||
@@ -137,7 +142,7 @@ export async function createAppFileHelpers(
|
||||
}
|
||||
await persistDatatables(workspaceRoot, datatables)
|
||||
|
||||
const helpers: AppAIChatHelpers = {
|
||||
const helpers: AppEvalChatHelpers = {
|
||||
listFrontendFiles: () => [
|
||||
...Object.keys(frontend).filter((path) => path !== '/wmill.d.ts'),
|
||||
'/wmill.d.ts'
|
||||
@@ -211,6 +216,34 @@ export async function createAppFileHelpers(
|
||||
},
|
||||
lint,
|
||||
getDatatables: async () => structuredClone(datatables),
|
||||
listDatatableTables: async () =>
|
||||
datatables.map(
|
||||
(datatable): AppDatatableMetadata => {
|
||||
const schemas = Object.fromEntries(
|
||||
Object.entries(datatable.schemas).map(([schemaName, tables]) => [
|
||||
schemaName,
|
||||
Object.keys(tables)
|
||||
])
|
||||
)
|
||||
return {
|
||||
datatable_name: datatable.datatable_name,
|
||||
schemas,
|
||||
tableCount: Object.values(schemas).reduce(
|
||||
(sum, tableNames) => sum + tableNames.length,
|
||||
0
|
||||
),
|
||||
error: datatable.error
|
||||
}
|
||||
}
|
||||
),
|
||||
getDatatableTableSchema: async (
|
||||
datatableName: string,
|
||||
schemaName: string,
|
||||
tableName: string
|
||||
) => {
|
||||
const datatable = datatables.find((entry) => entry.datatable_name === datatableName)
|
||||
return structuredClone(datatable?.schemas?.[schemaName]?.[tableName] ?? {})
|
||||
},
|
||||
getAvailableDatatableNames: () => datatables.map((datatable) => datatable.datatable_name),
|
||||
execDatatableSql: async (
|
||||
datatableName: string,
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
import { runEval } from "../shared";
|
||||
import type { ModeRunContext } from "../../../../core/types";
|
||||
import type { TokenUsage, ToolCallDetail } from "../shared/types";
|
||||
import type { FrontendEvalTransport } from "../../../../core/frontendTransport";
|
||||
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
|
||||
|
||||
export interface FlowFixture {
|
||||
@@ -48,8 +47,7 @@ export interface FlowEvalOptions {
|
||||
model?: string;
|
||||
maxIterations?: number;
|
||||
provider?: AIProvider;
|
||||
transport?: FrontendEvalTransport;
|
||||
backend?: WindmillBackendSettings;
|
||||
backend: WindmillBackendSettings;
|
||||
workspaceRoot?: string;
|
||||
runContext?: ModeRunContext;
|
||||
}
|
||||
@@ -57,7 +55,7 @@ export interface FlowEvalOptions {
|
||||
export async function runFlowEval(
|
||||
userPrompt: string,
|
||||
apiKey: string,
|
||||
options?: FlowEvalOptions,
|
||||
options: FlowEvalOptions,
|
||||
): Promise<FlowEvalResult> {
|
||||
const workspaceRoot =
|
||||
options?.workspaceRoot ??
|
||||
@@ -100,10 +98,9 @@ export async function runFlowEval(
|
||||
model,
|
||||
workspace: workspaceRoot,
|
||||
provider: options?.provider,
|
||||
transport: options?.transport,
|
||||
backend: options?.backend,
|
||||
proxyCaseId: options?.runContext?.caseId,
|
||||
proxyAttempt: options?.runContext?.attempt,
|
||||
backend: options.backend,
|
||||
caseId: options?.runContext?.caseId,
|
||||
attempt: options?.runContext?.attempt,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import type { AIProvider } from "$lib/gen/types.gen";
|
||||
import {
|
||||
globalTools,
|
||||
prepareGlobalSystemMessage,
|
||||
prepareGlobalUserMessage,
|
||||
} from "../../../../../frontend/src/lib/components/copilot/chat/global/core";
|
||||
import { globalDraftStore } from "../../../../../frontend/src/lib/components/copilot/chat/global/draftStore.svelte";
|
||||
import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
|
||||
import type { ModeRunContext } from "../../../../core/types";
|
||||
import type { GlobalDraftState } from "../../../../core/validators";
|
||||
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
|
||||
import {
|
||||
registerBenchmarkWorkspaceRunnables,
|
||||
unregisterBenchmarkWorkspaceRunnables,
|
||||
type BenchmarkWorkspaceRunnables,
|
||||
} from "../../mockBackend";
|
||||
import { runEval } from "../shared";
|
||||
import type { TokenUsage, ToolCallDetail } from "../shared/types";
|
||||
|
||||
const MUTATING_GLOBAL_TOOLS = new Set([
|
||||
"deploy_workspace_item",
|
||||
"delete_workspace_item",
|
||||
]);
|
||||
|
||||
export interface GlobalEvalResult {
|
||||
success: boolean;
|
||||
state: GlobalDraftState;
|
||||
error?: string;
|
||||
assistantMessageCount: number;
|
||||
toolCallCount: number;
|
||||
toolsUsed: string[];
|
||||
toolCallDetails: ToolCallDetail[];
|
||||
tokenUsage: TokenUsage;
|
||||
}
|
||||
|
||||
export interface GlobalEvalOptions {
|
||||
workspaceFixtures?: BenchmarkWorkspaceRunnables;
|
||||
model?: string;
|
||||
maxIterations?: number;
|
||||
provider?: AIProvider;
|
||||
backend: WindmillBackendSettings;
|
||||
workspaceRoot?: string;
|
||||
runContext?: ModeRunContext;
|
||||
}
|
||||
|
||||
export async function runGlobalEval(
|
||||
userPrompt: string,
|
||||
apiKey: string,
|
||||
options: GlobalEvalOptions,
|
||||
): Promise<GlobalEvalResult> {
|
||||
const workspaceRoot =
|
||||
options.workspaceRoot ??
|
||||
(await mkdtemp(join(tmpdir(), "wmill-frontend-global-benchmark-")));
|
||||
|
||||
globalDraftStore.clearDrafts(workspaceRoot);
|
||||
registerBenchmarkWorkspaceRunnables(workspaceRoot, options.workspaceFixtures ?? {});
|
||||
|
||||
try {
|
||||
const model = options.model ?? "claude-haiku-4-5-20251001";
|
||||
const rawResult = await runEval({
|
||||
userPrompt,
|
||||
systemMessage: prepareGlobalSystemMessage(),
|
||||
userMessage: prepareGlobalUserMessage(userPrompt),
|
||||
tools: getGlobalEvalTools(),
|
||||
helpers: {},
|
||||
apiKey,
|
||||
getOutput: () => ({ drafts: globalDraftStore.listDrafts(workspaceRoot) }),
|
||||
onAssistantMessageStart: options.runContext?.onAssistantMessageStart,
|
||||
onAssistantToken: options.runContext?.onAssistantChunk,
|
||||
onAssistantMessageEnd: options.runContext?.onAssistantMessageEnd,
|
||||
onToolCall: options.runContext?.onToolCall,
|
||||
options: {
|
||||
maxIterations: options.maxIterations,
|
||||
model,
|
||||
workspace: workspaceRoot,
|
||||
provider: options.provider,
|
||||
backend: options.backend,
|
||||
caseId: options.runContext?.caseId,
|
||||
attempt: options.runContext?.attempt,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
state: rawResult.output,
|
||||
success: rawResult.success,
|
||||
error: rawResult.error,
|
||||
assistantMessageCount: rawResult.iterations,
|
||||
toolCallCount: rawResult.toolCallsCount,
|
||||
toolsUsed: rawResult.toolsCalled,
|
||||
toolCallDetails: rawResult.toolCallDetails,
|
||||
tokenUsage: rawResult.tokenUsage,
|
||||
};
|
||||
} finally {
|
||||
globalDraftStore.clearDrafts(workspaceRoot);
|
||||
unregisterBenchmarkWorkspaceRunnables(workspaceRoot);
|
||||
if (!options.workspaceRoot) {
|
||||
await rm(workspaceRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getGlobalEvalTools(): ProductionTool<{}>[] {
|
||||
return (globalTools as ProductionTool<{}>[]).map((tool) => {
|
||||
if (!MUTATING_GLOBAL_TOOLS.has(tool.def.function.name)) {
|
||||
return tool;
|
||||
}
|
||||
|
||||
return {
|
||||
...tool,
|
||||
requiresConfirmation: false,
|
||||
validateBeforeConfirmation: undefined,
|
||||
fn: async () =>
|
||||
JSON.stringify(
|
||||
{
|
||||
success: false,
|
||||
error:
|
||||
"This mutating workspace tool is disabled during ai_evals global mode.",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import { createScriptFileHelpers, type ScriptEvalState } from "./fileHelpers";
|
||||
import { runEval } from "../shared";
|
||||
import type { ModeRunContext } from "../../../../core/types";
|
||||
import type { TokenUsage, ToolCallDetail } from "../shared/types";
|
||||
import type { FrontendEvalTransport } from "../../../../core/frontendTransport";
|
||||
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
|
||||
|
||||
export interface ScriptEvalResult {
|
||||
@@ -33,8 +32,7 @@ export interface ScriptEvalOptions {
|
||||
model?: string;
|
||||
maxIterations?: number;
|
||||
provider?: AIProvider;
|
||||
transport?: FrontendEvalTransport;
|
||||
backend?: WindmillBackendSettings;
|
||||
backend: WindmillBackendSettings;
|
||||
workspaceRoot?: string;
|
||||
runContext?: ModeRunContext;
|
||||
}
|
||||
@@ -98,10 +96,9 @@ export async function runScriptEval(
|
||||
model,
|
||||
workspace: workspaceRoot,
|
||||
provider: modelProvider.provider,
|
||||
transport: options.transport,
|
||||
backend: options.backend,
|
||||
proxyCaseId: options.runContext?.caseId,
|
||||
proxyAttempt: options.runContext?.attempt,
|
||||
caseId: options.runContext?.caseId,
|
||||
attempt: options.runContext?.attempt,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -40,8 +40,8 @@ export interface RunEvalParams<THelpers, TOutput> {
|
||||
apiKey: string;
|
||||
/** Function to get the current output state */
|
||||
getOutput: () => TOutput;
|
||||
/** Optional configuration */
|
||||
options?: EvalRunnerOptions;
|
||||
/** Model and Windmill backend configuration */
|
||||
options: EvalRunnerOptions;
|
||||
onAssistantMessageStart?: () => void;
|
||||
onAssistantToken?: (token: string) => void;
|
||||
onAssistantMessageEnd?: () => void;
|
||||
@@ -70,10 +70,10 @@ export async function runEval<THelpers, TOutput>(
|
||||
} = params;
|
||||
let shouldEmitMessageStart = true;
|
||||
|
||||
const model = options?.model ?? "gpt-4o";
|
||||
const maxIterations = options?.maxIterations ?? 20;
|
||||
const workspace = options?.workspace ?? "test-workspace";
|
||||
const provider = toFrontendEvalProvider(options?.provider);
|
||||
const model = options.model ?? "gpt-4o";
|
||||
const maxIterations = options.maxIterations ?? 20;
|
||||
const workspace = options.workspace ?? "test-workspace";
|
||||
const provider = toFrontendEvalProvider(options.provider);
|
||||
|
||||
const modelProvider = resolveEvalModelProvider(model, provider);
|
||||
|
||||
@@ -203,45 +203,31 @@ export async function runEval<THelpers, TOutput>(
|
||||
}
|
||||
};
|
||||
|
||||
if (options?.transport === "proxy") {
|
||||
const backendSettings = options.backend;
|
||||
if (!backendSettings) {
|
||||
throw new Error("Missing backend settings for proxy transport");
|
||||
}
|
||||
|
||||
const backendClient = new WindmillBackendClient(backendSettings);
|
||||
return await backendClient.withWorkspace(
|
||||
options.proxyCaseId ?? "eval",
|
||||
options.proxyAttempt ?? 1,
|
||||
async (proxyWorkspaceId) => {
|
||||
const resourcePath = buildProxyResourcePath(modelProvider.provider);
|
||||
await backendClient.upsertResource({
|
||||
workspaceId: proxyWorkspaceId,
|
||||
path: resourcePath,
|
||||
resourceType: modelProvider.provider,
|
||||
value: { api_key: apiKey },
|
||||
});
|
||||
const token = await backendClient.getToken();
|
||||
const clients = createEvalClients({
|
||||
provider: modelProvider.provider,
|
||||
apiKey,
|
||||
transport: "proxy",
|
||||
proxy: {
|
||||
baseURL: `${backendSettings.baseUrl}/api/w/${encodeURIComponent(proxyWorkspaceId)}/ai/proxy`,
|
||||
bearerToken: token,
|
||||
resourcePath,
|
||||
},
|
||||
}) as unknown as ChatClients;
|
||||
return await executeChatLoop(clients);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const clients = createEvalClients({
|
||||
provider: modelProvider.provider,
|
||||
apiKey,
|
||||
}) as unknown as ChatClients;
|
||||
return await executeChatLoop(clients);
|
||||
const backendSettings = options.backend;
|
||||
const backendClient = new WindmillBackendClient(backendSettings);
|
||||
return await backendClient.withWorkspace(
|
||||
options.caseId ?? "eval",
|
||||
options.attempt ?? 1,
|
||||
async (proxyWorkspaceId) => {
|
||||
const resourcePath = buildProxyResourcePath(modelProvider.provider);
|
||||
await backendClient.upsertResource({
|
||||
workspaceId: proxyWorkspaceId,
|
||||
path: resourcePath,
|
||||
resourceType: modelProvider.provider,
|
||||
value: { api_key: apiKey },
|
||||
});
|
||||
const token = await backendClient.getToken();
|
||||
const clients = createEvalClients({
|
||||
provider: modelProvider.provider,
|
||||
proxy: {
|
||||
baseURL: `${backendSettings.baseUrl}/api/w/${encodeURIComponent(proxyWorkspaceId)}/ai/proxy`,
|
||||
bearerToken: token,
|
||||
resourcePath,
|
||||
},
|
||||
}) as unknown as ChatClients;
|
||||
return await executeChatLoop(clients);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function toFrontendEvalProvider(
|
||||
@@ -250,7 +236,8 @@ function toFrontendEvalProvider(
|
||||
if (
|
||||
provider === "anthropic" ||
|
||||
provider === "openai" ||
|
||||
provider === "googleai"
|
||||
provider === "googleai" ||
|
||||
provider === "deepseek"
|
||||
) {
|
||||
return provider;
|
||||
}
|
||||
|
||||
@@ -2,35 +2,9 @@ import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
buildProxyHeaders,
|
||||
buildProxyResourcePath,
|
||||
buildOpenAICompatibleClientOptions,
|
||||
resolveEvalModelProvider,
|
||||
} from "./providerConfig";
|
||||
|
||||
describe("buildOpenAICompatibleClientOptions", () => {
|
||||
it("adds Gemini's OpenAI-compatible base URL and client header", () => {
|
||||
const options = buildOpenAICompatibleClientOptions(
|
||||
"googleai",
|
||||
"gemini-test-key",
|
||||
);
|
||||
|
||||
expect(options).toMatchObject({
|
||||
apiKey: "gemini-test-key",
|
||||
baseURL: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
defaultHeaders: {
|
||||
"x-goog-api-client": "windmill-ai-evals/1.0",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the default OpenAI-compatible config for OpenAI", () => {
|
||||
expect(
|
||||
buildOpenAICompatibleClientOptions("openai", "openai-test-key"),
|
||||
).toEqual({
|
||||
apiKey: "openai-test-key",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("proxy helpers", () => {
|
||||
it("builds provider-scoped proxy resource paths", () => {
|
||||
expect(buildProxyResourcePath("googleai")).toBe("f/evals/ai/googleai");
|
||||
@@ -53,6 +27,13 @@ describe("resolveEvalModelProvider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("infers deepseek from DeepSeek model ids", () => {
|
||||
expect(resolveEvalModelProvider("deepseek-v4-flash")).toEqual({
|
||||
provider: "deepseek",
|
||||
model: "deepseek-v4-flash",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an explicit provider", () => {
|
||||
expect(resolveEvalModelProvider("gemini-2.5-pro", "googleai")).toEqual({
|
||||
provider: "googleai",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import OpenAI from "openai";
|
||||
import type { FrontendEvalModelConfig } from "../../../../core/models";
|
||||
import type { FrontendEvalTransport } from "../../../../core/frontendTransport";
|
||||
|
||||
export type FrontendEvalProvider = FrontendEvalModelConfig["provider"];
|
||||
|
||||
@@ -15,15 +14,12 @@ export interface ResolvedEvalModelProvider {
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface EvalProxyClientConfig {
|
||||
export interface WindmillAiProxyClientConfig {
|
||||
baseURL: string;
|
||||
bearerToken: string;
|
||||
resourcePath: string;
|
||||
}
|
||||
|
||||
const GEMINI_OPENAI_BASE_URL =
|
||||
"https://generativelanguage.googleapis.com/v1beta/openai/";
|
||||
const GEMINI_GOOG_API_CLIENT = "windmill-ai-evals/1.0";
|
||||
const EVAL_PROXY_RESOURCE_PREFIX = "f/evals/ai";
|
||||
|
||||
export function buildProxyHeaders(
|
||||
@@ -40,25 +36,8 @@ export function buildProxyResourcePath(provider: FrontendEvalProvider): string {
|
||||
return `${EVAL_PROXY_RESOURCE_PREFIX}/${provider}`;
|
||||
}
|
||||
|
||||
export function buildOpenAICompatibleClientOptions(
|
||||
provider: Exclude<FrontendEvalProvider, "anthropic">,
|
||||
apiKey: string,
|
||||
): ConstructorParameters<typeof OpenAI>[0] {
|
||||
if (provider === "googleai") {
|
||||
return {
|
||||
apiKey,
|
||||
baseURL: GEMINI_OPENAI_BASE_URL,
|
||||
defaultHeaders: {
|
||||
"x-goog-api-client": GEMINI_GOOG_API_CLIENT,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { apiKey };
|
||||
}
|
||||
|
||||
function buildProxyOpenAIClientOptions(
|
||||
proxy: EvalProxyClientConfig,
|
||||
proxy: WindmillAiProxyClientConfig,
|
||||
): ConstructorParameters<typeof OpenAI>[0] {
|
||||
return {
|
||||
apiKey: "unused",
|
||||
@@ -69,52 +48,24 @@ function buildProxyOpenAIClientOptions(
|
||||
|
||||
export function createEvalClients(input: {
|
||||
provider: FrontendEvalProvider;
|
||||
apiKey: string;
|
||||
transport?: FrontendEvalTransport;
|
||||
proxy?: EvalProxyClientConfig;
|
||||
proxy: WindmillAiProxyClientConfig;
|
||||
}): EvalClients {
|
||||
const transport = input.transport ?? "direct";
|
||||
|
||||
if (input.provider === "anthropic") {
|
||||
if (transport === "proxy") {
|
||||
if (!input.proxy) {
|
||||
throw new Error(
|
||||
"Missing proxy client configuration for proxy transport",
|
||||
);
|
||||
}
|
||||
return {
|
||||
openai: new OpenAI({ apiKey: "unused" }),
|
||||
anthropic: new Anthropic({
|
||||
apiKey: "unused",
|
||||
baseURL: input.proxy.baseURL,
|
||||
defaultHeaders: buildProxyHeaders(
|
||||
input.proxy.bearerToken,
|
||||
input.proxy.resourcePath,
|
||||
),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
openai: new OpenAI({ apiKey: "unused" }),
|
||||
anthropic: new Anthropic({ apiKey: input.apiKey }),
|
||||
};
|
||||
}
|
||||
|
||||
if (transport === "proxy") {
|
||||
if (!input.proxy) {
|
||||
throw new Error("Missing proxy client configuration for proxy transport");
|
||||
}
|
||||
return {
|
||||
openai: new OpenAI(buildProxyOpenAIClientOptions(input.proxy)),
|
||||
anthropic: new Anthropic({ apiKey: "unused" }),
|
||||
anthropic: new Anthropic({
|
||||
apiKey: "unused",
|
||||
baseURL: input.proxy.baseURL,
|
||||
defaultHeaders: buildProxyHeaders(
|
||||
input.proxy.bearerToken,
|
||||
input.proxy.resourcePath,
|
||||
),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
openai: new OpenAI(
|
||||
buildOpenAICompatibleClientOptions(input.provider, input.apiKey),
|
||||
),
|
||||
openai: new OpenAI(buildProxyOpenAIClientOptions(input.proxy)),
|
||||
anthropic: new Anthropic({ apiKey: "unused" }),
|
||||
};
|
||||
}
|
||||
@@ -132,6 +83,9 @@ export function resolveEvalModelProvider(
|
||||
if (model.startsWith("gemini")) {
|
||||
return { provider: "googleai", model };
|
||||
}
|
||||
if (model.startsWith("deepseek")) {
|
||||
return { provider: "deepseek", model };
|
||||
}
|
||||
if (model.startsWith("gpt") || model.startsWith("o")) {
|
||||
return { provider: "openai", model };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions.mjs";
|
||||
import type { AIProvider } from "$lib/gen/types.gen";
|
||||
import type { FrontendEvalTransport } from "../../../../core/frontendTransport";
|
||||
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
|
||||
|
||||
export interface TokenUsage {
|
||||
@@ -15,14 +14,13 @@ export interface ToolCallDetail {
|
||||
}
|
||||
|
||||
export interface EvalRunnerOptions {
|
||||
backend: WindmillBackendSettings;
|
||||
maxIterations?: number;
|
||||
model?: string;
|
||||
workspace?: string;
|
||||
provider?: AIProvider;
|
||||
transport?: FrontendEvalTransport;
|
||||
backend?: WindmillBackendSettings;
|
||||
proxyCaseId?: string;
|
||||
proxyAttempt?: number;
|
||||
caseId?: string;
|
||||
attempt?: number;
|
||||
}
|
||||
|
||||
export interface RawEvalResult<TOutput> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type FrontendBenchmarkProgressSurface = 'flow' | 'app' | 'script'
|
||||
export type FrontendBenchmarkProgressSurface = 'flow' | 'app' | 'script' | 'global'
|
||||
|
||||
export type FrontendBenchmarkProgressEvent =
|
||||
| {
|
||||
|
||||
@@ -16,14 +16,13 @@ const FRONTEND_BENCHMARK_TEST =
|
||||
const FRONTEND_BENCHMARK_CONFIG =
|
||||
"../ai_evals/adapters/frontend/vitest.config.ts";
|
||||
|
||||
export type FrontendMode = "flow" | "app" | "script";
|
||||
export type FrontendMode = "flow" | "app" | "script" | "global";
|
||||
|
||||
export async function runFrontendBenchmarkAdapter(input: {
|
||||
mode: FrontendMode;
|
||||
caseIds: string[];
|
||||
runs: number;
|
||||
model?: string;
|
||||
transport?: string;
|
||||
verbose?: boolean;
|
||||
backendValidation?: string;
|
||||
}): Promise<BenchmarkRunResult> {
|
||||
@@ -44,10 +43,6 @@ export async function runFrontendBenchmarkAdapter(input: {
|
||||
WMILL_FRONTEND_AI_EVAL_BACKEND_VALIDATION: input.backendValidation ?? "",
|
||||
};
|
||||
|
||||
if (input.transport) {
|
||||
env.WMILL_FRONTEND_AI_EVAL_TRANSPORT = input.transport;
|
||||
}
|
||||
|
||||
try {
|
||||
await runVitestBenchmark(
|
||||
path.join(FRONTEND_DIR, "node_modules", ".bin", "vitest"),
|
||||
|
||||
@@ -65,6 +65,10 @@ vi.mock('$lib/gen', async () => {
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? (listBenchmarkScripts(data.workspace) ?? [])
|
||||
: actual.ScriptService.listScripts(data),
|
||||
existsScriptByPath: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? Boolean(getBenchmarkScriptByPath(data.workspace, data.path))
|
||||
: actual.ScriptService.existsScriptByPath(data),
|
||||
getScriptByPath: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
const script = getBenchmarkScriptByPath(data.workspace, data.path)
|
||||
@@ -91,6 +95,10 @@ vi.mock('$lib/gen', async () => {
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? (listBenchmarkFlows(data.workspace) ?? [])
|
||||
: actual.FlowService.listFlows(data),
|
||||
existsFlowByPath: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? Boolean(getBenchmarkFlowByPath(data.workspace, data.path))
|
||||
: actual.FlowService.existsFlowByPath(data),
|
||||
getFlowByPath: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
const flow = getBenchmarkFlowByPath(data.workspace, data.path)
|
||||
@@ -142,6 +150,16 @@ vi.mock('$lib/gen', async () => {
|
||||
}
|
||||
}),
|
||||
ScheduleService: wrapService(actual.ScheduleService, {
|
||||
existsSchedule: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? false : actual.ScheduleService.existsSchedule(data),
|
||||
listSchedules: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? [] : actual.ScheduleService.listSchedules(data),
|
||||
getSchedule: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
throw new Error(`Schedule "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return actual.ScheduleService.getSchedule(data)
|
||||
},
|
||||
previewSchedule: async (data: { requestBody?: Record<string, unknown> }) =>
|
||||
previewBenchmarkSchedule(data),
|
||||
createSchedule: async (data: { workspace: string; requestBody: Record<string, unknown> }) =>
|
||||
@@ -149,11 +167,167 @@ vi.mock('$lib/gen', async () => {
|
||||
? createBenchmarkSchedule(data)
|
||||
: actual.ScheduleService.createSchedule(data)
|
||||
}),
|
||||
ResourceService: wrapService(actual.ResourceService, {
|
||||
existsResource: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? false : actual.ResourceService.existsResource(data),
|
||||
listResource: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? [] : actual.ResourceService.listResource(data),
|
||||
getResource: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
throw new Error(`Resource "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return actual.ResourceService.getResource(data)
|
||||
},
|
||||
queryResourceTypes: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? [] : actual.ResourceService.queryResourceTypes(data)
|
||||
}),
|
||||
VariableService: wrapService(actual.VariableService, {
|
||||
existsVariable: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? false : actual.VariableService.existsVariable(data),
|
||||
listVariable: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? [] : actual.VariableService.listVariable(data),
|
||||
getVariable: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
throw new Error(`Variable "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return actual.VariableService.getVariable(data)
|
||||
}
|
||||
}),
|
||||
AppService: wrapService(actual.AppService, {
|
||||
existsApp: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? false : actual.AppService.existsApp(data),
|
||||
listApps: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? [] : actual.AppService.listApps(data),
|
||||
getAppByPath: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
throw new Error(`App "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return actual.AppService.getAppByPath(data)
|
||||
}
|
||||
}),
|
||||
HttpTriggerService: wrapService(actual.HttpTriggerService, {
|
||||
existsHttpTrigger: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? false : actual.HttpTriggerService.existsHttpTrigger(data),
|
||||
listHttpTriggers: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? [] : actual.HttpTriggerService.listHttpTriggers(data),
|
||||
getHttpTrigger: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
throw new Error(`HTTP trigger "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return actual.HttpTriggerService.getHttpTrigger(data)
|
||||
},
|
||||
createHttpTrigger: async (data: { workspace: string; requestBody: Record<string, unknown> }) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? createBenchmarkHttpTrigger(data)
|
||||
: actual.HttpTriggerService.createHttpTrigger(data)
|
||||
}),
|
||||
WebsocketTriggerService: wrapService(actual.WebsocketTriggerService, {
|
||||
existsWebsocketTrigger: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? false
|
||||
: actual.WebsocketTriggerService.existsWebsocketTrigger(data),
|
||||
listWebsocketTriggers: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? []
|
||||
: actual.WebsocketTriggerService.listWebsocketTriggers(data),
|
||||
getWebsocketTrigger: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
throw new Error(`Websocket trigger "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return actual.WebsocketTriggerService.getWebsocketTrigger(data)
|
||||
}
|
||||
}),
|
||||
KafkaTriggerService: wrapService(actual.KafkaTriggerService, {
|
||||
existsKafkaTrigger: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? false
|
||||
: actual.KafkaTriggerService.existsKafkaTrigger(data),
|
||||
listKafkaTriggers: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? [] : actual.KafkaTriggerService.listKafkaTriggers(data),
|
||||
getKafkaTrigger: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
throw new Error(`Kafka trigger "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return actual.KafkaTriggerService.getKafkaTrigger(data)
|
||||
}
|
||||
}),
|
||||
NatsTriggerService: wrapService(actual.NatsTriggerService, {
|
||||
existsNatsTrigger: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? false : actual.NatsTriggerService.existsNatsTrigger(data),
|
||||
listNatsTriggers: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? [] : actual.NatsTriggerService.listNatsTriggers(data),
|
||||
getNatsTrigger: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
throw new Error(`NATS trigger "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return actual.NatsTriggerService.getNatsTrigger(data)
|
||||
}
|
||||
}),
|
||||
PostgresTriggerService: wrapService(actual.PostgresTriggerService, {
|
||||
existsPostgresTrigger: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? false
|
||||
: actual.PostgresTriggerService.existsPostgresTrigger(data),
|
||||
listPostgresTriggers: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? []
|
||||
: actual.PostgresTriggerService.listPostgresTriggers(data),
|
||||
getPostgresTrigger: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
throw new Error(`Postgres trigger "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return actual.PostgresTriggerService.getPostgresTrigger(data)
|
||||
}
|
||||
}),
|
||||
MqttTriggerService: wrapService(actual.MqttTriggerService, {
|
||||
existsMqttTrigger: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? false : actual.MqttTriggerService.existsMqttTrigger(data),
|
||||
listMqttTriggers: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? [] : actual.MqttTriggerService.listMqttTriggers(data),
|
||||
getMqttTrigger: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
throw new Error(`MQTT trigger "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return actual.MqttTriggerService.getMqttTrigger(data)
|
||||
}
|
||||
}),
|
||||
SqsTriggerService: wrapService(actual.SqsTriggerService, {
|
||||
existsSqsTrigger: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? false : actual.SqsTriggerService.existsSqsTrigger(data),
|
||||
listSqsTriggers: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? [] : actual.SqsTriggerService.listSqsTriggers(data),
|
||||
getSqsTrigger: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
throw new Error(`SQS trigger "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return actual.SqsTriggerService.getSqsTrigger(data)
|
||||
}
|
||||
}),
|
||||
GcpTriggerService: wrapService(actual.GcpTriggerService, {
|
||||
existsGcpTrigger: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? false : actual.GcpTriggerService.existsGcpTrigger(data),
|
||||
listGcpTriggers: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? [] : actual.GcpTriggerService.listGcpTriggers(data),
|
||||
getGcpTrigger: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
throw new Error(`GCP trigger "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return actual.GcpTriggerService.getGcpTrigger(data)
|
||||
}
|
||||
}),
|
||||
AzureTriggerService: wrapService(actual.AzureTriggerService, {
|
||||
existsAzureTrigger: async (data: { workspace: string; path: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace)
|
||||
? false
|
||||
: actual.AzureTriggerService.existsAzureTrigger(data),
|
||||
listAzureTriggers: async (data: { workspace: string }) =>
|
||||
hasBenchmarkWorkspace(data.workspace) ? [] : actual.AzureTriggerService.listAzureTriggers(data),
|
||||
getAzureTrigger: async (data: { workspace: string; path: string }) => {
|
||||
if (hasBenchmarkWorkspace(data.workspace)) {
|
||||
throw new Error(`Azure trigger "${data.path}" not found in benchmark workspace`)
|
||||
}
|
||||
return actual.AzureTriggerService.getAzureTrigger(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import type { WindmillBackendSettings } from "../../core/windmillBackendSettings";
|
||||
import {
|
||||
WindmillBackendClient,
|
||||
assertWindmillBackendReachable,
|
||||
} from "./windmillBackend";
|
||||
|
||||
const ORIGINAL_FETCH = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = ORIGINAL_FETCH;
|
||||
});
|
||||
|
||||
describe("assertWindmillBackendReachable", () => {
|
||||
it("logs in to verify backend reachability", async () => {
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
||||
globalThis.fetch = mockFetch(requests, textResponse(200, "token"));
|
||||
|
||||
await expect(
|
||||
assertWindmillBackendReachable(
|
||||
buildSettings({ baseUrl: "http://backend.test/reachable" }),
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(requests.map((entry) => entry.url)).toEqual([
|
||||
"http://backend.test/reachable/api/auth/login",
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds setup guidance when the backend cannot be initialized", async () => {
|
||||
globalThis.fetch = mockFetch(
|
||||
[],
|
||||
textResponse(401, "invalid password"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
assertWindmillBackendReachable(
|
||||
buildSettings({ baseUrl: "http://backend.test/auth-failure" }),
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"Start a Windmill backend at that URL, or set WMILL_AI_EVAL_BACKEND_URL=<url>.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WindmillBackendClient", () => {
|
||||
it("creates or reuses the specified backend workspace without deleting it", async () => {
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
||||
globalThis.fetch = mockFetch(
|
||||
requests,
|
||||
textResponse(200, "token"),
|
||||
textResponse(200, "false"),
|
||||
textResponse(200, ""),
|
||||
);
|
||||
|
||||
const client = new WindmillBackendClient(
|
||||
buildSettings({
|
||||
baseUrl: "http://backend.test/shared-workspace",
|
||||
workspaceOverride: "shared-evals",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.withWorkspace("case-a", 1, async (workspaceId) => workspaceId),
|
||||
).resolves.toBe("shared-evals");
|
||||
|
||||
expect(requests.map((entry) => entry.url)).toEqual([
|
||||
"http://backend.test/shared-workspace/api/auth/login",
|
||||
"http://backend.test/shared-workspace/api/workspaces/exists",
|
||||
"http://backend.test/shared-workspace/api/workspaces/create",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function buildSettings(
|
||||
overrides: Partial<WindmillBackendSettings> = {},
|
||||
): WindmillBackendSettings {
|
||||
return {
|
||||
baseUrl: "http://backend.test/default",
|
||||
email: "admin@windmill.dev",
|
||||
password: "changeme",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockFetch(
|
||||
requests: Array<{ url: string; init?: RequestInit }>,
|
||||
...responses: Response[]
|
||||
): typeof fetch {
|
||||
const queue = [...responses];
|
||||
return async (input, init) => {
|
||||
const url = String(input);
|
||||
requests.push({ url, init });
|
||||
const next = queue.shift();
|
||||
if (!next) {
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
}
|
||||
|
||||
function textResponse(status: number, body: string): Response {
|
||||
return new Response(body, { status });
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { WindmillBackendSettings } from "../../core/windmillBackendSettings
|
||||
|
||||
const tokenCache = new Map<string, Promise<string>>();
|
||||
const sharedWorkspaceQueue = new Map<string, Promise<void>>();
|
||||
const DEFAULT_WORKSPACE_PREFIX = "ai-evals";
|
||||
|
||||
export class WindmillBackendClient {
|
||||
constructor(private readonly settings: WindmillBackendSettings) {}
|
||||
@@ -14,7 +15,7 @@ export class WindmillBackendClient {
|
||||
): Promise<T> {
|
||||
const workspaceId =
|
||||
this.settings.workspaceOverride ??
|
||||
buildWorkspaceId(this.settings.workspacePrefix, caseId, attempt);
|
||||
buildWorkspaceId(caseId, attempt);
|
||||
|
||||
const run = async () => {
|
||||
await this.ensureWorkspace(workspaceId);
|
||||
@@ -22,7 +23,7 @@ export class WindmillBackendClient {
|
||||
try {
|
||||
return await body(workspaceId);
|
||||
} finally {
|
||||
if (!this.settings.keepWorkspaces && !this.settings.workspaceOverride) {
|
||||
if (!this.settings.workspaceOverride) {
|
||||
await this.deleteWorkspace(workspaceId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -136,6 +137,24 @@ export class WindmillBackendClient {
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertWindmillBackendReachable(
|
||||
settings: WindmillBackendSettings,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await new WindmillBackendClient(settings).getToken();
|
||||
} catch (error) {
|
||||
const details = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
[
|
||||
`Could not initialize the Windmill backend for AI eval proxy at ${settings.baseUrl}.`,
|
||||
"Start a Windmill backend at that URL, or set WMILL_AI_EVAL_BACKEND_URL=<url>.",
|
||||
`Using login ${settings.email}; if authentication failed, set WMILL_AI_EVAL_BACKEND_EMAIL and WMILL_AI_EVAL_BACKEND_PASSWORD.`,
|
||||
`Details: ${details}`,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function withSharedWorkspaceLock<T>(
|
||||
workspaceId: string,
|
||||
body: () => Promise<T>,
|
||||
@@ -160,18 +179,14 @@ async function withSharedWorkspaceLock<T>(
|
||||
}
|
||||
}
|
||||
|
||||
function buildWorkspaceId(
|
||||
prefix: string,
|
||||
caseId: string,
|
||||
attempt: number,
|
||||
): string {
|
||||
function buildWorkspaceId(caseId: string, attempt: number): string {
|
||||
const caseSlug = caseId
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 30);
|
||||
const suffix = randomUUID().slice(0, 8);
|
||||
return `${prefix}-${caseSlug || "case"}-a${attempt}-${suffix}`;
|
||||
return `${DEFAULT_WORKSPACE_PREFIX}-${caseSlug || "case"}-a${attempt}-${suffix}`;
|
||||
}
|
||||
|
||||
async function expectOk(response: Response, context: string): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
- id: global-test1-script-create
|
||||
prompt: |-
|
||||
Create a draft Bun script at `f/evals/global/greet_user`.
|
||||
It should take a string `name` input and return `Hello, ${name}!`.
|
||||
Leave it as an AI draft only; do not deploy or save it.
|
||||
runtime:
|
||||
maxTurns: 8
|
||||
validate:
|
||||
draftCountExactly: 1
|
||||
requiredDrafts:
|
||||
- type: script
|
||||
path: f/evals/global/greet_user
|
||||
language: bun
|
||||
valueIncludes:
|
||||
- name
|
||||
- Hello
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- write_script
|
||||
forbiddenToolsUsed:
|
||||
- deploy_workspace_item
|
||||
- delete_workspace_item
|
||||
judgeChecklist:
|
||||
- creates a Bun script draft at f/evals/global/greet_user
|
||||
- the script accepts a name input
|
||||
- the script returns a greeting containing Hello, the provided name, and an exclamation mark
|
||||
- the result stays as an AI draft and is not deployed or saved to the workspace
|
||||
|
||||
- id: global-test2-script-edit-existing
|
||||
prompt: |-
|
||||
Update the existing workspace script at `f/evals/global/format_greeting`.
|
||||
Keep it as a Bun script, but change the greeting so the provided name is uppercased and the returned message ends with an exclamation mark.
|
||||
Leave the result as an AI draft only; do not deploy or save it.
|
||||
initial: ai_evals/fixtures/frontend/global/initial/format_greeting_script.json
|
||||
runtime:
|
||||
maxTurns: 8
|
||||
validate:
|
||||
draftCountExactly: 1
|
||||
requiredDrafts:
|
||||
- type: script
|
||||
path: f/evals/global/format_greeting
|
||||
language: bun
|
||||
valueIncludes:
|
||||
- toUpperCase
|
||||
- "!"
|
||||
toolExpect:
|
||||
forbiddenToolsUsed:
|
||||
- deploy_workspace_item
|
||||
- delete_workspace_item
|
||||
judgeChecklist:
|
||||
- creates an AI draft for the existing f/evals/global/format_greeting script
|
||||
- preserves the script as Bun
|
||||
- uppercases the provided name in the greeting
|
||||
- returns a message ending with an exclamation mark
|
||||
- does not deploy or save the draft to the workspace
|
||||
|
||||
- id: global-test3-flow-create
|
||||
prompt: |-
|
||||
Create a draft flow at `f/evals/global/sum_numbers`.
|
||||
It should take two numeric inputs, `a` and `b`, and return their sum.
|
||||
Leave it as an AI draft only; do not deploy or save it.
|
||||
runtime:
|
||||
maxTurns: 8
|
||||
validate:
|
||||
draftCountExactly: 1
|
||||
requiredDrafts:
|
||||
- type: flow
|
||||
path: f/evals/global/sum_numbers
|
||||
valueIncludes:
|
||||
- modules
|
||||
- rawscript
|
||||
- flow_input.a
|
||||
- flow_input.b
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- write_flow
|
||||
forbiddenToolsUsed:
|
||||
- deploy_workspace_item
|
||||
- delete_workspace_item
|
||||
toolCallArgs:
|
||||
- tool: write_flow
|
||||
field: modules
|
||||
stringStartsWithAnyOf:
|
||||
- "["
|
||||
judgeChecklist:
|
||||
- creates a flow draft at f/evals/global/sum_numbers
|
||||
- the flow accepts numeric inputs a and b
|
||||
- the flow returns the sum of a and b
|
||||
- the result stays as an AI draft and is not deployed or saved to the workspace
|
||||
+9
-23
@@ -27,11 +27,8 @@ import { EVAL_MODES, type EvalMode } from "../core/types";
|
||||
import { DEFAULT_JUDGE_MODEL } from "../core/judge";
|
||||
import { createCliModeRunner } from "../modes/cli";
|
||||
import { runFrontendBenchmarkAdapter } from "../adapters/frontend/runtime";
|
||||
import {
|
||||
FRONTEND_EVAL_TRANSPORTS,
|
||||
type FrontendEvalTransport,
|
||||
parseFrontendEvalTransport,
|
||||
} from "../core/frontendTransport";
|
||||
import { resolveWindmillBackendSettings } from "../core/windmillBackendSettings";
|
||||
import { assertWindmillBackendReachable } from "../adapters/frontend/windmillBackend";
|
||||
|
||||
async function main() {
|
||||
const program = new Command()
|
||||
@@ -56,6 +53,7 @@ async function main() {
|
||||
" bun run cli -- run flow --record",
|
||||
" bun run cli -- run flow --backend-validation preview",
|
||||
" bun run cli -- run flow flow-test5-simple-modification --runs 3",
|
||||
" bun run cli -- run global global-test1-script-create",
|
||||
" bun run cli -- run cli bun-hello-script",
|
||||
"",
|
||||
"Models:",
|
||||
@@ -73,7 +71,7 @@ async function main() {
|
||||
program
|
||||
.command("cases")
|
||||
.description("List available cases")
|
||||
.argument("[mode]", "cli, flow, script, or app", parseOptionalMode)
|
||||
.argument("[mode]", "cli, flow, script, app, or global", parseOptionalMode)
|
||||
.action(async (mode?: EvalMode) => {
|
||||
await handleCases(mode);
|
||||
});
|
||||
@@ -81,7 +79,7 @@ async function main() {
|
||||
program
|
||||
.command("run")
|
||||
.description("Run one benchmark mode")
|
||||
.argument("<mode>", "cli, flow, script, or app", parseMode)
|
||||
.argument("<mode>", "cli, flow, script, app, or global", parseMode)
|
||||
.argument("[caseIds...]", "specific case ids to run")
|
||||
.option(
|
||||
"--runs <n>",
|
||||
@@ -98,10 +96,6 @@ async function main() {
|
||||
"--models <names>",
|
||||
"comma-separated model aliases to run sequentially",
|
||||
)
|
||||
.option(
|
||||
"--transport <mode>",
|
||||
`frontend transport (${FRONTEND_EVAL_TRANSPORTS.join(", ")})`,
|
||||
)
|
||||
.option("--verbose", "stream assistant output during frontend runs")
|
||||
.option(
|
||||
"--record",
|
||||
@@ -120,7 +114,6 @@ async function main() {
|
||||
output?: string;
|
||||
model?: string;
|
||||
models?: string;
|
||||
transport?: string;
|
||||
verbose?: boolean;
|
||||
record?: boolean;
|
||||
backendValidation?: string;
|
||||
@@ -133,9 +126,6 @@ async function main() {
|
||||
outputPath: options.output,
|
||||
model: options.model,
|
||||
models: options.models,
|
||||
transport: options.transport
|
||||
? parseFrontendEvalTransport(options.transport)
|
||||
: undefined,
|
||||
verbose: options.verbose ?? false,
|
||||
record: options.record ?? false,
|
||||
backendValidation: options.backendValidation,
|
||||
@@ -163,7 +153,7 @@ function handleModels() {
|
||||
process.stdout.write("Available models\n");
|
||||
for (const model of EVAL_MODELS) {
|
||||
const supports = [
|
||||
...(model.frontend ? ["flow", "script", "app"] : []),
|
||||
...(model.frontend ? ["flow", "script", "app", "global"] : []),
|
||||
...(model.cli ? ["cli"] : []),
|
||||
];
|
||||
const aliases = [
|
||||
@@ -184,7 +174,6 @@ async function handleRun(input: {
|
||||
outputPath?: string;
|
||||
model?: string;
|
||||
models?: string;
|
||||
transport?: FrontendEvalTransport;
|
||||
verbose: boolean;
|
||||
record: boolean;
|
||||
backendValidation?: string;
|
||||
@@ -197,11 +186,6 @@ async function handleRun(input: {
|
||||
if (input.model && input.models) {
|
||||
throw new Error("Use either --model or --models, not both");
|
||||
}
|
||||
if (input.mode === "cli" && input.transport === "proxy") {
|
||||
throw new Error(
|
||||
"--transport proxy is only supported for flow, script, and app modes",
|
||||
);
|
||||
}
|
||||
|
||||
const selectedCases = await loadSelectedCases(input.mode, input.caseIds);
|
||||
const models = resolveRequestedModels(input.mode, input.model, input.models);
|
||||
@@ -220,6 +204,9 @@ async function handleRun(input: {
|
||||
"--backend-validation currently supports only flow and script modes",
|
||||
);
|
||||
}
|
||||
if (input.mode !== "cli") {
|
||||
await assertWindmillBackendReachable(resolveWindmillBackendSettings());
|
||||
}
|
||||
|
||||
const summaries: Array<{
|
||||
label: string;
|
||||
@@ -249,7 +236,6 @@ async function handleRun(input: {
|
||||
caseIds: input.caseIds,
|
||||
runs: input.runs,
|
||||
model: model.id,
|
||||
transport: input.transport,
|
||||
verbose: input.verbose,
|
||||
backendValidation,
|
||||
});
|
||||
|
||||
@@ -13,9 +13,7 @@ export interface BackendValidationSettings {
|
||||
baseUrl: string;
|
||||
email: string;
|
||||
password: string;
|
||||
keepWorkspaces: boolean;
|
||||
workspaceOverride?: string;
|
||||
workspacePrefix: string;
|
||||
pollIntervalMs: number;
|
||||
maxWaitMs: number;
|
||||
}
|
||||
|
||||
@@ -183,6 +183,26 @@ describe("loadCases", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("loads global draft validation and forbidden tool expectations", async () => {
|
||||
const globalCases = await loadCases("global");
|
||||
const caseEntry = globalCases.find((entry) => entry.id === "global-test1-script-create");
|
||||
|
||||
expect(caseEntry?.validate).toMatchObject({
|
||||
draftCountExactly: 1,
|
||||
requiredDrafts: [
|
||||
{
|
||||
type: "script",
|
||||
path: "f/evals/global/greet_user",
|
||||
language: "bun",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(caseEntry?.toolExpect).toMatchObject({
|
||||
requiredToolsUsed: ["write_script"],
|
||||
forbiddenToolsUsed: ["deploy_workspace_item", "delete_workspace_item"],
|
||||
});
|
||||
});
|
||||
|
||||
it("loads tool expectations for workspace mutation cases", async () => {
|
||||
const scriptCases = await loadCases("script");
|
||||
const caseEntry = scriptCases.find(
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import {
|
||||
parseFrontendEvalTransport,
|
||||
resolveFrontendEvalTransportSettings,
|
||||
} from "./frontendTransport";
|
||||
|
||||
const ORIGINAL_ENV = {
|
||||
WMILL_AI_EVAL_BACKEND_URL: process.env.WMILL_AI_EVAL_BACKEND_URL,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_ENV.WMILL_AI_EVAL_BACKEND_URL === undefined) {
|
||||
delete process.env.WMILL_AI_EVAL_BACKEND_URL;
|
||||
} else {
|
||||
process.env.WMILL_AI_EVAL_BACKEND_URL =
|
||||
ORIGINAL_ENV.WMILL_AI_EVAL_BACKEND_URL;
|
||||
}
|
||||
});
|
||||
|
||||
describe("parseFrontendEvalTransport", () => {
|
||||
it("defaults to direct when unset", () => {
|
||||
expect(parseFrontendEvalTransport(undefined)).toBe("direct");
|
||||
});
|
||||
|
||||
it("accepts proxy explicitly", () => {
|
||||
expect(parseFrontendEvalTransport("proxy")).toBe("proxy");
|
||||
});
|
||||
|
||||
it("rejects unsupported values", () => {
|
||||
expect(() => parseFrontendEvalTransport("worker")).toThrow(
|
||||
"Unsupported frontend eval transport: worker",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveFrontendEvalTransportSettings", () => {
|
||||
it("includes backend settings for proxy transport", () => {
|
||||
process.env.WMILL_AI_EVAL_BACKEND_URL = "http://127.0.0.1:8000/";
|
||||
|
||||
expect(
|
||||
resolveFrontendEvalTransportSettings({
|
||||
evalMode: "app",
|
||||
requestedTransport: "proxy",
|
||||
}),
|
||||
).toMatchObject({
|
||||
transport: "proxy",
|
||||
backend: {
|
||||
baseUrl: "http://127.0.0.1:8000",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps direct transport for cli runs", () => {
|
||||
expect(
|
||||
resolveFrontendEvalTransportSettings({
|
||||
evalMode: "cli",
|
||||
requestedTransport: "direct",
|
||||
}),
|
||||
).toEqual({
|
||||
transport: "direct",
|
||||
backend: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { EvalMode } from "./types";
|
||||
import type { WindmillBackendSettings } from "./windmillBackendSettings";
|
||||
import { resolveWindmillBackendSettings } from "./windmillBackendSettings";
|
||||
|
||||
export const FRONTEND_EVAL_TRANSPORTS = ["direct", "proxy"] as const;
|
||||
|
||||
export type FrontendEvalTransport = (typeof FRONTEND_EVAL_TRANSPORTS)[number];
|
||||
|
||||
export interface FrontendEvalTransportSettings {
|
||||
transport: FrontendEvalTransport;
|
||||
backend?: WindmillBackendSettings;
|
||||
}
|
||||
|
||||
export function parseFrontendEvalTransport(
|
||||
value?: string | null,
|
||||
): FrontendEvalTransport {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
|
||||
if (!normalized || normalized === "direct") {
|
||||
return "direct";
|
||||
}
|
||||
|
||||
if (normalized === "proxy") {
|
||||
return "proxy";
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unsupported frontend eval transport: ${value}. Use one of: ${FRONTEND_EVAL_TRANSPORTS.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveFrontendEvalTransportSettings(input: {
|
||||
evalMode: EvalMode;
|
||||
requestedTransport?: string | null;
|
||||
}): FrontendEvalTransportSettings {
|
||||
const transport = parseFrontendEvalTransport(input.requestedTransport);
|
||||
|
||||
if (transport === "proxy" && input.evalMode === "cli") {
|
||||
throw new Error(
|
||||
'Frontend eval transport "proxy" is only supported for flow, script, and app evals',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
transport,
|
||||
backend:
|
||||
transport === "proxy" ? resolveWindmillBackendSettings() : undefined,
|
||||
};
|
||||
}
|
||||
@@ -11,19 +11,34 @@ describe("resolveEvalModel", () => {
|
||||
provider: "googleai",
|
||||
model: "gemini-2.5-pro",
|
||||
});
|
||||
expect(resolveEvalModel("script", "gemini-3-flash-preview").frontend).toEqual({
|
||||
expect(
|
||||
resolveEvalModel("script", "gemini-3-flash-preview").frontend,
|
||||
).toEqual({
|
||||
provider: "googleai",
|
||||
model: "gemini-3-flash-preview",
|
||||
});
|
||||
expect(resolveEvalModel("flow", "gemini-3.1-pro-preview").frontend).toEqual({
|
||||
provider: "googleai",
|
||||
model: "gemini-3.1-pro-preview",
|
||||
expect(resolveEvalModel("flow", "gemini-3.1-pro-preview").frontend).toEqual(
|
||||
{
|
||||
provider: "googleai",
|
||||
model: "gemini-3.1-pro-preview",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("supports DeepSeek aliases for frontend evals", () => {
|
||||
expect(resolveEvalModel("flow", "deepseek").frontend).toEqual({
|
||||
provider: "deepseek",
|
||||
model: "deepseek-v4-flash",
|
||||
});
|
||||
expect(resolveEvalModel("script", "deepseek-v4-pro").frontend).toEqual({
|
||||
provider: "deepseek",
|
||||
model: "deepseek-v4-pro",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects Gemini aliases for cli evals", () => {
|
||||
expect(() => resolveEvalModel("cli", "gemini")).toThrow(
|
||||
"Model gemini-flash is not supported for cli mode"
|
||||
"Model gemini-flash is not supported for cli mode",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+39
-7
@@ -1,7 +1,7 @@
|
||||
import type { EvalMode } from "./types";
|
||||
|
||||
export interface FrontendEvalModelConfig {
|
||||
provider: "anthropic" | "openai" | "googleai";
|
||||
provider: "anthropic" | "openai" | "googleai" | "deepseek";
|
||||
model: string;
|
||||
}
|
||||
|
||||
@@ -117,15 +117,40 @@ export const EVAL_MODELS: EvalModelSpec[] = [
|
||||
{
|
||||
id: "gemini-3.1-pro-preview",
|
||||
label: "Gemini 3.1 Pro Preview",
|
||||
aliases: ["gemini-3.1-pro-preview", "gemini-3.1-pro", "gemini-3-pro-preview"],
|
||||
aliases: [
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3.1-pro",
|
||||
"gemini-3-pro-preview",
|
||||
],
|
||||
frontend: {
|
||||
provider: "googleai",
|
||||
model: "gemini-3.1-pro-preview",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "deepseek-v4-flash",
|
||||
label: "DeepSeek V4 Flash",
|
||||
aliases: ["deepseek", "deepseek-v4", "deepseek-v4-flash"],
|
||||
frontend: {
|
||||
provider: "deepseek",
|
||||
model: "deepseek-v4-flash",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "deepseek-v4-pro",
|
||||
label: "DeepSeek V4 Pro",
|
||||
aliases: ["deepseek-pro", "deepseek-v4-pro"],
|
||||
frontend: {
|
||||
provider: "deepseek",
|
||||
model: "deepseek-v4-pro",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function resolveEvalModel(mode: EvalMode, alias?: string): EvalModelSpec {
|
||||
export function resolveEvalModel(
|
||||
mode: EvalMode,
|
||||
alias?: string,
|
||||
): EvalModelSpec {
|
||||
const spec = alias ? findEvalModel(alias) : getDefaultEvalModel(mode);
|
||||
if (!spec) {
|
||||
throw new Error(`Unknown model: ${alias}`);
|
||||
@@ -145,21 +170,26 @@ export function resolveEvalModel(mode: EvalMode, alias?: string): EvalModelSpec
|
||||
export function getEvalModelHelpText(): string {
|
||||
return EVAL_MODELS.map((model) => {
|
||||
const modes = [
|
||||
...(model.frontend ? ["flow", "script", "app"] : []),
|
||||
...(model.frontend ? ["flow", "script", "app", "global"] : []),
|
||||
...(model.cli ? ["cli"] : []),
|
||||
];
|
||||
return ` ${model.id.padEnd(8)} ${model.label} (${modes.join(", ")})`;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
export function formatRunModelLabel(mode: EvalMode, model: EvalModelSpec): string {
|
||||
export function formatRunModelLabel(
|
||||
mode: EvalMode,
|
||||
model: EvalModelSpec,
|
||||
): string {
|
||||
if (mode === "cli") {
|
||||
return `${model.cli!.provider}:${model.cli!.model}`;
|
||||
}
|
||||
return `${model.frontend!.provider}:${model.frontend!.model}`;
|
||||
}
|
||||
|
||||
export function getFrontendEvalModel(model: EvalModelSpec): FrontendEvalModelConfig {
|
||||
export function getFrontendEvalModel(
|
||||
model: EvalModelSpec,
|
||||
): FrontendEvalModelConfig {
|
||||
if (!model.frontend) {
|
||||
throw new Error(`Model ${model.id} does not support frontend evals`);
|
||||
}
|
||||
@@ -180,6 +210,8 @@ function getDefaultEvalModel(mode: EvalMode): EvalModelSpec {
|
||||
function findEvalModel(alias: string): EvalModelSpec | undefined {
|
||||
const normalized = alias.trim().toLowerCase();
|
||||
return EVAL_MODELS.find((model) =>
|
||||
[model.id, ...model.aliases].some((candidate) => candidate.toLowerCase() === normalized)
|
||||
[model.id, ...model.aliases].some(
|
||||
(candidate) => candidate.toLowerCase() === normalized,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,7 +74,6 @@ export function buildRunResult(input: {
|
||||
mode: EvalMode;
|
||||
runs: number;
|
||||
runModel: string | null;
|
||||
transport?: BenchmarkRunResult["transport"];
|
||||
judgeModel: string | null;
|
||||
caseResults: BenchmarkCaseResult[];
|
||||
}): BenchmarkRunResult {
|
||||
@@ -116,7 +115,6 @@ export function buildRunResult(input: {
|
||||
gitSha: getGitSha(),
|
||||
runs: input.runs,
|
||||
runModel: input.runModel,
|
||||
transport: input.transport ?? null,
|
||||
judgeModel: input.judgeModel,
|
||||
caseCount: input.caseResults.length,
|
||||
attemptCount,
|
||||
@@ -142,9 +140,6 @@ export function formatRunSummary(result: BenchmarkRunResult): string {
|
||||
`Pass rate: ${formatPercent(result.passRate)} (${result.passedAttempts}/${result.attemptCount})`,
|
||||
`Average duration: ${Math.round(result.averageDurationMs)}ms`,
|
||||
];
|
||||
if (result.transport) {
|
||||
lines.splice(1, 0, `Transport: ${result.transport}`);
|
||||
}
|
||||
|
||||
const failures = collectFailures(result);
|
||||
if (failures.length > 0) {
|
||||
@@ -251,7 +246,6 @@ function toHistoryRecord(result: BenchmarkRunResult) {
|
||||
mode: result.mode,
|
||||
runs: result.runs,
|
||||
runModel: result.runModel,
|
||||
transport: result.transport,
|
||||
judgeModel: result.judgeModel,
|
||||
caseCount: result.caseCount,
|
||||
attemptCount: result.attemptCount,
|
||||
|
||||
+24
-4
@@ -1,7 +1,6 @@
|
||||
export const EVAL_MODES = ["cli", "flow", "script", "app"] as const;
|
||||
export const EVAL_MODES = ["cli", "flow", "script", "app", "global"] as const;
|
||||
|
||||
export type EvalMode = (typeof EVAL_MODES)[number];
|
||||
export type FrontendEvalTransport = "direct" | "proxy";
|
||||
|
||||
export interface EvalCaseRuntimeBackendPreview {
|
||||
args?: Record<string, unknown>;
|
||||
@@ -109,6 +108,27 @@ export interface AppValidationSpec {
|
||||
forbiddenAppContent?: string[];
|
||||
}
|
||||
|
||||
export interface GlobalDraftRequirement {
|
||||
type: string;
|
||||
path: string;
|
||||
triggerKind?: string;
|
||||
language?: string;
|
||||
summaryIncludes?: string[];
|
||||
valueIncludes?: string[];
|
||||
valueExcludes?: string[];
|
||||
}
|
||||
|
||||
export interface GlobalValidationSpec {
|
||||
draftCountAtLeast?: number;
|
||||
draftCountExactly?: number;
|
||||
requiredDrafts?: GlobalDraftRequirement[];
|
||||
forbiddenDrafts?: Array<{
|
||||
type: string;
|
||||
path: string;
|
||||
triggerKind?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CliValidationSpec {
|
||||
requiredSkills?: string[];
|
||||
forbiddenSkills?: string[];
|
||||
@@ -137,10 +157,11 @@ export interface ToolCallArgumentRule {
|
||||
|
||||
export interface ToolValidationSpec {
|
||||
requiredToolsUsed?: string[];
|
||||
forbiddenToolsUsed?: string[];
|
||||
toolCallArgs?: ToolCallArgumentRule[];
|
||||
}
|
||||
|
||||
export type EvalValidationSpec = FlowValidationSpec | AppValidationSpec;
|
||||
export type EvalValidationSpec = FlowValidationSpec | AppValidationSpec | GlobalValidationSpec;
|
||||
|
||||
export interface EvalCase {
|
||||
id: string;
|
||||
@@ -297,7 +318,6 @@ export interface BenchmarkRunResult {
|
||||
gitSha: string | null;
|
||||
runs: number;
|
||||
runModel: string | null;
|
||||
transport: FrontendEvalTransport | null;
|
||||
judgeModel: string | null;
|
||||
caseCount: number;
|
||||
attemptCount: number;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test";
|
||||
import {
|
||||
validateAppState,
|
||||
validateCliWorkspace,
|
||||
validateGlobalState,
|
||||
validateScriptState,
|
||||
validateToolExpectations,
|
||||
} from "./validators";
|
||||
@@ -117,6 +118,230 @@ describe("validateToolExpectations", () => {
|
||||
details: 'rejected prefixes: schedules/; values: "schedules/greet_user_daily"',
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects forbidden tool usage", () => {
|
||||
const checks = validateToolExpectations({
|
||||
run: {
|
||||
success: true,
|
||||
actual: {},
|
||||
assistantMessageCount: 1,
|
||||
toolCallCount: 1,
|
||||
toolsUsed: ["write_script", "deploy_workspace_item"],
|
||||
skillsInvoked: [],
|
||||
},
|
||||
toolExpect: {
|
||||
forbiddenToolsUsed: ["deploy_workspace_item"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(checks).toContainEqual({
|
||||
name: "does not use deploy_workspace_item",
|
||||
passed: false,
|
||||
details: "tools used: write_script, deploy_workspace_item",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateGlobalState", () => {
|
||||
it("accepts a required script draft", () => {
|
||||
const checks = validateGlobalState({
|
||||
actual: {
|
||||
drafts: [
|
||||
{
|
||||
type: "script",
|
||||
path: "f/evals/global/greet_user",
|
||||
language: "bun",
|
||||
value:
|
||||
"export async function main(name: string) {\n return `Hello, ${name}!`\n}\n",
|
||||
isDraft: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
validate: {
|
||||
draftCountExactly: 1,
|
||||
requiredDrafts: [
|
||||
{
|
||||
type: "script",
|
||||
path: "f/evals/global/greet_user",
|
||||
language: "bun",
|
||||
valueIncludes: ["Hello"],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(checks.every((check) => check.passed)).toBe(true);
|
||||
});
|
||||
|
||||
it("fails when a required draft is missing", () => {
|
||||
const checks = validateGlobalState({
|
||||
actual: {
|
||||
drafts: [],
|
||||
},
|
||||
validate: {
|
||||
requiredDrafts: [
|
||||
{
|
||||
type: "script",
|
||||
path: "f/evals/global/greet_user",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(checks).toContainEqual({
|
||||
name: "global includes script draft f/evals/global/greet_user",
|
||||
passed: false,
|
||||
details: "drafts: none",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not require a TypeScript entrypoint for non-TypeScript script drafts", () => {
|
||||
const checks = validateGlobalState({
|
||||
actual: {
|
||||
drafts: [
|
||||
{
|
||||
type: "script",
|
||||
path: "f/evals/global/greet_python",
|
||||
language: "python3",
|
||||
value: "def main(name: str):\n return f'Hello, {name}!'\n",
|
||||
isDraft: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(checks.some((check) => check.name.includes("exports entrypoint"))).toBe(
|
||||
false
|
||||
);
|
||||
expect(checks.every((check) => check.passed)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows read-only global cases without draft expectations", () => {
|
||||
const checks = validateGlobalState({
|
||||
actual: {
|
||||
drafts: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
checks.some(
|
||||
(check) => check.name === "global produced at least one draft"
|
||||
)
|
||||
).toBe(false);
|
||||
expect(checks.every((check) => check.passed)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches expected global draft fixtures", () => {
|
||||
const checks = validateGlobalState({
|
||||
actual: {
|
||||
drafts: [
|
||||
{
|
||||
type: "script",
|
||||
path: "f/evals/global/greet_user",
|
||||
language: "bun",
|
||||
value:
|
||||
"export async function main(name: string) {\r\n return `Hello, ${name}!`\r\n}\r\n",
|
||||
isDraft: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
expected: {
|
||||
drafts: [
|
||||
{
|
||||
type: "script",
|
||||
path: "f/evals/global/greet_user",
|
||||
language: "bun",
|
||||
value:
|
||||
"export async function main(name: string) {\n return `Hello, ${name}!`\n}\n",
|
||||
isDraft: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(checks).toContainEqual({
|
||||
name: "global drafts match expected",
|
||||
passed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails when expected global draft fixtures differ", () => {
|
||||
const checks = validateGlobalState({
|
||||
actual: {
|
||||
drafts: [
|
||||
{
|
||||
type: "script",
|
||||
path: "f/evals/global/greet_user",
|
||||
language: "bun",
|
||||
value:
|
||||
"export async function main(name: string) {\n return `Hello, ${name}!`\n}\n",
|
||||
isDraft: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
expected: {
|
||||
drafts: [
|
||||
{
|
||||
type: "script",
|
||||
path: "f/evals/global/greet_user",
|
||||
language: "bun",
|
||||
value:
|
||||
"export async function main(name: string) {\n return `Bonjour, ${name}!`\n}\n",
|
||||
isDraft: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const expectedMatchCheck = checks.find(
|
||||
(check) => check.name === "global drafts match expected"
|
||||
);
|
||||
expect(expectedMatchCheck?.passed).toBe(false);
|
||||
expect(expectedMatchCheck?.details).toContain(
|
||||
"script:f/evals/global/greet_user value differs"
|
||||
);
|
||||
expect(expectedMatchCheck?.details).toContain("Hello");
|
||||
expect(expectedMatchCheck?.details).toContain("Bonjour");
|
||||
});
|
||||
|
||||
it("explains expected global draft metadata mismatches", () => {
|
||||
const checks = validateGlobalState({
|
||||
actual: {
|
||||
drafts: [
|
||||
{
|
||||
type: "script",
|
||||
path: "f/evals/global/greet_user",
|
||||
language: "bun",
|
||||
value:
|
||||
"export async function main(name: string) {\n return `Hello, ${name}!`\n}\n",
|
||||
isDraft: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
expected: {
|
||||
drafts: [
|
||||
{
|
||||
type: "script",
|
||||
path: "f/evals/global/greet_user",
|
||||
language: "python3",
|
||||
value:
|
||||
"export async function main(name: string) {\n return `Hello, ${name}!`\n}\n",
|
||||
isDraft: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const expectedMatchCheck = checks.find(
|
||||
(check) => check.name === "global drafts match expected"
|
||||
);
|
||||
expect(expectedMatchCheck?.passed).toBe(false);
|
||||
expect(expectedMatchCheck?.details).toContain(
|
||||
"script:f/evals/global/greet_user language differs"
|
||||
);
|
||||
expect(expectedMatchCheck?.details).toContain('actual="bun"');
|
||||
expect(expectedMatchCheck?.details).toContain('expected="python3"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateAppState", () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
CliTrace,
|
||||
CliValidationSpec,
|
||||
FlowValidationSpec,
|
||||
GlobalValidationSpec,
|
||||
ModeRunOutput,
|
||||
ToolValidationSpec,
|
||||
} from "./types";
|
||||
@@ -51,6 +52,20 @@ export interface AppDatatableState {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface GlobalDraftState {
|
||||
drafts: GlobalDraft[];
|
||||
}
|
||||
|
||||
export interface GlobalDraft {
|
||||
type: string;
|
||||
path: string;
|
||||
triggerKind?: string;
|
||||
summary?: string;
|
||||
language?: string;
|
||||
value?: unknown;
|
||||
isDraft?: boolean;
|
||||
}
|
||||
|
||||
const TS_LIKE_LANGUAGES = new Set(["bun", "deno", "nativets", "bunnative", "ts", "typescript"]);
|
||||
const CONTROL_FLOW_MODULE_TYPES = new Set(["branchone", "branchall", "forloopflow", "whileloopflow"]);
|
||||
|
||||
@@ -154,6 +169,16 @@ export function validateToolExpectations(input: {
|
||||
);
|
||||
}
|
||||
|
||||
for (const toolName of expect.forbiddenToolsUsed ?? []) {
|
||||
checks.push(
|
||||
check(
|
||||
`does not use ${toolName}`,
|
||||
!input.run.toolsUsed.includes(toolName),
|
||||
`tools used: ${input.run.toolsUsed.join(", ") || "none"}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
for (const rule of expect.toolCallArgs ?? []) {
|
||||
const calls = toolCallDetails.filter((call) => call.name === rule.tool);
|
||||
checks.push(
|
||||
@@ -202,6 +227,161 @@ export function validateToolExpectations(input: {
|
||||
return checks;
|
||||
}
|
||||
|
||||
export function validateGlobalState(input: {
|
||||
actual: GlobalDraftState;
|
||||
expected?: GlobalDraftState;
|
||||
validate?: GlobalValidationSpec;
|
||||
}): BenchmarkCheck[] {
|
||||
const drafts = input.actual.drafts ?? [];
|
||||
const checks: BenchmarkCheck[] = [];
|
||||
|
||||
// Read-only global cases are valid; only enforce draft production when the
|
||||
// case explicitly asks for draft output.
|
||||
if (globalValidationExpectsDrafts(input)) {
|
||||
checks.push(
|
||||
check(
|
||||
"global produced at least one draft",
|
||||
drafts.length > 0,
|
||||
`drafts=${drafts.length}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
checks.push(
|
||||
check(
|
||||
"all global outputs are drafts",
|
||||
drafts.every((draft) => draft.isDraft === true),
|
||||
summarizeGlobalDrafts(drafts)
|
||||
)
|
||||
);
|
||||
|
||||
for (const draft of drafts) {
|
||||
if (draft.type !== "script" || typeof draft.value !== "string") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const language = (draft.language ?? "bun").toLowerCase();
|
||||
const syntaxErrors = getScriptSyntaxErrors(draft.value, language);
|
||||
if (TS_LIKE_LANGUAGES.has(language)) {
|
||||
checks.push(
|
||||
check(
|
||||
`script draft ${draft.path} exports entrypoint`,
|
||||
hasSupportedEntrypoint(draft.value)
|
||||
)
|
||||
);
|
||||
}
|
||||
checks.push(
|
||||
check(
|
||||
`script draft ${draft.path} has no syntax errors`,
|
||||
syntaxErrors.length === 0,
|
||||
summarizeProblems(syntaxErrors)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (input.expected) {
|
||||
checks.push(
|
||||
check(
|
||||
"global drafts match expected",
|
||||
globalDraftStatesEqual(input.actual, input.expected),
|
||||
describeGlobalDraftStateMismatch(input.actual, input.expected)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const validate = input.validate;
|
||||
if (!validate) {
|
||||
return checks;
|
||||
}
|
||||
|
||||
if (validate.draftCountAtLeast !== undefined) {
|
||||
checks.push(
|
||||
check(
|
||||
`global includes at least ${validate.draftCountAtLeast} draft(s)`,
|
||||
drafts.length >= validate.draftCountAtLeast,
|
||||
`drafts=${drafts.length}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (validate.draftCountExactly !== undefined) {
|
||||
checks.push(
|
||||
check(
|
||||
`global includes exactly ${validate.draftCountExactly} draft(s)`,
|
||||
drafts.length === validate.draftCountExactly,
|
||||
`drafts=${drafts.length}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
for (const required of validate.requiredDrafts ?? []) {
|
||||
const draft = findGlobalDraft(drafts, required.type, required.path, required.triggerKind);
|
||||
checks.push(
|
||||
check(
|
||||
`global includes ${required.type} draft ${required.path}`,
|
||||
Boolean(draft),
|
||||
summarizeGlobalDrafts(drafts)
|
||||
)
|
||||
);
|
||||
if (!draft) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (required.language !== undefined) {
|
||||
checks.push(
|
||||
check(
|
||||
`${required.type} draft ${required.path} uses ${required.language}`,
|
||||
draft.language === required.language,
|
||||
`language=${draft.language ?? "(none)"}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
for (const snippet of required.summaryIncludes ?? []) {
|
||||
checks.push(
|
||||
check(
|
||||
`${required.type} draft ${required.path} summary includes '${snippet}'`,
|
||||
normalizeText(draft.summary ?? "").includes(normalizeText(snippet)),
|
||||
`summary=${draft.summary ?? ""}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const valueText = stringifyGlobalDraftValue(draft.value);
|
||||
for (const snippet of required.valueIncludes ?? []) {
|
||||
checks.push(
|
||||
check(
|
||||
`${required.type} draft ${required.path} value includes '${snippet}'`,
|
||||
normalizeText(valueText).includes(normalizeText(snippet)),
|
||||
truncateForDetails(valueText)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
for (const snippet of required.valueExcludes ?? []) {
|
||||
checks.push(
|
||||
check(
|
||||
`${required.type} draft ${required.path} value excludes '${snippet}'`,
|
||||
!normalizeText(valueText).includes(normalizeText(snippet)),
|
||||
truncateForDetails(valueText)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const forbidden of validate.forbiddenDrafts ?? []) {
|
||||
checks.push(
|
||||
check(
|
||||
`global does not include ${forbidden.type} draft ${forbidden.path}`,
|
||||
!findGlobalDraft(drafts, forbidden.type, forbidden.path, forbidden.triggerKind),
|
||||
summarizeGlobalDrafts(drafts)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
export function validateAppState(input: {
|
||||
actual: AppFilesState;
|
||||
initial?: AppFilesState;
|
||||
@@ -433,6 +613,202 @@ function summarizeProblems(problems: string[], limit = 5): string | undefined {
|
||||
return `${problems.slice(0, limit).join("; ")}; ...and ${problems.length - limit} more`;
|
||||
}
|
||||
|
||||
function findGlobalDraft(
|
||||
drafts: GlobalDraft[],
|
||||
type: string,
|
||||
path: string,
|
||||
triggerKind?: string
|
||||
): GlobalDraft | undefined {
|
||||
return drafts.find(
|
||||
(draft) =>
|
||||
draft.type === type &&
|
||||
draft.path === path &&
|
||||
(triggerKind === undefined || draft.triggerKind === triggerKind)
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeGlobalDrafts(drafts: GlobalDraft[]): string {
|
||||
const summary = drafts
|
||||
.map((draft) => formatGlobalDraftKey(draft))
|
||||
.join(", ");
|
||||
return `drafts: ${summary || "none"}`;
|
||||
}
|
||||
|
||||
function formatGlobalDraftKey(draft: GlobalDraft): string {
|
||||
return `${draft.type}${draft.triggerKind ? `:${draft.triggerKind}` : ""}:${draft.path}`;
|
||||
}
|
||||
|
||||
function globalValidationExpectsDrafts(input: {
|
||||
expected?: GlobalDraftState;
|
||||
validate?: GlobalValidationSpec;
|
||||
}): boolean {
|
||||
const validate = input.validate;
|
||||
return (
|
||||
(input.expected?.drafts?.length ?? 0) > 0 ||
|
||||
(validate?.requiredDrafts?.length ?? 0) > 0 ||
|
||||
(validate?.draftCountAtLeast ?? 0) > 0 ||
|
||||
(validate?.draftCountExactly ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
function globalDraftStatesEqual(left: GlobalDraftState, right: GlobalDraftState): boolean {
|
||||
return (
|
||||
JSON.stringify(canonicalizeGlobalDrafts(left.drafts ?? [])) ===
|
||||
JSON.stringify(canonicalizeGlobalDrafts(right.drafts ?? []))
|
||||
);
|
||||
}
|
||||
|
||||
function describeGlobalDraftStateMismatch(
|
||||
actual: GlobalDraftState,
|
||||
expected: GlobalDraftState
|
||||
): string {
|
||||
const actualDrafts = actual.drafts ?? [];
|
||||
const expectedDrafts = expected.drafts ?? [];
|
||||
const actualByKey = new Map(
|
||||
actualDrafts.map((draft) => [globalDraftSortKey(draft), draft] as const)
|
||||
);
|
||||
const expectedByKey = new Map(
|
||||
expectedDrafts.map((draft) => [globalDraftSortKey(draft), draft] as const)
|
||||
);
|
||||
|
||||
for (const key of Array.from(expectedByKey.keys()).sort()) {
|
||||
const expectedDraft = expectedByKey.get(key);
|
||||
if (expectedDraft && !actualByKey.has(key)) {
|
||||
return `missing expected draft ${formatGlobalDraftKey(expectedDraft)}; actual=${summarizeGlobalDrafts(actualDrafts)}`;
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Array.from(actualByKey.keys()).sort()) {
|
||||
const actualDraft = actualByKey.get(key);
|
||||
if (actualDraft && !expectedByKey.has(key)) {
|
||||
return `unexpected draft ${formatGlobalDraftKey(actualDraft)}; expected=${summarizeGlobalDrafts(expectedDrafts)}`;
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Array.from(expectedByKey.keys()).sort()) {
|
||||
const actualDraft = actualByKey.get(key);
|
||||
const expectedDraft = expectedByKey.get(key);
|
||||
if (!actualDraft || !expectedDraft) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fieldMismatch = describeGlobalDraftFieldMismatch(
|
||||
formatGlobalDraftKey(expectedDraft),
|
||||
actualDraft,
|
||||
expectedDraft
|
||||
);
|
||||
if (fieldMismatch) {
|
||||
return fieldMismatch;
|
||||
}
|
||||
}
|
||||
|
||||
return `actual=${summarizeGlobalDrafts(actualDrafts)}; expected=${summarizeGlobalDrafts(expectedDrafts)}`;
|
||||
}
|
||||
|
||||
function describeGlobalDraftFieldMismatch(
|
||||
key: string,
|
||||
actual: GlobalDraft,
|
||||
expected: GlobalDraft
|
||||
): string | undefined {
|
||||
const fields: Array<"language" | "summary" | "value" | "isDraft"> = [
|
||||
"language",
|
||||
"summary",
|
||||
"value",
|
||||
"isDraft",
|
||||
];
|
||||
|
||||
for (const field of fields) {
|
||||
const actualValue = comparableGlobalDraftFieldValue(actual, field);
|
||||
const expectedValue = comparableGlobalDraftFieldValue(expected, field);
|
||||
if (JSON.stringify(actualValue) === JSON.stringify(expectedValue)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return `${key} ${field} differs: actual=${formatGlobalDraftFieldValue(
|
||||
actualValue
|
||||
)}; expected=${formatGlobalDraftFieldValue(expectedValue)}`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function comparableGlobalDraftFieldValue(
|
||||
draft: GlobalDraft,
|
||||
field: "language" | "summary" | "value" | "isDraft"
|
||||
): unknown {
|
||||
if (field === "summary" && typeof draft.summary === "string") {
|
||||
return normalizeText(draft.summary);
|
||||
}
|
||||
if (field === "value" && typeof draft.value === "string") {
|
||||
return normalizeText(draft.value);
|
||||
}
|
||||
if (field === "value") {
|
||||
return canonicalizeJsonValue(draft.value);
|
||||
}
|
||||
return draft[field];
|
||||
}
|
||||
|
||||
function formatGlobalDraftFieldValue(value: unknown): string {
|
||||
if (value === undefined) {
|
||||
return "(missing)";
|
||||
}
|
||||
return truncateForDetails(JSON.stringify(value), 300);
|
||||
}
|
||||
|
||||
function canonicalizeGlobalDrafts(drafts: GlobalDraft[]): unknown[] {
|
||||
return drafts
|
||||
.slice()
|
||||
.sort((left, right) => globalDraftSortKey(left).localeCompare(globalDraftSortKey(right)))
|
||||
.map((draft) =>
|
||||
canonicalizeJsonValue({
|
||||
type: draft.type,
|
||||
path: draft.path,
|
||||
triggerKind: draft.triggerKind,
|
||||
language: draft.language,
|
||||
summary:
|
||||
typeof draft.summary === "string" ? normalizeText(draft.summary) : draft.summary,
|
||||
value:
|
||||
typeof draft.value === "string"
|
||||
? normalizeText(draft.value)
|
||||
: canonicalizeJsonValue(draft.value),
|
||||
isDraft: draft.isDraft,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function globalDraftSortKey(draft: GlobalDraft): string {
|
||||
return `${draft.type}:${draft.triggerKind ?? ""}:${draft.path}`;
|
||||
}
|
||||
|
||||
function canonicalizeJsonValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(canonicalizeJsonValue);
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, nested]) => [key, canonicalizeJsonValue(nested)])
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stringifyGlobalDraftValue(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value ?? null, null, 2);
|
||||
}
|
||||
|
||||
function truncateForDetails(value: string, maxLength = 500): string {
|
||||
const normalized = value.replace(/\s+/g, " ").trim();
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
|
||||
}
|
||||
|
||||
function validateCliExpectations(
|
||||
assistantOutput: string,
|
||||
trace: CliTrace | undefined,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import { resolveWindmillBackendSettings } from "./windmillBackendSettings";
|
||||
|
||||
const ENV_KEYS = [
|
||||
"WMILL_AI_EVAL_BACKEND_URL",
|
||||
"WINDMILL_URL",
|
||||
"WINDMILL_BASE_URL",
|
||||
"REMOTE",
|
||||
"WMILL_AI_EVAL_BACKEND_EMAIL",
|
||||
"WMILL_AI_EVAL_BACKEND_PASSWORD",
|
||||
"WMILL_AI_EVAL_BACKEND_WORKSPACE",
|
||||
] as const;
|
||||
|
||||
const ORIGINAL_ENV = Object.fromEntries(
|
||||
ENV_KEYS.map((key) => [key, process.env[key]]),
|
||||
) as Record<(typeof ENV_KEYS)[number], string | undefined>;
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
const value = ORIGINAL_ENV[key];
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("resolveWindmillBackendSettings", () => {
|
||||
it("uses backend URL/auth defaults and the optional explicit workspace", () => {
|
||||
delete process.env.WMILL_AI_EVAL_BACKEND_URL;
|
||||
delete process.env.WINDMILL_URL;
|
||||
delete process.env.WINDMILL_BASE_URL;
|
||||
delete process.env.REMOTE;
|
||||
process.env.WMILL_AI_EVAL_BACKEND_WORKSPACE = "shared-evals";
|
||||
|
||||
expect(resolveWindmillBackendSettings()).toEqual({
|
||||
baseUrl: "http://127.0.0.1:8000",
|
||||
email: "admin@windmill.dev",
|
||||
password: "changeme",
|
||||
workspaceOverride: "shared-evals",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not expose workspace retention knobs", () => {
|
||||
process.env.WMILL_AI_EVAL_BACKEND_URL = "http://backend.test/";
|
||||
|
||||
const settings = resolveWindmillBackendSettings();
|
||||
|
||||
expect(settings).toEqual({
|
||||
baseUrl: "http://backend.test",
|
||||
email: "admin@windmill.dev",
|
||||
password: "changeme",
|
||||
workspaceOverride: undefined,
|
||||
});
|
||||
expect(Object.keys(settings).sort()).toEqual([
|
||||
"baseUrl",
|
||||
"email",
|
||||
"password",
|
||||
"workspaceOverride",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,7 @@ export interface WindmillBackendSettings {
|
||||
baseUrl: string;
|
||||
email: string;
|
||||
password: string;
|
||||
keepWorkspaces: boolean;
|
||||
workspaceOverride?: string;
|
||||
workspacePrefix: string;
|
||||
}
|
||||
|
||||
export function resolveWindmillBackendSettings(): WindmillBackendSettings {
|
||||
@@ -18,13 +16,9 @@ export function resolveWindmillBackendSettings(): WindmillBackendSettings {
|
||||
),
|
||||
email: process.env.WMILL_AI_EVAL_BACKEND_EMAIL ?? "admin@windmill.dev",
|
||||
password: process.env.WMILL_AI_EVAL_BACKEND_PASSWORD ?? "changeme",
|
||||
keepWorkspaces: isTruthy(process.env.WMILL_AI_EVAL_KEEP_WORKSPACES),
|
||||
workspaceOverride: sanitizeOptionalWorkspaceId(
|
||||
process.env.WMILL_AI_EVAL_BACKEND_WORKSPACE,
|
||||
),
|
||||
workspacePrefix: sanitizeWorkspacePrefix(
|
||||
process.env.WMILL_AI_EVAL_WORKSPACE_PREFIX ?? "ai-evals",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,25 +37,9 @@ function normalizeBaseUrl(value: string): string {
|
||||
return value.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function sanitizeWorkspacePrefix(value: string): string {
|
||||
const sanitized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return sanitized.length > 0 ? sanitized : "ai-evals";
|
||||
}
|
||||
|
||||
function sanitizeOptionalWorkspaceId(
|
||||
value: string | undefined,
|
||||
): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function isTruthy(value: string | undefined): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"workspace": {
|
||||
"scripts": [
|
||||
{
|
||||
"path": "f/evals/global/format_greeting",
|
||||
"summary": "Format a greeting for a provided name",
|
||||
"description": "Returns a plain greeting for the provided name.",
|
||||
"language": "bun",
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
},
|
||||
"content": "export async function main(name: string) {\n return `Hello, ${name}`\n}\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,12 @@ import type { FrontendEvalModelConfig } from "../core/models";
|
||||
import { validateAppState, type AppFilesState } from "../core/validators";
|
||||
import type { BenchmarkArtifactFile, ModeRunner } from "../core/types";
|
||||
import { runAppEval } from "../adapters/frontend/core/app/appEvalRunner";
|
||||
import {
|
||||
DEFAULT_FRONTEND_EVAL_MODEL,
|
||||
getFrontendApiKey,
|
||||
} from "./frontendCommon";
|
||||
import type { FrontendEvalTransportSettings } from "../core/frontendTransport";
|
||||
import { getFrontendApiKey } from "./frontendCommon";
|
||||
import type { WindmillBackendSettings } from "../core/windmillBackendSettings";
|
||||
|
||||
export function createAppModeRunner(
|
||||
modelConfig: FrontendEvalModelConfig = DEFAULT_FRONTEND_EVAL_MODEL,
|
||||
transportSettings?: FrontendEvalTransportSettings,
|
||||
modelConfig: FrontendEvalModelConfig,
|
||||
backendSettings: WindmillBackendSettings,
|
||||
): ModeRunner<AppFilesState, AppFilesState, AppFilesState> {
|
||||
return {
|
||||
mode: "app",
|
||||
@@ -37,8 +34,7 @@ export function createAppModeRunner(
|
||||
appContext: context.evalCase?.runtime?.appContext,
|
||||
provider: modelConfig.provider,
|
||||
model: modelConfig.model,
|
||||
transport: transportSettings?.transport,
|
||||
backend: transportSettings?.backend,
|
||||
backend: backendSettings,
|
||||
runContext: context,
|
||||
},
|
||||
);
|
||||
|
||||
+6
-10
@@ -7,11 +7,8 @@ import type { BenchmarkArtifactFile, ModeRunner } from "../core/types";
|
||||
import { runFlowEval } from "../adapters/frontend/core/flow/flowEvalRunner";
|
||||
import type { FlowWorkspaceFixtures } from "../adapters/frontend/core/flow/fileHelpers";
|
||||
import { BackendPreviewClient } from "../adapters/frontend/backendPreview";
|
||||
import {
|
||||
DEFAULT_FRONTEND_EVAL_MODEL,
|
||||
getFrontendApiKey,
|
||||
} from "./frontendCommon";
|
||||
import type { FrontendEvalTransportSettings } from "../core/frontendTransport";
|
||||
import { getFrontendApiKey } from "./frontendCommon";
|
||||
import type { WindmillBackendSettings } from "../core/windmillBackendSettings";
|
||||
import {
|
||||
normalizeFlowInitialFixture,
|
||||
normalizeFlowStateFixture,
|
||||
@@ -19,9 +16,9 @@ import {
|
||||
} from "./flowFixtures";
|
||||
|
||||
export function createFlowModeRunner(
|
||||
modelConfig: FrontendEvalModelConfig = DEFAULT_FRONTEND_EVAL_MODEL,
|
||||
backendValidation?: BackendValidationSettings,
|
||||
transportSettings?: FrontendEvalTransportSettings,
|
||||
modelConfig: FrontendEvalModelConfig,
|
||||
backendValidation: BackendValidationSettings | undefined,
|
||||
backendSettings: WindmillBackendSettings,
|
||||
): ModeRunner<FlowInitialFixture, FlowState, FlowState> {
|
||||
return {
|
||||
mode: "flow",
|
||||
@@ -49,8 +46,7 @@ export function createFlowModeRunner(
|
||||
maxIterations: context.evalCase?.runtime?.maxTurns,
|
||||
provider: modelConfig.provider,
|
||||
model: modelConfig.model,
|
||||
transport: transportSettings?.transport,
|
||||
backend: transportSettings?.backend,
|
||||
backend: backendSettings,
|
||||
runContext: context,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -5,12 +5,14 @@ const ORIGINAL_ENV = {
|
||||
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
|
||||
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
process.env.ANTHROPIC_API_KEY = ORIGINAL_ENV.ANTHROPIC_API_KEY;
|
||||
process.env.OPENAI_API_KEY = ORIGINAL_ENV.OPENAI_API_KEY;
|
||||
process.env.GEMINI_API_KEY = ORIGINAL_ENV.GEMINI_API_KEY;
|
||||
process.env.DEEPSEEK_API_KEY = ORIGINAL_ENV.DEEPSEEK_API_KEY;
|
||||
});
|
||||
|
||||
describe("getFrontendApiKey", () => {
|
||||
@@ -19,10 +21,15 @@ describe("getFrontendApiKey", () => {
|
||||
expect(getFrontendApiKey("googleai")).toBe("gemini-test-key");
|
||||
});
|
||||
|
||||
it("reads the DeepSeek API key for deepseek models", () => {
|
||||
process.env.DEEPSEEK_API_KEY = "deepseek-test-key";
|
||||
expect(getFrontendApiKey("deepseek")).toBe("deepseek-test-key");
|
||||
});
|
||||
|
||||
it("throws a provider-specific error when the key is missing", () => {
|
||||
delete process.env.GEMINI_API_KEY;
|
||||
expect(() => getFrontendApiKey("googleai")).toThrow(
|
||||
"GEMINI_API_KEY is required for frontend evals"
|
||||
"GEMINI_API_KEY is required for frontend evals",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import {
|
||||
getFrontendEvalModel,
|
||||
resolveEvalModel,
|
||||
type FrontendEvalModelConfig,
|
||||
} from "../core/models";
|
||||
import type { FrontendEvalModelConfig } from "../core/models";
|
||||
|
||||
export const DEFAULT_FRONTEND_EVAL_MODEL: FrontendEvalModelConfig = getFrontendEvalModel(
|
||||
resolveEvalModel("flow")
|
||||
);
|
||||
|
||||
export function getFrontendApiKey(provider: FrontendEvalModelConfig["provider"]): string {
|
||||
export function getFrontendApiKey(
|
||||
provider: FrontendEvalModelConfig["provider"],
|
||||
): string {
|
||||
const envName =
|
||||
provider === "anthropic"
|
||||
? "ANTHROPIC_API_KEY"
|
||||
: provider === "googleai"
|
||||
? "GEMINI_API_KEY"
|
||||
: "OPENAI_API_KEY";
|
||||
: provider === "deepseek"
|
||||
? "DEEPSEEK_API_KEY"
|
||||
: "OPENAI_API_KEY";
|
||||
const apiKey = process.env[envName];
|
||||
if (!apiKey) {
|
||||
throw new Error(`${envName} is required for frontend evals`);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { runGlobalEval } from "../adapters/frontend/core/global/globalEvalRunner";
|
||||
import type { BenchmarkWorkspaceRunnables } from "../adapters/frontend/mockBackend";
|
||||
import type { FrontendEvalModelConfig } from "../core/models";
|
||||
import type { BenchmarkArtifactFile, GlobalValidationSpec, ModeRunner } from "../core/types";
|
||||
import { validateGlobalState, type GlobalDraftState } from "../core/validators";
|
||||
import type { WindmillBackendSettings } from "../core/windmillBackendSettings";
|
||||
import { getFrontendApiKey } from "./frontendCommon";
|
||||
|
||||
export interface GlobalInitialFixture {
|
||||
workspace?: BenchmarkWorkspaceRunnables;
|
||||
}
|
||||
|
||||
export function createGlobalModeRunner(
|
||||
modelConfig: FrontendEvalModelConfig,
|
||||
backendSettings: WindmillBackendSettings,
|
||||
): ModeRunner<GlobalInitialFixture, GlobalDraftState, GlobalDraftState> {
|
||||
return {
|
||||
mode: "global",
|
||||
concurrency: 3,
|
||||
judgeThreshold: 80,
|
||||
async loadInitial(path) {
|
||||
return path ? await loadGlobalInitialFixture(path) : undefined;
|
||||
},
|
||||
async loadExpected(path) {
|
||||
return path ? await loadGlobalExpectedFixture(path) : undefined;
|
||||
},
|
||||
async run(prompt, initial, context) {
|
||||
const result = await runGlobalEval(
|
||||
prompt,
|
||||
getFrontendApiKey(modelConfig.provider),
|
||||
{
|
||||
workspaceFixtures: initial?.workspace,
|
||||
maxIterations: context.evalCase?.runtime?.maxTurns,
|
||||
provider: modelConfig.provider,
|
||||
model: modelConfig.model,
|
||||
backend: backendSettings,
|
||||
runContext: context,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
actual: result.state,
|
||||
error: result.error,
|
||||
assistantMessageCount: result.assistantMessageCount,
|
||||
toolCallCount: result.toolCallCount,
|
||||
toolsUsed: result.toolsUsed,
|
||||
toolCallDetails: result.toolCallDetails,
|
||||
skillsInvoked: [],
|
||||
tokenUsage: result.tokenUsage,
|
||||
};
|
||||
},
|
||||
validate({ evalCase, actual, expected }) {
|
||||
return validateGlobalState({
|
||||
actual,
|
||||
expected,
|
||||
validate: evalCase.validate as GlobalValidationSpec | undefined,
|
||||
});
|
||||
},
|
||||
buildArtifacts(actual): BenchmarkArtifactFile[] {
|
||||
return [
|
||||
{
|
||||
path: "global-drafts.json",
|
||||
content: JSON.stringify(actual, null, 2) + "\n",
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function loadGlobalInitialFixture(path: string): Promise<GlobalInitialFixture> {
|
||||
const parsed = JSON.parse(await readFile(path, "utf8")) as GlobalInitialFixture;
|
||||
return {
|
||||
workspace: parsed.workspace ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
async function loadGlobalExpectedFixture(path: string): Promise<GlobalDraftState> {
|
||||
return JSON.parse(await readFile(path, "utf8")) as GlobalDraftState;
|
||||
}
|
||||
@@ -6,16 +6,13 @@ import type { BenchmarkArtifactFile, ModeRunner } from "../core/types";
|
||||
import { BackendPreviewClient } from "../adapters/frontend/backendPreview";
|
||||
import { runScriptEval } from "../adapters/frontend/core/script/scriptEvalRunner";
|
||||
import type { ScriptEvalState } from "../adapters/frontend/core/script/fileHelpers";
|
||||
import {
|
||||
DEFAULT_FRONTEND_EVAL_MODEL,
|
||||
getFrontendApiKey,
|
||||
} from "./frontendCommon";
|
||||
import type { FrontendEvalTransportSettings } from "../core/frontendTransport";
|
||||
import { getFrontendApiKey } from "./frontendCommon";
|
||||
import type { WindmillBackendSettings } from "../core/windmillBackendSettings";
|
||||
|
||||
export function createScriptModeRunner(
|
||||
modelConfig: FrontendEvalModelConfig = DEFAULT_FRONTEND_EVAL_MODEL,
|
||||
backendValidation?: BackendValidationSettings,
|
||||
transportSettings?: FrontendEvalTransportSettings,
|
||||
modelConfig: FrontendEvalModelConfig,
|
||||
backendValidation: BackendValidationSettings | undefined,
|
||||
backendSettings: WindmillBackendSettings,
|
||||
): ModeRunner<ScriptEvalState, ScriptEvalState, ScriptEvalState> {
|
||||
return {
|
||||
mode: "script",
|
||||
@@ -40,8 +37,7 @@ export function createScriptModeRunner(
|
||||
maxIterations: context.evalCase?.runtime?.maxTurns,
|
||||
provider: modelConfig.provider,
|
||||
model: modelConfig.model,
|
||||
transport: transportSettings?.transport,
|
||||
backend: transportSettings?.backend,
|
||||
backend: backendSettings,
|
||||
runContext: context,
|
||||
},
|
||||
);
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled, NULL::text as workspace_id FROM password WHERE email = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "login_type",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "super_admin",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "devops",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "company",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "username",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "operator_only",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "first_time_user",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "role_source",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "disabled",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "0142d9dc9c1b57487dd5709a0376794f18d33e5bd6340c0189be7818cda64328"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT flow_version.path FROM flow_version\n INNER JOIN flow\n ON flow.path = flow_version.path AND\n flow.workspace_id = flow_version.workspace_id\n WHERE flow_version.id = $1 AND flow_version.workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0476ae2245aa678a50c5fd04cdee32cc151e29b177cc85caa088430b16336373"
|
||||
}
|
||||
+5
-5
@@ -46,11 +46,11 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email as \"email!\", login_type::text, verified as \"verified!\", super_admin as \"super_admin!\", devops as \"devops!\", name, company, username, NULL::bool as operator_only, first_time_user as \"first_time_user!\", role_source as \"role_source!\", disabled as \"disabled!\", NULL::text as workspace_id FROM password\n UNION ALL\n SELECT email as \"email!\", 'service_account'::text as login_type, true as \"verified!\", false as \"super_admin!\", false as \"devops!\", NULL::text as name, NULL::text as company, username, true as operator_only, false as \"first_time_user!\", 'service_account'::text as \"role_source!\", disabled as \"disabled!\", workspace_id\n FROM usr\n WHERE is_service_account IS true\n ORDER BY \"super_admin!\" DESC, \"devops!\" DESC, \"email!\"\n LIMIT $1 OFFSET $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "login_type",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "verified!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "super_admin!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "devops!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "company",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "username",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "operator_only",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "first_time_user!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "role_source!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "disabled!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "16b4496c21d0619dab4521dca22e5fe144c59156a8f06d8592291684c49b2f37"
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "2a49e5b5486b650d96f3e9038cba8a5f2e75d3b12ee4718452e82c7318b1bcf4"
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT label, token_prefix, expiration, created_at, last_used_at, scopes, workspace_id, read_only FROM token WHERE email = $1\n ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "label",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "token_prefix",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "expiration",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "last_used_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "scopes",
|
||||
"type_info": "TextArray"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "read_only",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "52777947d60d5ddd6d28852a1cf104a5af2fd565706204b7d5d33594ff91e61e"
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT workspace_id, path FROM app WHERE custom_path = $1 AND ($2::TEXT IS NULL OR workspace_id = $2) AND NOT (path = $3 AND workspace_id = $4) LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5347ec9ab6de69a99e8823d2199757b244b280e1262dcbccd2fc5189c7b3d25b"
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT s.item_kind, s.path\n FROM ws_specific s\n WHERE s.workspace_id = $1\n AND (\n (s.item_kind = 'resource' AND EXISTS (\n SELECT 1 FROM resource r\n WHERE r.workspace_id = s.workspace_id AND r.path = s.path\n ))\n OR (s.item_kind = 'variable' AND EXISTS (\n SELECT 1 FROM variable v\n WHERE v.workspace_id = s.workspace_id AND v.path = s.path\n ))\n )\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "item_kind",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "8b92a7d04fcdd8e61178d7dab97c31e10f89481908c479b4039af5e94fa0f8ac"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id, read_only)\n SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10\n WHERE $9::varchar IS NULL OR NOT EXISTS(\n SELECT 1 FROM workspace WHERE id = $9 AND deleted = true\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Timestamptz",
|
||||
"Bool",
|
||||
"TextArray",
|
||||
"Varchar",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9a1bf7c424154172b56bbd29b51ef468969b5452150ee6fffe31d59b52a4f1c2"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT bool_and(operator) FROM (\n SELECT operator FROM usr WHERE email = $1\n UNION ALL\n SELECT operator FROM workspace_invite WHERE email = $1\n ) t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "bool_and",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "9c85ba8d41bedbcb5466f44a7d4cf6b4946e1fd337f00d243f518283783833c9"
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM ws_specific WHERE workspace_id = $1 AND item_kind = 'variable' AND path = $2)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "b4162468afae99cf31c4668ca6769657fd73742b6ee8289b1e9736e381314cfb"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value from resource WHERE path = $1 AND workspace_id = $2 AND resource_type = 'app_theme'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "value",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "b845cbe97b10c6194fd89dbf2510216da2c1b8a4ce7ee1482ff5f347c3de145e"
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)\n SELECT email as \"email!\", (email NOT IN (SELECT email FROM authors)) as operator_only, login_type::text, verified as \"verified!\", super_admin as \"super_admin!\", devops as \"devops!\", name, company, username, first_time_user as \"first_time_user!\", role_source as \"role_source!\", disabled as \"disabled!\", NULL::text as workspace_id\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n UNION ALL\n SELECT email as \"email!\", true as operator_only, 'service_account'::text as login_type, true as \"verified!\", false as \"super_admin!\", false as \"devops!\", NULL::text as name, NULL::text as company, username, false as \"first_time_user!\", 'service_account'::text as \"role_source!\", disabled as \"disabled!\", workspace_id\n FROM usr\n WHERE is_service_account IS true\n ORDER BY \"super_admin!\" DESC, \"devops!\" DESC\n LIMIT $1 OFFSET $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "operator_only",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "login_type",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "verified!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "super_admin!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "devops!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "company",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "username",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "first_time_user!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "role_source!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "disabled!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "c17c39add3f70218dbae38595a909d02cfabe7e0864af577df6968428ac448ef"
|
||||
}
|
||||
+4
-3
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT item_kind, path FROM ws_specific WHERE workspace_id = $1",
|
||||
"query": "SELECT workspace_id, path FROM app WHERE custom_path = $1 AND ($2::TEXT IS NULL OR workspace_id = $2) LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "item_kind",
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
@@ -16,6 +16,7 @@
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
@@ -24,5 +25,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0c6e8f03a4e9f543cb85582e0aec1ed508d83695ef6d62ca06cfb612fd332b87"
|
||||
"hash": "debd7470025cf64cd1191e604fed9ae0ab27c8a76302a2570473046e28c91fdd"
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT label, token_prefix, expiration, created_at, last_used_at, scopes, workspace_id, read_only FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL)\n ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "label",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "token_prefix",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "expiration",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "last_used_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "scopes",
|
||||
"type_info": "TextArray"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "read_only",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "e001fb68c60fa736cecce52be49c9fb0714c9c91a7ea3924811d345bee94c013"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH potential AS (\n SELECT email, operator FROM usr\n UNION\n SELECT email, operator FROM workspace_invite\n ),\n per_user AS (\n SELECT email, bool_and(operator) AS only_operator FROM potential GROUP BY email\n )\n SELECT\n COUNT(*) FILTER (WHERE NOT only_operator) AS \"authors!\",\n COUNT(*) FILTER (WHERE only_operator) AS \"operators!\"\n FROM per_user",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "authors!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "operators!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "e1ada31c1625b453c2ff85edbcd7ad51a4cd5cbdc2fa34038530070d6a579455"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM variable WHERE workspace_id = $1 AND path = $2)",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM flow_version WHERE id = $1 AND workspace_id = $2)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -11,7 +11,7 @@
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
@@ -19,5 +19,5 @@
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "6be4bf59c404d2f557d1106c48c320bb3eff65255a44bd66799ae14288312ba4"
|
||||
"hash": "e70cbc2a48bfc5c7d2018b9367eadc104e87126c57230c9ed8eb3987e54b53a1"
|
||||
}
|
||||
+16
-21
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT label, email, scopes, workspace_id, super_admin, owner, expiration FROM token WHERE token_hash = $1",
|
||||
"query": "UPDATE token SET last_used_at = now() WHERE\n token_hash = $1\n AND (expiration > NOW() OR expiration IS NULL)\n AND (workspace_id IS NULL OR workspace_id = $2)\n RETURNING owner, email, super_admin, scopes, label, read_only",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "label",
|
||||
"name": "owner",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
@@ -15,44 +15,39 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "scopes",
|
||||
"type_info": "TextArray"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "super_admin",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "owner",
|
||||
"ordinal": 3,
|
||||
"name": "scopes",
|
||||
"type_info": "TextArray"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "label",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "expiration",
|
||||
"type_info": "Timestamptz"
|
||||
"ordinal": 5,
|
||||
"name": "read_only",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "406bcbf55758b10243c8eaff1c349b8082c0052d626bf67e08317e56ab9ad026"
|
||||
"hash": "ed82ffc4d806a19519701e39abc43e8e250cc9772cca4a7dae3442be86f4060c"
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT memory, worker, native_mode FROM worker_ping WHERE ping_at > now() - interval '2 minutes'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "memory",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "worker",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "native_mode",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f8f756bc498e5f084851f98e1e8d8c74cdc672a903d566baf5ac5ef50a4da1bd"
|
||||
}
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM app WHERE custom_path = $1 AND ($2::TEXT IS NULL OR workspace_id = $2) AND NOT (path = $3 AND workspace_id = $4))",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "fb1399c1dc171ec6bb24fee3477a1d606d9725e20e9c2d76a0887fadfd87f8df"
|
||||
}
|
||||
Generated
+764
-3080
File diff suppressed because it is too large
Load Diff
+62
-25
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.699.0"
|
||||
version = "1.703.3"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -87,7 +87,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.699.0"
|
||||
version = "1.703.3"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -207,6 +207,36 @@ all_sqlx_features = ["all_languages", "enterprise", "enterprise_saml", "embeddin
|
||||
object_store = { git = "https://github.com/apache/arrow-rs-object-store", rev = "36752c975d4f29e20b57c91f81a10872dcd48ae7" }
|
||||
# Use tiberius main branch for libgssapi 0.8.1 fix (https://github.com/prisma/tiberius/issues/343)
|
||||
tiberius = { git = "https://github.com/prisma/tiberius", rev = "59db57960a14b422fb3a1309aa4aa47880896ff8" }
|
||||
# Pin tokio-postgres / postgres-types / postgres-protocol to the
|
||||
# MaterializeInc fork. windmill-trigger-postgres already pulled this
|
||||
# fork in transitively for the postgres-replication crate
|
||||
# (CopyBothDuplex, LogicalReplicationStream, TupleData with binary
|
||||
# tuple support) which upstream rust-postgres has declined to merge
|
||||
# since 2021 (PR #752 → #778, both still unmerged).
|
||||
#
|
||||
# MI also carries a mitigation for the
|
||||
# Client::query_typed_raw / Client::prepare deadlock on result columns
|
||||
# whose Oid the client doesn't know about yet (citext, custom enums /
|
||||
# domains, postgis): MI's 2025-12-11 PR #33 resized the per-request
|
||||
# response channel from mpsc::channel(1) → mpsc::channel(1024).
|
||||
# bounded(1024) is sufficient for any realistic typeinfo deferral
|
||||
# (need ~2-3 batches) but leaves a theoretical failure mode at
|
||||
# >~64 MB results with a custom-Oid column. The strict-correct fix is
|
||||
# mpsc::unbounded(); a follow-up PR to MI is open proposing that.
|
||||
#
|
||||
# The [patch.crates-io] entries below force windmill-worker's
|
||||
# pg_executor (which imports `tokio_postgres::` directly from
|
||||
# crates.io) onto the same fork as windmill-trigger-postgres, so the
|
||||
# deadlock mitigation reaches both consumers.
|
||||
#
|
||||
# Upstream deadlock PRs (open, not on the critical path now that MI
|
||||
# is mitigated):
|
||||
# https://github.com/rust-postgres/rust-postgres/pull/1348
|
||||
# https://github.com/rust-postgres/rust-postgres/pull/1349
|
||||
# Reproducer: https://github.com/rubenfiszel/tokio-postgres-deadlock-repro
|
||||
tokio-postgres = { git = "https://github.com/MaterializeInc/rust-postgres", rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe" }
|
||||
postgres-types = { git = "https://github.com/MaterializeInc/rust-postgres", rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe" }
|
||||
postgres-protocol = { git = "https://github.com/MaterializeInc/rust-postgres", rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe" }
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
@@ -387,8 +417,7 @@ tokio-stream = { version = "0.1.17" }
|
||||
tower = "^0"
|
||||
tower-http = { version = "^0.6", features = ["trace", "cors", "catch-panic"] }
|
||||
tower-cookies = "^0.11"
|
||||
#stuck because of swc for now
|
||||
serde = "=1.0.220"
|
||||
serde = "^1"
|
||||
serde_json = { version = "^1", features = ["preserve_order", "raw_value"] }
|
||||
serde_yml = "0.0.12"
|
||||
uuid = { version = "^1", features = ["serde", "v4", "js"] }
|
||||
@@ -443,21 +472,29 @@ aws-sdk-rds = "^1"
|
||||
async-trait = "0.1.88"
|
||||
|
||||
|
||||
v8 = "=130.0.7" # Exact version NOTE: Do not forget to update version and hash in flake.nix
|
||||
deno_fetch = "0.214.0"
|
||||
deno_tls = "0.177.0"
|
||||
deno_console = "0.190.0"
|
||||
deno_url = "0.190.0"
|
||||
deno_webidl = "0.190.0"
|
||||
deno_web = "0.221.0"
|
||||
deno_io = "0.100.0"
|
||||
deno_net = "0.182.0"
|
||||
deno_core = "0.336.0"
|
||||
deno_ast = { version = "=0.44.0", features = ["transpiling"] }
|
||||
deno_permissions = "0.49.0"
|
||||
deno_runtime = { version = "0.198.0", features = ["transpile"] }
|
||||
deno_telemetry = "0.12.0"
|
||||
deno_error = "=0.5.5"
|
||||
v8 = "=137.1.0" # Exact version NOTE: Do not forget to update version and hash in flake.nix
|
||||
# deno_* pin set: deno v2.4.0 base, with deno_ast force-overridden to =0.51.0.
|
||||
# Rationale: deno_ast 0.51.0 is the first version pulling swc_common =14.0.4,
|
||||
# the first swc_common patch that dropped `pub use serde::__private as serde;`
|
||||
# (the line that capped our workspace serde pin at =1.0.220). v2.4.0's other
|
||||
# pins keep deno_tls at 0.196.0 which uses permissive `rustls ^0.23.11`,
|
||||
# compatible with aws-sdk-bedrockruntime's `^0.23.31` requirement. deno_tls
|
||||
# 0.198+ tightened that to exact `=0.23.28`, which would have made any
|
||||
# meaningful deno bump resolver-impossible against aws-sdk.
|
||||
deno_fetch = "0.233.0"
|
||||
deno_tls = "0.196.0"
|
||||
deno_console = "0.209.0"
|
||||
deno_url = "0.209.0"
|
||||
deno_webidl = "0.209.0"
|
||||
deno_web = "0.240.0"
|
||||
deno_io = "0.119.0"
|
||||
deno_fs = "0.119.0"
|
||||
deno_net = "0.201.0"
|
||||
deno_core = "0.352.0"
|
||||
deno_ast = { version = "=0.51.0", features = ["transpiling"] }
|
||||
deno_permissions = "0.68.0"
|
||||
deno_telemetry = "0.31.0"
|
||||
deno_error = "=0.6.1"
|
||||
rustls-pemfile = "2.2.0"
|
||||
|
||||
# only used with special deno_core_mac feature to prevent ffi issue on macos, requires libffi to be installed
|
||||
@@ -470,10 +507,10 @@ google-cloud-googleapis = {version = "0.16.1", features = ["pubsub"]}
|
||||
winapi = { version = "0.3.9", features = ["sysinfoapi"] }
|
||||
sysinfo = { version = "0.32.1" }
|
||||
|
||||
swc_common = "=0.37.5"
|
||||
swc_ecma_parser = "=0.149.1"
|
||||
swc_ecma_ast = "=0.118.2"
|
||||
swc_ecma_visit = "=0.104.8"
|
||||
swc_common = "=14.0.4"
|
||||
swc_ecma_parser = "=24.0.3"
|
||||
swc_ecma_ast = "=15.0.0"
|
||||
swc_ecma_visit = "=15.0.0"
|
||||
|
||||
|
||||
async-recursion = "^1"
|
||||
@@ -517,8 +554,8 @@ wasm-bindgen-test = "^0"
|
||||
convert_case = "0.6.0"
|
||||
getrandom = "0.2"
|
||||
tokio-postgres = {version = "^0.7", features = ["array-impls", "with-serde_json-1", "with-chrono-0_4", "with-uuid-1", "with-bit-vec-0_6"]}
|
||||
rust-postgres = { package = "tokio-postgres", git = "https://github.com/imor/rust-postgres", rev = "20265ef38e32a06f76b6f9b678e2077fc2211f6b"}
|
||||
rust-postgres-native-tls = { package = "postgres-native-tls", git = "https://github.com/imor/rust-postgres", features = ["runtime"], rev = "20265ef38e32a06f76b6f9b678e2077fc2211f6b" }
|
||||
rust-postgres = { package = "tokio-postgres", git = "https://github.com/MaterializeInc/rust-postgres", rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe"}
|
||||
rust-postgres-native-tls = { package = "postgres-native-tls", git = "https://github.com/MaterializeInc/rust-postgres", features = ["runtime"], rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe" }
|
||||
bit-vec = "=0.6.3"
|
||||
mappable-rc = "^0"
|
||||
mysql_async = { version = "*", default-features = false, features = ["minimal", "default", "native-tls-tls", "rust_decimal"]}
|
||||
|
||||
@@ -1 +1 @@
|
||||
d3bc7fa85195b46b7a38d43c2f806520bf8b5454
|
||||
19a76a09ffb43649ee19e62d07e8b8a42d78757b
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Remove "assets" key from operator_settings
|
||||
UPDATE workspace_settings
|
||||
SET operator_settings = operator_settings - 'assets'
|
||||
WHERE operator_settings IS NOT NULL
|
||||
AND operator_settings ? 'assets';
|
||||
|
||||
-- Revert the column default
|
||||
ALTER TABLE workspace_settings
|
||||
ALTER COLUMN operator_settings SET DEFAULT '{
|
||||
"runs": true,
|
||||
"groups": true,
|
||||
"folders": true,
|
||||
"workers": true,
|
||||
"triggers": true,
|
||||
"resources": true,
|
||||
"schedules": true,
|
||||
"variables": true,
|
||||
"audit_logs": true
|
||||
}';
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Add "assets": true to operator_settings for all workspaces that have operator_settings
|
||||
-- but don't already have an "assets" key
|
||||
UPDATE workspace_settings
|
||||
SET operator_settings = operator_settings || '{"assets": true}'::jsonb
|
||||
WHERE operator_settings IS NOT NULL
|
||||
AND NOT operator_settings ? 'assets';
|
||||
|
||||
-- Update the column default to include assets
|
||||
ALTER TABLE workspace_settings
|
||||
ALTER COLUMN operator_settings SET DEFAULT '{
|
||||
"runs": true,
|
||||
"groups": true,
|
||||
"folders": true,
|
||||
"workers": true,
|
||||
"triggers": true,
|
||||
"resources": true,
|
||||
"schedules": true,
|
||||
"variables": true,
|
||||
"audit_logs": true,
|
||||
"assets": true
|
||||
}';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE token DROP COLUMN IF EXISTS read_only;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Add a flag to restrict a token to read-only HTTP endpoints.
|
||||
-- Orthogonal to `scopes`: even if scopes grant write/run, this flag denies
|
||||
-- mutating methods (POST/PUT/PATCH/DELETE) and Run actions.
|
||||
ALTER TABLE token ADD COLUMN read_only BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- No-op: clearing a stray `auto_kind = 'lib'` value on failure/trigger/approval
|
||||
-- scripts is not reversible (the original NULL/'lib' distinction is lost), and
|
||||
-- restoring `'lib'` here would re-hide these scripts from their pickers.
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Failure, Trigger, and Approval scripts are runnable entrypoints by
|
||||
-- definition. A prior parser regression occasionally classified them as
|
||||
-- `auto_kind = 'lib'`, which hid them from the flow error-handler /
|
||||
-- trigger / approval pickers. Clear those stray values so existing
|
||||
-- affected scripts re-appear without requiring a redeploy.
|
||||
UPDATE script
|
||||
SET auto_kind = NULL
|
||||
WHERE auto_kind = 'lib'
|
||||
AND kind IN ('failure', 'trigger', 'approval');
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use swc_common::{sync::Lrc, FileName, SourceMap, Spanned};
|
||||
use swc_ecma_ast::{CallExpr, Expr, Lit, MemberExpr, MemberProp, Str};
|
||||
use swc_ecma_ast::{CallExpr, Expr, Lit, MemberExpr, MemberProp, ObjectLit, Prop, PropName, Str};
|
||||
use swc_ecma_parser::{lexer::Lexer, Parser, StringInput, Syntax, TsSyntax};
|
||||
use swc_ecma_visit::{Visit, VisitWith};
|
||||
use windmill_parser::asset_parser::{
|
||||
@@ -12,7 +12,7 @@ use AssetUsageAccessType::*;
|
||||
|
||||
pub fn parse_assets(code: &str) -> anyhow::Result<ParseAssetsOutput> {
|
||||
let cm: Lrc<SourceMap> = Default::default();
|
||||
let fm = cm.new_source_file(FileName::Custom("main.ts".into()).into(), code.into());
|
||||
let fm = cm.new_source_file(FileName::Custom("main.ts".into()).into(), code.to_string());
|
||||
let lexer = Lexer::new(
|
||||
// We want to parse ecmascript
|
||||
Syntax::Typescript(TsSyntax::default()),
|
||||
@@ -309,6 +309,56 @@ impl Visit for AssetsFinder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a string-literal property value from an object literal.
|
||||
/// Returns `Some(value)` for `{ name: "value" }`, ignoring computed,
|
||||
/// shorthand, spread, and non-string-literal properties.
|
||||
fn object_str_prop(obj: &ObjectLit, name: &str) -> Option<String> {
|
||||
for prop in &obj.props {
|
||||
let swc_ecma_ast::PropOrSpread::Prop(p) = prop else {
|
||||
continue;
|
||||
};
|
||||
let Prop::KeyValue(kv) = p.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let key = match &kv.key {
|
||||
PropName::Ident(i) => i.sym.as_str(),
|
||||
PropName::Str(s) => s.value.as_str(),
|
||||
_ => continue,
|
||||
};
|
||||
if key != name {
|
||||
continue;
|
||||
}
|
||||
if let Expr::Lit(Lit::Str(s)) = kv.value.as_ref() {
|
||||
return Some(s.value.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve the SDK `S3Object` argument of `loadS3File`/`loadS3FileStream`/
|
||||
/// `writeS3File` to a canonical asset path, mirroring the runtime
|
||||
/// `parseS3Object`: an object `{ s3: "<key>", storage?: "<bucket>" }` maps to
|
||||
/// the URI `s3://<bucket>/<key>` (empty bucket for default storage, i.e.
|
||||
/// `s3:///<key>`), and a bare `"s3://bucket/key"` string is passed through.
|
||||
/// The resulting URI is fed through `parse_asset_syntax` so the stored path
|
||||
/// matches the `// on s3:///…` trigger form exactly.
|
||||
fn s3_object_arg_path(arg: &Expr) -> Option<String> {
|
||||
let uri = match arg {
|
||||
Expr::Lit(Lit::Str(s)) => s.value.to_string(),
|
||||
Expr::Object(obj) => {
|
||||
let key = object_str_prop(obj, "s3")?;
|
||||
let storage = object_str_prop(obj, "storage").unwrap_or_default();
|
||||
format!("s3://{storage}/{key}")
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(
|
||||
parse_asset_syntax(&uri, false)
|
||||
.map(|(_, p)| p.to_string())
|
||||
.unwrap_or(uri),
|
||||
)
|
||||
}
|
||||
|
||||
impl AssetsFinder {
|
||||
fn visit_call_expr_inner(&mut self, node: &swc_ecma_ast::CallExpr) -> Result<(), ()> {
|
||||
let ident = match node.callee.as_expr().map(AsRef::as_ref) {
|
||||
@@ -331,20 +381,20 @@ impl AssetsFinder {
|
||||
|
||||
let arg_value = node.args.get(arg_pos);
|
||||
|
||||
match arg_value.map(|e| e.expr.as_ref()) {
|
||||
Some(Expr::Lit(Lit::Str(Str { value, .. }))) => {
|
||||
let path = parse_asset_syntax(&value, false)
|
||||
.map(|(_, p)| p)
|
||||
.unwrap_or(&value);
|
||||
self.assets.push(ParseAssetsResult {
|
||||
kind,
|
||||
path: path.to_string(),
|
||||
access_type,
|
||||
columns: None,
|
||||
});
|
||||
}
|
||||
// S3 helpers take an `S3Object` (`{ s3, storage? }`) or an
|
||||
// `s3://bucket/key` string — the form every real script uses. Other
|
||||
// helpers take a bare resource-path string literal.
|
||||
let is_s3_helper = matches!(kind, AssetKind::S3Object);
|
||||
|
||||
let path = match arg_value.map(|e| e.expr.as_ref()) {
|
||||
Some(arg) if is_s3_helper => s3_object_arg_path(arg).ok_or(())?,
|
||||
Some(Expr::Lit(Lit::Str(Str { value, .. }))) => parse_asset_syntax(&value, false)
|
||||
.map(|(_, p)| p.to_string())
|
||||
.unwrap_or_else(|| value.to_string()),
|
||||
_ => return Err(()),
|
||||
}
|
||||
};
|
||||
self.assets
|
||||
.push(ParseAssetsResult { kind, path, access_type, columns: None });
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -375,6 +425,136 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ts_asset_parser_write_s3_object_arg() {
|
||||
// The SDK signature is `writeS3File(s3object: S3Object, ...)` and every
|
||||
// real script passes the object form with a bare key. It must resolve
|
||||
// to the same canonical path as a `// on s3:///<key>` trigger.
|
||||
let input = r#"
|
||||
import * as wmill from "windmill-client"
|
||||
export async function main() {
|
||||
await wmill.writeS3File(
|
||||
{ s3: "pipelines/km_real/raw_events.json" },
|
||||
JSON.stringify([]),
|
||||
undefined,
|
||||
"application/json"
|
||||
)
|
||||
}
|
||||
"#;
|
||||
let s = parse_assets(input);
|
||||
assert_eq!(
|
||||
s.map(|r| r.assets).map_err(|e| e.to_string()),
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::S3Object,
|
||||
path: "/pipelines/km_real/raw_events.json".to_string(),
|
||||
access_type: Some(W),
|
||||
columns: None,
|
||||
},])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ts_asset_parser_s3_object_with_storage() {
|
||||
// `{ s3, storage }` maps to `s3://<storage>/<key>`, matching the
|
||||
// `s3://bucket/key` string form and `parseS3Object`.
|
||||
let input = r#"
|
||||
import * as wmill from "windmill-client"
|
||||
export async function main() {
|
||||
await wmill.loadS3File({ s3: "dir/in.csv", storage: "mybucket" })
|
||||
}
|
||||
"#;
|
||||
let s = parse_assets(input);
|
||||
assert_eq!(
|
||||
s.map(|r| r.assets).map_err(|e| e.to_string()),
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::S3Object,
|
||||
path: "mybucket/dir/in.csv".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None,
|
||||
},])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ts_asset_parser_multiple_s3_object_writes() {
|
||||
// Mirrors the f/km/r_seed shape: several direct object-form writes in
|
||||
// main() — all four outputs must be detected.
|
||||
let input = r#"
|
||||
import * as wmill from "windmill-client"
|
||||
export async function main() {
|
||||
await wmill.writeS3File({ s3: "pipelines/km_real/raw_events.json" }, "[]")
|
||||
await wmill.writeS3File({ s3: "pipelines/km_real/enriched.json" }, "[]")
|
||||
await wmill.writeS3File({ s3: "pipelines/km_real/summary.json" }, "[]")
|
||||
await wmill.writeS3File({ s3: "pipelines/km_real/report.json" }, "{}")
|
||||
}
|
||||
"#;
|
||||
// merge_assets returns a deterministic (path-sorted) order.
|
||||
let s = parse_assets(input);
|
||||
assert_eq!(
|
||||
s.map(|r| r.assets).map_err(|e| e.to_string()),
|
||||
Ok(vec![
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::S3Object,
|
||||
path: "/pipelines/km_real/enriched.json".to_string(),
|
||||
access_type: Some(W),
|
||||
columns: None,
|
||||
},
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::S3Object,
|
||||
path: "/pipelines/km_real/raw_events.json".to_string(),
|
||||
access_type: Some(W),
|
||||
columns: None,
|
||||
},
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::S3Object,
|
||||
path: "/pipelines/km_real/report.json".to_string(),
|
||||
access_type: Some(W),
|
||||
columns: None,
|
||||
},
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::S3Object,
|
||||
path: "/pipelines/km_real/summary.json".to_string(),
|
||||
access_type: Some(W),
|
||||
columns: None,
|
||||
},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ts_asset_parser_s3_object_quoted_key() {
|
||||
let input = r#"
|
||||
import * as wmill from "windmill-client"
|
||||
export async function main() {
|
||||
await wmill.writeS3File({ "s3": "out.json" }, "{}")
|
||||
}
|
||||
"#;
|
||||
let s = parse_assets(input);
|
||||
assert_eq!(
|
||||
s.map(|r| r.assets).map_err(|e| e.to_string()),
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::S3Object,
|
||||
path: "/out.json".to_string(),
|
||||
access_type: Some(W),
|
||||
columns: None,
|
||||
},])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ts_asset_parser_s3_object_dynamic_key_no_false_positive() {
|
||||
// A computed key can't be resolved statically — must yield nothing
|
||||
// rather than a bogus path.
|
||||
let input = r#"
|
||||
import * as wmill from "windmill-client"
|
||||
export async function main(name: string) {
|
||||
await wmill.writeS3File({ s3: `pipelines/${name}.json` }, "{}")
|
||||
}
|
||||
"#;
|
||||
let s = parse_assets(input);
|
||||
assert_eq!(s.map(|r| r.assets).map_err(|e| e.to_string()), Ok(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ts_asset_parser_unused_sql() {
|
||||
let input = r#"
|
||||
|
||||
@@ -129,7 +129,7 @@ impl Visit for ImportsFinder {
|
||||
/// See also: [`parse_relative_imports`] for resolved absolute paths.
|
||||
pub fn parse_expr_for_imports(code: &str, skip_type_only: bool) -> anyhow::Result<Vec<String>> {
|
||||
let cm: Lrc<SourceMap> = Default::default();
|
||||
let fm = cm.new_source_file(FileName::Custom("main.d.ts".into()).into(), code.into());
|
||||
let fm = cm.new_source_file(FileName::Custom("main.d.ts".into()).into(), code.to_string());
|
||||
let mut tss = TsSyntax::default();
|
||||
tss.disallow_ambiguous_jsx_like;
|
||||
tss.tsx = true;
|
||||
@@ -263,7 +263,7 @@ impl Visit for OutputFinder {
|
||||
|
||||
pub fn parse_expr_for_ids(code: &str) -> anyhow::Result<Vec<(String, String)>> {
|
||||
let cm: Lrc<SourceMap> = Default::default();
|
||||
let fm = cm.new_source_file(FileName::Custom("main.ts".into()).into(), code.into());
|
||||
let fm = cm.new_source_file(FileName::Custom("main.ts".into()).into(), code.to_string());
|
||||
let lexer = Lexer::new(
|
||||
// We want to parse ecmascript
|
||||
Syntax::Es(EsSyntax { jsx: false, ..Default::default() }),
|
||||
@@ -305,7 +305,7 @@ pub fn parse_deno_signature(
|
||||
entrypoint_override: Option<String>,
|
||||
) -> anyhow::Result<MainArgSignature> {
|
||||
let cm: Lrc<SourceMap> = Default::default();
|
||||
let fm = cm.new_source_file(FileName::Custom("main.ts".into()).into(), code.into());
|
||||
let fm = cm.new_source_file(FileName::Custom("main.ts".into()).into(), code.to_string());
|
||||
let lexer = Lexer::new(
|
||||
// We want to parse ecmascript
|
||||
Syntax::Typescript(TsSyntax::default()),
|
||||
|
||||
@@ -712,7 +712,7 @@ fn extract_ts_params(params: &[swc_ecma_ast::Param], cm: &Lrc<SourceMap>) -> Vec
|
||||
|
||||
pub fn parse_ts_workflow(code: &str) -> Result<WorkflowDag, Vec<CompileError>> {
|
||||
let cm: Lrc<SourceMap> = Default::default();
|
||||
let fm = cm.new_source_file(FileName::Custom("workflow.ts".into()).into(), code.into());
|
||||
let fm = cm.new_source_file(FileName::Custom("workflow.ts".into()).into(), code.to_string());
|
||||
let lexer = Lexer::new(
|
||||
Syntax::Typescript(TsSyntax::default()),
|
||||
Default::default(),
|
||||
|
||||
+24
-24
@@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.699.0"
|
||||
version = "1.703.3"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
@@ -6263,7 +6263,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.699.0"
|
||||
version = "1.703.3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6275,7 +6275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.699.0"
|
||||
version = "1.703.3"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"serde",
|
||||
@@ -6284,7 +6284,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.699.0"
|
||||
version = "1.703.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6296,7 +6296,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.699.0"
|
||||
version = "1.703.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6308,7 +6308,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.699.0"
|
||||
version = "1.703.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -6320,7 +6320,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.699.0"
|
||||
version = "1.703.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6332,7 +6332,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.699.0"
|
||||
version = "1.703.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6344,7 +6344,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.699.0"
|
||||
version = "1.703.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -6355,7 +6355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.699.0"
|
||||
version = "1.703.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6366,7 +6366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.699.0"
|
||||
version = "1.703.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6378,7 +6378,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.699.0"
|
||||
version = "1.703.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6389,7 +6389,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.699.0"
|
||||
version = "1.703.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -6411,7 +6411,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.699.0"
|
||||
version = "1.703.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6423,7 +6423,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.699.0"
|
||||
version = "1.703.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6437,7 +6437,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.699.0"
|
||||
version = "1.703.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case",
|
||||
@@ -6454,7 +6454,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.699.0"
|
||||
version = "1.703.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6467,7 +6467,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.699.0"
|
||||
version = "1.703.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6479,7 +6479,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.699.0"
|
||||
version = "1.703.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6497,7 +6497,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.699.0"
|
||||
version = "1.703.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -6513,7 +6513,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.699.0"
|
||||
version = "1.703.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6529,7 +6529,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wasm"
|
||||
version = "1.699.0"
|
||||
version = "1.703.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"getrandom 0.2.17",
|
||||
@@ -6561,7 +6561,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.699.0"
|
||||
version = "1.703.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6572,7 +6572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.699.0"
|
||||
version = "1.703.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
|
||||
@@ -12,7 +12,7 @@ resolver = "2"
|
||||
members = ["."]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.699.0"
|
||||
version = "1.703.3"
|
||||
edition = "2021"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
|
||||
|
||||
+81
-54
@@ -6,6 +6,8 @@ use windmill_common::{
|
||||
pub const DEFAULT_MAX_CONNECTIONS_SERVER: u32 = 50;
|
||||
pub const DEFAULT_MAX_CONNECTIONS_WORKER: u32 = 5;
|
||||
pub const DEFAULT_MAX_CONNECTIONS_INDEXER: u32 = 5;
|
||||
#[cfg(feature = "operator")]
|
||||
pub const DEFAULT_MAX_CONNECTIONS_OPERATOR: u32 = 2;
|
||||
|
||||
pub async fn initial_connection() -> Result<sqlx::Pool<sqlx::Postgres>, error::Error> {
|
||||
let connect_options = get_database_url().await?.connect_options().await?;
|
||||
@@ -16,12 +18,35 @@ pub async fn initial_connection() -> Result<sqlx::Pool<sqlx::Postgres>, error::E
|
||||
.map_err(|err| Error::ConnectingToDatabase(err.to_string()))
|
||||
}
|
||||
|
||||
/// Connect to the database for the Kubernetes operator process.
|
||||
///
|
||||
/// Long-running operator pods need IAM RDS / Entra ID token refresh just like the server,
|
||||
/// otherwise new pool connections start failing once the initial token expires (~15 min).
|
||||
#[cfg(feature = "operator")]
|
||||
pub async fn operator_connection(
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
killpill_rx: tokio::sync::broadcast::Receiver<()>,
|
||||
) -> anyhow::Result<sqlx::Pool<sqlx::Postgres>> {
|
||||
let database_url = get_database_url().await?;
|
||||
let pool = connect(
|
||||
database_url.clone(),
|
||||
DEFAULT_MAX_CONNECTIONS_OPERATOR,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
spawn_token_refresh_task(pool.clone(), database_url, killpill_rx);
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
pub async fn connect_db(
|
||||
server_mode: bool,
|
||||
indexer_mode: bool,
|
||||
worker_mode: bool,
|
||||
num_workers: i32,
|
||||
#[cfg(feature = "private")] mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
|
||||
#[cfg(feature = "private")] killpill_rx: tokio::sync::broadcast::Receiver<()>,
|
||||
) -> anyhow::Result<sqlx::Pool<sqlx::Postgres>> {
|
||||
use anyhow::Context;
|
||||
|
||||
@@ -43,70 +68,72 @@ pub async fn connect_db(
|
||||
let pool = connect(database_url.clone(), max_connections, worker_mode).await?;
|
||||
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
{
|
||||
let needs_token_refresh = matches!(
|
||||
database_url,
|
||||
DatabaseUrl::IamRds(_) | DatabaseUrl::EntraId(_)
|
||||
);
|
||||
let label = match &database_url {
|
||||
DatabaseUrl::IamRds(_) => "IAM RDS",
|
||||
DatabaseUrl::EntraId(_) => "Entra ID",
|
||||
DatabaseUrl::Static(_) => "",
|
||||
};
|
||||
if needs_token_refresh {
|
||||
let pool2 = pool.clone();
|
||||
let database_url2 = database_url.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = killpill_rx.recv() => {
|
||||
break;
|
||||
}
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(10)) => {
|
||||
if !database_url2.needs_refresh().await {
|
||||
continue;
|
||||
}
|
||||
let new_url = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
get_database_url(),
|
||||
)
|
||||
.await;
|
||||
match new_url {
|
||||
Ok(Ok(new_url)) => {
|
||||
match new_url.connect_options().await {
|
||||
Ok(connect_options) => {
|
||||
pool2.set_connect_options(connect_options);
|
||||
tracing::info!("Refreshed {label} URL successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Error getting {label} connect options, retrying in 10s: {e}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!(
|
||||
"Error refreshing {label} URL, trying again in 10s: {e}"
|
||||
);
|
||||
continue;
|
||||
spawn_token_refresh_task(pool.clone(), database_url, killpill_rx);
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
/// Spawn a background task that refreshes IAM RDS / Entra ID tokens before they expire
|
||||
/// and updates the pool's connect options so new connections use the fresh token.
|
||||
/// No-op for static (password-based) database URLs.
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
pub fn spawn_token_refresh_task(
|
||||
pool: sqlx::Pool<sqlx::Postgres>,
|
||||
database_url: DatabaseUrl,
|
||||
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
|
||||
) {
|
||||
let label = match &database_url {
|
||||
DatabaseUrl::IamRds(_) => "IAM RDS",
|
||||
DatabaseUrl::EntraId(_) => "Entra ID",
|
||||
DatabaseUrl::Static(_) => return,
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = killpill_rx.recv() => {
|
||||
break;
|
||||
}
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(10)) => {
|
||||
if !database_url.needs_refresh().await {
|
||||
continue;
|
||||
}
|
||||
let new_url = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
get_database_url(),
|
||||
)
|
||||
.await;
|
||||
match new_url {
|
||||
Ok(Ok(new_url)) => {
|
||||
match new_url.connect_options().await {
|
||||
Ok(connect_options) => {
|
||||
pool.set_connect_options(connect_options);
|
||||
tracing::info!("Refreshed {label} URL successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Timeout after 10s refreshing {label} URL, trying again in 10s: {e}"
|
||||
"Error getting {label} connect options, retrying in 10s: {e}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!(
|
||||
"Error refreshing {label} URL, trying again in 10s: {e}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Timeout after 10s refreshing {label} URL, trying again in 10s: {e}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(pool)
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn connect(
|
||||
|
||||
@@ -8,6 +8,6 @@ pub async fn set_license_key(_license_key: String, _db: Option<&windmill_common:
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "enterprise", not(feature = "private")))]
|
||||
pub async fn verify_license_key() -> () {
|
||||
pub async fn verify_license_key(_db: Option<&windmill_common::db::DB>) -> () {
|
||||
// Implementation is not open source
|
||||
}
|
||||
|
||||
+19
-2
@@ -670,7 +670,24 @@ async fn windmill_main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
tracing::info!("Starting Windmill Kubernetes operator...");
|
||||
tracing::info!("Connecting to database...");
|
||||
let db = crate::db_connect::initial_connection().await?;
|
||||
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
let (operator_killpill_tx, operator_killpill_rx) =
|
||||
tokio::sync::broadcast::channel::<()>(2);
|
||||
|
||||
let db = crate::db_connect::operator_connection(
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
operator_killpill_rx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
#[cfg(all(feature = "enterprise", feature = "private"))]
|
||||
tokio::spawn(async move {
|
||||
if let Ok(()) = tokio::signal::ctrl_c().await {
|
||||
let _ = operator_killpill_tx.send(());
|
||||
}
|
||||
});
|
||||
|
||||
tracing::info!("Database connected. Starting ConfigMap watcher...");
|
||||
windmill_operator::run(db).await?;
|
||||
return Ok(());
|
||||
@@ -1441,7 +1458,7 @@ Windmill Community Edition {GIT_VERSION}
|
||||
tracing::error!("Failed to reload license key on agent: {e:#}");
|
||||
}
|
||||
#[cfg(feature = "enterprise")]
|
||||
ee_oss::verify_license_key().await;
|
||||
ee_oss::verify_license_key(conn.as_sql()).await;
|
||||
}
|
||||
|
||||
// update min version explicitly.
|
||||
|
||||
+46
-12
@@ -886,11 +886,13 @@ pub async fn reload_otel_tracing_proxy_setting(conn: &Connection) {
|
||||
let mut current = OTEL_TRACING_PROXY_SETTINGS.write().await;
|
||||
if current.enabled != new_settings.enabled
|
||||
|| current.enabled_languages != new_settings.enabled_languages
|
||||
|| current.no_proxy_hosts != new_settings.no_proxy_hosts
|
||||
{
|
||||
tracing::info!(
|
||||
"OTEL tracing proxy settings changed: enabled={}, languages={:?}",
|
||||
"OTEL tracing proxy settings changed: enabled={}, languages={:?}, no_proxy_hosts={:?}",
|
||||
new_settings.enabled,
|
||||
new_settings.enabled_languages
|
||||
new_settings.enabled_languages,
|
||||
new_settings.no_proxy_hosts,
|
||||
);
|
||||
*current = new_settings;
|
||||
}
|
||||
@@ -1110,15 +1112,29 @@ pub async fn delete_expired_items(db: &DB) -> () {
|
||||
let audit_retention_days = audit_log_retention_days().await;
|
||||
let audit_retention_secs: i64 = audit_retention_days * 60 * 60 * 24;
|
||||
|
||||
// Clean up old (non-partitioned) audit table — will eventually be empty and dropped
|
||||
if let Err(e) = sqlx::query_scalar!(
|
||||
"DELETE FROM audit WHERE timestamp <= now() - ($1::bigint::text || ' s')::interval",
|
||||
audit_retention_secs,
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Error deleting audit log: {:?}", e);
|
||||
// Clean up old (non-partitioned) audit table — will eventually be empty and dropped.
|
||||
// Batched to avoid excessive DB load on instances with very large legacy audit tables.
|
||||
let mut total_deleted_audit: i64 = 0;
|
||||
loop {
|
||||
match sqlx::query_scalar!(
|
||||
"DELETE FROM audit WHERE ctid IN (SELECT ctid FROM audit WHERE timestamp <= now() - ($1::bigint::text || ' s')::interval LIMIT 10000) RETURNING id",
|
||||
audit_retention_secs,
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await
|
||||
{
|
||||
Ok(rows) if rows.is_empty() => break,
|
||||
Ok(rows) => {
|
||||
total_deleted_audit += rows.len() as i64;
|
||||
if total_deleted_audit >= 100000 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Error deleting audit log: {:?}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = sqlx::query_scalar!(
|
||||
@@ -2373,7 +2389,19 @@ pub async fn monitor_db(
|
||||
let verify_license_key_f = async {
|
||||
#[cfg(feature = "enterprise")]
|
||||
if !initial_load {
|
||||
verify_license_key().await;
|
||||
verify_license_key(conn.as_sql()).await;
|
||||
}
|
||||
};
|
||||
|
||||
let enforce_offline_caps_f = async {
|
||||
#[cfg(feature = "enterprise")]
|
||||
if server_mode && !initial_load {
|
||||
if let Some(db) = conn.as_sql() {
|
||||
// Cheap: one query for workers active in the last 2 minutes.
|
||||
if let Err(e) = windmill_common::ee_oss::enforce_offline_caps(db).await {
|
||||
tracing::error!("Failed to enforce offline license caps: {e:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2522,6 +2550,7 @@ pub async fn monitor_db(
|
||||
vacuum_queue_f,
|
||||
expose_queue_metrics_f,
|
||||
verify_license_key_f,
|
||||
enforce_offline_caps_f,
|
||||
worker_groups_alerts_f,
|
||||
jobs_waiting_alerts_f,
|
||||
low_disk_alerts_f,
|
||||
@@ -2853,6 +2882,11 @@ pub async fn reload_base_url_setting(conn: &Connection) -> error::Result<()> {
|
||||
|
||||
IS_SECURE.store(is_secure, Ordering::Relaxed);
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
{
|
||||
crate::ee_oss::verify_license_key(conn.as_sql()).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Regression test for the cross-workspace custom_path conflict.
|
||||
//!
|
||||
//! When custom paths are instance-global (CLOUD_HOSTED unset and
|
||||
//! `app_workspaced_route` off — the default for dedicated instances), a
|
||||
//! custom_path is a single global route slot. The uniqueness check correctly
|
||||
//! blocks two apps from claiming it, including the same logical app deployed
|
||||
//! to two workspaces (staging/prod, git-sync). The bug was that the error
|
||||
//! ("App with custom path <x> already exists") gave the operator no idea
|
||||
//! where the conflicting copy lived. This test pins down:
|
||||
//! - a single-workspace edit keeping its own custom_path still succeeds
|
||||
//! (the app's own row is excluded),
|
||||
//! - a real conflict is still rejected, and
|
||||
//! - the error now names the conflicting app's path and workspace.
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", format!("Bearer {}", token))
|
||||
}
|
||||
|
||||
fn new_app(path: &str, custom_path: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"path": path,
|
||||
"summary": "Test app",
|
||||
"value": { "type": "rawapp", "inline_script": null },
|
||||
"policy": { "execution_mode": "anonymous", "triggerables": {} },
|
||||
"custom_path": custom_path
|
||||
})
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("app_custom_path_cross_workspace"))]
|
||||
async fn test_custom_path_cross_workspace_deploy(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let ws_a = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
let ws_b = format!("http://localhost:{port}/api/w/test-workspace-2");
|
||||
|
||||
let app_path = "f/Newsletter/newsletter_composer";
|
||||
let custom_path = "newsletter";
|
||||
|
||||
// 1. Create the app with a custom path in workspace A.
|
||||
let resp = authed(client().post(format!("{ws_a}/apps/create")), "SECRET_TOKEN")
|
||||
.json(&new_app(app_path, custom_path))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
201,
|
||||
"create app in ws A should succeed: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// 2. Editing the app in its own workspace, keeping the same custom path,
|
||||
// must still succeed — the app's own row is excluded from the check.
|
||||
// (This is the common single-workspace deploy; it must not regress.)
|
||||
let resp = authed(
|
||||
client().post(format!("{ws_a}/apps/update/{app_path}")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({
|
||||
"summary": "Test app (edited)",
|
||||
"value": { "type": "rawapp", "inline_script": null },
|
||||
"policy": { "execution_mode": "anonymous", "triggerables": {} },
|
||||
"custom_path": custom_path
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"editing an app in its own workspace keeping its custom path must succeed: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// 3. Deploying the same app (same path) to a second workspace is a real
|
||||
// conflict in global mode (one global route slot). It must be rejected,
|
||||
// and the error must name the conflicting workspace + app so the
|
||||
// operator knows what to resolve.
|
||||
let resp = authed(client().post(format!("{ws_b}/apps/create")), "SECRET_TOKEN")
|
||||
.json(&new_app(app_path, custom_path))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
assert_eq!(
|
||||
status, 400,
|
||||
"same custom path in another workspace is a global conflict: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("test-workspace") && body.contains(app_path),
|
||||
"error must name the conflicting workspace and app, got: {body}"
|
||||
);
|
||||
|
||||
// 4. A genuinely different app claiming the in-use custom path is still
|
||||
// rejected, with the same actionable message.
|
||||
let resp = authed(client().post(format!("{ws_a}/apps/create")), "SECRET_TOKEN")
|
||||
.json(&new_app("f/Other/other_app", custom_path))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
assert_eq!(
|
||||
status, 400,
|
||||
"a different app must not steal an in-use custom path: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains(app_path),
|
||||
"error must name the app already using the custom path, got: {body}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -577,6 +577,63 @@ export function main() {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Regression test: a `//nobundling` script that pulls a package whose CJS
|
||||
/// internals do bare-specifier `require()` of a sibling dependency.
|
||||
///
|
||||
/// Before the `--preserve-symlinks` fix, Bun 1.2/1.3+ would follow the
|
||||
/// directory symlink in `node_modules/@langchain/core` to its global cache
|
||||
/// entry, walk parent dirs from the cache realpath, and fail to find
|
||||
/// `node_modules/zod` — producing:
|
||||
/// ENOENT while resolving package 'zod/v3' from
|
||||
/// '.../cache_nomount/bun/@langchain/core@<ver>@@@1/dist/runnables/base.js'
|
||||
///
|
||||
/// The fix passes `--preserve-symlinks` so Bun resolves from the
|
||||
/// symlink path under `<job_dir>/node_modules/`, where `zod` is a sibling.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_bun_nobundling_transitive_require(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let content = r#"//nobundling
|
||||
import { ChatPromptTemplate } from "@langchain/core/prompts";
|
||||
|
||||
export async function main() {
|
||||
const tpl = ChatPromptTemplate.fromMessages([
|
||||
["system", "you are a {role}"],
|
||||
["human", "{input}"],
|
||||
]);
|
||||
const out = await tpl.formatMessages({ role: "tester", input: "ping" });
|
||||
return out.length;
|
||||
}
|
||||
"#
|
||||
.to_owned();
|
||||
|
||||
let job = JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content,
|
||||
path: None,
|
||||
language: ScriptLang::Bun,
|
||||
lock: None,
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
|
||||
.into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
modules: None,
|
||||
tag: None,
|
||||
});
|
||||
|
||||
let result = run_job_in_new_worker_until_complete(&db, false, job, port)
|
||||
.await
|
||||
.json_result()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, serde_json::json!(2));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Native Mode Tests (requires deno_core feature)
|
||||
// ============================================================================
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
-- Fixture for app_custom_path_cross_workspace regression test.
|
||||
-- Two workspaces sharing the same admin user, so the same logical app
|
||||
-- (same `path`) can be deployed to both — exercising the instance-global
|
||||
-- custom_path uniqueness behavior (CLOUD_HOSTED unset and
|
||||
-- app_workspaced_route off, the default for dedicated instances).
|
||||
|
||||
INSERT INTO workspace
|
||||
(id, name, owner)
|
||||
VALUES ('test-workspace', 'test-workspace', 'test-user');
|
||||
|
||||
INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES
|
||||
('test-workspace', 'test@windmill.dev', 'test-user', true, 'Admin');
|
||||
|
||||
INSERT INTO workspace_key(workspace_id, kind, key) VALUES
|
||||
('test-workspace', 'cloud', 'test-key');
|
||||
|
||||
INSERT INTO workspace_settings (workspace_id) VALUES
|
||||
('test-workspace');
|
||||
|
||||
INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES
|
||||
('test-workspace', 'all', 'All users', '{}');
|
||||
|
||||
INSERT INTO password(email, password_hash, login_type, super_admin, verified, name, username)
|
||||
VALUES ('test@windmill.dev', 'not-a-real-hash', 'password', true, true, 'Test User', 'test-user');
|
||||
|
||||
-- Second workspace, same admin user. Lets us deploy the same app path to
|
||||
-- two workspaces, which is what triggered the custom_path conflict.
|
||||
INSERT INTO workspace (id, name, owner) VALUES
|
||||
('test-workspace-2', 'test-workspace-2', 'test-user');
|
||||
|
||||
INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES
|
||||
('test-workspace-2', 'test@windmill.dev', 'test-user', true, 'Admin');
|
||||
|
||||
INSERT INTO workspace_key(workspace_id, kind, key) VALUES
|
||||
('test-workspace-2', 'cloud', 'test-key-2');
|
||||
|
||||
INSERT INTO workspace_settings (workspace_id) VALUES
|
||||
('test-workspace-2');
|
||||
|
||||
INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES
|
||||
('test-workspace-2', 'all', 'All users', '{}');
|
||||
|
||||
-- super_admin token so custom_path edits pass require_admin in both workspaces.
|
||||
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin)
|
||||
VALUES (encode(sha256('SECRET_TOKEN'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN', 'test@windmill.dev', 'test token', true);
|
||||
|
||||
GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin;
|
||||
GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_user;
|
||||
|
||||
CREATE FUNCTION "notify_insert_on_completed_job" ()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify('completed', NEW.id::text);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE PLPGSQL;
|
||||
|
||||
CREATE TRIGGER "notify_insert_on_completed_job"
|
||||
AFTER INSERT ON "v2_job_completed"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION "notify_insert_on_completed_job" ();
|
||||
|
||||
|
||||
CREATE FUNCTION "notify_queue" ()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify('queued', NEW.id::text);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE PLPGSQL;
|
||||
|
||||
CREATE TRIGGER "notify_queue_after_insert"
|
||||
AFTER INSERT ON "v2_job_queue"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION "notify_queue" ();
|
||||
|
||||
CREATE TRIGGER "notify_queue_after_flow_status_update"
|
||||
AFTER UPDATE ON "v2_job_status"
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.flow_status IS DISTINCT FROM OLD.flow_status)
|
||||
EXECUTE FUNCTION "notify_queue" ();
|
||||
|
||||
-- Apply phase 4:
|
||||
DROP FUNCTION IF EXISTS v2_job_after_update CASCADE;
|
||||
DROP FUNCTION IF EXISTS v2_job_completed_before_insert CASCADE;
|
||||
DROP FUNCTION IF EXISTS v2_job_completed_before_update CASCADE;
|
||||
DROP FUNCTION IF EXISTS v2_job_queue_after_insert CASCADE;
|
||||
DROP FUNCTION IF EXISTS v2_job_queue_before_insert CASCADE;
|
||||
DROP FUNCTION IF EXISTS v2_job_queue_before_update CASCADE;
|
||||
DROP FUNCTION IF EXISTS v2_job_runtime_before_insert CASCADE;
|
||||
DROP FUNCTION IF EXISTS v2_job_runtime_before_update CASCADE;
|
||||
DROP FUNCTION IF EXISTS v2_job_status_before_insert CASCADE;
|
||||
DROP FUNCTION IF EXISTS v2_job_status_before_update CASCADE;
|
||||
|
||||
DROP VIEW IF EXISTS completed_job, completed_job_view, job, queue, queue_view CASCADE;
|
||||
|
||||
ALTER TABLE v2_job_queue
|
||||
DROP COLUMN IF EXISTS __parent_job CASCADE,
|
||||
DROP COLUMN IF EXISTS __created_by CASCADE,
|
||||
DROP COLUMN IF EXISTS __script_hash CASCADE,
|
||||
DROP COLUMN IF EXISTS __script_path CASCADE,
|
||||
DROP COLUMN IF EXISTS __args CASCADE,
|
||||
DROP COLUMN IF EXISTS __logs CASCADE,
|
||||
DROP COLUMN IF EXISTS __raw_code CASCADE,
|
||||
DROP COLUMN IF EXISTS __canceled CASCADE,
|
||||
DROP COLUMN IF EXISTS __last_ping CASCADE,
|
||||
DROP COLUMN IF EXISTS __job_kind CASCADE,
|
||||
DROP COLUMN IF EXISTS __env_id CASCADE,
|
||||
DROP COLUMN IF EXISTS __schedule_path CASCADE,
|
||||
DROP COLUMN IF EXISTS __permissioned_as CASCADE,
|
||||
DROP COLUMN IF EXISTS __flow_status CASCADE,
|
||||
DROP COLUMN IF EXISTS __raw_flow CASCADE,
|
||||
DROP COLUMN IF EXISTS __is_flow_step CASCADE,
|
||||
DROP COLUMN IF EXISTS __language CASCADE,
|
||||
DROP COLUMN IF EXISTS __same_worker CASCADE,
|
||||
DROP COLUMN IF EXISTS __raw_lock CASCADE,
|
||||
DROP COLUMN IF EXISTS __pre_run_error CASCADE,
|
||||
DROP COLUMN IF EXISTS __email CASCADE,
|
||||
DROP COLUMN IF EXISTS __visible_to_owner CASCADE,
|
||||
DROP COLUMN IF EXISTS __mem_peak CASCADE,
|
||||
DROP COLUMN IF EXISTS __root_job CASCADE,
|
||||
DROP COLUMN IF EXISTS __leaf_jobs CASCADE,
|
||||
DROP COLUMN IF EXISTS __concurrent_limit CASCADE,
|
||||
DROP COLUMN IF EXISTS __concurrency_time_window_s CASCADE,
|
||||
DROP COLUMN IF EXISTS __timeout CASCADE,
|
||||
DROP COLUMN IF EXISTS __flow_step_id CASCADE,
|
||||
DROP COLUMN IF EXISTS __cache_ttl CASCADE;
|
||||
|
||||
LOCK TABLE v2_job_queue IN ACCESS EXCLUSIVE MODE;
|
||||
ALTER TABLE v2_job_completed
|
||||
DROP COLUMN IF EXISTS __parent_job CASCADE,
|
||||
DROP COLUMN IF EXISTS __created_by CASCADE,
|
||||
DROP COLUMN IF EXISTS __created_at CASCADE,
|
||||
DROP COLUMN IF EXISTS __success CASCADE,
|
||||
DROP COLUMN IF EXISTS __script_hash CASCADE,
|
||||
DROP COLUMN IF EXISTS __script_path CASCADE,
|
||||
DROP COLUMN IF EXISTS __args CASCADE,
|
||||
DROP COLUMN IF EXISTS __logs CASCADE,
|
||||
DROP COLUMN IF EXISTS __raw_code CASCADE,
|
||||
DROP COLUMN IF EXISTS __canceled CASCADE,
|
||||
DROP COLUMN IF EXISTS __job_kind CASCADE,
|
||||
DROP COLUMN IF EXISTS __env_id CASCADE,
|
||||
DROP COLUMN IF EXISTS __schedule_path CASCADE,
|
||||
DROP COLUMN IF EXISTS __permissioned_as CASCADE,
|
||||
DROP COLUMN IF EXISTS __raw_flow CASCADE,
|
||||
DROP COLUMN IF EXISTS __is_flow_step CASCADE,
|
||||
DROP COLUMN IF EXISTS __language CASCADE,
|
||||
DROP COLUMN IF EXISTS __is_skipped CASCADE,
|
||||
DROP COLUMN IF EXISTS __raw_lock CASCADE,
|
||||
DROP COLUMN IF EXISTS __email CASCADE,
|
||||
DROP COLUMN IF EXISTS __visible_to_owner CASCADE,
|
||||
DROP COLUMN IF EXISTS __tag CASCADE,
|
||||
DROP COLUMN IF EXISTS __priority CASCADE;
|
||||
@@ -760,6 +760,112 @@ def main():
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "python")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_python_result_preserves_infinity_in_string(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let content = r#"
|
||||
def main():
|
||||
return {
|
||||
"plain": "Infinity",
|
||||
"embedded": "value=-Infinity end",
|
||||
"nan_word": "this is NaN inside text",
|
||||
"nested": [{"k": "Infinity"}],
|
||||
}
|
||||
"#
|
||||
.to_owned();
|
||||
|
||||
let job = JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content,
|
||||
path: None,
|
||||
language: ScriptLang::Python3,
|
||||
lock: None,
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
|
||||
.into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
modules: None,
|
||||
tag: None,
|
||||
});
|
||||
|
||||
let result = run_job_in_new_worker_until_complete(&db, false, job, port)
|
||||
.await
|
||||
.json_result()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
serde_json::json!({
|
||||
"plain": "Infinity",
|
||||
"embedded": "value=-Infinity end",
|
||||
"nan_word": "this is NaN inside text",
|
||||
"nested": [{"k": "Infinity"}],
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "python")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_python_result_non_finite_floats_become_null(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let content = r#"
|
||||
def main():
|
||||
return {
|
||||
"inf": float("inf"),
|
||||
"neg_inf": float("-inf"),
|
||||
"nan": float("nan"),
|
||||
"finite": 1.5,
|
||||
"nested": [float("inf"), {"x": float("nan")}],
|
||||
}
|
||||
"#
|
||||
.to_owned();
|
||||
|
||||
let job = JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content,
|
||||
path: None,
|
||||
language: ScriptLang::Python3,
|
||||
lock: None,
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
|
||||
.into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
modules: None,
|
||||
tag: None,
|
||||
});
|
||||
|
||||
let result = run_job_in_new_worker_until_complete(&db, false, job, port)
|
||||
.await
|
||||
.json_result()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
serde_json::json!({
|
||||
"inf": null,
|
||||
"neg_inf": null,
|
||||
"nan": null,
|
||||
"finite": 1.5,
|
||||
"nested": [null, {"x": null}],
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "python")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_python_global_site_packages(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_api_client::types::{NewScript, ScriptLang};
|
||||
use windmill_test_utils::init_client;
|
||||
|
||||
fn quick_ns(content: &str, path: &str, kind: Option<&str>) -> NewScript {
|
||||
NewScript {
|
||||
content: content.into(),
|
||||
language: ScriptLang::Bun,
|
||||
lock: None,
|
||||
parent_hash: None,
|
||||
path: path.into(),
|
||||
concurrent_limit: None,
|
||||
concurrency_time_window_s: None,
|
||||
cache_ttl: None,
|
||||
dedicated_worker: None,
|
||||
description: "".to_string(),
|
||||
draft_only: None,
|
||||
envs: vec![],
|
||||
is_template: None,
|
||||
kind: kind.map(|s| s.to_string()),
|
||||
summary: "".to_string(),
|
||||
tag: None,
|
||||
schema: HashMap::new(),
|
||||
ws_error_handler_muted: Some(false),
|
||||
priority: None,
|
||||
delete_after_secs: None,
|
||||
timeout: None,
|
||||
restart_unless_cancelled: None,
|
||||
deployment_message: None,
|
||||
concurrency_key: None,
|
||||
visible_to_runner_only: None,
|
||||
auto_kind: None,
|
||||
codebase: None,
|
||||
has_preprocessor: None,
|
||||
on_behalf_of_email: None,
|
||||
assets: vec![],
|
||||
modules: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression: a `failure`-kind script must never be marked `auto_kind = 'lib'`
|
||||
/// even if the parser fails to detect a `main` function, because the flow
|
||||
/// error-handler picker filters out lib scripts and would otherwise hide it.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn failure_kind_script_without_main_is_not_marked_lib(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
let (client, _port, _s) = init_client(db.clone()).await;
|
||||
|
||||
// Content with no `main` — TS parser would normally set auto_kind = 'lib'.
|
||||
client
|
||||
.create_script(
|
||||
"test-workspace",
|
||||
&quick_ns(
|
||||
"export function notMain() { return 42 }",
|
||||
"u/test-user/failure_no_main",
|
||||
Some("failure"),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let auto_kind: Option<String> = sqlx::query_scalar(
|
||||
"SELECT auto_kind FROM script \
|
||||
WHERE workspace_id = $1 AND path = $2",
|
||||
)
|
||||
.bind("test-workspace")
|
||||
.bind("u/test-user/failure_no_main")
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
|
||||
assert_ne!(
|
||||
auto_kind.as_deref(),
|
||||
Some("lib"),
|
||||
"failure-kind script must not be marked as 'lib' auto_kind, got {:?}",
|
||||
auto_kind
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sibling: a normal `script` kind WITHOUT main should still be marked `lib`
|
||||
/// (so it stays hidden from the regular script picker). Guards against an
|
||||
/// over-broad sanitizer accidentally clearing the value for plain scripts.
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn regular_script_without_main_is_still_marked_lib(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
let (client, _port, _s) = init_client(db.clone()).await;
|
||||
|
||||
client
|
||||
.create_script(
|
||||
"test-workspace",
|
||||
&quick_ns(
|
||||
"export function notMain() { return 42 }",
|
||||
"u/test-user/script_no_main",
|
||||
Some("script"),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let auto_kind: Option<String> = sqlx::query_scalar(
|
||||
"SELECT auto_kind FROM script \
|
||||
WHERE workspace_id = $1 AND path = $2",
|
||||
)
|
||||
.bind("test-workspace")
|
||||
.bind("u/test-user/script_no_main")
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
auto_kind.as_deref(),
|
||||
Some("lib"),
|
||||
"regular script without main should be marked 'lib', got {:?}",
|
||||
auto_kind
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Regression test for `auto_parent` when all versions at a script path are
|
||||
//! archived (e.g. after a rename).
|
||||
//!
|
||||
//! The CLI's `wmill sync push` sends `parent_hash` together with
|
||||
//! `auto_parent: true`, delegating parent resolution to the backend. When every
|
||||
//! version at the target path is archived, there is no active head, so the
|
||||
//! stale `parent_hash` (an archived ancestor) used to leak into the lineage
|
||||
//! check and produce a spurious
|
||||
//! `lineage must be linear: no 2 scripts can have the same parent` error
|
||||
//! whenever that archived hash already had a child from the prior rename.
|
||||
//!
|
||||
//! The fix clears `parent_hash` to `None` in that case so the push starts a
|
||||
//! fresh lineage instead of failing.
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", format!("Bearer {}", token))
|
||||
}
|
||||
|
||||
fn new_script(path: &str, content: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"path": path,
|
||||
"summary": "",
|
||||
"description": "",
|
||||
"content": content,
|
||||
"language": "deno",
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_auto_parent_starts_fresh_lineage_when_all_versions_archived(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace");
|
||||
|
||||
let original_path = "u/test-user/script_archived_parent";
|
||||
let renamed_path = "u/test-user/script_renamed";
|
||||
|
||||
// 1. Create the initial version at the original path.
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/scripts/create")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&new_script(
|
||||
original_path,
|
||||
"export async function main() { return 1; }",
|
||||
))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201);
|
||||
let original_hash: String = resp.text().await?;
|
||||
|
||||
// 2. Rename the script (new path, parent_hash pointing at v1). This
|
||||
// archives the original hash and gives the new version a
|
||||
// `parent_hashes[1]` equal to `original_hash`, so the original path now
|
||||
// has only archived versions.
|
||||
let mut rename = new_script(renamed_path, "export async function main() { return 2; }");
|
||||
rename["parent_hash"] = json!(original_hash);
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/scripts/create")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&rename)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
201,
|
||||
"rename should succeed: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
|
||||
// Sanity: the original path has no active (non-archived) version.
|
||||
let active_at_original: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND archived = false AND workspace_id = $2)",
|
||||
)
|
||||
.bind(original_path)
|
||||
.bind("test-workspace")
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert!(
|
||||
!active_at_original,
|
||||
"all versions at the original path should be archived after rename"
|
||||
);
|
||||
|
||||
// 3. Reproduce `wmill sync push`: push back to the original path with the
|
||||
// stale archived `parent_hash` AND `auto_parent: true`. Before the fix
|
||||
// this returned 400 "lineage must be linear" because the archived hash
|
||||
// already had a child (the renamed version) sharing the same parent.
|
||||
let mut push = new_script(original_path, "export async function main() { return 3; }");
|
||||
push["parent_hash"] = json!(original_hash);
|
||||
push["auto_parent"] = json!(true);
|
||||
let resp = authed(
|
||||
client().post(format!("{base}/scripts/create")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&push)
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
assert_eq!(
|
||||
status, 201,
|
||||
"auto_parent push to a path with only archived versions should start a \
|
||||
fresh lineage, got {status}: {body}"
|
||||
);
|
||||
|
||||
// 4. There is now exactly one active version at the original path and it is
|
||||
// a fresh lineage with no parent (rather than attaching to the archived
|
||||
// ancestor).
|
||||
let active: Vec<Option<Vec<i64>>> = sqlx::query_scalar(
|
||||
"SELECT parent_hashes FROM script \
|
||||
WHERE path = $1 AND archived = false AND workspace_id = $2",
|
||||
)
|
||||
.bind(original_path)
|
||||
.bind("test-workspace")
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
active.len(),
|
||||
1,
|
||||
"exactly one active version expected at the original path"
|
||||
);
|
||||
assert!(
|
||||
active[0].is_none(),
|
||||
"fresh lineage should have no parent_hashes, got {:?}",
|
||||
active[0]
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -174,6 +174,7 @@ fn make_authed() -> windmill_api_auth::ApiAuthed {
|
||||
scopes: None,
|
||||
username_override: None,
|
||||
token_prefix: None,
|
||||
read_only: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ base64.workspace = true
|
||||
bytes.workspace = true
|
||||
eventsource-stream.workspace = true
|
||||
futures.workspace = true
|
||||
http.workspace = true
|
||||
mime_guess.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -326,8 +326,10 @@ pub fn json_to_document(value: serde_json::Value) -> aws_smithy_types::Document
|
||||
}
|
||||
Value::Array(arr) => Document::Array(arr.into_iter().map(json_to_document).collect()),
|
||||
Value::Number(num) => {
|
||||
if let Some(i) = num.as_i64() {
|
||||
Document::Number(aws_smithy_types::Number::PosInt(i as u64))
|
||||
if let Some(u) = num.as_u64() {
|
||||
Document::Number(aws_smithy_types::Number::PosInt(u))
|
||||
} else if let Some(i) = num.as_i64() {
|
||||
Document::Number(aws_smithy_types::Number::NegInt(i))
|
||||
} else if let Some(f) = num.as_f64() {
|
||||
Document::Number(aws_smithy_types::Number::Float(f))
|
||||
} else {
|
||||
@@ -844,6 +846,36 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_to_document_preserves_negative_integers() {
|
||||
let value = serde_json::json!(-1);
|
||||
let doc = json_to_document(value);
|
||||
assert!(matches!(
|
||||
doc,
|
||||
aws_smithy_types::Document::Number(aws_smithy_types::Number::NegInt(-1))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_to_document_handles_large_u64_above_i64_max() {
|
||||
let value = serde_json::json!(u64::MAX);
|
||||
let doc = json_to_document(value);
|
||||
assert!(matches!(
|
||||
doc,
|
||||
aws_smithy_types::Document::Number(aws_smithy_types::Number::PosInt(u)) if u == u64::MAX
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_to_document_handles_positive_integers() {
|
||||
let value = serde_json::json!(42);
|
||||
let doc = json_to_document(value);
|
||||
assert!(matches!(
|
||||
doc,
|
||||
aws_smithy_types::Document::Number(aws_smithy_types::Number::PosInt(42))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_messages_to_bedrock_adds_cache_points_when_enabled() {
|
||||
let messages = vec![
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* This file contains shared AI provider utilities used by both the API and worker.
|
||||
*/
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use windmill_common::db::DB;
|
||||
use windmill_common::error::{Error, Result};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
/// Deserializes an Option<String> where empty strings become None.
|
||||
/// Use with `#[serde(default, deserialize_with = "empty_string_as_none")]`
|
||||
@@ -65,13 +65,19 @@ impl AIProvider {
|
||||
pub async fn get_base_url(&self, resource_base_url: Option<String>, db: &DB) -> Result<String> {
|
||||
if let Some(base_url) = resource_base_url {
|
||||
if !*ALLOW_PRIVATE_AI_BASE_URLS {
|
||||
use windmill_common::ssrf::SsrfValidationError;
|
||||
windmill_common::ssrf::validate_url_for_ssrf(&base_url)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::BadRequest(format!(
|
||||
.map_err(|e| match e {
|
||||
// The env-var hint is only actionable when the URL is
|
||||
// well-formed but blocked for targeting a private
|
||||
// address. For a malformed URL or bad scheme, surface
|
||||
// the real error so users fix the URL (issue #9171).
|
||||
e @ SsrfValidationError::Private { .. } => Error::BadRequest(format!(
|
||||
"{e}. If you need to use private/internal AI endpoints, \
|
||||
set the ALLOW_PRIVATE_AI_BASE_URLS=true environment variable"
|
||||
))
|
||||
set the ALLOW_PRIVATE_AI_BASE_URLS=true environment variable"
|
||||
)),
|
||||
e => Error::from(e),
|
||||
})?;
|
||||
}
|
||||
return Ok(base_url);
|
||||
|
||||
@@ -5,6 +5,8 @@ pub mod ai_google;
|
||||
pub mod ai_providers;
|
||||
pub mod ai_types;
|
||||
pub mod image_handler;
|
||||
pub mod providers;
|
||||
pub mod proxy;
|
||||
pub mod query_builder;
|
||||
pub mod sse;
|
||||
pub mod types;
|
||||
|
||||
+4
-4
@@ -1,7 +1,4 @@
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::value::RawValue;
|
||||
use windmill_ai::{
|
||||
use crate::{
|
||||
ai_google::parse_data_url,
|
||||
ai_providers::AIProvider,
|
||||
image_handler::prepare_messages_for_api,
|
||||
@@ -10,6 +7,9 @@ use windmill_ai::{
|
||||
types::*,
|
||||
utils::{extract_text_content, should_use_structured_output_tool},
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::value::RawValue;
|
||||
use windmill_common::{client::AuthedClient, error::Error};
|
||||
|
||||
/// Anthropic API version for standard API
|
||||
+6
-6
@@ -1,4 +1,4 @@
|
||||
//! AWS Bedrock provider for the AI agent.
|
||||
//! AWS Bedrock provider for AI requests.
|
||||
//!
|
||||
//! Uses shared SDK code from windmill_ai::ai_bedrock for:
|
||||
//! - BedrockClient (SDK wrapper with auth)
|
||||
@@ -6,16 +6,16 @@
|
||||
//! - Stream event parsing
|
||||
//! - Helper utilities
|
||||
|
||||
use std::collections::HashMap;
|
||||
use windmill_ai::{
|
||||
use crate::{
|
||||
image_handler::prepare_messages_for_api,
|
||||
query_builder::{ParsedResponse, StreamEventSink},
|
||||
types::{OpenAIMessage, StreamingEvent, TokenUsage, ToolDef},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use windmill_common::{client::AuthedClient, error::Error};
|
||||
|
||||
// Import shared Bedrock helpers for worker-specific orchestration.
|
||||
use windmill_ai::ai_bedrock::{
|
||||
// Import shared Bedrock helpers for provider orchestration.
|
||||
use crate::ai_bedrock::{
|
||||
bedrock_model_supports_prompt_caching, bedrock_stream_event_is_block_stop,
|
||||
bedrock_stream_event_to_text, bedrock_stream_event_to_tool_delta,
|
||||
bedrock_stream_event_to_tool_start, build_tool_config, create_inference_config,
|
||||
@@ -24,7 +24,7 @@ use windmill_ai::ai_bedrock::{
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Query Builder (Worker-specific orchestration)
|
||||
// Query Builder
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Default)]
|
||||
+2
-2
@@ -1,5 +1,4 @@
|
||||
use async_trait::async_trait;
|
||||
use windmill_ai::{
|
||||
use crate::{
|
||||
ai_google::{
|
||||
openai_messages_to_gemini, openai_tools_to_gemini, GeminiGenerationConfig,
|
||||
GeminiImageContent, GeminiImageRequest, GeminiImageResponse, GeminiInlineData, GeminiPart,
|
||||
@@ -10,6 +9,7 @@ use windmill_ai::{
|
||||
sse::{GeminiSSEParser, SSEParser},
|
||||
types::*,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use windmill_common::{client::AuthedClient, error::Error};
|
||||
|
||||
// ============================================================================
|
||||
@@ -0,0 +1,49 @@
|
||||
pub mod anthropic;
|
||||
#[cfg(feature = "bedrock")]
|
||||
pub mod bedrock;
|
||||
pub mod google_ai;
|
||||
pub mod openai;
|
||||
pub mod openrouter;
|
||||
pub mod other;
|
||||
|
||||
use crate::{
|
||||
ai_providers::AIProvider, proxy::ProviderCredentials, query_builder::QueryBuilder,
|
||||
types::ProviderWithResource,
|
||||
};
|
||||
|
||||
use self::{
|
||||
anthropic::AnthropicQueryBuilder, google_ai::GoogleAIQueryBuilder, openai::OpenAIQueryBuilder,
|
||||
openrouter::OpenRouterQueryBuilder, other::OtherQueryBuilder,
|
||||
};
|
||||
|
||||
/// Factory function to create the appropriate query builder for a provider.
|
||||
pub fn create_query_builder(provider: &ProviderWithResource) -> Box<dyn QueryBuilder> {
|
||||
match provider.kind {
|
||||
AIProvider::GoogleAI => {
|
||||
Box::new(GoogleAIQueryBuilder::new(provider.get_platform().clone()))
|
||||
}
|
||||
AIProvider::OpenAI => Box::new(OpenAIQueryBuilder::new(provider.kind.clone())),
|
||||
AIProvider::Anthropic => Box::new(AnthropicQueryBuilder::new(
|
||||
provider.kind.clone(),
|
||||
provider.get_platform().clone(),
|
||||
provider.get_enable_1m_context(),
|
||||
)),
|
||||
AIProvider::OpenRouter => Box::new(OpenRouterQueryBuilder::new()),
|
||||
_ => Box::new(OtherQueryBuilder::new(provider.kind.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Factory function to create the appropriate query builder from resolved proxy credentials.
|
||||
pub fn create_proxy_query_builder(credentials: &ProviderCredentials) -> Box<dyn QueryBuilder> {
|
||||
match credentials.provider {
|
||||
AIProvider::GoogleAI => Box::new(GoogleAIQueryBuilder::new(credentials.platform.clone())),
|
||||
AIProvider::OpenAI => Box::new(OpenAIQueryBuilder::new(credentials.provider.clone())),
|
||||
AIProvider::Anthropic => Box::new(AnthropicQueryBuilder::new(
|
||||
credentials.provider.clone(),
|
||||
credentials.platform.clone(),
|
||||
credentials.enable_1m_context,
|
||||
)),
|
||||
AIProvider::OpenRouter => Box::new(OpenRouterQueryBuilder::new()),
|
||||
_ => Box::new(OtherQueryBuilder::new(credentials.provider.clone())),
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user