mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-11 08:07:15 +00:00
Merge origin/main into change-24583b5f
Conflict resolution kept the UserDraft persistence on this branch and
incorporated the picker-navigation improvements from main:
- editPathFor / invalidate calls for workspace picker support
- loadFlowToken / loadAppToken / loadScriptToken stale-load guards
- onNavigate prop wiring
Dropped the legacy localStorage `flow-{path}` / `rawapp-{path}` /
`app-{path}` autosave paths that main still carried — they are
superseded by UserDraft (key `userdraft/w/{ws}/{kind}/{path}`).
Dropped main's `decodeState`/window.location.hash autosave for the
script editor for the same reason. The replaceStateFn prop that main
re-added to ScriptBuilder is also dropped (no longer exists on the
component).
Pre-existing typecheck errors in InstanceSetting.svelte and
useJobsLoader.svelte.ts come from main and reflect a stale
$lib/gen client — unrelated to the merge.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)",
|
||||
|
||||
@@ -82,10 +82,11 @@ jobs:
|
||||
EVENT_TITLE: ${{ github.event.pull_request.title }}
|
||||
EVENT_BODY: ${{ github.event.pull_request.body }}
|
||||
EVENT_FORK: ${{ github.event.pull_request.head.repo.fork }}
|
||||
EVENT_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
run: |
|
||||
if [ -n "$INPUT_PR_NUMBER" ]; then
|
||||
PR_JSON=$(gh pr view "$INPUT_PR_NUMBER" --repo "${{ github.repository }}" \
|
||||
--json number,baseRefName,baseRefOid,headRefOid,title,body,isCrossRepository)
|
||||
--json number,baseRefName,baseRefOid,headRefOid,title,body,isCrossRepository,author)
|
||||
PR_NUMBER=$(echo "$PR_JSON" | jq -r '.number')
|
||||
BASE_REF=$(echo "$PR_JSON" | jq -r '.baseRefName')
|
||||
BASE_SHA=$(echo "$PR_JSON" | jq -r '.baseRefOid')
|
||||
@@ -93,6 +94,7 @@ jobs:
|
||||
PR_TITLE=$(echo "$PR_JSON" | jq -r '.title')
|
||||
PR_BODY=$(echo "$PR_JSON" | jq -r '.body // ""')
|
||||
IS_FORK=$(echo "$PR_JSON" | jq -r '.isCrossRepository')
|
||||
PR_AUTHOR=$(echo "$PR_JSON" | jq -r '.author.login // ""')
|
||||
else
|
||||
PR_NUMBER="$EVENT_PR_NUMBER"
|
||||
BASE_REF="$EVENT_BASE_REF"
|
||||
@@ -101,6 +103,7 @@ jobs:
|
||||
PR_TITLE="$EVENT_TITLE"
|
||||
PR_BODY="$EVENT_BODY"
|
||||
IS_FORK="$EVENT_FORK"
|
||||
PR_AUTHOR="$EVENT_AUTHOR"
|
||||
fi
|
||||
if [ "$IS_FORK" = "true" ]; then
|
||||
echo "Skipping Codex review for fork PR."
|
||||
@@ -113,6 +116,7 @@ jobs:
|
||||
echo "base_ref=$BASE_REF"
|
||||
echo "base_sha=$BASE_SHA"
|
||||
echo "head_sha=$HEAD_SHA"
|
||||
echo "pr_author=$PR_AUTHOR"
|
||||
echo 'title<<PR_TITLE_EOF'
|
||||
printf '%s\n' "$PR_TITLE"
|
||||
echo 'PR_TITLE_EOF'
|
||||
@@ -211,6 +215,7 @@ jobs:
|
||||
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
PR_TITLE: ${{ steps.pr.outputs.title }}
|
||||
PR_BODY: ${{ steps.pr.outputs.body }}
|
||||
PR_AUTHOR: ${{ steps.pr.outputs.pr_author }}
|
||||
EXTRA_PROMPT: ${{ inputs.extra_prompt }}
|
||||
run: |
|
||||
mkdir -p .github/codex
|
||||
@@ -219,6 +224,11 @@ jobs:
|
||||
const lines = [
|
||||
`Repository: ${process.env.PR_REPOSITORY}`,
|
||||
`PR number: ${process.env.PR_NUMBER}`,
|
||||
];
|
||||
if (process.env.PR_AUTHOR) {
|
||||
lines.push(`PR AUTHOR: ${process.env.PR_AUTHOR}`);
|
||||
}
|
||||
lines.push(
|
||||
`Base SHA: ${process.env.PR_BASE_SHA}`,
|
||||
`Head SHA: ${process.env.PR_HEAD_SHA}`,
|
||||
'',
|
||||
@@ -236,7 +246,7 @@ jobs:
|
||||
'',
|
||||
'Full review diff command:',
|
||||
`git diff --unified=0 ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`
|
||||
];
|
||||
);
|
||||
if (process.env.EXTRA_PROMPT && process.env.EXTRA_PROMPT.trim()) {
|
||||
lines.push('', 'Additional reviewer instructions:', process.env.EXTRA_PROMPT.trim());
|
||||
}
|
||||
|
||||
@@ -82,10 +82,11 @@ jobs:
|
||||
EVENT_TITLE: ${{ github.event.pull_request.title }}
|
||||
EVENT_BODY: ${{ github.event.pull_request.body }}
|
||||
EVENT_FORK: ${{ github.event.pull_request.head.repo.fork }}
|
||||
EVENT_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
run: |
|
||||
if [ -n "$INPUT_PR_NUMBER" ]; then
|
||||
PR_JSON=$(gh pr view "$INPUT_PR_NUMBER" --repo "${{ github.repository }}" \
|
||||
--json number,baseRefName,baseRefOid,headRefOid,title,body,isCrossRepository)
|
||||
--json number,baseRefName,baseRefOid,headRefOid,title,body,isCrossRepository,author)
|
||||
PR_NUMBER=$(echo "$PR_JSON" | jq -r '.number')
|
||||
BASE_REF=$(echo "$PR_JSON" | jq -r '.baseRefName')
|
||||
BASE_SHA=$(echo "$PR_JSON" | jq -r '.baseRefOid')
|
||||
@@ -93,6 +94,7 @@ jobs:
|
||||
PR_TITLE=$(echo "$PR_JSON" | jq -r '.title')
|
||||
PR_BODY=$(echo "$PR_JSON" | jq -r '.body // ""')
|
||||
IS_FORK=$(echo "$PR_JSON" | jq -r '.isCrossRepository')
|
||||
PR_AUTHOR=$(echo "$PR_JSON" | jq -r '.author.login // ""')
|
||||
else
|
||||
PR_NUMBER="$EVENT_PR_NUMBER"
|
||||
BASE_REF="$EVENT_BASE_REF"
|
||||
@@ -101,6 +103,7 @@ jobs:
|
||||
PR_TITLE="$EVENT_TITLE"
|
||||
PR_BODY="$EVENT_BODY"
|
||||
IS_FORK="$EVENT_FORK"
|
||||
PR_AUTHOR="$EVENT_AUTHOR"
|
||||
fi
|
||||
if [ "$IS_FORK" = "true" ]; then
|
||||
echo "Skipping Pi review for fork PR."
|
||||
@@ -113,6 +116,7 @@ jobs:
|
||||
echo "base_ref=$BASE_REF"
|
||||
echo "base_sha=$BASE_SHA"
|
||||
echo "head_sha=$HEAD_SHA"
|
||||
echo "pr_author=$PR_AUTHOR"
|
||||
echo 'title<<PR_TITLE_EOF'
|
||||
printf '%s\n' "$PR_TITLE"
|
||||
echo 'PR_TITLE_EOF'
|
||||
@@ -195,6 +199,7 @@ jobs:
|
||||
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
|
||||
PR_TITLE: ${{ steps.pr.outputs.title }}
|
||||
PR_BODY: ${{ steps.pr.outputs.body }}
|
||||
PR_AUTHOR: ${{ steps.pr.outputs.pr_author }}
|
||||
EXTRA_PROMPT: ${{ inputs.extra_prompt }}
|
||||
run: |
|
||||
mkdir -p .github/pi
|
||||
@@ -203,6 +208,11 @@ jobs:
|
||||
const lines = [
|
||||
`Repository: ${process.env.PR_REPOSITORY}`,
|
||||
`PR number: ${process.env.PR_NUMBER}`,
|
||||
];
|
||||
if (process.env.PR_AUTHOR) {
|
||||
lines.push(`PR AUTHOR: ${process.env.PR_AUTHOR}`);
|
||||
}
|
||||
lines.push(
|
||||
`Base SHA: ${process.env.PR_BASE_SHA}`,
|
||||
`Head SHA: ${process.env.PR_HEAD_SHA}`,
|
||||
'',
|
||||
@@ -220,7 +230,7 @@ jobs:
|
||||
'',
|
||||
'Full review diff command:',
|
||||
`git diff --unified=0 ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`
|
||||
];
|
||||
);
|
||||
if (process.env.EXTRA_PROMPT && process.env.EXTRA_PROMPT.trim()) {
|
||||
lines.push('', 'Additional reviewer instructions:', process.env.EXTRA_PROMPT.trim());
|
||||
}
|
||||
|
||||
@@ -90,14 +90,21 @@ jobs:
|
||||
- name: Resolve PR number
|
||||
id: resolve
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
INPUT_PR_NUMBER: ${{ inputs.pr_number }}
|
||||
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
EVENT_PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
run: |
|
||||
if [ -n "$INPUT_PR_NUMBER" ]; then
|
||||
echo "pr_number=$INPUT_PR_NUMBER" >> "$GITHUB_OUTPUT"
|
||||
PR_NUMBER="$INPUT_PR_NUMBER"
|
||||
PR_AUTHOR=$(gh api "repos/$REPO/pulls/$PR_NUMBER" --jq '.user.login')
|
||||
else
|
||||
echo "pr_number=$EVENT_PR_NUMBER" >> "$GITHUB_OUTPUT"
|
||||
PR_NUMBER="$EVENT_PR_NUMBER"
|
||||
PR_AUTHOR="$EVENT_PR_AUTHOR"
|
||||
fi
|
||||
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_author=$PR_AUTHOR" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Fetch prior PR discussion
|
||||
id: prior
|
||||
@@ -148,6 +155,7 @@ jobs:
|
||||
prompt: |
|
||||
REPO: ${{ github.repository }}
|
||||
PR NUMBER: ${{ steps.resolve.outputs.pr_number }}
|
||||
PR AUTHOR: ${{ steps.resolve.outputs.pr_author }}
|
||||
|
||||
${{ env.REVIEW_PROMPT }}
|
||||
claude_args: |
|
||||
|
||||
@@ -1,5 +1,84 @@
|
||||
# Changelog
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* parse windmill_failure field to tag run as failure ([#9073](https://github.com/windmill-labs/windmill/issues/9073)) ([dd53202](https://github.com/windmill-labs/windmill/commit/dd5320205f200dd058db2ff7d44d5c4bbcf25ec9))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **cli:** bump svelte version in `wmill app new` template ([#9084](https://github.com/windmill-labs/windmill/issues/9084)) ([4b4aa0e](https://github.com/windmill-labs/windmill/commit/4b4aa0e303f9c47c4f931511977107f42f93abc3))
|
||||
* **flows:** populate error handler input args from failure picker ([#9087](https://github.com/windmill-labs/windmill/issues/9087)) ([f37d360](https://github.com/windmill-labs/windmill/commit/f37d3606446d23f8b11a94ea1ce5f5d4836fae17))
|
||||
* hide _ENTRYPOINT_OVERRIDE jobs from script/flow history panel ([#9088](https://github.com/windmill-labs/windmill/issues/9088)) ([935c666](https://github.com/windmill-labs/windmill/commit/935c666d50ef30d89a3669c76094af7506fbb448))
|
||||
* **native-triggers:** serialize Google channel renewal across replicas ([#9060](https://github.com/windmill-labs/windmill/issues/9060)) ([ee3d82f](https://github.com/windmill-labs/windmill/commit/ee3d82f01f52d835218f544dad6de9b7c3184fbb))
|
||||
* **python:** verify wheel RECORD on cache pull/install, finalize piptar ([#9090](https://github.com/windmill-labs/windmill/issues/9090)) ([98ff146](https://github.com/windmill-labs/windmill/commit/98ff146cfabf45418c95c027ad6d07b08069cfcd))
|
||||
* reject root-rooted paths in ansible playbook validator on windows ([#9081](https://github.com/windmill-labs/windmill/issues/9081)) ([d37277d](https://github.com/windmill-labs/windmill/commit/d37277d2341c83faf72efa0035cbf70e2cfbd596))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **flows:** gate flow_env resolve on expr text and share cache with handle_flow ([#9085](https://github.com/windmill-labs/windmill/issues/9085)) ([23af6c2](https://github.com/windmill-labs/windmill/commit/23af6c2ea31265a1898d0632e72cd2fd826e4044))
|
||||
|
||||
## [1.698.0](https://github.com/windmill-labs/windmill/compare/v1.697.0...v1.698.0) (2026-05-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **cli:** add --parallel flag to generate-metadata ([#9074](https://github.com/windmill-labs/windmill/issues/9074)) ([bc527fd](https://github.com/windmill-labs/windmill/commit/bc527fd929577ac57d4e24196069ed236b702d71))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **cli-tests:** stabilize flow lock-gen race + Windows path ([#9080](https://github.com/windmill-labs/windmill/issues/9080)) ([1c56148](https://github.com/windmill-labs/windmill/commit/1c56148714861aafc4f489916c71aa4674e938c0))
|
||||
* **cli:** forward HEADERS env var on every backend fetch call ([#9075](https://github.com/windmill-labs/windmill/issues/9075)) ([d647686](https://github.com/windmill-labs/windmill/commit/d6476862b30692e450cceda09c58d47964f87d32))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **flows:** cache resolved flow_env per flow execution ([#9079](https://github.com/windmill-labs/windmill/issues/9079)) ([e1a7c75](https://github.com/windmill-labs/windmill/commit/e1a7c75e192b72b3b0d854c1901653e0b9386bf2))
|
||||
* **flows:** skip flow_env DB+transform work when no resolution is needed ([#9078](https://github.com/windmill-labs/windmill/issues/9078)) ([2067e07](https://github.com/windmill-labs/windmill/commit/2067e0719fd1fd1b899b015badec0f222c054e66))
|
||||
|
||||
## [1.697.0](https://github.com/windmill-labs/windmill/compare/v1.696.2...v1.697.0) (2026-05-07)
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ You are reviewing a GitHub pull request for this repository. Apply this policy a
|
||||
|
||||
## Verdict (first line of the review)
|
||||
|
||||
Start every review with a single verdict line, before any other section. Pick exactly one:
|
||||
Start every review with a single verdict line, before any other section (the only thing that may appear above the verdict is the optional `cc @<PR_AUTHOR>` ping described in "Pinging the author" below). Pick exactly one:
|
||||
|
||||
- **Good to merge** — no blocking issues and no nits worth surfacing.
|
||||
- **Mergeable, but should ideally address nits: <short list>** — no blockers, but P2 findings that are worth a look. The list must name each nit briefly (e.g. "doc/code mismatch in `foo.rs`, half-finished `pub fn bar`").
|
||||
@@ -17,6 +17,10 @@ Start every review with a single verdict line, before any other section. Pick ex
|
||||
|
||||
The names in the list must match findings detailed later in the review. If you list a nit or issue here, it must appear with full context in the body. Do not invent items that aren't in the body, and do not bury blockers in the body without surfacing them in the verdict.
|
||||
|
||||
## Pinging the author
|
||||
|
||||
If the prompt context provides a `PR AUTHOR` (GitHub login) and the verdict is NOT "Good to merge" (i.e. it is "Mergeable, but should ideally address nits: ..." or "Should address issues before merging: ..."), prepend a single line `cc @<PR_AUTHOR>` to the top-level review comment, above the verdict line. This pings the author so they get a notification that there are items to address. Skip the ping entirely when the verdict is "Good to merge" — there is nothing for the author to act on. Do not add the ping to inline comments; the top-level summary comment is the only place it belongs.
|
||||
|
||||
## Review policy
|
||||
|
||||
- Only report issues you are confident are real and introduced by this pull request.
|
||||
|
||||
+7
-11
@@ -55,7 +55,7 @@ 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 cli bun-hello-script
|
||||
```
|
||||
@@ -72,7 +72,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
|
||||
@@ -145,26 +144,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
|
||||
|
||||
@@ -182,7 +178,7 @@ If `--record` is used, the CLI also appends one compact JSON line to:
|
||||
|
||||
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)
|
||||
|
||||
@@ -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,6 +8,7 @@ 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";
|
||||
@@ -36,17 +36,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 +63,6 @@ export async function runFrontendBenchmarkFromEnv(): Promise<BenchmarkRunResult>
|
||||
mode,
|
||||
runs,
|
||||
runModel,
|
||||
transport: transportSettings.transport,
|
||||
judgeModel: DEFAULT_JUDGE_MODEL,
|
||||
caseResults,
|
||||
});
|
||||
@@ -76,18 +72,18 @@ 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -23,7 +23,6 @@ export async function runFrontendBenchmarkAdapter(input: {
|
||||
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"),
|
||||
|
||||
@@ -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> {
|
||||
|
||||
+5
-20
@@ -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()
|
||||
@@ -98,10 +95,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 +113,6 @@ async function main() {
|
||||
output?: string;
|
||||
model?: string;
|
||||
models?: string;
|
||||
transport?: string;
|
||||
verbose?: boolean;
|
||||
record?: boolean;
|
||||
backendValidation?: string;
|
||||
@@ -133,9 +125,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,
|
||||
@@ -184,7 +173,6 @@ async function handleRun(input: {
|
||||
outputPath?: string;
|
||||
model?: string;
|
||||
models?: string;
|
||||
transport?: FrontendEvalTransport;
|
||||
verbose: boolean;
|
||||
record: boolean;
|
||||
backendValidation?: string;
|
||||
@@ -197,11 +185,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 +203,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 +235,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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export const EVAL_MODES = ["cli", "flow", "script", "app"] as const;
|
||||
|
||||
export type EvalMode = (typeof EVAL_MODES)[number];
|
||||
export type FrontendEvalTransport = "direct" | "proxy";
|
||||
|
||||
export interface EvalCaseRuntimeBackendPreview {
|
||||
args?: Record<string, unknown>;
|
||||
@@ -297,7 +296,6 @@ export interface BenchmarkRunResult {
|
||||
gitSha: string | null;
|
||||
runs: number;
|
||||
runModel: string | null;
|
||||
transport: FrontendEvalTransport | null;
|
||||
judgeModel: string | null;
|
||||
caseCount: number;
|
||||
attemptCount: number;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
getFrontendEvalModel,
|
||||
resolveEvalModel,
|
||||
type FrontendEvalModelConfig,
|
||||
} from "../core/models";
|
||||
|
||||
export const DEFAULT_FRONTEND_EVAL_MODEL: FrontendEvalModelConfig = getFrontendEvalModel(
|
||||
resolveEvalModel("flow")
|
||||
);
|
||||
import type { FrontendEvalModelConfig } from "../core/models";
|
||||
|
||||
export function getFrontendApiKey(provider: FrontendEvalModelConfig["provider"]): string {
|
||||
const envName =
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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 ",
|
||||
"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 ORDER BY s.item_kind, s.path\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -24,5 +24,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "8b92a7d04fcdd8e61178d7dab97c31e10f89481908c479b4039af5e94fa0f8ac"
|
||||
"hash": "290599fc173947acb518344d6fb631af9f524389309f17ef04f70c773b1d5e75"
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT service_config, webhook_token_hash\n FROM native_trigger\n WHERE workspace_id = $1\n AND service_name = $2\n AND external_id = $3\n FOR UPDATE SKIP LOCKED\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "service_config",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "webhook_token_hash",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "native_trigger_service",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"nextcloud",
|
||||
"google",
|
||||
"github"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6474dbc070afdce3e6bc74b02f93d94c27fcbbf989a4a70678dbc54e0d896e24"
|
||||
}
|
||||
+6
-18
@@ -1,42 +1,32 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT label, email, scopes, workspace_id, super_admin, owner, expiration FROM token WHERE token_hash = $1",
|
||||
"query": "SELECT email, scopes, workspace_id, super_admin, owner FROM token WHERE token_hash = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "label",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"ordinal": 1,
|
||||
"name": "scopes",
|
||||
"type_info": "TextArray"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"ordinal": 2,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"ordinal": 3,
|
||||
"name": "super_admin",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"ordinal": 4,
|
||||
"name": "owner",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "expiration",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -48,11 +38,9 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "406bcbf55758b10243c8eaff1c349b8082c0052d626bf67e08317e56ab9ad026"
|
||||
"hash": "676405f5ba49c1c711646bd0882fb888d53c5e4aa53bfc43ca296863bc61813f"
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM variable WHERE workspace_id = $1 AND path = $2)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "6be4bf59c404d2f557d1106c48c320bb3eff65255a44bd66799ae14288312ba4"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+7
-7
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT item_kind, path FROM ws_specific WHERE workspace_id = $1",
|
||||
"query": "SELECT label, expiration FROM token WHERE token_hash = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "item_kind",
|
||||
"name": "label",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
"name": "expiration",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -20,9 +20,9 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "0c6e8f03a4e9f543cb85582e0aec1ed508d83695ef6d62ca06cfb612fd332b87"
|
||||
"hash": "b02d3563b72ee8989dea128a3c6a2be62ce1f8f7edf3793d7ad4af9eb11b648f"
|
||||
}
|
||||
-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"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
Generated
+753
-3063
File diff suppressed because it is too large
Load Diff
+62
-25
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.697.0"
|
||||
version = "1.700.2"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -87,7 +87,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.697.0"
|
||||
version = "1.700.2"
|
||||
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 @@
|
||||
c8d100d74b8de6bd26fc973d5edbd8853d54dd8b
|
||||
f9494c6320bb5fd07c1e9e09734b7fd5fbe7aa38
|
||||
|
||||
@@ -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
|
||||
}';
|
||||
@@ -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()),
|
||||
|
||||
@@ -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.697.0"
|
||||
version = "1.700.2"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
@@ -6263,7 +6263,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.697.0"
|
||||
version = "1.700.2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6275,7 +6275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.697.0"
|
||||
version = "1.700.2"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"serde",
|
||||
@@ -6284,7 +6284,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.697.0"
|
||||
version = "1.700.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6296,7 +6296,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.697.0"
|
||||
version = "1.700.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6308,7 +6308,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.697.0"
|
||||
version = "1.700.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -6320,7 +6320,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.697.0"
|
||||
version = "1.700.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6332,7 +6332,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.697.0"
|
||||
version = "1.700.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6344,7 +6344,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.697.0"
|
||||
version = "1.700.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -6355,7 +6355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.697.0"
|
||||
version = "1.700.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6366,7 +6366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.697.0"
|
||||
version = "1.700.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6378,7 +6378,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.697.0"
|
||||
version = "1.700.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6389,7 +6389,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.697.0"
|
||||
version = "1.700.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -6411,7 +6411,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.697.0"
|
||||
version = "1.700.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6423,7 +6423,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.697.0"
|
||||
version = "1.700.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6437,7 +6437,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.697.0"
|
||||
version = "1.700.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case",
|
||||
@@ -6454,7 +6454,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.697.0"
|
||||
version = "1.700.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6467,7 +6467,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.697.0"
|
||||
version = "1.700.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6479,7 +6479,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.697.0"
|
||||
version = "1.700.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6497,7 +6497,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.697.0"
|
||||
version = "1.700.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -6513,7 +6513,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.697.0"
|
||||
version = "1.700.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6529,7 +6529,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wasm"
|
||||
version = "1.697.0"
|
||||
version = "1.700.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"getrandom 0.2.17",
|
||||
@@ -6561,7 +6561,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.697.0"
|
||||
version = "1.700.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6572,7 +6572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.697.0"
|
||||
version = "1.700.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
|
||||
@@ -12,7 +12,7 @@ resolver = "2"
|
||||
members = ["."]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.697.0"
|
||||
version = "1.700.2"
|
||||
edition = "2021"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+1
-1
@@ -1441,7 +1441,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.
|
||||
|
||||
+19
-1
@@ -2373,7 +2373,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 +2534,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 +2866,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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@ windmill-mcp = { workspace = true, optional = true }
|
||||
|
||||
async-trait.workspace = true
|
||||
base64.workspace = true
|
||||
bytes.workspace = true
|
||||
eventsource-stream.workspace = true
|
||||
futures.workspace = true
|
||||
mime_guess.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -29,6 +33,8 @@ uuid.workspace = true
|
||||
lazy_static.workspace = true
|
||||
tracing.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
ulid.workspace = true
|
||||
|
||||
# Bedrock (optional)
|
||||
aws-config = { workspace = true, optional = 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![
|
||||
|
||||
+18
-10
@@ -1,16 +1,18 @@
|
||||
use crate::types::*;
|
||||
use base64::Engine;
|
||||
use futures;
|
||||
use ulid;
|
||||
use uuid::Uuid;
|
||||
use windmill_common::{client::AuthedClient, error::Error};
|
||||
use windmill_queue::MiniPulledJob;
|
||||
use windmill_types::s3::S3Object;
|
||||
|
||||
use crate::ai::types::*;
|
||||
|
||||
/// Upload image to S3 and return S3Object
|
||||
/// Upload image to S3 and return S3Object.
|
||||
///
|
||||
/// The caller must provide an AuthedClient authorized for `workspace_id`.
|
||||
pub async fn upload_image_to_s3(
|
||||
base64_image: &str,
|
||||
job: &MiniPulledJob,
|
||||
workspace_id: &str,
|
||||
job_id: &Uuid,
|
||||
client: &AuthedClient,
|
||||
) -> Result<S3Object, Error> {
|
||||
let image_bytes = base64::engine::general_purpose::STANDARD
|
||||
@@ -19,7 +21,7 @@ pub async fn upload_image_to_s3(
|
||||
|
||||
// Generate unique S3 key
|
||||
let unique_id = ulid::Ulid::new().to_string();
|
||||
let s3_key = format!("ai_images/{}/{}.png", job.id, unique_id);
|
||||
let s3_key = format!("ai_images/{}/{}.png", job_id, unique_id);
|
||||
|
||||
// Create byte stream
|
||||
let byte_stream = futures::stream::once(async move {
|
||||
@@ -29,7 +31,7 @@ pub async fn upload_image_to_s3(
|
||||
// Upload to S3
|
||||
client
|
||||
.upload_s3_file(
|
||||
&job.workspace_id,
|
||||
workspace_id,
|
||||
s3_key.clone(),
|
||||
None, // storage - use default
|
||||
byte_stream,
|
||||
@@ -45,7 +47,9 @@ pub async fn upload_image_to_s3(
|
||||
})
|
||||
}
|
||||
|
||||
/// Download an S3 image and convert it to a base64 data URL
|
||||
/// Download an S3 image and convert it to a base64 data URL.
|
||||
///
|
||||
/// The caller must provide an AuthedClient authorized for `workspace_id`.
|
||||
pub async fn download_and_encode_s3_image(
|
||||
image: &S3Object,
|
||||
client: &AuthedClient,
|
||||
@@ -71,6 +75,8 @@ pub async fn download_and_encode_s3_image(
|
||||
}
|
||||
|
||||
/// Convert an S3Object to the appropriate ContentPart based on MIME type.
|
||||
///
|
||||
/// The caller must provide an AuthedClient authorized for `workspace_id`.
|
||||
pub async fn s3_object_to_content_part(
|
||||
s3_object: &S3Object,
|
||||
client: &AuthedClient,
|
||||
@@ -80,7 +86,7 @@ pub async fn s3_object_to_content_part(
|
||||
download_and_encode_s3_image(s3_object, client, workspace_id).await?;
|
||||
let data_url = format!("data:{};base64,{}", mime_type, file_bytes);
|
||||
|
||||
if windmill_ai::ai_types::is_document_mime(&mime_type) {
|
||||
if crate::ai_types::is_document_mime(&mime_type) {
|
||||
let filename = s3_object
|
||||
.s3
|
||||
.rsplit('/')
|
||||
@@ -93,7 +99,9 @@ pub async fn s3_object_to_content_part(
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepare messages for API by converting S3Objects to base64 ImageUrls
|
||||
/// Prepare messages for API by converting S3Objects to base64 ImageUrls.
|
||||
///
|
||||
/// The caller must provide an AuthedClient authorized for `workspace_id`.
|
||||
pub async fn prepare_messages_for_api(
|
||||
messages: &[OpenAIMessage],
|
||||
client: &AuthedClient,
|
||||
@@ -4,5 +4,9 @@ pub mod ai_cache;
|
||||
pub mod ai_google;
|
||||
pub mod ai_providers;
|
||||
pub mod ai_types;
|
||||
pub mod image_handler;
|
||||
pub mod providers;
|
||||
pub mod query_builder;
|
||||
pub mod sse;
|
||||
pub mod types;
|
||||
pub mod utils;
|
||||
|
||||
+7
-7
@@ -1,16 +1,16 @@
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::value::RawValue;
|
||||
use windmill_ai::{ai_google::parse_data_url, ai_providers::AIProvider};
|
||||
use windmill_common::{client::AuthedClient, error::Error};
|
||||
|
||||
use crate::ai::{
|
||||
use crate::{
|
||||
ai_google::parse_data_url,
|
||||
ai_providers::AIProvider,
|
||||
image_handler::prepare_messages_for_api,
|
||||
query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventSink},
|
||||
sse::{AnthropicSSEParser, SSEParser},
|
||||
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
|
||||
const ANTHROPIC_VERSION_STANDARD: &str = "2023-06-01";
|
||||
+7
-10
@@ -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,28 +6,25 @@
|
||||
//! - Stream event parsing
|
||||
//! - Helper utilities
|
||||
|
||||
use crate::ai::{
|
||||
use crate::{
|
||||
image_handler::prepare_messages_for_api,
|
||||
query_builder::{ParsedResponse, StreamEventSink},
|
||||
types::StreamingEvent,
|
||||
types::TokenUsage,
|
||||
types::{OpenAIMessage, ToolDef},
|
||||
types::{OpenAIMessage, StreamingEvent, TokenUsage, ToolDef},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use windmill_common::{client::AuthedClient, error::Error};
|
||||
|
||||
// Re-export from shared module for use by other parts of the worker
|
||||
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,
|
||||
format_bedrock_error, openai_messages_to_bedrock, streaming_tool_calls_to_openai,
|
||||
StreamingToolCall,
|
||||
BedrockClient, StreamingToolCall,
|
||||
};
|
||||
pub use windmill_ai::ai_bedrock::{check_env_credentials, BedrockClient};
|
||||
|
||||
// ============================================================================
|
||||
// Query Builder (Worker-specific orchestration)
|
||||
// Query Builder
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Default)]
|
||||
+8
-9
@@ -1,17 +1,16 @@
|
||||
use async_trait::async_trait;
|
||||
use windmill_ai::ai_google::{
|
||||
openai_messages_to_gemini, openai_tools_to_gemini, GeminiGenerationConfig, GeminiImageContent,
|
||||
GeminiImageRequest, GeminiImageResponse, GeminiInlineData, GeminiPart, GeminiPredictContent,
|
||||
GeminiTextRequest, GeminiTool,
|
||||
};
|
||||
use windmill_common::{client::AuthedClient, error::Error};
|
||||
|
||||
use crate::ai::{
|
||||
use crate::{
|
||||
ai_google::{
|
||||
openai_messages_to_gemini, openai_tools_to_gemini, GeminiGenerationConfig,
|
||||
GeminiImageContent, GeminiImageRequest, GeminiImageResponse, GeminiInlineData, GeminiPart,
|
||||
GeminiPredictContent, GeminiTextRequest, GeminiTool,
|
||||
},
|
||||
image_handler::{download_and_encode_s3_image, prepare_messages_for_api},
|
||||
query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventSink},
|
||||
sse::{GeminiSSEParser, SSEParser},
|
||||
types::*,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use windmill_common::{client::AuthedClient, error::Error};
|
||||
|
||||
// ============================================================================
|
||||
// Query Builder Implementation
|
||||
@@ -0,0 +1,31 @@
|
||||
pub mod anthropic;
|
||||
#[cfg(feature = "bedrock")]
|
||||
pub mod bedrock;
|
||||
pub mod google_ai;
|
||||
pub mod openai;
|
||||
pub mod openrouter;
|
||||
pub mod other;
|
||||
|
||||
use crate::{ai_providers::AIProvider, query_builder::QueryBuilder, types::ProviderWithResource};
|
||||
|
||||
use self::{
|
||||
anthropic::AnthropicQueryBuilder, google_ai::GoogleAIQueryBuilder, openai::OpenAIQueryBuilder,
|
||||
openrouter::OpenRouterQueryBuilder, other::OtherQueryBuilder,
|
||||
};
|
||||
|
||||
/// Factory function to create the appropriate query builder for a provider.
|
||||
pub fn create_query_builder(provider: &ProviderWithResource) -> Box<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())),
|
||||
}
|
||||
}
|
||||
+7
-8
@@ -1,17 +1,16 @@
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::value::RawValue;
|
||||
use windmill_ai::ai_providers::AIProvider;
|
||||
use windmill_ai::ai_types::OpenAIToolCall;
|
||||
use windmill_common::{client::AuthedClient, error::Error};
|
||||
|
||||
use crate::ai::{
|
||||
use crate::{
|
||||
ai_providers::AIProvider,
|
||||
ai_types::OpenAIToolCall,
|
||||
image_handler::{prepare_messages_for_api, s3_object_to_content_part},
|
||||
query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventSink},
|
||||
sse::{OpenAIResponsesSSEParser, SSEParser},
|
||||
types::*,
|
||||
utils::extract_text_content,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::value::RawValue;
|
||||
use windmill_common::{client::AuthedClient, error::Error};
|
||||
|
||||
// Responses API structures
|
||||
#[derive(Deserialize)]
|
||||
+8
-8
@@ -1,15 +1,15 @@
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json;
|
||||
use windmill_ai::ai_providers::AIProvider;
|
||||
use windmill_common::{client::AuthedClient, error::Error};
|
||||
|
||||
use crate::ai::{
|
||||
use crate::{
|
||||
ai_providers::AIProvider,
|
||||
image_handler::prepare_messages_for_api,
|
||||
providers::other::OtherQueryBuilder,
|
||||
query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventSink},
|
||||
types::*,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json;
|
||||
use windmill_common::{client::AuthedClient, error::Error};
|
||||
|
||||
use crate::providers::other::OtherQueryBuilder;
|
||||
|
||||
// OpenRouter-specific types
|
||||
#[derive(Serialize)]
|
||||
+6
-7
@@ -1,16 +1,15 @@
|
||||
use async_trait::async_trait;
|
||||
use serde::Serialize;
|
||||
use serde_json;
|
||||
use windmill_ai::ai_providers::AIProvider;
|
||||
use windmill_common::{client::AuthedClient, error::Error};
|
||||
|
||||
use crate::ai::{
|
||||
use crate::{
|
||||
ai_providers::AIProvider,
|
||||
image_handler::prepare_messages_for_api,
|
||||
query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder, StreamEventSink},
|
||||
sse::{OpenAISSEParser, SSEParser},
|
||||
types::*,
|
||||
utils::should_use_structured_output_tool,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use serde::Serialize;
|
||||
use serde_json;
|
||||
use windmill_common::{client::AuthedClient, error::Error};
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -3,17 +3,15 @@ use std::collections::HashMap;
|
||||
use eventsource_stream::Eventsource;
|
||||
use reqwest::Response;
|
||||
use serde::Deserialize;
|
||||
use serde_json;
|
||||
use tokio_stream::StreamExt;
|
||||
use windmill_ai::{
|
||||
ai_google::{parse_gemini_sse_event, GeminiUsageMetadata},
|
||||
ai_types::{ExtraContent, GoogleExtraContent, OpenAIFunction, OpenAIToolCall},
|
||||
};
|
||||
use windmill_common::{error::Error, utils::rd_string};
|
||||
|
||||
use crate::ai::{
|
||||
use crate::{
|
||||
ai_google::{parse_gemini_sse_event, GeminiUsageMetadata},
|
||||
ai_types::UrlCitation,
|
||||
ai_types::{ExtraContent, GoogleExtraContent, OpenAIFunction, OpenAIToolCall},
|
||||
query_builder::StreamEventSink,
|
||||
types::{StreamingEvent, UrlCitation},
|
||||
types::StreamingEvent,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -64,6 +62,7 @@ lazy_static::lazy_static! {
|
||||
.parse::<bool>()
|
||||
.unwrap_or(false);
|
||||
}
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait SSEParser {
|
||||
async fn parse_event_data(&mut self, data: &str) -> Result<(), Error>;
|
||||
|
||||
@@ -459,11 +458,11 @@ impl SSEParser for AnthropicSSEParser {
|
||||
// Gemini SSE Parser
|
||||
// ============================================================================
|
||||
|
||||
/// Accumulates Gemini streaming events and converts them into the worker's
|
||||
/// internal [`OpenAIToolCall`] / [`StreamingEvent`] representation.
|
||||
/// Accumulates Gemini streaming events and converts them into the shared
|
||||
/// [`OpenAIToolCall`] / [`StreamingEvent`] representation.
|
||||
///
|
||||
/// The actual SSE parsing is delegated to [`parse_gemini_sse_event`] from
|
||||
/// `windmill_common::ai_google` so the logic can be shared with the API proxy.
|
||||
/// `windmill_ai::ai_google` so the logic can be shared with the API proxy.
|
||||
pub struct GeminiSSEParser {
|
||||
pub accumulated_content: String,
|
||||
pub accumulated_tool_calls: HashMap<i64, OpenAIToolCall>,
|
||||
@@ -0,0 +1,56 @@
|
||||
use crate::{
|
||||
ai_providers::AIProvider,
|
||||
ai_types::{ContentPart, OpenAIContent},
|
||||
};
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
/// Parse AI_HTTP_HEADERS environment variable into a vector of (header_name, header_value) tuples
|
||||
/// Format: "header1: value1, header2: value2"
|
||||
pub static ref AI_HTTP_HEADERS: Vec<(String, String)> = {
|
||||
std::env::var("AI_HTTP_HEADERS")
|
||||
.ok()
|
||||
.map(|headers_str| {
|
||||
headers_str
|
||||
.split(',')
|
||||
.filter_map(|header| {
|
||||
let parts: Vec<&str> = header.splitn(2, ':').collect();
|
||||
if parts.len() == 2 {
|
||||
let name = parts[0].trim().to_string();
|
||||
let value = parts[1].trim().to_string();
|
||||
if !name.is_empty() && !value.is_empty() {
|
||||
Some((name, value))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
}
|
||||
|
||||
/// AWS Bedrock do not handle structured output query param, so we use a tool for structured output. Same for every Claude models.
|
||||
pub fn should_use_structured_output_tool(provider: &AIProvider, model: &str) -> bool {
|
||||
model.contains("claude") || provider == &AIProvider::AWSBedrock
|
||||
}
|
||||
|
||||
/// Extract text content from OpenAIContent, joining parts with space if multiple
|
||||
pub fn extract_text_content(content: &OpenAIContent) -> String {
|
||||
match content {
|
||||
OpenAIContent::Text(text) => text.clone(),
|
||||
OpenAIContent::Parts(parts) => parts
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
if let ContentPart::Text { text } = p {
|
||||
Some(text.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(""),
|
||||
}
|
||||
}
|
||||
@@ -715,7 +715,8 @@ pub async fn resolve_opt_job_authed(
|
||||
fn username_override_from_label(label: Option<String>) -> Option<String> {
|
||||
match label {
|
||||
Some(label)
|
||||
if label.starts_with("webhook-")
|
||||
if label.starts_with("ephemeral-webhook-")
|
||||
|| label.starts_with("webhook-")
|
||||
|| label.starts_with("http-")
|
||||
|| label.starts_with("email-")
|
||||
|| label.starts_with("ws-") =>
|
||||
|
||||
@@ -172,6 +172,7 @@ async fn get_input_history(
|
||||
kind IN ('preview', 'flowpreview') as is_preview \
|
||||
FROM v2_job JOIN v2_job_completed USING (id) \
|
||||
WHERE v2_job.workspace_id = $3 AND {} = $1 AND kind = any($2) \
|
||||
AND v2_job.script_entrypoint_override IS NULL \
|
||||
{args_query} AND v2_job_completed.status != 'skipped' {include_non_root} \
|
||||
ORDER BY v2_job.created_at DESC LIMIT $4\
|
||||
) t ORDER BY completed_at DESC LIMIT $5 OFFSET $6",
|
||||
|
||||
@@ -274,7 +274,9 @@ async fn test_plaintext_backward_compat(db: Pool<Postgres>) -> anyhow::Result<()
|
||||
);
|
||||
|
||||
// --- Phase 2: All workers upgraded (version >= 1.650.0) ---
|
||||
MIN_VERSION.store(std::sync::Arc::new(MIN_VERSION_SUPPORTS_TOKEN_HASH.version().clone()));
|
||||
MIN_VERSION.store(std::sync::Arc::new(
|
||||
MIN_VERSION_SUPPORTS_TOKEN_HASH.version().clone(),
|
||||
));
|
||||
|
||||
let resp = authed(client().post(format!("{base}/tokens/create")))
|
||||
.json(&json!({"label": "new-worker-token"}))
|
||||
@@ -324,7 +326,7 @@ async fn test_plaintext_backward_compat(db: Pool<Postgres>) -> anyhow::Result<()
|
||||
async fn test_rotate_webhook_token(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
use windmill_native_triggers::{delete_token_by_hash, rotate_webhook_token};
|
||||
use windmill_native_triggers::{delete_token_by_hash, rotate_webhook_token, ServiceName};
|
||||
|
||||
// Insert a token directly with known values
|
||||
let original_token = "test-webhook-token-original-1234";
|
||||
@@ -342,7 +344,7 @@ async fn test_rotate_webhook_token(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
.await?;
|
||||
|
||||
// Rotate the token
|
||||
let rotated = rotate_webhook_token(&db, &original_hash)
|
||||
let rotated = rotate_webhook_token(&db, &original_hash, ServiceName::Google)
|
||||
.await?
|
||||
.expect("rotate must return Some for existing token");
|
||||
|
||||
@@ -350,16 +352,27 @@ async fn test_rotate_webhook_token(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
assert_ne!(rotated.new_token, original_token);
|
||||
assert_eq!(rotated.old_token_hash, original_hash);
|
||||
|
||||
// New token's hash should exist in DB
|
||||
// New token's hash should exist in DB with the per-service label and expiration
|
||||
let new_hash = hash_token(&rotated.new_token);
|
||||
let exists: bool = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM token WHERE token_hash = $1) AS exists",
|
||||
let new_row = sqlx::query!(
|
||||
"SELECT label, expiration FROM token WHERE token_hash = $1",
|
||||
new_hash
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
assert!(exists, "new token hash must exist in DB after rotation");
|
||||
.expect("new token hash must exist in DB after rotation");
|
||||
assert!(
|
||||
new_row
|
||||
.label
|
||||
.as_deref()
|
||||
.is_some_and(|l| l.starts_with("ephemeral-webhook-google-")),
|
||||
"rotated token must carry an ephemeral-webhook-google-* label, got {:?}",
|
||||
new_row.label
|
||||
);
|
||||
assert!(
|
||||
new_row.expiration.is_some(),
|
||||
"rotated Google token must carry an expiration"
|
||||
);
|
||||
|
||||
// Old token should still exist (deletion deferred to caller)
|
||||
let old_exists: bool = sqlx::query_scalar!(
|
||||
@@ -389,7 +402,7 @@ async fn test_rotate_webhook_token(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
assert!(!old_gone, "old token must be gone after explicit deletion");
|
||||
|
||||
// Rotating a non-existent hash should return None
|
||||
let result = rotate_webhook_token(&db, "nonexistent_hash").await?;
|
||||
let result = rotate_webhook_token(&db, "nonexistent_hash", ServiceName::Google).await?;
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"rotating a non-existent token must return None"
|
||||
|
||||
@@ -226,6 +226,7 @@ async fn get_concurrent_intervals(
|
||||
trigger_kind: _,
|
||||
include_args: _,
|
||||
broad_filter: _,
|
||||
excludes_entrypoint_override: _,
|
||||
} => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
@@ -248,8 +248,11 @@ lazy_static::lazy_static! {
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WindmillCompositeResult {
|
||||
#[serde(alias = "wm_status_code")]
|
||||
windmill_status_code: Option<u16>,
|
||||
#[serde(alias = "wm_content_type")]
|
||||
windmill_content_type: Option<String>,
|
||||
#[serde(alias = "wm_headers")]
|
||||
windmill_headers: Option<HashMap<String, String>>,
|
||||
result: Option<Box<RawValue>>,
|
||||
}
|
||||
|
||||
@@ -216,6 +216,10 @@ pub fn filter_list_queue_query(
|
||||
sqlb.and_where("trigger_kind IS DISTINCT FROM 'schedule'");
|
||||
}
|
||||
|
||||
if lq.excludes_entrypoint_override.unwrap_or(false) {
|
||||
sqlb.and_where_is_null("v2_job.script_entrypoint_override");
|
||||
}
|
||||
|
||||
if let Some(tk) = &lq.trigger_kind {
|
||||
let quoted: Vec<_> = tk.values.iter().map(|v| quote(&format!("{}", v))).collect();
|
||||
if tk.negated {
|
||||
@@ -519,6 +523,10 @@ pub fn filter_list_completed_query(
|
||||
sqlb.and_where("trigger_kind IS DISTINCT FROM 'schedule'");
|
||||
}
|
||||
|
||||
if lq.excludes_entrypoint_override.unwrap_or(false) {
|
||||
sqlb.and_where_is_null("v2_job.script_entrypoint_override");
|
||||
}
|
||||
|
||||
if let Some(tk) = &lq.trigger_kind {
|
||||
let quoted: Vec<_> = tk.values.iter().map(|v| quote(&format!("{}", v))).collect();
|
||||
if tk.negated {
|
||||
@@ -624,6 +632,7 @@ mod tests {
|
||||
trigger_path: None,
|
||||
include_args: None,
|
||||
broad_filter: None,
|
||||
excludes_entrypoint_override: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -668,6 +677,7 @@ mod tests {
|
||||
trigger_path: None,
|
||||
include_args: None,
|
||||
broad_filter: None,
|
||||
excludes_entrypoint_override: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -122,6 +122,7 @@ pub struct ListQueueQuery {
|
||||
pub trigger_path: Option<NegatedListFilter<String>>,
|
||||
pub include_args: Option<bool>,
|
||||
pub broad_filter: Option<String>,
|
||||
pub excludes_entrypoint_override: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
@@ -167,6 +168,7 @@ pub struct ListCompletedQuery {
|
||||
pub trigger_path: Option<NegatedListFilter<String>>,
|
||||
pub include_args: Option<bool>,
|
||||
pub broad_filter: Option<String>,
|
||||
pub excludes_entrypoint_override: Option<bool>,
|
||||
}
|
||||
|
||||
impl From<ListCompletedQuery> for ListQueueQuery {
|
||||
@@ -202,6 +204,7 @@ impl From<ListCompletedQuery> for ListQueueQuery {
|
||||
trigger_path: lcq.trigger_path,
|
||||
include_args: lcq.include_args,
|
||||
broad_filter: lcq.broad_filter,
|
||||
excludes_entrypoint_override: lcq.excludes_entrypoint_override,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -704,6 +707,7 @@ mod tests {
|
||||
trigger_path: None,
|
||||
include_args: None,
|
||||
broad_filter: None,
|
||||
excludes_entrypoint_override: None,
|
||||
};
|
||||
|
||||
let lqq: ListQueueQuery = lcq.into();
|
||||
@@ -772,6 +776,7 @@ mod tests {
|
||||
trigger_path: None,
|
||||
include_args: None,
|
||||
broad_filter: None,
|
||||
excludes_entrypoint_override: None,
|
||||
};
|
||||
|
||||
let lqq: ListQueueQuery = lcq.into();
|
||||
|
||||
@@ -8,7 +8,11 @@ use anyhow::anyhow;
|
||||
pub async fn validate_license_key(
|
||||
_license_key: String,
|
||||
_db: Option<&windmill_common::DB>,
|
||||
) -> anyhow::Result<(String, bool)> {
|
||||
) -> anyhow::Result<(
|
||||
String,
|
||||
bool,
|
||||
Option<windmill_common::ee_oss::OfflineMetadata>,
|
||||
)> {
|
||||
// Implementation is not open source
|
||||
Err(anyhow!("License can't be validated in Windmill CE"))
|
||||
}
|
||||
|
||||
@@ -37,13 +37,13 @@ use axum::{
|
||||
use serde_json::json;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use windmill_ai::ai_cache::bump_instance_ai_config_revision;
|
||||
#[cfg(feature = "enterprise")]
|
||||
use windmill_common::ee_oss::{send_critical_alert, CriticalAlertKind, CriticalErrorChannel};
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
use windmill_common::secret_backend::{
|
||||
AwsSecretsManagerSettings, AzureKeyVaultSettings, SecretMigrationReport, VaultSettings,
|
||||
};
|
||||
use windmill_ai::ai_cache::bump_instance_ai_config_revision;
|
||||
use windmill_common::{
|
||||
email_oss::send_email_plain_text,
|
||||
error::{self, JsonResult, Result},
|
||||
@@ -118,6 +118,8 @@ pub fn global_service() -> Router {
|
||||
get(get_latest_key_renewal_attempt),
|
||||
)
|
||||
.route("/renew_license_key", post(renew_license_key))
|
||||
.route("/offline_license_status", get(get_offline_license_status))
|
||||
.route("/instance_hash", get(get_instance_hash))
|
||||
.route("/customer_portal", post(create_customer_portal_session))
|
||||
.route("/test_critical_channels", post(test_critical_channels))
|
||||
.route("/critical_alerts", get(get_critical_alerts))
|
||||
@@ -340,7 +342,7 @@ pub async fn test_license_key(
|
||||
Json(TestKey { license_key }): Json<TestKey>,
|
||||
) -> error::Result<String> {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
let (_, expired) = validate_license_key(license_key, Some(&db)).await?;
|
||||
let (_, expired, _offline_meta) = validate_license_key(license_key, Some(&db)).await?;
|
||||
|
||||
if expired {
|
||||
Err(error::Error::BadRequest("Expired license key".to_string()))
|
||||
@@ -349,6 +351,53 @@ pub async fn test_license_key(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct InstanceHash {
|
||||
pub instance_hash: Option<String>,
|
||||
}
|
||||
|
||||
/// Returns the live cap status for an offline license, or `null` when no
|
||||
/// offline license is loaded. Used by the superadmin settings panel.
|
||||
pub async fn get_offline_license_status(
|
||||
Extension(db): Extension<DB>,
|
||||
authed: ApiAuthed,
|
||||
) -> error::JsonResult<Option<windmill_common::ee_oss::OfflineCapStatus>> {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
|
||||
let offline = (**windmill_common::ee_oss::LICENSE_OFFLINE_METADATA.load()).clone();
|
||||
let is_offline = matches!(&offline, Some(m) if m.is_offline());
|
||||
|
||||
if !is_offline {
|
||||
return Ok(Json(None));
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
let cap = windmill_common::ee_oss::enforce_offline_caps(&db)
|
||||
.await
|
||||
.map_err(|e| error::Error::internal_err(format!("enforce_offline_caps: {e:#}")))?;
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
let cap: Option<windmill_common::ee_oss::OfflineCapStatus> = None;
|
||||
|
||||
Ok(Json(cap))
|
||||
}
|
||||
|
||||
/// Returns the per-instance binding hash that goes into offline license keys.
|
||||
/// Admin invokes via `curl` with their personal token when requesting a key
|
||||
/// from support.
|
||||
pub async fn get_instance_hash(
|
||||
Extension(db): Extension<DB>,
|
||||
authed: ApiAuthed,
|
||||
) -> error::JsonResult<InstanceHash> {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
#[cfg(feature = "enterprise")]
|
||||
let hash = windmill_common::ee_oss::compute_instance_hash(&db)
|
||||
.await
|
||||
.map_err(|e| error::Error::internal_err(format!("compute_instance_hash: {e:#}")))?;
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
let hash: Option<String> = None;
|
||||
Ok(Json(InstanceHash { instance_hash: hash }))
|
||||
}
|
||||
|
||||
pub async fn get_local_settings(
|
||||
Extension(db): Extension<DB>,
|
||||
authed: ApiAuthed,
|
||||
|
||||
@@ -5233,6 +5233,13 @@ async fn invite_user(
|
||||
|
||||
nu.email = nu.email.to_lowercase();
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
if let Some(msg) =
|
||||
windmill_common::ee_oss::check_seat_cap_for_new_user(&db, &nu.email, nu.operator).await?
|
||||
{
|
||||
return Err(Error::BadRequest(msg));
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
let already_in_workspace = sqlx::query_scalar!(
|
||||
@@ -5306,6 +5313,13 @@ async fn add_user(
|
||||
|
||||
nu.email = nu.email.to_lowercase();
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
if let Some(msg) =
|
||||
windmill_common::ee_oss::check_seat_cap_for_new_user(&db, &nu.email, nu.operator).await?
|
||||
{
|
||||
return Err(Error::BadRequest(msg));
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
let already_exists_email = sqlx::query_scalar!(
|
||||
@@ -7351,6 +7365,7 @@ async fn list_ws_specific(
|
||||
WHERE v.workspace_id = s.workspace_id AND v.path = s.path
|
||||
))
|
||||
)
|
||||
ORDER BY s.item_kind, s.path
|
||||
"#,
|
||||
&w_id
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.697.0
|
||||
version: 1.700.2
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -1779,6 +1779,63 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/settings/offline_license_status:
|
||||
get:
|
||||
summary: get cap-usage status for the currently-loaded offline license
|
||||
description: |
|
||||
Returns the live cap status (seats used vs cap, current CU vs cap) for
|
||||
the offline license key currently in use. Returns `null` if no offline
|
||||
license is loaded. Super-admin only.
|
||||
operationId: getOfflineLicenseStatus
|
||||
tags:
|
||||
- setting
|
||||
responses:
|
||||
"200":
|
||||
description: cap status (or null when no offline license)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
nullable: true
|
||||
properties:
|
||||
seats_used:
|
||||
type: number
|
||||
description: Author-equivalent seats consumed (authors + 0.5 × operators)
|
||||
seats_cap:
|
||||
type: integer
|
||||
author_count:
|
||||
type: integer
|
||||
operator_count:
|
||||
type: integer
|
||||
current_cu:
|
||||
type: number
|
||||
description: Sum of CU rate across workers that pinged in the last 2 minutes.
|
||||
cu_cap:
|
||||
type: number
|
||||
cu_over_cap:
|
||||
type: boolean
|
||||
|
||||
/settings/instance_hash:
|
||||
get:
|
||||
summary: per-instance binding hash for offline license issuance
|
||||
description: |
|
||||
Returns the hash a superadmin shares with Windmill support when
|
||||
requesting an offline license. Super-admin only.
|
||||
operationId: getInstanceHash
|
||||
tags:
|
||||
- setting
|
||||
responses:
|
||||
'200':
|
||||
description: instance hash
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
instance_hash:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
/settings/customer_portal:
|
||||
post:
|
||||
summary: create customer portal session
|
||||
@@ -12000,6 +12057,11 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
- name: excludes_entrypoint_override
|
||||
description: exclude jobs that were started with a `_ENTRYPOINT_OVERRIDE` arg (e.g. dynamic-select helper runs and preprocessor previews)
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
- name: broad_filter
|
||||
description: broad search across multiple fields (case-insensitive substring match on path, tag, schedule path, trigger kind, label)
|
||||
in: query
|
||||
@@ -20958,6 +21020,9 @@ components:
|
||||
jwt_role:
|
||||
type: string
|
||||
description: Vault JWT auth role name for Windmill (optional, if not provided token auth is used)
|
||||
jwt_mount_path:
|
||||
type: string
|
||||
description: Mount path for the JWT auth method in Vault (optional, defaults to "jwt"). Set this when the JWT auth method is mounted at a non-default path, e.g. via `vault auth enable -path=<mount> jwt`.
|
||||
namespace:
|
||||
type: string
|
||||
description: Vault Enterprise namespace (optional)
|
||||
|
||||
@@ -15,11 +15,12 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, value::RawValue};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use windmill_audit::{audit_oss::audit_log, ActionKind};
|
||||
use windmill_ai::ai_cache::current_instance_ai_config_revision;
|
||||
use windmill_ai::ai_providers::{
|
||||
empty_string_as_none, AIPlatform, AIProvider, ProviderConfig, ProviderModel,
|
||||
};
|
||||
use windmill_ai::utils::AI_HTTP_HEADERS;
|
||||
use windmill_audit::{audit_oss::audit_log, ActionKind};
|
||||
use windmill_common::db::UserDB;
|
||||
use windmill_common::error::{to_anyhow, Error, Result};
|
||||
use windmill_common::utils::configure_client;
|
||||
@@ -101,32 +102,6 @@ lazy_static::lazy_static! {
|
||||
|
||||
pub static ref AI_REQUEST_CACHE: Cache<(String, AIProvider), ExpiringAIRequestConfig> = Cache::new(500);
|
||||
|
||||
/// Parse AI_HTTP_HEADERS environment variable into a vector of (header_name, header_value) tuples
|
||||
/// Format: "header1: value1, header2: value2"
|
||||
static ref AI_HTTP_HEADERS: Vec<(String, String)> = {
|
||||
std::env::var("AI_HTTP_HEADERS")
|
||||
.ok()
|
||||
.map(|headers_str| {
|
||||
headers_str
|
||||
.split(',')
|
||||
.filter_map(|header| {
|
||||
let parts: Vec<&str> = header.splitn(2, ':').collect();
|
||||
if parts.len() == 2 {
|
||||
let name = parts[0].trim().to_string();
|
||||
let value = parts[1].trim().to_string();
|
||||
if !name.is_empty() && !value.is_empty() {
|
||||
Some((name, value))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn invalidate_ai_request_cache_for_workspace(workspace_id: &str) {
|
||||
|
||||
@@ -10,7 +10,11 @@ use anyhow::anyhow;
|
||||
pub async fn validate_license_key(
|
||||
_license_key: String,
|
||||
_db: Option<&crate::db::DB>,
|
||||
) -> anyhow::Result<(String, bool)> {
|
||||
) -> anyhow::Result<(
|
||||
String,
|
||||
bool,
|
||||
Option<windmill_common::ee_oss::OfflineMetadata>,
|
||||
)> {
|
||||
// Implementation is not open source
|
||||
Err(anyhow!("License can't be validated in Windmill CE"))
|
||||
}
|
||||
|
||||
@@ -1067,10 +1067,15 @@ impl<'a> GetQuery<'a> {
|
||||
.ok()
|
||||
.inspect(|data| job.raw_flow = Some(sqlx::types::Json(data.raw_flow.clone())));
|
||||
}
|
||||
if self.with_code && job.job_kind() == &JobKind::Preview {
|
||||
if self.with_code
|
||||
&& matches!(
|
||||
job.job_kind(),
|
||||
JobKind::Preview | JobKind::FlowScript | JobKind::AppScript
|
||||
)
|
||||
{
|
||||
// Try to fetch the code from the cache, fallback to the preview code.
|
||||
// NOTE: This could check for the job kinds instead of the `or_else` but it's not
|
||||
// necessary as `fetch_script` return early if the job kind is not a preview one.
|
||||
// `fetch_script` resolves FlowScript / AppScript via their runnable_id; for
|
||||
// Preview jobs it returns early and we fall through to `fetch_preview_script`.
|
||||
let conn = Connection::from(db.clone());
|
||||
cache::job::fetch_script(db.clone(), job.job_kind(), hash)
|
||||
.or_else(|_| cache::job::fetch_preview_script(&conn, &id, raw_lock, raw_code))
|
||||
|
||||
@@ -18,6 +18,60 @@ lazy_static::lazy_static! {
|
||||
pub static ref LICENSE_KEY_VALID: AtomicBool = AtomicBool::new(true);
|
||||
pub static ref LICENSE_KEY_ID: arc_swap::ArcSwap<String> = arc_swap::ArcSwap::from_pointee("".to_string());
|
||||
pub static ref LICENSE_KEY: arc_swap::ArcSwap<String> = arc_swap::ArcSwap::from_pointee("".to_string());
|
||||
pub static ref LICENSE_OFFLINE_METADATA: arc_swap::ArcSwap<Option<OfflineMetadata>> = arc_swap::ArcSwap::from_pointee(None);
|
||||
pub static ref LICENSE_OFFLINE_OVER_CU_CAP: AtomicBool = AtomicBool::new(false);
|
||||
pub static ref LICENSE_OFFLINE_LAST_STATUS: arc_swap::ArcSwap<Option<OfflineCapStatus>> = arc_swap::ArcSwap::from_pointee(None);
|
||||
pub static ref LICENSE_OFFLINE_LAST_CHECKED_AT: arc_swap::ArcSwap<Option<chrono::DateTime<chrono::Utc>>> = arc_swap::ArcSwap::from_pointee(None);
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
#[derive(Clone, Debug, Deserialize, serde::Serialize)]
|
||||
pub struct OfflineMetadata {
|
||||
pub v: u32,
|
||||
pub kind: String,
|
||||
pub hash: String,
|
||||
pub seats: i64,
|
||||
pub cu_limit: f64,
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
impl OfflineMetadata {
|
||||
pub fn is_offline(&self) -> bool {
|
||||
self.kind == "offline"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
pub struct OfflineCapStatus {
|
||||
pub seats_used: f64,
|
||||
pub seats_cap: i64,
|
||||
pub author_count: i64,
|
||||
pub operator_count: i64,
|
||||
pub current_cu: f64,
|
||||
pub cu_cap: f64,
|
||||
pub cu_over_cap: bool,
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "enterprise", not(feature = "private")))]
|
||||
pub async fn check_seat_cap_for_new_user(
|
||||
_db: &DB,
|
||||
_email: &str,
|
||||
_new_user_is_operator: bool,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "enterprise", not(feature = "private")))]
|
||||
pub async fn compute_instance_hash(_db: &DB) -> anyhow::Result<Option<String>> {
|
||||
// Implementation is not open source
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "enterprise", not(feature = "private")))]
|
||||
pub async fn enforce_offline_caps(_db: &DB) -> anyhow::Result<Option<OfflineCapStatus>> {
|
||||
// Implementation is not open source
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
|
||||
@@ -200,7 +200,15 @@ fn opaque_json_schema(_: &mut schemars::gen::SchemaGenerator) -> schemars::schem
|
||||
|
||||
/// Typed global settings with schema validation.
|
||||
/// Known settings have explicit fields; unknown settings pass through via `extra`.
|
||||
///
|
||||
/// `#[serde(remote = "Self")]` turns the derived (de)serializers into inherent
|
||||
/// associated functions so we can wrap them with the manual `Deserialize` impl
|
||||
/// below. The wrapper preserves explicit `null` values (which the typed
|
||||
/// `Option<T>` fields would otherwise silently drop on round-trip through
|
||||
/// `to_settings_map`) by stashing them in `extra`, where they survive
|
||||
/// re-serialization and reach `diff_global_settings` as proper deletes.
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
|
||||
#[serde(remote = "Self")]
|
||||
#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))]
|
||||
pub struct GlobalSettings {
|
||||
// Numeric settings
|
||||
@@ -373,6 +381,42 @@ pub struct GlobalSettings {
|
||||
pub extra: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl Serialize for GlobalSettings {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
Self::serialize(self, serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for GlobalSettings {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
// Capture top-level keys explicitly set to `null` so they survive the
|
||||
// `to_settings_map` round-trip — typed `Option<T>` fields all use
|
||||
// `skip_serializing_if = "Option::is_none"`, which would otherwise
|
||||
// silently drop the null. We stash the null entries in `extra`, which
|
||||
// serializes them back out as `null` for `diff_global_settings` to
|
||||
// route to deletes.
|
||||
let mut value = serde_json::Value::deserialize(deserializer)?;
|
||||
let null_keys: Vec<String> = value
|
||||
.as_object()
|
||||
.map(|m| {
|
||||
m.iter()
|
||||
.filter_map(|(k, v)| v.is_null().then(|| k.clone()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
for k in &null_keys {
|
||||
obj.remove(k);
|
||||
}
|
||||
}
|
||||
let mut s = Self::deserialize(value).map_err(serde::de::Error::custom)?;
|
||||
for k in null_keys {
|
||||
s.extra.insert(k, serde_json::Value::Null);
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl GlobalSettings {
|
||||
/// Convert to a flat `BTreeMap` suitable for DB sync.
|
||||
pub fn to_settings_map(&self) -> BTreeMap<String, serde_json::Value> {
|
||||
@@ -1930,6 +1974,101 @@ mod tests {
|
||||
// to_settings_map edge cases
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Regression test for bulk-endpoint deletion of typed settings.
|
||||
///
|
||||
/// A naive `#[derive(Deserialize)]` would route
|
||||
/// `{"object_store_cache_config": null}` to the typed `Option<...>` field
|
||||
/// as `None`, and `skip_serializing_if = "Option::is_none"` would then
|
||||
/// strip it from `to_settings_map`, so `diff_global_settings` (Merge mode)
|
||||
/// would never see the deletion. The manual `Deserialize` impl on
|
||||
/// `GlobalSettings` captures explicit top-level nulls into `extra` so they
|
||||
/// survive the round-trip and reach `diff_global_settings` as proper
|
||||
/// deletes.
|
||||
#[test]
|
||||
fn explicit_null_on_typed_field_survives_round_trip() {
|
||||
let json = serde_json::json!({
|
||||
"object_store_cache_config": null,
|
||||
"secret_backend": null,
|
||||
"smtp_settings": null,
|
||||
"base_url": "https://x",
|
||||
});
|
||||
let settings: GlobalSettings =
|
||||
serde_json::from_value(json).expect("null should deserialize");
|
||||
assert!(settings.object_store_cache_config.is_none());
|
||||
assert!(settings.secret_backend.is_none());
|
||||
assert!(settings.smtp_settings.is_none());
|
||||
assert_eq!(settings.base_url.as_deref(), Some("https://x"));
|
||||
let map = settings.to_settings_map();
|
||||
assert_eq!(map["object_store_cache_config"], serde_json::Value::Null);
|
||||
assert_eq!(map["secret_backend"], serde_json::Value::Null);
|
||||
assert_eq!(map["smtp_settings"], serde_json::Value::Null);
|
||||
assert_eq!(map["base_url"], serde_json::json!("https://x"));
|
||||
}
|
||||
|
||||
/// Absent keys remain absent — critical so a PUT that only sets a single
|
||||
/// field doesn't accidentally delete every other setting in Merge mode.
|
||||
#[test]
|
||||
fn absent_typed_fields_do_not_appear_in_map() {
|
||||
let settings: GlobalSettings =
|
||||
serde_json::from_value(serde_json::json!({"base_url": "https://x"})).unwrap();
|
||||
let map = settings.to_settings_map();
|
||||
assert!(!map.contains_key("object_store_cache_config"));
|
||||
assert!(!map.contains_key("smtp_settings"));
|
||||
assert_eq!(map.get("base_url"), Some(&serde_json::json!("https://x")));
|
||||
}
|
||||
|
||||
/// End-to-end: deserialize → `to_settings_map` → diff in Merge mode with
|
||||
/// an explicit null produces a delete (the scenario that silently failed
|
||||
/// before the manual `Deserialize` impl).
|
||||
#[test]
|
||||
fn deserialize_then_diff_deletes_typed_null() {
|
||||
let mut current = BTreeMap::new();
|
||||
current.insert(
|
||||
"object_store_cache_config".to_string(),
|
||||
serde_json::json!({"type": "S3", "bucket": "b"}),
|
||||
);
|
||||
let desired: GlobalSettings =
|
||||
serde_json::from_value(serde_json::json!({"object_store_cache_config": null})).unwrap();
|
||||
let desired_map = desired.to_settings_map();
|
||||
let diff = diff_global_settings(¤t, &desired_map, ApplyMode::Merge);
|
||||
assert!(diff.upserts.is_empty());
|
||||
assert_eq!(diff.deletes, vec!["object_store_cache_config".to_string()]);
|
||||
}
|
||||
|
||||
/// Nested nulls inside a typed sub-struct are not promoted to top-level
|
||||
/// deletes — only the top-level key matters, mirroring the per-key API.
|
||||
#[test]
|
||||
fn nested_null_inside_typed_field_is_not_treated_as_top_level_null() {
|
||||
let settings: GlobalSettings =
|
||||
serde_json::from_value(serde_json::json!({"smtp_settings": {"smtp_host": null}}))
|
||||
.unwrap();
|
||||
let map = settings.to_settings_map();
|
||||
assert!(
|
||||
map["smtp_settings"].is_object(),
|
||||
"smtp_settings should be an object, not null"
|
||||
);
|
||||
}
|
||||
|
||||
/// Unknown/extra keys with explicit null still flow through `extra` — this
|
||||
/// was already correct before the manual impl; guard against regression.
|
||||
#[test]
|
||||
fn explicit_null_on_extra_field_survives_round_trip() {
|
||||
let settings: GlobalSettings =
|
||||
serde_json::from_value(serde_json::json!({"unknown_legacy_setting": null})).unwrap();
|
||||
let map = settings.to_settings_map();
|
||||
assert_eq!(map["unknown_legacy_setting"], serde_json::Value::Null);
|
||||
}
|
||||
|
||||
/// Top-level non-object input must reject with a deserialize error rather
|
||||
/// than silently producing defaults. Matches the previous derive behavior.
|
||||
#[test]
|
||||
fn non_object_top_level_input_errors() {
|
||||
assert!(serde_json::from_value::<GlobalSettings>(serde_json::json!(null)).is_err());
|
||||
assert!(serde_json::from_value::<GlobalSettings>(serde_json::json!("s")).is_err());
|
||||
assert!(serde_json::from_value::<GlobalSettings>(serde_json::json!(42)).is_err());
|
||||
assert!(serde_json::from_value::<GlobalSettings>(serde_json::json!([])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_settings_map_empty_defaults() {
|
||||
let settings = GlobalSettings::default();
|
||||
|
||||
@@ -121,6 +121,36 @@ pub const PRIVATE_HUB_MIN_VERSION: i32 = 10_000_000;
|
||||
pub const SERVICE_LOG_RETENTION_SECS: i64 = 60 * 60 * 24 * 14; // 2 weeks retention period for logs
|
||||
pub const WM_DEPLOYERS_GROUP: &str = "wm_deployers";
|
||||
|
||||
/// Canonical form of a base URL, used as one of the inputs to the offline-license
|
||||
/// instance hash (`compute_instance_hash`).
|
||||
///
|
||||
/// Rules: lowercase scheme and host, drop default ports (80/443), strip path/query/fragment,
|
||||
/// strip trailing slash. If URL parsing fails, falls back to a best-effort lowercase +
|
||||
/// trailing-slash strip so two semantically-equivalent inputs still produce the same
|
||||
/// canonical form.
|
||||
pub fn canonical_base_url(input: &str) -> String {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
match url::Url::parse(trimmed) {
|
||||
Ok(u) => {
|
||||
let scheme = u.scheme().to_ascii_lowercase();
|
||||
let host = u
|
||||
.host_str()
|
||||
.map(|h| h.to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
let port = match (u.port(), scheme.as_str()) {
|
||||
(Some(80), "http") | (Some(443), "https") => String::new(),
|
||||
(Some(p), _) => format!(":{p}"),
|
||||
(None, _) => String::new(),
|
||||
};
|
||||
format!("{scheme}://{host}{port}")
|
||||
}
|
||||
Err(_) => trimmed.trim_end_matches('/').to_ascii_lowercase(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if the user is allowed to preserve on_behalf_of values (admin or deployer).
|
||||
pub fn can_preserve_on_behalf_of(authed: &impl db::Authable) -> bool {
|
||||
authed.is_admin() || authed.groups().iter().any(|g| g == &WM_DEPLOYERS_GROUP)
|
||||
|
||||
@@ -122,6 +122,11 @@ pub struct VaultSettings {
|
||||
/// Optional - if not provided, token auth is used
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub jwt_role: Option<String>,
|
||||
/// Mount path for the JWT auth method in Vault (defaults to "jwt").
|
||||
/// Set this when the JWT auth method is mounted at a non-default path,
|
||||
/// e.g. via `vault auth enable -path=my-mount jwt`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub jwt_mount_path: Option<String>,
|
||||
/// Vault Enterprise namespace (optional)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub namespace: Option<String>,
|
||||
|
||||
@@ -26,6 +26,7 @@ mod tests {
|
||||
address: "http://127.0.0.1:8200".to_string(),
|
||||
mount_path: "windmill".to_string(),
|
||||
jwt_role: Some("windmill-secrets".to_string()),
|
||||
jwt_mount_path: None,
|
||||
namespace: None,
|
||||
token: Some("test-root-token".to_string()),
|
||||
skip_ssl_verify: None,
|
||||
|
||||
@@ -447,7 +447,9 @@ pub async fn get_license_id_or_uid<'c, E: sqlx::Executor<'c, Database = Postgres
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_instance_uid<'c, E: sqlx::Executor<'c, Database = Postgres>>(db: E) -> Result<String> {
|
||||
pub async fn get_instance_uid<'c, E: sqlx::Executor<'c, Database = Postgres>>(
|
||||
db: E,
|
||||
) -> Result<String> {
|
||||
let uid_value = sqlx::query_scalar!(
|
||||
"SELECT value FROM global_settings WHERE name = $1",
|
||||
UNIQUE_ID_SETTING
|
||||
|
||||
@@ -91,6 +91,7 @@ mod tests {
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:8200".to_string()),
|
||||
mount_path: "windmill".to_string(),
|
||||
jwt_role: None, // Static token mode
|
||||
jwt_mount_path: None,
|
||||
namespace: None,
|
||||
token: Some(
|
||||
std::env::var("VAULT_TOKEN").unwrap_or_else(|_| "test-root-token".to_string()),
|
||||
@@ -106,6 +107,7 @@ mod tests {
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:8200".to_string()),
|
||||
mount_path: "windmill".to_string(),
|
||||
jwt_role: Some("windmill-secrets".to_string()), // JWT mode
|
||||
jwt_mount_path: None,
|
||||
namespace: None,
|
||||
token: None, // No static token - use JWT
|
||||
skip_ssl_verify: None,
|
||||
@@ -203,7 +205,10 @@ mod tests {
|
||||
println!("Testing Vault connection with JWT auth...");
|
||||
println!(" Address: {}", settings.address);
|
||||
println!(" JWT Role: {:?}", settings.jwt_role);
|
||||
println!(" BASE_URL: {}", (**windmill_common::BASE_URL.load()).clone());
|
||||
println!(
|
||||
" BASE_URL: {}",
|
||||
(**windmill_common::BASE_URL.load()).clone()
|
||||
);
|
||||
|
||||
let result = test_vault_connection(&settings, Some(&db)).await;
|
||||
assert!(
|
||||
@@ -274,13 +279,15 @@ mod tests {
|
||||
// Encrypt fixture placeholders with real workspace keys
|
||||
encrypt_fixture_secrets(&db).await;
|
||||
|
||||
let secret_count = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM variable WHERE is_secret = true"
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.expect("Failed to count secrets");
|
||||
println!("Found {} secrets in database before migration", secret_count.unwrap_or(0));
|
||||
let secret_count =
|
||||
sqlx::query_scalar!("SELECT COUNT(*) FROM variable WHERE is_secret = true")
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.expect("Failed to count secrets");
|
||||
println!(
|
||||
"Found {} secrets in database before migration",
|
||||
secret_count.unwrap_or(0)
|
||||
);
|
||||
|
||||
// Run migration
|
||||
println!("Migrating secrets to Vault...");
|
||||
@@ -288,8 +295,10 @@ mod tests {
|
||||
.await
|
||||
.expect("Migration to Vault failed");
|
||||
|
||||
println!("Migration report: total={}, migrated={}, failed={}",
|
||||
report.total_secrets, report.migrated_count, report.failed_count);
|
||||
println!(
|
||||
"Migration report: total={}, migrated={}, failed={}",
|
||||
report.total_secrets, report.migrated_count, report.failed_count
|
||||
);
|
||||
|
||||
if !report.failures.is_empty() {
|
||||
for f in &report.failures {
|
||||
@@ -307,7 +316,11 @@ mod tests {
|
||||
.get_secret(ws, path)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("Failed to read {}/{} from Vault: {:?}", ws, path, e));
|
||||
assert_eq!(value, expected_plaintext, "Vault value mismatch for {}/{}", ws, path);
|
||||
assert_eq!(
|
||||
value, expected_plaintext,
|
||||
"Vault value mismatch for {}/{}",
|
||||
ws, path
|
||||
);
|
||||
println!(" ✓ {}/{} correct in Vault", ws, path);
|
||||
}
|
||||
|
||||
@@ -346,8 +359,10 @@ mod tests {
|
||||
.await
|
||||
.expect("Migration to database failed");
|
||||
|
||||
println!("Migration report: total={}, migrated={}, failed={}",
|
||||
report.total_secrets, report.migrated_count, report.failed_count);
|
||||
println!(
|
||||
"Migration report: total={}, migrated={}, failed={}",
|
||||
report.total_secrets, report.migrated_count, report.failed_count
|
||||
);
|
||||
|
||||
assert_eq!(report.failed_count, 0, "Migration had failures");
|
||||
assert!(report.migrated_count > 0, "No secrets were migrated");
|
||||
@@ -364,7 +379,11 @@ mod tests {
|
||||
|
||||
let mc = build_crypt(&db, ws).await.unwrap();
|
||||
let decrypted = decrypt(&mc, row).expect("Failed to decrypt restored value");
|
||||
assert_eq!(decrypted, expected_plaintext, "Restored value mismatch for {}/{}", ws, path);
|
||||
assert_eq!(
|
||||
decrypted, expected_plaintext,
|
||||
"Restored value mismatch for {}/{}",
|
||||
ws, path
|
||||
);
|
||||
println!(" ✓ {}/{} correctly restored in DB", ws, path);
|
||||
}
|
||||
|
||||
@@ -488,11 +507,19 @@ mod tests {
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("Secret {}/{} not found after round-trip", ws, path));
|
||||
|
||||
assert_ne!(encrypted, "ROUND_TRIP_CLEARED", "Secret {}/{} was not restored", ws, path);
|
||||
assert_ne!(
|
||||
encrypted, "ROUND_TRIP_CLEARED",
|
||||
"Secret {}/{} was not restored",
|
||||
ws, path
|
||||
);
|
||||
|
||||
let mc = build_crypt(&db, ws).await.unwrap();
|
||||
let decrypted = decrypt(&mc, encrypted).expect("Failed to decrypt");
|
||||
assert_eq!(decrypted, expected_plaintext, "Round-trip value mismatch for {}/{}", ws, path);
|
||||
assert_eq!(
|
||||
decrypted, expected_plaintext,
|
||||
"Round-trip value mismatch for {}/{}",
|
||||
ws, path
|
||||
);
|
||||
println!(" ✓ {}/{}: round-trip OK", ws, path);
|
||||
}
|
||||
|
||||
@@ -522,10 +549,7 @@ mod tests {
|
||||
.get_secret("test-workspace", "u/test-user/other_secret")
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
cross_access.is_err(),
|
||||
"Cross-workspace access should fail!"
|
||||
);
|
||||
assert!(cross_access.is_err(), "Cross-workspace access should fail!");
|
||||
println!("✓ Cross-workspace access correctly denied");
|
||||
|
||||
// Verify own workspace access works
|
||||
|
||||
@@ -23,19 +23,21 @@
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_common::error::Result;
|
||||
use windmill_common::secret_backend::{
|
||||
vault_oss::{migrate_secrets_to_database, migrate_secrets_to_vault, test_vault_connection, VaultBackend},
|
||||
vault_oss::{
|
||||
migrate_secrets_to_database, migrate_secrets_to_vault, test_vault_connection, VaultBackend,
|
||||
},
|
||||
SecretBackend, VaultSettings,
|
||||
};
|
||||
|
||||
fn test_vault_settings() -> VaultSettings {
|
||||
VaultSettings {
|
||||
address: std::env::var("VAULT_ADDR").unwrap_or_else(|_| "http://127.0.0.1:8200".to_string()),
|
||||
address: std::env::var("VAULT_ADDR")
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:8200".to_string()),
|
||||
mount_path: "windmill".to_string(),
|
||||
jwt_role: Some("windmill-secrets".to_string()),
|
||||
jwt_mount_path: None,
|
||||
namespace: None,
|
||||
token: Some(
|
||||
std::env::var("VAULT_TOKEN").unwrap_or_else(|_| "test-root-token".to_string()),
|
||||
),
|
||||
token: Some(std::env::var("VAULT_TOKEN").unwrap_or_else(|_| "test-root-token".to_string())),
|
||||
skip_ssl_verify: None,
|
||||
}
|
||||
}
|
||||
@@ -47,7 +49,11 @@ async fn test_vault_connection_works(db: Pool<Postgres>) {
|
||||
let settings = test_vault_settings();
|
||||
|
||||
let result = test_vault_connection(&settings, Some(&db)).await;
|
||||
assert!(result.is_ok(), "Failed to connect to Vault: {:?}", result.err());
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to connect to Vault: {:?}",
|
||||
result.err()
|
||||
);
|
||||
println!("✓ Successfully connected to Vault at {}", settings.address);
|
||||
}
|
||||
|
||||
@@ -70,7 +76,10 @@ async fn test_migrate_db_to_vault(db: Pool<Postgres>) {
|
||||
.await
|
||||
.expect("Failed to query secrets");
|
||||
|
||||
println!("Found {} secrets in database before migration:", secrets_before.len());
|
||||
println!(
|
||||
"Found {} secrets in database before migration:",
|
||||
secrets_before.len()
|
||||
);
|
||||
for s in &secrets_before {
|
||||
println!(" - {}/{}: {} chars", s.workspace_id, s.path, s.value.len());
|
||||
}
|
||||
@@ -111,7 +120,10 @@ async fn test_migrate_db_to_vault(db: Pool<Postgres>) {
|
||||
secret.path,
|
||||
result.err()
|
||||
);
|
||||
println!(" ✓ {}/{} exists in Vault", secret.workspace_id, secret.path);
|
||||
println!(
|
||||
" ✓ {}/{} exists in Vault",
|
||||
secret.workspace_id, secret.path
|
||||
);
|
||||
}
|
||||
|
||||
println!("\n✓ Migration to Vault completed successfully");
|
||||
@@ -133,8 +145,14 @@ async fn test_migrate_vault_to_db(db: Pool<Postgres>) {
|
||||
let to_vault_report = migrate_secrets_to_vault(&db, &settings)
|
||||
.await
|
||||
.expect("Initial migration to Vault failed");
|
||||
assert!(to_vault_report.migrated_count > 0, "No secrets to test with");
|
||||
println!(" Migrated {} secrets to Vault", to_vault_report.migrated_count);
|
||||
assert!(
|
||||
to_vault_report.migrated_count > 0,
|
||||
"No secrets to test with"
|
||||
);
|
||||
println!(
|
||||
" Migrated {} secrets to Vault",
|
||||
to_vault_report.migrated_count
|
||||
);
|
||||
|
||||
// Clear the database values to simulate fresh migration back
|
||||
println!("\nClearing database secret values...");
|
||||
@@ -150,7 +168,10 @@ async fn test_migrate_vault_to_db(db: Pool<Postgres>) {
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.expect("Failed to count cleared");
|
||||
println!(" Cleared {} secret values in database", cleared.count.unwrap_or(0));
|
||||
println!(
|
||||
" Cleared {} secret values in database",
|
||||
cleared.count.unwrap_or(0)
|
||||
);
|
||||
|
||||
// Now migrate from Vault back to database
|
||||
println!("\nMigrating secrets from Vault to database...");
|
||||
@@ -206,15 +227,14 @@ async fn test_full_round_trip_migration(db: Pool<Postgres>) {
|
||||
.expect("Failed to connect to Vault");
|
||||
|
||||
// Get original secrets
|
||||
let original_secrets: std::collections::HashMap<(String, String), String> = sqlx::query!(
|
||||
"SELECT workspace_id, path, value FROM variable WHERE is_secret = true"
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await
|
||||
.expect("Failed to query original secrets")
|
||||
.into_iter()
|
||||
.map(|r| ((r.workspace_id, r.path), r.value))
|
||||
.collect();
|
||||
let original_secrets: std::collections::HashMap<(String, String), String> =
|
||||
sqlx::query!("SELECT workspace_id, path, value FROM variable WHERE is_secret = true")
|
||||
.fetch_all(&db)
|
||||
.await
|
||||
.expect("Failed to query original secrets")
|
||||
.into_iter()
|
||||
.map(|r| ((r.workspace_id, r.path), r.value))
|
||||
.collect();
|
||||
|
||||
println!("Original secrets: {} entries", original_secrets.len());
|
||||
|
||||
@@ -243,21 +263,23 @@ async fn test_full_round_trip_migration(db: Pool<Postgres>) {
|
||||
|
||||
// Step 4: Verify round-trip integrity
|
||||
println!("\n=== Step 4: Verify round-trip integrity ===");
|
||||
let restored_secrets: std::collections::HashMap<(String, String), String> = sqlx::query!(
|
||||
"SELECT workspace_id, path, value FROM variable WHERE is_secret = true"
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await
|
||||
.expect("Failed to query restored secrets")
|
||||
.into_iter()
|
||||
.map(|r| ((r.workspace_id, r.path), r.value))
|
||||
.collect();
|
||||
let restored_secrets: std::collections::HashMap<(String, String), String> =
|
||||
sqlx::query!("SELECT workspace_id, path, value FROM variable WHERE is_secret = true")
|
||||
.fetch_all(&db)
|
||||
.await
|
||||
.expect("Failed to query restored secrets")
|
||||
.into_iter()
|
||||
.map(|r| ((r.workspace_id, r.path), r.value))
|
||||
.collect();
|
||||
|
||||
// Compare original and restored
|
||||
for ((ws, path), _original_value) in &original_secrets {
|
||||
let restored_value = restored_secrets
|
||||
.get(&(ws.clone(), path.clone()))
|
||||
.expect(&format!("Secret {}/{} not found after round-trip", ws, path));
|
||||
.expect(&format!(
|
||||
"Secret {}/{} not found after round-trip",
|
||||
ws, path
|
||||
));
|
||||
|
||||
// Note: Values might differ slightly due to encryption/decryption
|
||||
// but they should not be the cleared value
|
||||
@@ -266,7 +288,12 @@ async fn test_full_round_trip_migration(db: Pool<Postgres>) {
|
||||
"Secret {}/{} was not restored",
|
||||
ws, path
|
||||
);
|
||||
println!(" ✓ {}/{}: restored ({} chars)", ws, path, restored_value.len());
|
||||
println!(
|
||||
" ✓ {}/{}: restored ({} chars)",
|
||||
ws,
|
||||
path,
|
||||
restored_value.len()
|
||||
);
|
||||
}
|
||||
|
||||
println!("\n✓ Full round-trip migration completed successfully!");
|
||||
@@ -289,7 +316,10 @@ async fn test_workspace_isolation(db: Pool<Postgres>) {
|
||||
.await
|
||||
.expect("Migration failed");
|
||||
|
||||
println!("Migrated {} secrets across workspaces", report.migrated_count);
|
||||
println!(
|
||||
"Migrated {} secrets across workspaces",
|
||||
report.migrated_count
|
||||
);
|
||||
|
||||
// Verify workspace isolation in Vault
|
||||
let vault_backend = VaultBackend::new(settings.clone());
|
||||
@@ -309,13 +339,19 @@ async fn test_workspace_isolation(db: Pool<Postgres>) {
|
||||
let ws1_result: Result<String> = vault_backend
|
||||
.get_secret("test-workspace", "u/test-user/db_password")
|
||||
.await;
|
||||
assert!(ws1_result.is_ok(), "test-workspace secret should be accessible");
|
||||
assert!(
|
||||
ws1_result.is_ok(),
|
||||
"test-workspace secret should be accessible"
|
||||
);
|
||||
println!("✓ test-workspace secrets accessible");
|
||||
|
||||
let ws2_result: Result<String> = vault_backend
|
||||
.get_secret("test-workspace-2", "u/test-user/other_secret")
|
||||
.await;
|
||||
assert!(ws2_result.is_ok(), "test-workspace-2 secret should be accessible");
|
||||
assert!(
|
||||
ws2_result.is_ok(),
|
||||
"test-workspace-2 secret should be accessible"
|
||||
);
|
||||
println!("✓ test-workspace-2 secrets accessible");
|
||||
|
||||
println!("\n✓ Workspace isolation verified!");
|
||||
|
||||
@@ -323,9 +323,10 @@ impl Google {
|
||||
}
|
||||
|
||||
/// Renew an expiring Google watch channel.
|
||||
/// Rotates the webhook token (creating a new one with the same label),
|
||||
/// stops the old channel and creates a new one with a fresh channel ID
|
||||
/// (Google rejects reused channel IDs with `channelIdNotUnique`).
|
||||
/// Rotates the webhook token (mints a fresh `ephemeral-webhook-google-{rd5}` label
|
||||
/// and a 14-day expiration via `rotate_webhook_token`), stops the old channel and
|
||||
/// creates a new one with a fresh channel ID (Google rejects reused channel IDs
|
||||
/// with `channelIdNotUnique`).
|
||||
/// Returns (new_service_config, new_plaintext_token, old_token_hash).
|
||||
/// Callers should delete old_token_hash after successfully updating the trigger.
|
||||
pub async fn renew_channel(
|
||||
@@ -341,7 +342,13 @@ impl Google {
|
||||
.transpose()?
|
||||
.ok_or_else(|| Error::InternalErr("Missing service config".to_string()))?;
|
||||
|
||||
let rotated = match rotate_webhook_token(db, &trigger.webhook_token_hash).await? {
|
||||
let rotated = match rotate_webhook_token(
|
||||
db,
|
||||
&trigger.webhook_token_hash,
|
||||
ServiceName::Google,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(r) => r,
|
||||
None => {
|
||||
return Err(Error::InternalErr(format!(
|
||||
@@ -464,6 +471,115 @@ pub fn should_renew_channel(service_config: &serde_json::Value) -> bool {
|
||||
remaining_ms < renewal_window_ms
|
||||
}
|
||||
|
||||
enum RenewOutcome {
|
||||
Renewed,
|
||||
/// Another replica holds the lock, or the row was already renewed.
|
||||
Skipped,
|
||||
}
|
||||
|
||||
/// Renew one Google watch channel under a row lock.
|
||||
/// `sync_all_triggers` runs on every replica with no leader election — without
|
||||
/// the lock, parallel renewals orphan the losers' new tokens and Google channels.
|
||||
async fn try_renew_channel_locked(
|
||||
handler: &Google,
|
||||
db: &DB,
|
||||
workspace_id: &str,
|
||||
trigger: &NativeTrigger,
|
||||
) -> Result<RenewOutcome> {
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
let row = sqlx::query!(
|
||||
r#"
|
||||
SELECT service_config, webhook_token_hash
|
||||
FROM native_trigger
|
||||
WHERE workspace_id = $1
|
||||
AND service_name = $2
|
||||
AND external_id = $3
|
||||
FOR UPDATE SKIP LOCKED
|
||||
"#,
|
||||
workspace_id,
|
||||
ServiceName::Google as ServiceName,
|
||||
trigger.external_id,
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let Some(row) = row else {
|
||||
return Ok(RenewOutcome::Skipped);
|
||||
};
|
||||
|
||||
let Some(service_config) = row.service_config else {
|
||||
// Anomalous: a Google trigger row should always carry a service_config.
|
||||
tracing::warn!(
|
||||
"Google trigger '{}' has NULL service_config — skipping renewal",
|
||||
trigger.external_id
|
||||
);
|
||||
return Ok(RenewOutcome::Skipped);
|
||||
};
|
||||
|
||||
// Re-check after the lock — a contending replica may have just renewed.
|
||||
if !should_renew_channel(&service_config) {
|
||||
return Ok(RenewOutcome::Skipped);
|
||||
}
|
||||
|
||||
// Use freshly-read fields — webhook_token_hash may have rotated since list time.
|
||||
let fresh_trigger = NativeTrigger {
|
||||
service_config: Some(service_config),
|
||||
webhook_token_hash: row.webhook_token_hash,
|
||||
..trigger.clone()
|
||||
};
|
||||
|
||||
let (new_config, new_token, old_token_hash) = handler
|
||||
.renew_channel(workspace_id, &fresh_trigger, db)
|
||||
.await?;
|
||||
|
||||
// Past this point a new Google channel exists. Any failure leaks it.
|
||||
if let Err(e) = update_native_trigger_service_config(
|
||||
&mut *tx,
|
||||
workspace_id,
|
||||
ServiceName::Google,
|
||||
&trigger.external_id,
|
||||
&new_config,
|
||||
Some(&new_token),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
"DB update failed after creating new Google channel for '{}' — channel orphaned in Google: {}",
|
||||
trigger.external_id,
|
||||
e
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
if let Err(e) = tx.commit().await {
|
||||
tracing::error!(
|
||||
"Commit failed after creating new Google channel for '{}' — channel orphaned in Google: {}",
|
||||
trigger.external_id,
|
||||
e
|
||||
);
|
||||
return Err(e.into());
|
||||
}
|
||||
|
||||
// With the lock + rotation in place, the old token row must exist here.
|
||||
// Ok(false) means a concurrent path deleted it (or the expiry sweep collected it).
|
||||
match crate::delete_token_by_hash(db, &old_token_hash).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => tracing::warn!(
|
||||
"Old webhook token already gone after renewal for '{}' (hash {})",
|
||||
trigger.external_id,
|
||||
old_token_hash
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
"Failed to delete old webhook token after channel renewal for '{}': {}",
|
||||
trigger.external_id,
|
||||
e
|
||||
),
|
||||
}
|
||||
|
||||
Ok(RenewOutcome::Renewed)
|
||||
}
|
||||
|
||||
async fn renew_expiring_channels(
|
||||
handler: &Google,
|
||||
db: &DB,
|
||||
@@ -488,53 +604,25 @@ async fn renew_expiring_channels(
|
||||
workspace_id
|
||||
);
|
||||
|
||||
match handler.renew_channel(workspace_id, trigger, db).await {
|
||||
Ok((new_config, new_token, old_token_hash)) => {
|
||||
match update_native_trigger_service_config(
|
||||
db,
|
||||
workspace_id,
|
||||
ServiceName::Google,
|
||||
&trigger.external_id,
|
||||
&new_config,
|
||||
Some(&new_token),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
// Trigger updated — clean up old token (best-effort)
|
||||
if let Err(e) = crate::delete_token_by_hash(db, &old_token_hash).await {
|
||||
tracing::warn!(
|
||||
"Failed to delete old webhook token after channel renewal for {}: {}",
|
||||
trigger.external_id, e
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
"Renewed Google channel {} for '{}'",
|
||||
trigger.external_id,
|
||||
trigger.script_path
|
||||
);
|
||||
synced.push(TriggerSyncInfo {
|
||||
external_id: trigger.external_id.clone(),
|
||||
script_path: trigger.script_path.clone(),
|
||||
action: SyncAction::ConfigUpdated,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"Failed to update DB after renewing Google channel {}: {}",
|
||||
trigger.external_id,
|
||||
e
|
||||
);
|
||||
errors.push(SyncError {
|
||||
resource_path: format!("workspace:{}", workspace_id),
|
||||
error_message: format!(
|
||||
"Failed to update DB after channel renewal for {}: {}",
|
||||
trigger.external_id, e
|
||||
),
|
||||
error_type: "channel_renewal_error".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
match try_renew_channel_locked(handler, db, workspace_id, trigger).await {
|
||||
Ok(RenewOutcome::Renewed) => {
|
||||
tracing::info!(
|
||||
"Renewed Google channel {} for '{}'",
|
||||
trigger.external_id,
|
||||
trigger.script_path
|
||||
);
|
||||
synced.push(TriggerSyncInfo {
|
||||
external_id: trigger.external_id.clone(),
|
||||
script_path: trigger.script_path.clone(),
|
||||
action: SyncAction::ConfigUpdated,
|
||||
});
|
||||
}
|
||||
Ok(RenewOutcome::Skipped) => {
|
||||
// Expected outcome under SKIP LOCKED: contending replica or already-renewed row.
|
||||
tracing::debug!(
|
||||
"Skipped Google channel renewal for '{}': another replica is renewing or the row was already renewed",
|
||||
trigger.external_id
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use crate::{
|
||||
decrypt_oauth_data, delete_native_trigger, delete_token_by_hash, get_native_trigger,
|
||||
list_native_triggers, rotate_webhook_token, store_native_trigger, update_native_trigger_error,
|
||||
External, NativeTrigger, NativeTriggerConfig, NativeTriggerData, ServiceName,
|
||||
webhook_token_label, External, NativeTrigger, NativeTriggerConfig, NativeTriggerData,
|
||||
ServiceName,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query},
|
||||
@@ -18,7 +19,6 @@ use windmill_audit::{audit_oss::audit_log, ActionKind};
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{Error, JsonResult, Result},
|
||||
utils::rd_string,
|
||||
DB,
|
||||
};
|
||||
|
||||
@@ -84,10 +84,13 @@ async fn new_webhook_token(
|
||||
let kind = if is_flow { "flows" } else { "scripts" };
|
||||
|
||||
let scopes = vec![format!("jobs:run:{kind}:{script_path}")];
|
||||
let label = format!("webhook-{}-{}", service_name.as_str(), rd_string(5));
|
||||
let label = webhook_token_label(service_name);
|
||||
let expiration = service_name
|
||||
.webhook_token_expiration()
|
||||
.map(|d| chrono::Utc::now() + d);
|
||||
let token_config = NewToken::new(
|
||||
Some(label),
|
||||
None,
|
||||
expiration,
|
||||
None,
|
||||
Some(scopes),
|
||||
Some(workspace_id.to_owned()),
|
||||
@@ -255,8 +258,8 @@ async fn update_native_trigger_handler<T: External>(
|
||||
tx = user_db.begin(&authed).await?;
|
||||
token
|
||||
} else {
|
||||
// Same runnable — rotate the token keeping the same label
|
||||
match rotate_webhook_token(&db, &existing.webhook_token_hash).await? {
|
||||
// Same runnable — rotate the token (mints a fresh label + expiration)
|
||||
match rotate_webhook_token(&db, &existing.webhook_token_hash, service_name).await? {
|
||||
Some(rotated) => {
|
||||
old_token_hash_to_delete = Some(rotated.old_token_hash);
|
||||
rotated.new_token
|
||||
|
||||
@@ -180,6 +180,17 @@ impl ServiceName {
|
||||
pub fn integration_service(&self) -> ServiceName {
|
||||
*self
|
||||
}
|
||||
|
||||
/// How long webhook tokens for this service should remain valid. `None` = no expiry.
|
||||
/// Google channels turn over on a tight schedule (24h Drive, 7d Calendar) — a finite
|
||||
/// TTL lets `delete_expired_items` (`monitor.rs`) sweep orphaned tokens automatically.
|
||||
/// Persistent-webhook services (Nextcloud, GitHub) return `None`.
|
||||
pub fn webhook_token_expiration(&self) -> Option<chrono::Duration> {
|
||||
match self {
|
||||
ServiceName::Google => Some(chrono::Duration::days(14)),
|
||||
ServiceName::Nextcloud | ServiceName::Github => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ServiceName {
|
||||
@@ -759,22 +770,23 @@ async fn update_oauth_token_resource(
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new webhook token that keeps the same label as the old one.
|
||||
/// The old token is **not** deleted — callers must call `delete_token_by_hash`
|
||||
/// on `old_token_hash` after the trigger row has been successfully updated.
|
||||
/// This ensures the trigger keeps working if the external service call or
|
||||
/// subsequent DB update fails.
|
||||
/// Create a new webhook token, minting a fresh `ephemeral-webhook-{service}-{rd5}`
|
||||
/// label and the per-service expiration (see `ServiceName::webhook_token_expiration`).
|
||||
/// The old token is **not** deleted — callers must call `delete_token_by_hash` on
|
||||
/// `old_token_hash` after the trigger row has been successfully updated.
|
||||
///
|
||||
/// Returns `Ok(None)` if the old token no longer exists (e.g. manually deleted by user).
|
||||
/// In that case, `renew_channel` returns an error which `renew_expiring_channels` writes
|
||||
/// to the trigger's `error` column — visible in the UI so the user can re-create the trigger.
|
||||
pub async fn rotate_webhook_token(db: &DB, old_token_hash: &str) -> Result<Option<RotatedToken>> {
|
||||
pub async fn rotate_webhook_token(
|
||||
db: &DB,
|
||||
old_token_hash: &str,
|
||||
service_name: ServiceName,
|
||||
) -> Result<Option<RotatedToken>> {
|
||||
use windmill_common::auth::{hash_token, TOKEN_PREFIX_LEN};
|
||||
use windmill_common::min_version::MIN_VERSION_SUPPORTS_TOKEN_HASH;
|
||||
use windmill_common::utils::rd_string;
|
||||
|
||||
let old = match sqlx::query!(
|
||||
"SELECT label, email, scopes, workspace_id, super_admin, owner, expiration FROM token WHERE token_hash = $1",
|
||||
"SELECT email, scopes, workspace_id, super_admin, owner FROM token WHERE token_hash = $1",
|
||||
old_token_hash
|
||||
)
|
||||
.fetch_optional(db)
|
||||
@@ -799,6 +811,11 @@ pub async fn rotate_webhook_token(db: &DB, old_token_hash: &str) -> Result<Optio
|
||||
Some(&new_token)
|
||||
};
|
||||
|
||||
let new_label = webhook_token_label(service_name);
|
||||
let new_expiration = service_name
|
||||
.webhook_token_expiration()
|
||||
.map(|d| Utc::now() + d);
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes, workspace_id, owner, expiration)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
|
||||
@@ -806,12 +823,12 @@ pub async fn rotate_webhook_token(db: &DB, old_token_hash: &str) -> Result<Optio
|
||||
new_prefix,
|
||||
plaintext as Option<&str>,
|
||||
old.email,
|
||||
old.label,
|
||||
new_label,
|
||||
old.super_admin,
|
||||
old.scopes.as_deref(),
|
||||
old.workspace_id,
|
||||
old.owner,
|
||||
old.expiration,
|
||||
new_expiration,
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
@@ -822,6 +839,19 @@ pub async fn rotate_webhook_token(db: &DB, old_token_hash: &str) -> Result<Optio
|
||||
}))
|
||||
}
|
||||
|
||||
/// Mint the standard label used for native-trigger webhook tokens.
|
||||
/// The `ephemeral-` prefix opts the token out of the user-token email/critical-alert
|
||||
/// notification paths (`is_user_token` in `monitor.rs`, `register_token_expiry_notification`
|
||||
/// in `windmill-api-auth/src/lib.rs`, `isUserToken` in the frontend).
|
||||
pub fn webhook_token_label(service_name: ServiceName) -> String {
|
||||
use windmill_common::utils::rd_string;
|
||||
format!(
|
||||
"ephemeral-webhook-{}-{}",
|
||||
service_name.as_str(),
|
||||
rd_string(5)
|
||||
)
|
||||
}
|
||||
|
||||
pub struct RotatedToken {
|
||||
pub new_token: String,
|
||||
/// Hash of the old token — callers should delete this after the
|
||||
@@ -829,7 +859,10 @@ pub struct RotatedToken {
|
||||
pub old_token_hash: String,
|
||||
}
|
||||
|
||||
/// Delete a token from the token table using its hash (exact match).
|
||||
/// Delete a token by hash. Returns `Ok(false)` when no row matched.
|
||||
/// Some call sites legitimately race against expiry sweeps or concurrent deletes;
|
||||
/// callers that consider 0-rows anomalous should log themselves at the appropriate
|
||||
/// level rather than have this helper warn unconditionally.
|
||||
pub async fn delete_token_by_hash<'c, E: sqlx::Executor<'c, Database = Postgres>>(
|
||||
db: E,
|
||||
token_hash: &str,
|
||||
|
||||
@@ -630,12 +630,45 @@ pub struct WrappedError {
|
||||
pub trait ValidableJson {
|
||||
fn is_valid_json(&self) -> bool;
|
||||
fn wm_labels(&self) -> Option<Vec<String>>;
|
||||
fn wm_failure(&self) -> Option<String>;
|
||||
fn result_metadata(&self) -> ResultMetadata;
|
||||
fn size(&self) -> usize;
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ResultLabels {
|
||||
wm_labels: Vec<String>,
|
||||
/// The Windmill-specific markers we look for inside a job's result.
|
||||
/// `wm_failure` retags a successful run as a failure with the
|
||||
/// given message; `wm_labels` adds runtime labels to the job row.
|
||||
#[derive(serde::Deserialize, Default, Debug, Clone)]
|
||||
pub struct ResultMetadata {
|
||||
pub wm_labels: Option<Vec<String>>,
|
||||
pub wm_failure: Option<String>,
|
||||
}
|
||||
|
||||
/// Sentinel `error.name` we inject into a result when retagging a successful
|
||||
/// run as a failure due to `wm_failure`. Used downstream to detect that
|
||||
/// the result is already in the standard `{ error: { name, message }, ... }`
|
||||
/// shape and must not be wrapped a second time by `WrappedError`.
|
||||
pub const MANUAL_FAILURE_ERROR_NAME: &str = "ManualFailure";
|
||||
|
||||
/// Returns true when the result already carries our injected
|
||||
/// `error: { name: "ManualFailure", ... }` marker — i.e. it was shaped by
|
||||
/// `process_jc`'s wm_failure path. A real runtime failure whose raw
|
||||
/// result happens to contain a `wm_failure` field but no such error
|
||||
/// key returns false (and so still goes through the standard wrap path).
|
||||
pub fn is_pre_shaped_wm_failure_result(result: &str) -> bool {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Marker {
|
||||
error: Option<NameOnly>,
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct NameOnly {
|
||||
name: String,
|
||||
}
|
||||
serde_json::from_str::<Marker>(result)
|
||||
.ok()
|
||||
.and_then(|m| m.error)
|
||||
.map(|e| e.name == MANUAL_FAILURE_ERROR_NAME)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
impl ValidableJson for WrappedError {
|
||||
@@ -647,6 +680,14 @@ impl ValidableJson for WrappedError {
|
||||
None
|
||||
}
|
||||
|
||||
fn wm_failure(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn result_metadata(&self) -> ResultMetadata {
|
||||
ResultMetadata::default()
|
||||
}
|
||||
|
||||
fn size(&self) -> usize {
|
||||
0
|
||||
}
|
||||
@@ -658,9 +699,15 @@ impl ValidableJson for Box<RawValue> {
|
||||
}
|
||||
|
||||
fn wm_labels(&self) -> Option<Vec<String>> {
|
||||
serde_json::from_str::<ResultLabels>(self.get())
|
||||
.ok()
|
||||
.map(|r| r.wm_labels)
|
||||
self.result_metadata().wm_labels
|
||||
}
|
||||
|
||||
fn wm_failure(&self) -> Option<String> {
|
||||
self.result_metadata().wm_failure
|
||||
}
|
||||
|
||||
fn result_metadata(&self) -> ResultMetadata {
|
||||
serde_json::from_str::<ResultMetadata>(self.get()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn size(&self) -> usize {
|
||||
@@ -677,6 +724,14 @@ impl<T: ValidableJson> ValidableJson for Arc<T> {
|
||||
T::wm_labels(&self)
|
||||
}
|
||||
|
||||
fn wm_failure(&self) -> Option<String> {
|
||||
T::wm_failure(&self)
|
||||
}
|
||||
|
||||
fn result_metadata(&self) -> ResultMetadata {
|
||||
T::result_metadata(&self)
|
||||
}
|
||||
|
||||
fn size(&self) -> usize {
|
||||
T::size(&self)
|
||||
}
|
||||
@@ -688,9 +743,15 @@ impl ValidableJson for serde_json::Value {
|
||||
}
|
||||
|
||||
fn wm_labels(&self) -> Option<Vec<String>> {
|
||||
serde_json::from_value::<ResultLabels>(self.clone())
|
||||
.ok()
|
||||
.map(|r| r.wm_labels)
|
||||
self.result_metadata().wm_labels
|
||||
}
|
||||
|
||||
fn wm_failure(&self) -> Option<String> {
|
||||
self.result_metadata().wm_failure
|
||||
}
|
||||
|
||||
fn result_metadata(&self) -> ResultMetadata {
|
||||
serde_json::from_value::<ResultMetadata>(self.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn size(&self) -> usize {
|
||||
@@ -707,6 +768,14 @@ impl<T: ValidableJson> ValidableJson for Json<T> {
|
||||
self.0.wm_labels()
|
||||
}
|
||||
|
||||
fn wm_failure(&self) -> Option<String> {
|
||||
self.0.wm_failure()
|
||||
}
|
||||
|
||||
fn result_metadata(&self) -> ResultMetadata {
|
||||
self.0.result_metadata()
|
||||
}
|
||||
|
||||
fn size(&self) -> usize {
|
||||
self.0.size()
|
||||
}
|
||||
@@ -742,16 +811,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn add_completed_job_error(
|
||||
db: &Pool<Postgres>,
|
||||
completed_job: &MiniCompletedJob,
|
||||
mem_peak: i32,
|
||||
canceled_by: Option<CanceledBy>,
|
||||
e: serde_json::Value,
|
||||
_worker_name: &str,
|
||||
flow_is_done: bool,
|
||||
duration: Option<i64>,
|
||||
) -> Result<WrappedError, Error> {
|
||||
async fn record_failure_metrics(completed_job: &MiniCompletedJob, _worker_name: &str) {
|
||||
#[cfg(feature = "prometheus")]
|
||||
register_metric(
|
||||
&WORKER_EXECUTION_FAILED,
|
||||
@@ -772,6 +832,64 @@ pub async fn add_completed_job_error(
|
||||
.await;
|
||||
|
||||
otel_incr_worker_execution_failed(&completed_job.tag);
|
||||
}
|
||||
|
||||
/// Tag a completed job as a failure while storing the result as-is, without
|
||||
/// the standard `WrappedError` `{ error: ... }` wrap. Use for jobs whose result
|
||||
/// is already shaped (e.g. when `wm_failure` injected a top-level
|
||||
/// `error` key, while preserving sibling fields like `windmill_status_code`).
|
||||
///
|
||||
/// This is a worker-internal helper called by trusted result-processing code
|
||||
/// after the worker has authenticated and pulled the job. Callers MUST verify
|
||||
/// upstream auth (i.e. the job was legitimately pulled by this worker) — this
|
||||
/// function performs no authorization check itself, mirroring the contract of
|
||||
/// `add_completed_job_error`.
|
||||
pub async fn add_completed_job_pre_shaped_failure<T: Serialize + Send + Sync + ValidableJson>(
|
||||
db: &Pool<Postgres>,
|
||||
completed_job: &MiniCompletedJob,
|
||||
mem_peak: i32,
|
||||
canceled_by: Option<CanceledBy>,
|
||||
result: Json<&T>,
|
||||
worker_name: &str,
|
||||
flow_is_done: bool,
|
||||
duration: Option<i64>,
|
||||
) -> Result<(), Error> {
|
||||
record_failure_metrics(completed_job, worker_name).await;
|
||||
|
||||
tracing::error!(
|
||||
"job {} in {} did not succeed (wm_failure)",
|
||||
completed_job.id,
|
||||
completed_job.workspace_id,
|
||||
);
|
||||
let _ = add_completed_job(
|
||||
db,
|
||||
completed_job,
|
||||
false,
|
||||
false,
|
||||
result,
|
||||
None,
|
||||
mem_peak,
|
||||
canceled_by,
|
||||
flow_is_done,
|
||||
duration,
|
||||
false,
|
||||
)
|
||||
.warn_after_seconds(10)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add_completed_job_error(
|
||||
db: &Pool<Postgres>,
|
||||
completed_job: &MiniCompletedJob,
|
||||
mem_peak: i32,
|
||||
canceled_by: Option<CanceledBy>,
|
||||
e: serde_json::Value,
|
||||
worker_name: &str,
|
||||
flow_is_done: bool,
|
||||
duration: Option<i64>,
|
||||
) -> Result<WrappedError, Error> {
|
||||
record_failure_metrics(completed_job, worker_name).await;
|
||||
|
||||
let result = WrappedError { error: e };
|
||||
tracing::error!(
|
||||
|
||||
@@ -28,9 +28,9 @@ deno_ast.workspace = true
|
||||
deno_tls.workspace = true
|
||||
deno_permissions.workspace = true
|
||||
deno_io.workspace = true
|
||||
deno_fs.workspace = true
|
||||
deno_telemetry.workspace = true
|
||||
deno_error.workspace = true
|
||||
deno_runtime.workspace = true
|
||||
winapi.workspace = true
|
||||
|
||||
itertools.workspace = true
|
||||
@@ -60,6 +60,7 @@ deno_ast.workspace = true
|
||||
deno_tls.workspace = true
|
||||
deno_permissions.workspace = true
|
||||
deno_io.workspace = true
|
||||
deno_runtime.workspace = true
|
||||
deno_fs.workspace = true
|
||||
deno_telemetry.workspace = true
|
||||
deno_error.workspace = true
|
||||
winapi.workspace = true
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
use deno_ast::{MediaType, ParseParams};
|
||||
use deno_core::{ModuleCodeString, ModuleName, SourceMapData};
|
||||
use deno_error::JsErrorBox;
|
||||
use deno_fetch::FetchPermissions;
|
||||
use deno_net::NetPermissions;
|
||||
use deno_web::{BlobStore, TimersPermission};
|
||||
@@ -22,10 +25,30 @@ impl FetchPermissions for PermissionsContainer {
|
||||
#[inline(always)]
|
||||
fn check_read<'a>(
|
||||
&mut self,
|
||||
_resolved: bool,
|
||||
_p: &'a std::path::Path,
|
||||
_path: Cow<'a, Path>,
|
||||
_api_name: &str,
|
||||
) -> Result<Cow<'a, std::path::Path>, deno_io::fs::FsError> {
|
||||
_get_path: &'a dyn deno_fs::GetPath,
|
||||
) -> Result<deno_fs::CheckedPath<'a>, deno_io::fs::FsError> {
|
||||
unreachable!("snapshotting")
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn check_write<'a>(
|
||||
&mut self,
|
||||
_path: Cow<'a, Path>,
|
||||
_api_name: &str,
|
||||
_get_path: &'a dyn deno_fs::GetPath,
|
||||
) -> Result<deno_fs::CheckedPath<'a>, deno_io::fs::FsError> {
|
||||
unreachable!("snapshotting")
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn check_net_vsock(
|
||||
&mut self,
|
||||
_cid: u32,
|
||||
_port: u32,
|
||||
_api_name: &str,
|
||||
) -> Result<(), deno_permissions::PermissionCheckError> {
|
||||
unreachable!("snapshotting")
|
||||
}
|
||||
}
|
||||
@@ -38,17 +61,17 @@ impl TimersPermission for PermissionsContainer {
|
||||
}
|
||||
|
||||
impl NetPermissions for PermissionsContainer {
|
||||
fn check_read<'a>(
|
||||
fn check_read(
|
||||
&mut self,
|
||||
_p: &'a str,
|
||||
_p: &str,
|
||||
_api_name: &str,
|
||||
) -> Result<PathBuf, deno_permissions::PermissionCheckError> {
|
||||
unreachable!("snapshotting")
|
||||
}
|
||||
|
||||
fn check_write<'a>(
|
||||
fn check_write(
|
||||
&mut self,
|
||||
_p: &'a str,
|
||||
_p: &str,
|
||||
_api_name: &str,
|
||||
) -> Result<PathBuf, deno_permissions::PermissionCheckError> {
|
||||
unreachable!("snapshotting")
|
||||
@@ -64,10 +87,19 @@ impl NetPermissions for PermissionsContainer {
|
||||
|
||||
fn check_write_path<'a>(
|
||||
&mut self,
|
||||
_: &'a Path,
|
||||
_: &str,
|
||||
_p: Cow<'a, Path>,
|
||||
_api_name: &str,
|
||||
) -> Result<Cow<'a, Path>, deno_permissions::PermissionCheckError> {
|
||||
todo!()
|
||||
unreachable!("snapshotting")
|
||||
}
|
||||
|
||||
fn check_vsock(
|
||||
&mut self,
|
||||
_cid: u32,
|
||||
_port: u32,
|
||||
_api_name: &str,
|
||||
) -> Result<(), deno_permissions::PermissionCheckError> {
|
||||
unreachable!("snapshotting")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,22 +109,87 @@ deno_core::extension!(
|
||||
esm = ["src/runtime.js"],
|
||||
);
|
||||
|
||||
// `extension_transpiler` callback for `deno_core::snapshot::create_snapshot`.
|
||||
//
|
||||
// Specialized to our snapshot's inputs. Of the seven deno_* extensions
|
||||
// we register via `init()`, six ship pre-built `.js` files
|
||||
// in their `esm` lists (webidl/url/console/web/fetch/net) — only
|
||||
// `deno_telemetry`'s `extension!` macro lists `.ts` files
|
||||
// (`telemetry.ts`, `util.ts`), so the TypeScript branch is needed
|
||||
// solely for that crate. Our local `fetch` extension contributes
|
||||
// `src/runtime.js` (pure JS). No `node:` imports happen at snapshot
|
||||
// build time, no `.mjs`, no user-supplied modules. So:
|
||||
// - `.js` → pass through.
|
||||
// - `.ts` → transpile via deno_ast (deno_telemetry only).
|
||||
// - anything else → build bug (deno shipping an unexpected file type
|
||||
// or us mislabelling one), panic loudly rather than emit a broken
|
||||
// snapshot.
|
||||
//
|
||||
// No source maps: the snapshot is a binary blob the runtime loads — source
|
||||
// maps would never be consumed.
|
||||
//
|
||||
// The signature still returns `Result<_, JsErrorBox>` because that's what
|
||||
// `extension_transpiler` expects, but we never construct one — parse and
|
||||
// transpile failures are build-time bugs in deno's own .ts internals (or
|
||||
// in our runtime.js, if we ever change its extension), so they panic.
|
||||
//
|
||||
// This replaces a call to `deno_runtime::transpile::maybe_transpile_source`
|
||||
// from `deno_runtime 0.198.0`. The original is more general (handles
|
||||
// `node:` modules, `.mjs`, emits source maps in debug builds, plumbs
|
||||
// errors via `JsErrorBox`); none of that surface is reachable in our
|
||||
// build. Dropping the `deno_runtime` dep eliminates a
|
||||
// `deno_cache → rusqlite → libsqlite3-sys 0.35` transitive chain that
|
||||
// collides with sqlx-sqlite's `libsqlite3-sys 0.30` (cargo's
|
||||
// `links = "sqlite3"` rule).
|
||||
fn maybe_transpile_source(
|
||||
name: ModuleName,
|
||||
source: ModuleCodeString,
|
||||
) -> Result<(ModuleCodeString, Option<SourceMapData>), JsErrorBox> {
|
||||
let media_type = MediaType::from_path(Path::new(&name));
|
||||
match media_type {
|
||||
MediaType::JavaScript => return Ok((source, None)),
|
||||
MediaType::TypeScript => {}
|
||||
_ => panic!("unexpected media type {media_type:?} for {name} during snapshot build"),
|
||||
}
|
||||
|
||||
let parsed = deno_ast::parse_module(ParseParams {
|
||||
specifier: deno_core::url::Url::parse(&name).unwrap(),
|
||||
text: source.into(),
|
||||
media_type,
|
||||
capture_tokens: false,
|
||||
scope_analysis: false,
|
||||
maybe_syntax: None,
|
||||
})
|
||||
.unwrap_or_else(|e| panic!("snapshot transpile: parse failed for {name}: {e}"));
|
||||
|
||||
let transpiled = parsed
|
||||
.transpile(
|
||||
&deno_ast::TranspileOptions {
|
||||
imports_not_used_as_values: deno_ast::ImportsNotUsedAsValues::Remove,
|
||||
..Default::default()
|
||||
},
|
||||
&deno_ast::TranspileModuleOptions::default(),
|
||||
&deno_ast::EmitOptions::default(),
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("snapshot transpile: emit failed for {name}: {e}"))
|
||||
.into_source();
|
||||
|
||||
Ok((transpiled.text.into(), None))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rustc-env=TARGET={}", env::var("TARGET").unwrap());
|
||||
println!("cargo:rustc-env=PROFILE={}", env::var("PROFILE").unwrap());
|
||||
|
||||
let exts = vec![
|
||||
deno_telemetry::deno_telemetry::init_ops_and_esm(),
|
||||
deno_webidl::deno_webidl::init_ops_and_esm(),
|
||||
deno_url::deno_url::init_ops_and_esm(),
|
||||
deno_console::deno_console::init_ops_and_esm(),
|
||||
deno_web::deno_web::init_ops_and_esm::<PermissionsContainer>(
|
||||
Arc::new(BlobStore::default()),
|
||||
None,
|
||||
),
|
||||
deno_fetch::deno_fetch::init_ops_and_esm::<PermissionsContainer>(Default::default()),
|
||||
deno_net::deno_net::init_ops_and_esm::<PermissionsContainer>(None, None),
|
||||
fetch::init_ops_and_esm(),
|
||||
deno_telemetry::deno_telemetry::init(),
|
||||
deno_webidl::deno_webidl::init(),
|
||||
deno_url::deno_url::init(),
|
||||
deno_console::deno_console::init(),
|
||||
deno_web::deno_web::init::<PermissionsContainer>(Arc::new(BlobStore::default()), None),
|
||||
deno_fetch::deno_fetch::init::<PermissionsContainer>(Default::default()),
|
||||
deno_net::deno_net::init::<PermissionsContainer>(None, None),
|
||||
fetch::init(),
|
||||
];
|
||||
|
||||
// Build the file path to the snapshot.
|
||||
@@ -105,7 +202,7 @@ fn main() {
|
||||
cargo_manifest_dir: env!("CARGO_MANIFEST_DIR"),
|
||||
startup_snapshot: None,
|
||||
extension_transpiler: Some(std::rc::Rc::new(|specifier, source| {
|
||||
deno_runtime::transpile::maybe_transpile_source(specifier, source)
|
||||
maybe_transpile_source(specifier, source)
|
||||
})),
|
||||
extensions: exts,
|
||||
with_runtime_cb: None,
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
mod dedicated;
|
||||
pub use dedicated::{ExecutingIsolate, PrewarmedIsolate, PrewarmedResult};
|
||||
|
||||
#[cfg(test)]
|
||||
mod smoke_tests;
|
||||
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
cell::RefCell,
|
||||
@@ -47,6 +50,28 @@ use windmill_common::error::Error;
|
||||
use windmill_common::result_stream::append_result_stream_db;
|
||||
use windmill_common::worker::{write_file, Connection, WINDMILL_DIR};
|
||||
|
||||
// ── Snapshot-matched extensions ──────────────────────────────────────
|
||||
//
|
||||
// `deno_core` 0.352 validates that the snapshot's extension list is a
|
||||
// *prefix* of the runtime's extension list (snapshot does not need an
|
||||
// exact match — runtime is allowed to add extensions at the tail, but
|
||||
// must not reorder or omit any that the snapshot baked in).
|
||||
//
|
||||
// Our snapshot (in build.rs) is the same eight deno_* extensions ending
|
||||
// with this local `fetch` ext. The runtime adds one extra entry at the
|
||||
// end — the windmill `ext` carrying our own ops — which is fine because
|
||||
// it's after the snapshot prefix.
|
||||
//
|
||||
// This local `fetch` extension declaration must be present in both
|
||||
// build.rs and lib.rs so the type passes through the `init()` macro.
|
||||
// The ESM is already in the snapshot, so this `init()` call at runtime
|
||||
// is a no-op for esm — the registration just records the ext.
|
||||
deno_core::extension!(
|
||||
fetch,
|
||||
esm_entry_point = "ext:fetch/src/runtime.js",
|
||||
esm = ["src/runtime.js"],
|
||||
);
|
||||
|
||||
// ── Permission container ─────────────────────────────────────────────
|
||||
|
||||
pub struct PermissionsContainer;
|
||||
@@ -64,11 +89,31 @@ impl FetchPermissions for PermissionsContainer {
|
||||
#[inline(always)]
|
||||
fn check_read<'a>(
|
||||
&mut self,
|
||||
_resolved: bool,
|
||||
p: &'a std::path::Path,
|
||||
path: Cow<'a, std::path::Path>,
|
||||
_api_name: &str,
|
||||
) -> Result<Cow<'a, std::path::Path>, deno_io::fs::FsError> {
|
||||
Ok(Cow::Borrowed(p))
|
||||
_get_path: &'a dyn deno_fs::GetPath,
|
||||
) -> Result<deno_fs::CheckedPath<'a>, deno_io::fs::FsError> {
|
||||
Ok(deno_fs::CheckedPath::Unresolved(path))
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn check_write<'a>(
|
||||
&mut self,
|
||||
path: Cow<'a, std::path::Path>,
|
||||
_api_name: &str,
|
||||
_get_path: &'a dyn deno_fs::GetPath,
|
||||
) -> Result<deno_fs::CheckedPath<'a>, deno_io::fs::FsError> {
|
||||
Ok(deno_fs::CheckedPath::Unresolved(path))
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn check_net_vsock(
|
||||
&mut self,
|
||||
_cid: u32,
|
||||
_port: u32,
|
||||
_api_name: &str,
|
||||
) -> Result<(), deno_permissions::PermissionCheckError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,17 +125,17 @@ impl TimersPermission for PermissionsContainer {
|
||||
}
|
||||
|
||||
impl NetPermissions for PermissionsContainer {
|
||||
fn check_read<'a>(
|
||||
fn check_read(
|
||||
&mut self,
|
||||
p: &'a str,
|
||||
p: &str,
|
||||
_api_name: &str,
|
||||
) -> Result<PathBuf, deno_permissions::PermissionCheckError> {
|
||||
Ok(PathBuf::from(p))
|
||||
}
|
||||
|
||||
fn check_write<'a>(
|
||||
fn check_write(
|
||||
&mut self,
|
||||
p: &'a str,
|
||||
p: &str,
|
||||
_api_name: &str,
|
||||
) -> Result<PathBuf, deno_permissions::PermissionCheckError> {
|
||||
Ok(PathBuf::from(p))
|
||||
@@ -106,10 +151,19 @@ impl NetPermissions for PermissionsContainer {
|
||||
|
||||
fn check_write_path<'a>(
|
||||
&mut self,
|
||||
p: &'a std::path::Path,
|
||||
p: Cow<'a, std::path::Path>,
|
||||
_api_name: &str,
|
||||
) -> Result<std::borrow::Cow<'a, std::path::Path>, deno_permissions::PermissionCheckError> {
|
||||
Ok(Cow::Borrowed(p))
|
||||
) -> Result<Cow<'a, std::path::Path>, deno_permissions::PermissionCheckError> {
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn check_vsock(
|
||||
&mut self,
|
||||
_cid: u32,
|
||||
_port: u32,
|
||||
_api_name: &str,
|
||||
) -> Result<(), deno_permissions::PermissionCheckError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,7 +435,7 @@ pub(crate) fn create_nativets_runtime(
|
||||
let fetch_options = deno_fetch::Options {
|
||||
root_cert_store_provider: None,
|
||||
user_agent: ann.useragent.unwrap_or_else(|| "windmill/beta".to_string()),
|
||||
proxy: ann.proxy.map(|x| deno_tls::Proxy {
|
||||
proxy: ann.proxy.map(|x| deno_tls::Proxy::Http {
|
||||
url: x.0,
|
||||
basic_auth: x
|
||||
.1
|
||||
@@ -391,13 +445,14 @@ pub(crate) fn create_nativets_runtime(
|
||||
};
|
||||
|
||||
let exts: Vec<Extension> = vec![
|
||||
deno_telemetry::deno_telemetry::init_ops(),
|
||||
deno_webidl::deno_webidl::init_ops(),
|
||||
deno_url::deno_url::init_ops(),
|
||||
deno_console::deno_console::init_ops(),
|
||||
deno_web::deno_web::init_ops::<PermissionsContainer>(Arc::new(BlobStore::default()), None),
|
||||
deno_fetch::deno_fetch::init_ops::<PermissionsContainer>(fetch_options),
|
||||
deno_net::deno_net::init_ops::<PermissionsContainer>(None, None),
|
||||
deno_telemetry::deno_telemetry::init(),
|
||||
deno_webidl::deno_webidl::init(),
|
||||
deno_url::deno_url::init(),
|
||||
deno_console::deno_console::init(),
|
||||
deno_web::deno_web::init::<PermissionsContainer>(Arc::new(BlobStore::default()), None),
|
||||
deno_fetch::deno_fetch::init::<PermissionsContainer>(fetch_options),
|
||||
deno_net::deno_net::init::<PermissionsContainer>(None, None),
|
||||
fetch::init(),
|
||||
ext,
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Opt-in smoke tests for the nativets V8 runtime.
|
||||
//!
|
||||
//! Exercise the deno_core / deno_ast / swc surface (TypeScript transpile,
|
||||
//! fetch, timers, URL, structuredClone, error propagation, concurrent
|
||||
//! isolates, large payload roundtrip) that the standard worker-level
|
||||
//! nativets tests in `backend/tests/worker.rs` don't reach — those tests
|
||||
//! validate value passing through the job queue, but not the JS API
|
||||
//! surface a deno_core bump would actually move.
|
||||
//!
|
||||
//! These tests are `#[ignore]`'d so the regular `cargo test` flow doesn't
|
||||
//! pay their cost (each spawns a V8 isolate; some hit the network). Run
|
||||
//! when changing the `deno_core` / `deno_ast` / `deno_runtime` / `swc_*`
|
||||
//! pins in `backend/Cargo.toml`:
|
||||
//!
|
||||
//! cargo test -p windmill-runtime-nativets smoke -- --ignored
|
||||
//!
|
||||
//! Tests prefixed `smoke_net_` hit the public internet (httpbin.org,
|
||||
//! example.com) and will fail if the runner has no egress. Skip them
|
||||
//! locally with `cargo test -p windmill-runtime-nativets smoke -- --ignored --skip smoke_net_`.
|
||||
|
||||
use crate::{transpile_ts, NativeAnnotation, PrewarmedIsolate, PrewarmedResult};
|
||||
|
||||
/// Compile a TS snippet, run it through a fresh isolate with the given
|
||||
/// positional args, and return the isolate's result + captured logs.
|
||||
async fn run_ts(ts: &str, arg_names: &[&str], args: serde_json::Value) -> PrewarmedResult {
|
||||
let js = transpile_ts(ts.to_string()).expect("transpile_ts failed");
|
||||
let ann = NativeAnnotation { useragent: None, proxy: None };
|
||||
let arg_names: Vec<String> = arg_names.iter().map(|s| s.to_string()).collect();
|
||||
let mut iso = PrewarmedIsolate::spawn(String::new(), js, ann, arg_names, None);
|
||||
iso.wait_ready().await.expect("isolate failed to pre-warm");
|
||||
iso.start_execution(args.to_string())
|
||||
.wait()
|
||||
.await
|
||||
.expect("isolate execution panicked")
|
||||
}
|
||||
|
||||
fn unwrap_value(r: &PrewarmedResult) -> serde_json::Value {
|
||||
let raw = r.result.as_ref().expect("script returned an error");
|
||||
serde_json::from_str(raw.get()).expect("result not valid JSON")
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Local (no network) — these still need V8 / deno_core ops to be wired.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "deno_core upgrade smoke; run with --ignored"]
|
||||
async fn smoke_basic_value_passing() {
|
||||
let ts = r#"
|
||||
export async function main(x: number): Promise<number> {
|
||||
return x + 1;
|
||||
}
|
||||
"#;
|
||||
let r = run_ts(ts, &["x"], serde_json::json!({"x": 41})).await;
|
||||
assert_eq!(unwrap_value(&r), serde_json::json!(42));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "deno_core upgrade smoke; run with --ignored"]
|
||||
async fn smoke_transpile_enum_and_union() {
|
||||
// Enums + discriminated union + as-cast exercise the swc_ecma_ast +
|
||||
// swc_ecma_parser TS-syntax paths the bare value tests don't.
|
||||
let ts = r#"
|
||||
enum Direction { Up = "U", Down = "D" }
|
||||
type Msg = { kind: "move"; dir: Direction } | { kind: "stop" };
|
||||
export async function main(): Promise<string> {
|
||||
const msgs: Msg[] = [
|
||||
{ kind: "move", dir: Direction.Up },
|
||||
{ kind: "stop" },
|
||||
{ kind: "move", dir: Direction.Down },
|
||||
];
|
||||
return msgs.map(m => m.kind === "move" ? m.dir : "_").join(",");
|
||||
}
|
||||
"#;
|
||||
let r = run_ts(ts, &[], serde_json::json!({})).await;
|
||||
assert_eq!(unwrap_value(&r), serde_json::json!("U,_,D"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "deno_core upgrade smoke; run with --ignored"]
|
||||
async fn smoke_set_timeout_and_promise_all() {
|
||||
// setTimeout lives in deno_web; Promise.all hits the V8 microtask
|
||||
// queue. A bump that breaks timer-op registration or microtask drain
|
||||
// would surface here (script would hang or return wrong order).
|
||||
let ts = r#"
|
||||
export async function main(): Promise<number[]> {
|
||||
const delays = [40, 10, 20, 30];
|
||||
return await Promise.all(delays.map(d =>
|
||||
new Promise<number>(resolve => setTimeout(() => resolve(d), d))
|
||||
));
|
||||
}
|
||||
"#;
|
||||
let r = run_ts(ts, &[], serde_json::json!({})).await;
|
||||
// Promise.all preserves input order regardless of resolution order.
|
||||
assert_eq!(unwrap_value(&r), serde_json::json!([40, 10, 20, 30]));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "deno_core upgrade smoke; run with --ignored"]
|
||||
async fn smoke_url_and_searchparams() {
|
||||
// deno_url surface: URL ctor, URLSearchParams parsing + iteration.
|
||||
let ts = r#"
|
||||
export async function main(): Promise<{ host: string; pairs: [string, string][] }> {
|
||||
const u = new URL("https://example.com:8443/path?b=2&a=1&a=3");
|
||||
const pairs: [string, string][] = [];
|
||||
for (const [k, v] of u.searchParams) pairs.push([k, v]);
|
||||
return { host: u.host, pairs };
|
||||
}
|
||||
"#;
|
||||
let r = run_ts(ts, &[], serde_json::json!({})).await;
|
||||
assert_eq!(
|
||||
unwrap_value(&r),
|
||||
serde_json::json!({
|
||||
"host": "example.com:8443",
|
||||
"pairs": [["b", "2"], ["a", "1"], ["a", "3"]],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "deno_core upgrade smoke; run with --ignored"]
|
||||
async fn smoke_web_blob_btoa_atob() {
|
||||
// deno_web surface: Blob, atob/btoa. `structuredClone` is *not* wired
|
||||
// into the nativets global (the deno_web binding doesn't expose it
|
||||
// here) — if that's ever changed, extend this test to cover it.
|
||||
let ts = r#"
|
||||
export async function main(): Promise<{ b64: string; round_trip: string; size: number }> {
|
||||
const blob = new Blob(["hello"], { type: "text/plain" });
|
||||
const b64 = btoa("hello");
|
||||
const round_trip = atob(b64);
|
||||
return { b64, round_trip, size: blob.size };
|
||||
}
|
||||
"#;
|
||||
let r = run_ts(ts, &[], serde_json::json!({})).await;
|
||||
assert_eq!(
|
||||
unwrap_value(&r),
|
||||
serde_json::json!({
|
||||
"b64": "aGVsbG8=",
|
||||
"round_trip": "hello",
|
||||
"size": 5,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "deno_core upgrade smoke; run with --ignored"]
|
||||
async fn smoke_large_payload_roundtrip() {
|
||||
// ~512 KB string in and out — exercises arg encoding + result
|
||||
// serialization through the deno_core <-> host op boundary at sizes
|
||||
// an op-table change could break.
|
||||
let big_in: String = "a".repeat(512 * 1024);
|
||||
let ts = r#"
|
||||
export async function main(s: string): Promise<{ in_len: number; out: string }> {
|
||||
if (typeof s !== "string") throw new Error(`expected string, got ${typeof s}`);
|
||||
return { in_len: s.length, out: "b".repeat(512 * 1024) };
|
||||
}
|
||||
"#;
|
||||
let r = run_ts(ts, &["s"], serde_json::json!({"s": big_in})).await;
|
||||
let v = unwrap_value(&r);
|
||||
assert_eq!(v.get("in_len").and_then(|x| x.as_u64()), Some(512 * 1024));
|
||||
let out_len = v
|
||||
.get("out")
|
||||
.and_then(|x| x.as_str())
|
||||
.map(|s| s.len())
|
||||
.unwrap_or(0);
|
||||
assert_eq!(out_len, 512 * 1024);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "deno_core upgrade smoke; run with --ignored"]
|
||||
async fn smoke_error_propagation_with_message() {
|
||||
// Throwing a typed Error must surface as PrewarmedResult::Err with
|
||||
// the original message. A deno_core bump that changes the host-side
|
||||
// error wrapping would lose this contract.
|
||||
let ts = r#"
|
||||
export async function main(): Promise<void> {
|
||||
throw new Error("nativets_smoke_marker_xyz");
|
||||
}
|
||||
"#;
|
||||
let r = run_ts(ts, &[], serde_json::json!({})).await;
|
||||
let err = r.result.expect_err("expected script to fail");
|
||||
assert!(
|
||||
err.contains("nativets_smoke_marker_xyz"),
|
||||
"thrown error message did not reach result: {err}",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "deno_core upgrade smoke; run with --ignored"]
|
||||
async fn smoke_concurrent_isolates() {
|
||||
// Spawn N isolates in parallel from the same tokio runtime. Each
|
||||
// PrewarmedIsolate uses spawn_blocking + a fresh V8 isolate.
|
||||
// Catches isolate-setup races (V8_ISOLATE_CREATE_LOCK ordering) and
|
||||
// any per-isolate state that a deno_core bump could break under
|
||||
// concurrency.
|
||||
let ts = r#"
|
||||
export async function main(i: number): Promise<number> {
|
||||
return i * 10;
|
||||
}
|
||||
"#;
|
||||
let js = transpile_ts(ts.to_string()).expect("transpile_ts failed");
|
||||
|
||||
const N: i64 = 8;
|
||||
let mut handles = Vec::with_capacity(N as usize);
|
||||
for i in 0..N {
|
||||
let js = js.clone();
|
||||
let h = tokio::spawn(async move {
|
||||
let ann = NativeAnnotation { useragent: None, proxy: None };
|
||||
let mut iso =
|
||||
PrewarmedIsolate::spawn(String::new(), js, ann, vec!["i".to_string()], None);
|
||||
iso.wait_ready().await.expect("pre-warm failed");
|
||||
let res = iso
|
||||
.start_execution(serde_json::json!({"i": i}).to_string())
|
||||
.wait()
|
||||
.await
|
||||
.expect("isolate panicked");
|
||||
res.result.expect("script errored")
|
||||
});
|
||||
handles.push(h);
|
||||
}
|
||||
|
||||
let mut got: Vec<i64> = Vec::with_capacity(N as usize);
|
||||
for h in handles {
|
||||
let raw = h.await.expect("join failed");
|
||||
let v: serde_json::Value = serde_json::from_str(raw.get()).expect("not JSON");
|
||||
got.push(v.as_i64().unwrap_or(-1));
|
||||
}
|
||||
got.sort();
|
||||
let expected: Vec<i64> = (0..N).map(|i| i * 10).collect();
|
||||
assert_eq!(got, expected);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Network — actually exercise deno_fetch end-to-end. Skip in air-gapped CI
|
||||
// with `--skip smoke_net_`.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "deno_core upgrade smoke (network); run with --ignored"]
|
||||
async fn smoke_net_fetch_example_com() {
|
||||
// example.com is one of the most stable hosts on the internet and
|
||||
// returns a tiny known-text body, so we can both assert "fetch works"
|
||||
// and "the response body parses correctly through deno_fetch".
|
||||
let ts = r#"
|
||||
export async function main(): Promise<{ status: number; has_marker: boolean }> {
|
||||
const r = await fetch("https://example.com/");
|
||||
const body = await r.text();
|
||||
return { status: r.status, has_marker: body.includes("Example Domain") };
|
||||
}
|
||||
"#;
|
||||
let r = run_ts(ts, &[], serde_json::json!({})).await;
|
||||
let v = unwrap_value(&r);
|
||||
assert_eq!(v.get("status").and_then(|x| x.as_u64()), Some(200));
|
||||
assert_eq!(v.get("has_marker"), Some(&serde_json::json!(true)));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "deno_core upgrade smoke (network); run with --ignored"]
|
||||
async fn smoke_net_fetch_json_and_headers() {
|
||||
// httpbin.org/anything echoes request metadata back as JSON, so we
|
||||
// can verify: deno_fetch sends custom headers, parses JSON response,
|
||||
// and propagates query params end-to-end.
|
||||
let ts = r#"
|
||||
export async function main(): Promise<{ ua: string; arg: string }> {
|
||||
const r = await fetch("https://httpbin.org/anything?nativets=ok", {
|
||||
headers: { "x-windmill-smoke": "1" },
|
||||
});
|
||||
if (!r.ok) throw new Error(`status ${r.status}`);
|
||||
const j: any = await r.json();
|
||||
return {
|
||||
ua: j.headers["X-Windmill-Smoke"] ?? "",
|
||||
arg: j.args.nativets ?? "",
|
||||
};
|
||||
}
|
||||
"#;
|
||||
let r = run_ts(ts, &[], serde_json::json!({})).await;
|
||||
let v = unwrap_value(&r);
|
||||
assert_eq!(v.get("ua").and_then(|x| x.as_str()), Some("1"));
|
||||
assert_eq!(v.get("arg").and_then(|x| x.as_str()), Some("ok"));
|
||||
}
|
||||
@@ -1238,10 +1238,39 @@ async fn delete_resources_bulk(
|
||||
.await?;
|
||||
|
||||
if let Some(res_data) = trash_resource {
|
||||
// Per-resource linked vars so each resource's trash entry carries
|
||||
// exactly the variables that vanished with it (matching the
|
||||
// single-delete shape: trash_data["linked_variables"]).
|
||||
let mut this_linked: Vec<String> = Vec::new();
|
||||
if let Some(value) = res_data.get("value") {
|
||||
collect_var_refs(value, &mut linked_var_paths);
|
||||
collect_var_refs(value, &mut this_linked);
|
||||
}
|
||||
this_linked.sort();
|
||||
this_linked.dedup();
|
||||
|
||||
let trash_linked_vars: Vec<serde_json::Value> = if this_linked.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let placeholders: Vec<String> = this_linked
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, _)| format!("${}", i + 2))
|
||||
.collect();
|
||||
let query = format!(
|
||||
"SELECT to_jsonb(t) FROM variable t WHERE workspace_id = $1 AND path IN ({})",
|
||||
placeholders.join(", ")
|
||||
);
|
||||
let mut q = sqlx::query_scalar::<_, serde_json::Value>(&query).bind(&w_id);
|
||||
for var_path in &this_linked {
|
||||
q = q.bind(var_path);
|
||||
}
|
||||
q.fetch_all(&mut *tx).await?
|
||||
};
|
||||
|
||||
let mut trash_data = serde_json::json!({"row": res_data});
|
||||
if !trash_linked_vars.is_empty() {
|
||||
trash_data["linked_variables"] = serde_json::Value::Array(trash_linked_vars);
|
||||
}
|
||||
let trash_data = serde_json::json!({"row": res_data});
|
||||
windmill_common::trashbin::move_to_trash(
|
||||
&mut *tx,
|
||||
&w_id,
|
||||
@@ -1251,6 +1280,8 @@ async fn delete_resources_bulk(
|
||||
&authed.username,
|
||||
)
|
||||
.await?;
|
||||
|
||||
linked_var_paths.extend(this_linked);
|
||||
}
|
||||
}
|
||||
linked_var_paths.sort();
|
||||
|
||||
@@ -755,6 +755,17 @@ async fn delete_variables_bulk(
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
// Mirror single delete_variable: clean the linked-resource ws_specific
|
||||
// markers BEFORE deleting the resource rows so they don't survive as
|
||||
// orphans. A resource later created at the same path would otherwise
|
||||
// inherit a stale ws_specific flag.
|
||||
sqlx::query!(
|
||||
"DELETE FROM ws_specific WHERE workspace_id = $1 AND item_kind = 'resource' AND path = ANY($2)",
|
||||
w_id,
|
||||
&deleted_paths
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"DELETE FROM resource WHERE path = ANY($1) AND workspace_id = $2",
|
||||
&deleted_paths,
|
||||
@@ -1019,6 +1030,20 @@ async fn update_variable(
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// The linked resource at the same path is renamed above; move
|
||||
// its ws_specific 'resource' marker too so an explicitly-flagged
|
||||
// resource doesn't lose its ws_specific status on rename and
|
||||
// doesn't leave a stale marker at the old path. Symmetric with
|
||||
// update_resource's rename block.
|
||||
sqlx::query!(
|
||||
"UPDATE ws_specific SET path = $1 WHERE workspace_id = $2 AND item_kind = 'resource' AND path = $3",
|
||||
npath,
|
||||
w_id,
|
||||
path
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user