Compare commits

..
Author SHA1 Message Date
centdix 00392ba548 fix 2026-06-23 16:23:41 +02:00
2597 changed files with 35568 additions and 265196 deletions
-15
View File
@@ -31,21 +31,6 @@ The `up.sql` usually defines:
- trigger-specific fields
- Indexes on foreign keys + any frequently-filtered columns
- Foreign key to `workspace`
- The RLS policies (`see_own`, `see_member`, `see_folder_extra_perms_user_*`, `see_extra_perms_user_*`, `see_extra_perms_groups_*`), copied from an existing trigger table
**RLS: wrap every session GUC read in a scalar sub-select.** Write the session
reads as `(select current_setting('session.user'))`,
`= any((select regexp_split_to_array(current_setting('session.groups'), ','))::text[])`,
`?| (select regexp_split_to_array(current_setting('session.pgroups'), ','))::text[]`,
`? (select concat('u/', current_setting('session.user')))`, etc. — not the bare
`current_setting(...)`. The GUCs are set with `SET LOCAL`, so the sub-select
hoists them to a one-time InitPlan instead of re-evaluating per scanned row.
Put the `::text[]` cast **outside** the sub-select for the array cases: in an
`= any (...)` context, casting inside — `= any((select ...::text[]))` — makes
Postgres parse the operand as a row-returning subquery and fails at CREATE with
`operator does not exist: text = text[]`. The outside cast keeps it in
array-operand form. See migration `20260714230440_wrap_session_gucs_in_rls_policies`
for the canonical wrapped forms.
Down migration drops the table and any enum types.
-40
View File
@@ -1,40 +0,0 @@
---
name: ai-chat
description: Guidance for improving the Windmill AI chat (copilot), especially global mode — tools, prompts, and context-window discipline. Use when editing chat tools, system prompts, or tool-result shapes under frontend/src/lib/components/copilot/chat, or when changing how the chat manages its context window.
---
## Always benchmark before and after
No context or behavior change ships without an `ai_evals` A/B on the affected mode.
Add or adjust cases for exactly what you changed — see the `ai-evals` skill for
authoring and the full run reference.
Run the affected mode **before** your change and **after**, same model(s), same cases.
## Measure the window first, and cumulative second
Optimize **`finalContextTokens`** (window occupancy — what drives overflow and
compaction), then cumulative prompt tokens.
## Context discipline
The dominant fixed cost is per-iteration overhead: the system prompt **plus every
tool schema** is re-sent on every loop iteration. So:
- **Every tool and every parameter is a permanent tax.** Justify each one and measure
it; an extra "locate" round-trip can cost more than the reads it saves. Strip dead
params rather than leaving them in the schema.
- **Tool results return the minimum.** Never echo content the model already has. The
canonical mistake: a write tool that returns the whole edited artifact right after
the model authored it — return `{ success, message }` instead. When you touch a
*shared* write helper (e.g. `finishAppDraftWrite` in `global/core.ts`), re-check
this invariant for **all** the write tools routing through it — the echo has
regressed before via a shared refactor.
## Prompts and tool descriptions are part of the surface
The system prompt and tool descriptions steer behavior as much as the tools
themselves, and are benchmarkable the same way. A description that advertises
truncation makes the model self-limit; the path-conventions block changes where
drafts land. Treat prompt/description edits as real changes and A/B them — a
pure-prompt change is a legitimate, measurable improvement.
-87
View File
@@ -1,87 +0,0 @@
---
name: ai-evals
description: Author and run black-box benchmark cases for the Windmill AI generation modes (flow/app/script/cli/global) in ai_evals/. Use when adding or changing eval cases, or when running before/after benchmarks for AI chat / copilot changes.
---
# AI evals — authoring and running benchmark cases
`ai_evals/` is a black-box benchmark runner for the Windmill AI generation modes:
`flow`, `app`, `script`, `cli`, `global`. It always tests the **current** production
prompts, tools, and guidance in this checkout. Each attempt runs the real production
path, deterministic validation, then LLM judging.
The goal is to test current production guidance with realistic user requests — **not**
to pin one exact implementation shape.
## Running benchmarks
```bash
cd ai_evals
bun install # first time; frontend modes also need `cd frontend && bun install`
bun run cli -- models # list model aliases
bun run cli -- cases global # list cases for a mode
bun run cli -- run global global-test1-script-create --model sonnet
```
Frontend modes (`flow`/`script`/`app`/`global`) route model calls through a Windmill
backend's `/api/w/<ws>/ai/proxy`, so you need **any** reachable backend:
```bash
WMILL_AI_EVAL_BACKEND_URL=http://127.0.0.1:<port> WMILL_AI_EVAL_BACKEND_WORKSPACE=integration-tests \
bun run cli -- run global <caseIds...> --models sonnet,gpt-5.5,gemini-3.1-pro-preview
```
- **Reuse an existing workspace.** CE builds cap workspaces, so temp-workspace
creation 400s ("reached workspace limit"). Always set
`WMILL_AI_EVAL_BACKEND_WORKSPACE=integration-tests` (or any existing workspace) to
reuse one. The only side effect of a run is upserting an `f/evals/ai/<provider>`
resource there.
- Provider keys live in `ai_evals/.env` and are auto-loaded by bun. The judge is a
separate Anthropic call (default `claude-sonnet-4-6`) regardless of the model under
test.
## Authoring core rules
1. Write prompts like a real user request.
2. Prefer behavior, inputs, constraints, and outcomes over internal implementation.
3. Keep deterministic validation narrow and hard.
4. Put semantic expectations in `judgeChecklist`.
5. Use `expected` fixtures only when exact structure really matters.
### Prompt writing
Prompts should sound like something a user would naturally ask. Do not write prompts
as if the user knows Windmill internals unless the case explicitly tests a power-user
workflow.
Good:
- "Create a flow that routes support requests based on customer tier."
- "Add a reset button that sets the counter back to 0."
- "Create a flow that reuses the existing greeting script instead of duplicating the logic."
Bad:
- "Use `branchone` with 3 branches and a default branch."
- "Create a `rawscript` step with this exact topology."
- "This is a benchmark harness."
### Deterministic validation
Use deterministic checks only for hard failures: missing required files; unexpected
extra files when the prompt says not to create them; syntax errors; unresolved flow
refs; missing required special modules or suspend config; obvious corruption.
Do **not** encode one preferred implementation. Bad hard checks: exact step topology
for a creation flow; exact branch structure when the prompt only asked for routing;
exact input shape when multiple reasonable shapes are acceptable.
### Judge checklist
Every non-trivial case should have a `judgeChecklist` capturing user-visible behavior
that must be present, important constraints, and key completion criteria — not
low-level implementation details unless truly required.
Good: "the flow calculates the order total with 8% tax"; "the flow reuses the existing
workspace script instead of rewriting the logic". Bad: "uses `branchone`"; "contains a
`rawscript` node".
See `ai_evals/README.md` for the full case format, fields, and fixture details.
@@ -1,50 +0,0 @@
---
name: local-review-codex
description: Run the CI Codex PR review locally against this branch's unpushed work (committed + uncommitted) before pushing. Same policy, model, and reasoning effort as the codex-pr-review GitHub action.
---
# Local Codex Review (pre-push)
Runs the exact same review Codex performs in CI (`.github/workflows/codex-pr-review.yml`),
but locally and scoped to work you have not pushed yet — so you catch what CI would flag
before the PR exists. Use this before `git push` on a non-trivial change.
**Correspondence with CI** — identical:
- Policy: `REVIEW.md` (severity triage, public-surface checklist, AGENTS.md compliance, test coverage).
- Model: `gpt-5.6-sol`, `model_reasoning_effort="xhigh"`.
- Output: markdown starting with `## Codex Review`, findings tagged P0 / P1 / P2 with file:line.
**Differences from CI** — local-only:
- Scope is the current branch vs `main` at the merge-base, **including uncommitted changes** (CI reviews a pushed PR diff).
- Sandbox is `read-only` (CI uses `danger-full-access` on an ephemeral runner). Codex reads the diff and files but cannot modify your working tree.
- Fresh context is inherent: `codex exec` is a separate cold process, so it does not anchor on the current chat session — the same reason `local-review` insists on a subagent.
## Prerequisites
- `codex` CLI **>= 0.144.1** installed and authed (`codex login` or `OPENAI_API_KEY`). Older CLIs reject `gpt-5.6-sol` with "requires a newer version of Codex". Upgrade with `npm install --global @openai/codex@0.144.1` (may need `sudo` for a global install). Keep this in sync with the pin in `.github/workflows/codex-pr-review.yml`.
- `git fetch` the base ref if it's stale, so the merge-base is accurate.
## Run
```bash
bash .agents/skills/local-review-codex/run.sh # review vs main (default)
bash .agents/skills/local-review-codex/run.sh <base> # review vs a different base ref
```
Invoke with `bash` (or run the executable directly) — the script needs Bash for
`set -o pipefail`; `sh` is Dash on Debian/Ubuntu and would fail. If `main` isn't a
local branch (e.g. a fresh single-branch checkout), the runner falls back to
`origin/main` automatically.
The script computes `BASE_SHA = git merge-base HEAD <base>`, feeds Codex `REVIEW.md` plus a
diff context pointing at `git diff <BASE_SHA>` (which folds in uncommitted edits), and prints
the review. It writes only temp files — nothing lands in the working tree.
## Relaying the result
Print the Codex output verbatim. Do not re-summarize or filter it — the value of a cold Codex
pass is surfacing what the current session would rationalize away. Then decide with the user
whether to address findings before pushing.
For a Claude-native review instead, use `local-review` (branch-diff-reviewer subagent). This
skill is the Codex counterpart; run both for independent perspectives.
-91
View File
@@ -1,91 +0,0 @@
#!/usr/bin/env bash
# Local Codex review — mirrors the .github/workflows/codex-pr-review.yml CI job,
# but scoped to this branch's unpushed work (committed + uncommitted) so you can
# review before pushing. Same policy (REVIEW.md), same model (gpt-5.6-sol) and
# reasoning effort (xhigh) as CI. Runs read-only: Codex cannot modify your tree.
#
# Usage: run.sh [BASE_REF] (BASE_REF defaults to "main")
set -euo pipefail
BASE_REF="${1:-main}"
REPO_ROOT="$(git rev-parse --show-toplevel)"
cd "$REPO_ROOT"
if ! command -v codex >/dev/null 2>&1; then
echo "codex CLI not found. Install with: npm install --global @openai/codex@0.144.1" >&2
exit 1
fi
# Resolve the base to a concrete commit, preferring a local ref but falling back to
# the remote-tracking ref — checkouts (CI, single-branch clones) often have only
# origin/main, not a local main.
if git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null; then
BASE_COMMITISH="$BASE_REF"
elif git rev-parse --verify --quiet "origin/${BASE_REF}^{commit}" >/dev/null; then
BASE_COMMITISH="origin/${BASE_REF}"
else
echo "Base ref '$BASE_REF' not found as '$BASE_REF' or 'origin/$BASE_REF'. Try: git fetch origin $BASE_REF" >&2
exit 1
fi
# Diff from the merge-base so only this branch's changes are reviewed. Using the
# base SHA with a single-ref `git diff` also folds in uncommitted working-tree edits,
# but `git diff` never sees untracked files — those are gathered separately below so
# brand-new files (a whole new module, a new skill dir) are not silently skipped.
BASE_SHA="$(git merge-base HEAD "$BASE_COMMITISH")"
HEAD_SHA="$(git rev-parse HEAD)"
UNTRACKED="$(git ls-files --others --exclude-standard)"
if [ "$BASE_SHA" = "$HEAD_SHA" ] && git diff --quiet "$BASE_SHA" && [ -z "$UNTRACKED" ]; then
echo "No changes vs $BASE_REF — nothing to review." >&2
exit 0
fi
PROMPT="$(mktemp)"
OUT="$(mktemp)"
trap 'rm -f "$PROMPT" "$OUT"' EXIT
# REVIEW.md is the shared policy CI feeds Codex. Append the local output-format
# and diff context inline (CI reads these from a generated context file; inlining
# keeps the working tree clean — no scratch files land in the repo).
cat REVIEW.md > "$PROMPT"
cat >> "$PROMPT" <<EOF
# Codex output format
- This is a pre-push LOCAL review of unpushed work; there is no PR yet.
- Inspect the changes by running the diff commands in the review context below.
- Untracked files do NOT appear in \`git diff\`. Review every untracked path listed below by reading it directly (\`cat\`) — treat its entire contents as newly added.
- Return markdown starting with \`## Codex Review\`.
- Tag each finding with a severity (P0 / P1 / P2), file path, and line number when known confidently.
# Review context
Local review (pre-push): current branch vs $BASE_REF
Base SHA: $BASE_SHA
Head SHA: $HEAD_SHA (plus any uncommitted working-tree changes)
Changed commits command:
git log --oneline $BASE_SHA..HEAD
Changed files command:
git diff --stat $BASE_SHA
Full review diff command (tracked changes, includes uncommitted edits):
git diff --unified=0 $BASE_SHA
Untracked files (NOT in the diff above — read each one directly, it is entirely new):
$(if [ -n "$UNTRACKED" ]; then printf '%s\n' "$UNTRACKED"; else echo "(none)"; fi)
EOF
codex exec \
-C "$REPO_ROOT" \
-m gpt-5.6-sol \
-c 'model_reasoning_effort="xhigh"' \
-s read-only \
-o "$OUT" \
- < "$PROMPT"
echo
echo "===== Codex review ====="
cat "$OUT"
+3 -44
View File
@@ -1,19 +1,18 @@
---
name: pr
user_invocable: true
description: Open a draft pull request on GitHub and drive CI review rounds until it is ready. MUST use when you want to create/open a PR.
description: Open a draft pull request on GitHub. MUST use when you want to create/open a PR.
---
# Pull Request Skill
Create a draft pull request with a clear title and explicit description of changes, then drive it through CI review rounds to ready.
Create a draft pull request with a clear title and explicit description of changes.
## Instructions
1. **Analyze branch changes**: Understand all commits since diverging from main
2. **Push to remote**: Ensure all commits are pushed
3. **Create draft PR**: Always open as draft for review before merging
4. **Drive review rounds**: trigger CI reviews on the draft and only flip to ready once every verdict is a go (see "Review rounds" below)
## PR Title Format
@@ -97,11 +96,7 @@ and continue once they confirm it's done.
1. Run `git status` to check for uncommitted changes
2. Run `git log main..HEAD --oneline` to see all commits in this branch
3. Run `git diff main...HEAD` to see the full diff against main
4. **Review the diff before creating the PR — run both reviews, do not skip:**
- **`local-review`** — Claude-native branch-diff-reviewer (`/local-review` in Claude Code, `$local-review` in Codex, `pi --skill local-review` / `/skill:local-review` in Pi).
- **`local-review-codex`** — cold Codex pass, the same review CI runs, for an independent perspective the Claude pass misses (`/local-review-codex` in Claude Code, or `bash .agents/skills/local-review-codex/run.sh`). If the `codex` CLI is missing or older than the version pinned in that skill, note it in your summary and continue — never block the PR on codex being unavailable.
Run both — they catch different things. If either surfaces issues, fix them and commit before proceeding.
4. **Invoke the `local-review` skill** before creating the PR (`/local-review` in Claude Code, `$local-review` in Codex, `pi --skill local-review` / `/skill:local-review` in Pi). If issues are found, fix them and commit before proceeding. Do not skip this step.
5. **Screenshots for frontend changes**: if `git diff main...HEAD --name-only` matches `^frontend/`, capture and embed screenshots of the affected UI per "Screenshots" above before writing the PR body (skip only if there is no visible UI effect).
6. Check if remote branch exists and is up to date:
```bash
@@ -125,42 +120,6 @@ and continue once they confirm it's done.
)"
```
9. Return the PR URL to the user
10. Drive the PR through CI review rounds to ready (see "Review rounds" below)
## Review rounds (draft → ready)
A PR leaves draft **only after a clean CI review round**. Never run `gh pr ready` before that.
1. **Trigger a round and wait for it**: launch the waiter as a background Bash task (a round takes 1030 min; you are woken when it exits — do not stop the session or poll in the foreground while it runs):
```bash
bash .agents/skills/pr/review-round.sh <PR_NUMBER>
```
It comments `/review` on the PR — which runs the Codex, Claude and Pi CI reviewers even on a draft — waits for the spawned `PR Review Commands` workflow run(s) to complete, then prints one verdict line per reviewer and saves the full review comments to files.
`/review` (and `/codex`) are **idempotent per head SHA**: if a running or successful review already covers the current head, they skip that agent and post nothing new — the waiter reads the existing verdict for that head, so a skipped agent is *not* a missing one. A cancelled/failed head run is re-run in place; a fresh run is launched only when nothing covers the head. So an unchanged-head re-review is a near no-op, not a new round — push a commit to get genuinely fresh reviews.
2. **Judge the round.** Codex is mandatory; Claude, Pi and cubic count whenever they posted. Every review starts with one of the three `REVIEW.md` verdicts:
- Codex verdict missing → the round is void: the waiter warns only when the head has no green Codex run (cancelled/failed/absent — not merely skipped-because-already-reviewed). Comment `/codex` on the PR, which re-runs the interrupted run in place (or launches one if none exists), wait the same way, and judge again.
- Any **"Should address issues before merging"** → fix the P0/P1 findings (and the nits while you're there), commit, push, and start a new round (step 1).
- Only **"Mergeable, but should ideally address nits"** and/or **"Good to merge"** → fix the nits too; a nit that is wrong or genuinely not worth fixing may instead be dismissed by replying to the review comment with your reasoning. Push nit-only fixes without starting another full round.
3. **Flip to ready with the marker comment.** The review workflows skip the redundant `ready_for_review`-triggered round when the PR author has posted a marker naming the current head SHA **and** the PR's latest Codex review *posted before the marker* has a non-blocking verdict (reviewer evidence — a bare marker with no round behind it, or one whose last pre-marker Codex verdict is "Should address issues", skips nothing). Keep the prefix exact and use the full 40-char SHA of the head you are flipping:
- every verdict was "Good to merge" (head unchanged since the round):
`✅ Review round clean @ <head-sha>`
- nit-only round, nits fixed or dismissed afterwards (head may have moved past the reviewed SHA — say so):
`✅ Review round clean @ <head-sha> — nit-only verdicts at <round-sha>; nits addressed in <commit sha(s)> / dismissed in review replies`
```bash
gh pr comment <PR_NUMBER> --body "✅ Review round clean @ $(git rev-parse HEAD)"
gh pr ready <PR_NUMBER>
```
If any P0/P1 finding is unaddressed or the head moved for reasons other than nit fixes, do **not** post the marker or flip — run another round instead.
## EE Companion PR (when `*_ee.rs` files were modified)
-172
View File
@@ -1,172 +0,0 @@
#!/usr/bin/env bash
# Trigger a CI review round on a PR and wait for it to finish.
#
# Usage: bash .agents/skills/pr/review-round.sh [PR_NUMBER]
# PR_NUMBER defaults to the current branch's PR.
#
# Comments `/review` on the PR (works on drafts), waits for the spawned
# "PR Review Commands" workflow run(s) to complete, then prints one verdict
# line per reviewer and saves each full review comment to a file. A round
# takes 10-30 minutes: run this in the background and act on its output when
# it exits, per the pr skill ("Review rounds").
set -euo pipefail
REPO=${REPO:-$(gh repo view --json nameWithOwner --jq .nameWithOwner)}
PR=${1:-$(gh pr view --json number --jq .number)}
# Timestamp of the trigger comment, straight from GitHub, so local clock skew
# can't make the run/comment filters below miss part of the round.
TRIGGER_TIME=$(gh api "repos/$REPO/issues/$PR/comments" -f body='/review' --jq .created_at)
echo "Review round triggered on $REPO#$PR at $TRIGGER_TIME"
# Retry wrapper for one-off gh/API hiccups: a 45-minute wait must not die on
# a single transient failure.
retry() {
local attempt
for attempt in 1 2 3; do
if "$@"; then return 0; fi
sleep 10
done
return 1
}
# The /review comment spawns one "PR Review Commands" run holding the
# claude/codex/pi jobs. Runs aren't linked to a PR, so wait on every run of
# that workflow created after the trigger: a concurrent round on another PR
# can only delay the answer, never truncate it. Every issue comment on any PR
# spawns a fast-completing parse run of the same workflow, so the round's own
# run may briefly lag the listing while unrelated runs already show completed:
# require the all-completed state to hold past a floor and across two
# consecutive polls before trusting it.
DEADLINE=$(( $(date +%s) + 45 * 60 ))
NO_RUN_DEADLINE=$(( $(date +%s) + 5 * 60 ))
MIN_WAIT_UNTIL=$(( $(date +%s) + 3 * 60 ))
STABLE=0
FAILURES=0
while :; do
if RUNS=$(gh run list --repo "$REPO" --workflow=pr-review-commands.yml \
--created ">=$TRIGGER_TIME" --limit 100 --json status); then
FAILURES=0
else
FAILURES=$(( FAILURES + 1 ))
if [ "$FAILURES" -ge 5 ]; then
echo "ERROR: listing workflow runs failed $FAILURES times in a row; aborting the wait." >&2
exit 1
fi
echo "WARNING: listing workflow runs failed (attempt $FAILURES/5); retrying in 60s." >&2
sleep 60
continue
fi
TOTAL=$(jq length <<<"$RUNS")
PENDING=$(jq '[.[] | select(.status != "completed")] | length' <<<"$RUNS")
NOW=$(date +%s)
if [ "$TOTAL" -gt 0 ] && [ "$PENDING" -eq 0 ] && [ "$NOW" -gt "$MIN_WAIT_UNTIL" ]; then
STABLE=$(( STABLE + 1 ))
if [ "$STABLE" -ge 2 ]; then
break
fi
else
STABLE=0
fi
if [ "$TOTAL" -eq 0 ] && [ "$NOW" -gt "$NO_RUN_DEADLINE" ]; then
echo "ERROR: no 'PR Review Commands' run appeared within 5 minutes of the /review comment; check that the comment author has write access and the workflow is enabled." >&2
exit 1
fi
if [ "$NOW" -gt "$DEADLINE" ]; then
echo "WARNING: review round still pending after 45 minutes; reporting whatever has been posted so far." >&2
break
fi
sleep 60
done
# Head SHA at trigger time. `/review` is idempotent per head: it skips an agent a
# running/successful review already covers, re-runs a cancelled/failed one in place on a
# separate head-tied run, and launches fresh only when nothing covers the head. Verdict
# reading below therefore keys off the head, not just the trigger timestamp.
HEAD_SHA=$(retry gh api "repos/$REPO/pulls/$PR" --jq .head.sha)
echo "Reviewing head $HEAD_SHA"
# Newest non-skipped run of <workflow> tied to the head ("status conclusion"), or empty
# when none exists. A re-run-in-place or an already-covering review resolves on such a
# head-tied run — separate from the pr-review-commands run waited on above (a fresh
# launch instead runs inside it, and posts after the trigger). A `skipped` run is the
# draft/fork gate and produced no review, so it is ignored.
head_run_state() {
gh run list --repo "$REPO" --workflow "$1" --commit "$HEAD_SHA" --limit 20 \
--json databaseId,status,conclusion \
--jq '[.[] | select(.conclusion != "skipped")] | sort_by(.databaseId) | last | if . then "\(.status) \(.conclusion // "-")" else empty end' 2>/dev/null || true
}
# A re-run-in-place review lands on a head-tied run that finishes after the fast
# pr-review-commands run, so let those settle before reading verdicts.
for wf in codex-pr-review.yml pi-pr-review.yml pr-ready-review.yml; do
while :; do
case "$(head_run_state "$wf")" in
""|"completed "*) break ;;
*) if [ "$(date +%s)" -gt "$DEADLINE" ]; then break; fi; sleep 30 ;;
esac
done
done
OUT_DIR=$(mktemp -d -t review-round-XXXXXX)
COMMENTS_RAW=$(retry gh api "repos/$REPO/issues/$PR/comments?per_page=100" --paginate)
# Two views: comments from THIS round (after the trigger) and the full history. A fresh
# launch posts after the trigger; an idempotent skip leaves the covering verdict in the
# earlier run's comment, so fall back to history when that agent's head run is green.
jq -s --arg t "$TRIGGER_TIME" '[.[][] | select(.created_at > $t)]' \
<<<"$COMMENTS_RAW" > "$OUT_DIR/comments.json"
jq -s '[.[][]]' <<<"$COMMENTS_RAW" > "$OUT_DIR/comments-all.json"
# cubic posts through the PR reviews API, not issue comments.
REVIEWS_RAW=$(retry gh api "repos/$REPO/pulls/$PR/reviews?per_page=100" --paginate)
jq -s --arg t "$TRIGGER_TIME" '[.[][] | select((.submitted_at // "") > $t)]' \
<<<"$REVIEWS_RAW" > "$OUT_DIR/pr-reviews.json"
VERDICT_RE='(Good to merge|Mergeable, but should ideally address nits|Should address issues before merging)'
body_by_header() { # <file> <header-substring>
jq -r --arg h "$2" '[.[] | select(.body // "" | contains($h))] | last | .body // empty' "$1"
}
body_by_login() { # <file> <login>
jq -r --arg l "$2" '[.[] | select(.user.login == $l)] | last | .body // empty' "$1"
}
head_ok() { [ "$(head_run_state "$1")" = "completed success" ]; }
# Latest verdict for a reviewer: prefer this round's comment; if none and the reviewer's
# head run succeeded (an idempotent /review skipped re-reviewing an already-green head),
# fall back to the covering comment from the full history.
verdict_body() { # <header|login> <value> <workflow>
local body
body=$("body_by_$1" "$OUT_DIR/comments.json" "$2")
if [ -z "$body" ] && head_ok "$3"; then
body=$("body_by_$1" "$OUT_DIR/comments-all.json" "$2")
fi
printf '%s' "$body"
}
report() { # <reviewer-name> <comment-body>
local name=$1 body=$2 verdict
if [ -z "$body" ]; then
echo "$name: (no review posted for this head)"
return
fi
printf '%s\n' "$body" > "$OUT_DIR/$name.md"
verdict=$(printf '%s\n' "$body" | grep -m1 -oE "${VERDICT_RE}.*" | sed 's/\*\*//g' || true)
echo "$name: ${verdict:-(review posted but no verdict line; read $OUT_DIR/$name.md)}"
}
echo
echo "=== Review round verdicts for $REPO#$PR (head $HEAD_SHA) ==="
CODEX_BODY=$(verdict_body header '## Codex Review' codex-pr-review.yml)
report codex "$CODEX_BODY"
report claude "$(verdict_body login 'claude[bot]' pr-ready-review.yml)"
report pi "$(verdict_body header '## Pi Review' pi-pr-review.yml)"
CUBIC_BODY=$(jq -r '[.[] | select(.user.login | test("^cubic(-dev-ai)?(\\[bot\\])?$"; "i"))] | last | .body // empty' \
"$OUT_DIR/pr-reviews.json")
if [ -z "$CUBIC_BODY" ]; then
CUBIC_BODY=$(jq -r '[.[] | select(.user.login | test("^cubic(-dev-ai)?(\\[bot\\])?$"; "i"))] | last | .body // empty' \
"$OUT_DIR/comments.json")
fi
report cubic "$CUBIC_BODY"
echo
echo "Full round output: $OUT_DIR (comments.json, pr-reviews.json, one .md per reviewer)"
if [ -z "$CODEX_BODY" ]; then
echo "WARNING: no Codex verdict for $HEAD_SHA - its head run is not green (cancelled/failed/absent, not merely skipped-because-already-reviewed). Re-trigger with a '/codex' PR comment (re-runs the interrupted run in place, or launches one) and wait again." >&2
fi
+1 -74
View File
@@ -9,75 +9,11 @@ Windmill uses `SQLX_OFFLINE=true` in CI, which requires all `sqlx::query!` / `sq
## When to Run
Run after **adding or editing** a SQL query in Rust source. Without it, CI fails with:
Run after any change to SQL queries in Rust source files. Without it, CI will fail with:
```
error: `SQLX_OFFLINE=true` but there is no cached data for this query
```
**Do NOT run it when a change only *removes* queries.** The cache is already complete for
CI; all that is left are orphaned entries, which are cosmetic and never break a build.
Running `prepare` to tidy them risks destroying the cache for no gain. Delete them
offline instead: for each `.sqlx/query-*.json`, normalize its `query` field (strip `\`
line-continuations, collapse whitespace) and check whether it still appears in any `.rs`
file. That detector reports ~48 false positives in a CE checkout — EE queries live in
`*_ee.rs` symlinks it cannot read — so **filter to the tables your change touched** and
delete only those.
## Before You Run Anything
1. **Back the cache up.** `prepare` deletes `.sqlx/` *before* regenerating, so any compile
failure leaves it gutted (observed: 2350 → 142 entries).
```bash
cp -r backend/.sqlx /tmp/sqlx_backup # restore with: rm -rf backend/.sqlx && cp -r /tmp/sqlx_backup backend/.sqlx
```
2. **Point `DATABASE_URL` at THIS worktree's database.** `prepare` compiles every
`sqlx::query!` against the **live** database. Another worktree's DB lacks your
migrations, so every new-table query fails and takes the cache down with it. The
symptom is `relation "<your_new_table>" does not exist` — that is a wrong
`DATABASE_URL`, not a broken query. See AGENTS.md → "Per-worktree ports and database".
## Queries Inside Tests Need `--all-targets`, Which Fails In A CE Checkout
`prepare` only caches queries in code it compiles, and `--workspace` alone does **not**
compile test targets. A `sqlx::query!` inside `tests/*.rs` therefore gets no entry, and CI
fails on the test target with the usual "no cached data" error even though the lib built
clean. `SQLX_OFFLINE=true cargo check --workspace --all-targets` is what reproduces it.
Adding `--all-targets` caches them — and, in a CE checkout, **aborts partway through**:
`backend/tests/otel.rs` imports `windmill_common::otel_ee`, which exists only behind the
`private` feature, so the compile dies after `prepare` has already emptied `.sqlx/`.
Observed: 2435 → 4 entries, `error: cargo check failed with status: exit status: 101`.
Do not fight it — the abort is a pre-existing EE gap, not something your change caused.
Take the entries you need and put the backup back:
```bash
cd backend
cp -r .sqlx /tmp/sqlx_backup
ls /tmp/sqlx_backup | sort > /tmp/before.txt
DATABASE_URL=<this worktree's db> \
cargo sqlx prepare --workspace -- --workspace --features all_sqlx_features --all-targets
# expected to fail; it still wrote the entries it got to before dying
ls .sqlx | sort > /tmp/after.txt
mkdir -p /tmp/newq
comm -13 /tmp/before.txt /tmp/after.txt | while read f; do cp ".sqlx/$f" /tmp/newq/; done
rm -rf .sqlx && cp -r /tmp/sqlx_backup .sqlx && cp /tmp/newq/*.json .sqlx/
```
**Read every file in `/tmp/newq` before copying it in** — print each one's `query` field and
confirm it is one of yours. The set is small (one per new test query), and anything else in
there means the run got further than you think.
Then verify both targets, since the lib passing says nothing about the tests:
```bash
SQLX_OFFLINE=true cargo check --workspace --features all_sqlx_features # lib
SQLX_OFFLINE=true cargo check -p <your-crate> --all-targets # tests
```
## The Problem
`cargo sqlx prepare --workspace` **deletes all existing cache files** and regenerates only the ones found in the current compilation. If you don't compile with every feature flag (especially `private` for EE files), you will **silently delete EE query caches**, breaking CI for enterprise tests.
@@ -132,16 +68,7 @@ But if it fails with EE compilation errors, use the safe procedure above.
- **Never** run `cargo sqlx prepare --workspace` with only OSS features and commit the result — it will delete EE caches.
- **Never** set `SQLX_OFFLINE=true` for local `cargo sqlx prepare` — use a live database per CLAUDE.md. (CI runs with `SQLX_OFFLINE=true`, which is why the cache must be complete.)
- **Never** run `prepare` without a `.sqlx` backup, or against a `DATABASE_URL` you have not confirmed belongs to this worktree.
- **Never** run `prepare` at all for a removal-only change.
- **Never** skip the verification step (step 4 above).
- **Never** leave a `--all-targets` run's output in place after it aborts — it is a
near-empty cache. Restore the backup and graft on only the entries you verified.
Step 4 compares against `origin/main` because step 1 restored from it, so the two agree.
If you did **not** run step 1 — auditing a branch's cache on its own, say — compare
against `git merge-base HEAD origin/main` instead: `origin/main` advances, so its newer
entries would read as losses on your branch.
## Verification
-179
View File
@@ -1,179 +0,0 @@
#!/usr/bin/env bash
# PreToolUse allowance for scratch file ops: auto-allow a single, plain, single-line
# `mkdir` / `cp` / `mv` / `touch` / `chmod` / `tar` / `unzip` whose every path operand
# resolves under /tmp. Anything else makes no decision (exit 0) and falls back to the normal
# permission flow — where `Bash(mv:*)` and `Bash(chmod:*)` in the `ask` list prompt. A
# PreToolUse `allow` overrides those ask rules, which is why this is a hook and not an allow
# rule: permission rules match a command prefix, so they can only constrain the FIRST operand.
# `cp /tmp/x ~/.zshrc` matches a `cp /tmp/` prefix, and requiring every operand is the point.
#
# Requiring the sources under /tmp too (not just the destination) keeps this from becoming a
# read-exfiltration path around the `Read(**/.env)` / `Read(**/secrets/**)` deny rules: a copy
# out of the project into /tmp would land the content somewhere `Read(/tmp/**)` allows.
#
# Deny-by-default tokenizing, in the same spirit as guard-rm-outside-tmp.sh: every path token
# must consist only of alphanumerics and `. _ / -`. That set contains none of the characters
# bash uses for quoting, expansion, or command separation ($ ` ~ { } ( ) ' " \ ; & | < >), nor
# any glob character, so all of those forms fail by construction. `realpath -m` then resolves
# `..` and existing symlinks, so `/tmp/link` pointing at /etc/passwd is caught.
#
# `tar` and `unzip` get their own parser: their write destination arrives as a flag VALUE
# (`-C`, `-d`) rather than a positional, and a bundle like `-xzf` consumes the token after it.
# Flags are an allowlist, not a denylist, so `-P` / `--absolute-names` — which turn off tar's
# refusal to extract `..` and absolute member paths — defer rather than needing enumeration.
# Extraction additionally requires an explicit destination under /tmp, or a cwd already under
# /tmp, since otherwise members land in the project checkout.
#
# Residual risk accepted: an archive whose members include a symlink pointing out of /tmp
# followed by a write through it can still escape, because tar applies member symlinks as it
# extracts. The archive itself must be under /tmp to get here, so this is a hazard only for
# archives fetched from an untrusted source into the scratch dir.
#
# Assumes GNU `realpath` (-m) and `jq`, both present in this repo's Linux dev env.
set -uo pipefail
input=$(cat)
command -v jq >/dev/null 2>&1 || exit 0
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null)
[ -z "$cmd" ] && exit 0
cwd=$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null)
# A newline separates commands, and the tokenizer below only reads the first line — defer.
case "$cmd" in *$'\n'*) exit 0 ;; esac
read -r -a toks <<< "$cmd"
# 0 iff the token is charset-safe and resolves to a path strictly inside /tmp.
under_tmp() {
local t="$1" canon
# Globs never auto-allow. Bash expands them only after this hook has decided, so realpath
# sees the unexpanded pattern: `/tmp/link*` canonicalizes to itself and passes, then
# expands onto a symlink whose target is outside /tmp. chmod and cp follow command-line
# symlinks, so that is a write to the target. guard-rm-outside-tmp.sh can allow globs
# because `rm` unlinks the symlink itself rather than following it.
case "$t" in *[*?[]*) return 1 ;; esac
[ -n "$(printf '%s' "$t" | tr -d 'A-Za-z0-9._/-')" ] && return 1
# Absolute only. Resolving a relative operand against the cwd makes any bare word look like
# a safe path whenever the cwd is under /tmp, while the tool itself reads it as an option:
# `tar P -xf ...` is --absolute-names, not ./P, and `cp /tmp/t -RL /tmp/o` is a
# dereferencing recursive copy, not a file named -RL.
case "$t" in /*) ;; *) return 1 ;; esac
canon=$(realpath -m -- "$t" 2>/dev/null)
[ -n "$canon" ] || return 1
# /tmp itself is never a target — only paths strictly inside it.
case "$canon" in /tmp/?*) return 0 ;; esac
return 1
}
allow() {
jq -nc --arg r "$1" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"allow",permissionDecisionReason:$r}}'
exit 0
}
# Bare command word only; wrappers (`timeout cp`), env prefixes, and `/bin/cp` defer.
# Options are an allowlist per command, so anything that changes how symlinks are followed
# defers instead of needing enumeration. `cp -L` / `-H` matter most: they dereference while
# recursing, which copies the CONTENT of a symlink target from outside /tmp into a scratch
# dir that `Read(/tmp/**)` then exposes. Plain `-r` and `-a` (which implies `-d`) recreate
# such a symlink as a symlink instead, so no outside content is materialized.
case "${toks[0]:-}" in
mkdir) takes_mode=0; ok_opts='pv' ;;
cp) takes_mode=0; ok_opts='rRvfnpa' ;;
mv) takes_mode=0; ok_opts='vfn' ;;
touch) takes_mode=0; ok_opts='acmv' ;;
chmod) takes_mode=1; ok_opts='Rvfc' ;; # chmod's first operand is a mode, not a path
tar) ok_flags='xctzjJavfC'; val_flags='fC' ;;
unzip) ok_flags='oqnljvd'; val_flags='d' ;;
*) exit 0 ;;
esac
# ---------------------------------------------------------------- tar / unzip
if [ -n "${ok_flags:-}" ]; then
saw_archive=0 saw_dest=0 extracting=0 listing=0 end_opts=0
i=1
while [ "$i" -lt "${#toks[@]}" ]; do
t="${toks[$i]}"
i=$((i + 1))
if [ "$end_opts" = 0 ]; then
[ "$t" = "--" ] && { end_opts=1; continue; }
case "$t" in
-?*)
flags="${t#-}"
# Allowlist: a long option, -P/--absolute-names, --transform, -I and friends all
# leave a residue here and defer rather than being enumerated as denials.
[ -n "$(printf '%s' "$flags" | tr -d "$ok_flags")" ] && exit 0
case "$flags" in *x*) extracting=1 ;; esac
case "${toks[0]}$flags" in unzip*[lv]*) listing=1 ;; esac
# A flag consuming the next token must be alone in its bundle's final position
# (`-xzf a.tar`), else the token it eats is ambiguous.
case "${flags%?}" in *[$val_flags]*) exit 0 ;; esac
case "${flags: -1}" in
[$val_flags])
val="${toks[$i]:-}"
i=$((i + 1))
[ -n "$val" ] || exit 0
under_tmp "$val" || exit 0
case "${flags: -1}" in
f) saw_archive=1 ;;
C | d) saw_dest=1 ;;
esac
;;
esac
continue
;;
esac
fi
# Positional. For tar these are sources (create) or member names (extract); for unzip the
# first is the archive. Requiring every one under /tmp is conservative for member names,
# which are not filesystem paths — those defer rather than being wrongly allowed.
under_tmp "$t" || exit 0
[ "${toks[0]}" = "unzip" ] && saw_archive=1
done
[ "$saw_archive" = 1 ] || exit 0 # tar without -f reads a tape/stdin; unzip needs an archive
# Writes land relative to the working directory unless a destination was given. `unzip -l`
# and `-v` only list, so they need no destination.
if [ "$extracting" = 1 ] || { [ "${toks[0]}" = "unzip" ] && [ "$listing" = 0 ]; }; then
[ "$saw_dest" = 1 ] || under_tmp "${cwd:-$PWD}" || exit 0
fi
allow "archive paths and extraction target are under /tmp"
fi
# ------------------------------------------- mkdir / cp / mv / touch / chmod
path_operand=0
seen_mode=0
end_opts=0
i=1
while [ "$i" -lt "${#toks[@]}" ]; do
t="${toks[$i]}"
i=$((i + 1))
if [ "$end_opts" = 0 ]; then
[ "$t" = "--" ] && { end_opts=1; continue; }
# Checked at any position, not just before the first operand: GNU utils permute, so
# `cp /tmp/tree -RL /tmp/out` still enables dereferencing recursion.
case "$t" in
-?*)
# Allowlist: long options and the dereferencing flags leave a residue and defer.
[ -n "$(printf '%s' "${t#-}" | tr -d "$ok_opts")" ] && exit 0
continue
;;
esac
fi
# chmod: consume the mode operand without a path check. Octal, or symbolic clauses.
if [ "$takes_mode" = 1 ] && [ "$seen_mode" = 0 ]; then
case "$t" in
[0-7] | [0-7][0-7] | [0-7][0-7][0-7] | [0-7][0-7][0-7][0-7]) ;;
*) printf '%s' "$t" | grep -Eq '^[ugoa]*[+=-][rwxXst]*(,[ugoa]*[+=-][rwxXst]*)*$' || exit 0 ;;
esac
seen_mode=1
continue
fi
under_tmp "$t" || exit 0
path_operand=1
done
[ "$path_operand" = 1 ] || exit 0
allow "every path operand is under /tmp"
+2 -6
View File
@@ -10,12 +10,8 @@ if [ -z "$FILE_PATH" ]; then
exit 0
fi
# Only the frontend app itself, i.e. a "frontend" directory sitting at a repo root.
# A bare */frontend/* substring also matches ai_evals/adapters/frontend and the
# ai_evals app fixtures, which no prettier config governs — prettier then falls back
# to its defaults and rewrites the whole file. Anchoring to $CLAUDE_PROJECT_DIR
# instead would skip worktrees edited from a session rooted elsewhere.
if [[ "$FILE_PATH" == *"/frontend/"* ]] && [[ -e "${FILE_PATH%%/frontend/*}/.git" ]]; then
# Check if the file is in the frontend directory
if [[ "$FILE_PATH" == *"/frontend/"* ]]; then
# Check if it's a formattable file type
if [[ "$FILE_PATH" =~ \.(ts|js|svelte|json|css|html|md)$ ]]; then
cd "$CLAUDE_PROJECT_DIR/frontend" || exit 0
-106
View File
@@ -1,106 +0,0 @@
#!/usr/bin/env bash
# PreToolUse guard for `rm`: auto-allow ONLY a single, plain, single-line `rm` whose every
# operand is a whitelisted target — under /tmp, or inside a git working tree located in $HOME
# (a version-controlled project dir). Anything else makes no decision (exit 0) and falls back
# to the normal permission flow, where the `Bash(rm:*)` ask rule prompts (classifier as a
# backstop).
#
# The git-tree allowance trades on "this is a project under version control" being lower-stakes
# than a delete elsewhere — NOT on full recoverability: committed content is restorable via git,
# but untracked / .gitignore'd / uncommitted content, and an independent nested repo's history
# under a recursively-deleted parent, are NOT. Accepted as a deliberate convenience tradeoff.
#
# Deny-by-default: every token must consist only of a safe character set (alphanumerics,
# `. _ / -` and glob chars `* ? [ ]`). That set contains none of the characters bash uses for
# quoting, expansion, or command separation ($ ` ~ { } ( ) ' " \ ; & | < >), so those forms
# fail by construction rather than needing to be enumerated. `realpath -m` then resolves `..`
# and existing symlinks (so a symlink out of the allowed roots is caught), and a wildcard in a
# non-final path segment is refused because it can expand through a symlink realpath can't see.
#
# The git-repo allowance covers targets inside a git working tree under $HOME, and the tree's
# own root folder only when it is a linked worktree (`.git` is a pointer file, so history in
# the main repo survives); a primary checkout's root (`.git` is a history dir) and any `.git`
# path are never auto-allowed. Globs auto-allow only under /tmp — elsewhere their expansion
# could reach `.git` or a dotfile the literal checks never see. Relative operands resolve
# against the command's cwd (from the hook input). A PreToolUse `allow` overrides the ask rule.
#
# Assumes GNU `realpath` (-m) and `jq`, both present in this repo's Linux dev env.
set -uo pipefail
input=$(cat)
command -v jq >/dev/null 2>&1 || exit 0
cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null)
[ -z "$cmd" ] && exit 0
cwd=$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null)
# A newline separates commands, and the tokenizer below only reads the first line — defer.
case "$cmd" in *$'\n'*) exit 0 ;; esac
read -r -a toks <<< "$cmd"
# Bare leading `rm` only; wrappers (`timeout rm`), env prefixes, and `/bin/rm` defer.
[ "${toks[0]:-}" = "rm" ] || exit 0
# 0 (allow) iff the canonical path is an auto-allowable rm target: under /tmp, or strictly
# inside a git working tree located under $HOME. The walk stops at $HOME, so a dotfiles repo at
# ~ can't make all of $HOME deletable, and top-level ~ files stay protected.
allowed_target() {
local canon="$1" d root=""
case "$canon" in /tmp/?*) return 0 ;; esac
[ -n "${HOME:-}" ] || return 1
case "$canon" in "$HOME"/?*) ;; *) return 1 ;; esac
case "$canon" in *"/.git" | *"/.git/"*) return 1 ;; esac # protect history, not recoverable
d="$canon"
while [ "$d" != "/" ] && [ "$d" != "$HOME" ]; do
[ -e "$d/.git" ] && { root="$d"; break; }
d=$(dirname "$d")
done
[ -n "$root" ] || return 1 # not inside a git working tree under $HOME
if [ "$canon" = "$root" ]; then
# Deleting the repo root folder itself: allow only for a linked worktree, whose `.git` is
# a file/pointer so the history lives in the main repo and survives. A primary checkout's
# `.git` is a directory holding the history, so deleting it is unrecoverable — defer.
[ -f "$root/.git" ] && return 0
return 1
fi
return 0
}
had_operand=0
end_opts=0
i=1
while [ "$i" -lt "${#toks[@]}" ]; do
t="${toks[$i]}"
i=$((i + 1))
# Whitelist every token (flags included, so an operator hidden in a flag like `-rf;rm`
# can't slip past): any character outside the safe set makes it unsafe to reason about.
[ -n "$(printf '%s' "$t" | tr -d 'A-Za-z0-9._/*?[]-')" ] && exit 0
# A glob in an option-looking token (`-[-]`) can expand to `--` and turn a later `-name`
# into an operand — never a real option, so defer.
case "$t" in -*[*?[]*) exit 0 ;; esac
if [ "$end_opts" = 0 ]; then
[ "$t" = "--" ] && { end_opts=1; continue; }
# Skip real options only before the first operand. A bare `-` is a filename, and under
# POSIXLY_CORRECT GNU rm stops option parsing at the first operand, so a later `-name`
# is a filename too — validate it rather than skipping it.
if [ "$had_operand" = 0 ]; then
case "$t" in -?*) continue ;; esac
fi
fi
had_operand=1
# No wildcard in a non-final path segment (`a/*/b`): it can expand through a symlink
# realpath can't see. A slashless glob (`*.rs`) is a final-segment match — fine.
case "$t" in */*) case "${t%/*}" in *[*?[]*) exit 0 ;; esac ;; esac
case "$t" in
/*) canon=$(realpath -m -- "$t" 2>/dev/null) ;;
*) canon=$(realpath -m -- "${cwd:-$PWD}/$t" 2>/dev/null) ;;
esac
[ -n "$canon" ] || exit 0
# A glob may auto-allow only under /tmp, where everything is deletable. Elsewhere its
# expansion could match `.git`, a dotfile like `.*`, or a nested checkout root that the
# literal-path checks never see — so require literal operands in git repos.
case "$t" in *[*?[]*) case "$canon" in /tmp/?*) ;; *) exit 0 ;; esac ;; esac
allowed_target "$canon" || exit 0
done
[ "$had_operand" = 1 ] || exit 0
jq -nc '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"allow",permissionDecisionReason:"rm operands are under /tmp or inside a git checkout in $HOME"}}'
+16 -24
View File
@@ -48,11 +48,21 @@
"Read(/tmp/**)",
"Write(/tmp/**)",
"Edit(/tmp/**)",
"mcp__claude_ai_Gmail__search_threads",
"mcp__claude_ai_Gmail__get_thread",
"mcp__claude_ai_Gmail__get_message",
"mcp__claude_ai_Gmail__list_labels",
"mcp__claude_ai_Gmail__list_drafts"
"Bash(rm:/tmp/*)",
"Bash(rm:/tmp/**)",
"Bash(rmdir:/tmp/*)",
"Bash(mkdir:/tmp/*)",
"Bash(mkdir:/tmp/**)",
"Bash(cp:/tmp/*)",
"Bash(cp:/tmp/**)",
"Bash(mv:/tmp/*)",
"Bash(mv:/tmp/**)",
"Bash(touch:/tmp/*)",
"Bash(touch:/tmp/**)",
"Bash(chmod:/tmp/*)",
"Bash(chmod:/tmp/**)",
"Bash(tar * /tmp/*)",
"Bash(unzip * /tmp/*)"
],
"deny": [
"Read(.env)",
@@ -82,15 +92,7 @@
"Bash(shred:*)",
"Bash(unlink:*)",
"mcp__claude_ai_Stripe",
"mcp__claude_ai_Gmail__create_draft",
"mcp__claude_ai_Gmail__update_draft",
"mcp__claude_ai_Gmail__create_label",
"mcp__claude_ai_Gmail__label_message",
"mcp__claude_ai_Gmail__label_thread",
"mcp__claude_ai_Gmail__unlabel_message",
"mcp__claude_ai_Gmail__unlabel_thread",
"mcp__claude_ai_Gmail__apply_sensitive_message_label",
"mcp__claude_ai_Gmail__apply_sensitive_thread_label",
"mcp__claude_ai_Gmail",
"mcp__claude_ai_Google_Calendar",
"mcp__claude_ai_Google_Drive",
"mcp__claude_ai_Slack",
@@ -107,16 +109,6 @@
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-main-branch.sh",
"timeout": 5
},
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-rm-outside-tmp.sh",
"timeout": 5
},
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/allow-fileops-in-tmp.sh",
"timeout": 5
}
]
}
-1
View File
@@ -1 +0,0 @@
../../../.agents/skills/ai-chat/SKILL.md
-1
View File
@@ -1 +0,0 @@
../../../.agents/skills/ai-evals/SKILL.md
@@ -1 +0,0 @@
../../../.agents/skills/local-review-codex/SKILL.md
-3
View File
@@ -1,3 +0,0 @@
# Files a generator owns. Collapsed in review diffs and left out of language
# stats: reviewing them means reviewing the generator instead.
*.gen.ts linguist-generated=true
+1 -1
View File
@@ -28,7 +28,7 @@ ENV PATH="${PATH}:/usr/local/go/bin"
ENV GO_PATH=/usr/local/go/bin/go
# UV
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.11.24/uv-installer.sh | sh && mv /usr/local/cargo/bin/uv /usr/local/bin/uv
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.25/uv-installer.sh | sh && mv /usr/local/cargo/bin/uv /usr/local/bin/uv
ENV TZ=Etc/UTC
-5
View File
@@ -14,7 +14,6 @@ sed -i -e "/version: /s/: .*/: $VERSION/" ${root_dirpath}/openflow.openapi.yaml
sed -i -e "/\"version\": /s/: .*,/: \"$VERSION\",/" ${root_dirpath}/typescript-client/package.json
sed -i -e "/\"version\": /s/: .*,/: \"$VERSION\",/" ${root_dirpath}/typescript-client/jsr.json
sed -i -e "/\"version\": /s/: .*,/: \"$VERSION\",/" ${root_dirpath}/frontend/package.json
sed -i -e "/\"version\": /s/: .*,/: \"$VERSION\",/" ${root_dirpath}/windmill-yaml-validator/package.json
sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/python-client/wmill/pyproject.toml
sed -i -e "/^windmill-api =/s/= .*/= \"\\^$VERSION\"/" ${root_dirpath}/python-client/wmill/pyproject.toml
sed -i -e "/^[[:space:]]*ModuleVersion[[:space:]]*=/s/= .*/= '$VERSION'/" ${root_dirpath}/powershell-client/WindmillClient/WindmillClient.psd1
@@ -29,7 +28,3 @@ sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/backend/parsers/w
sed -i -zE "s/(name = \"windmill[^\"]*\"\nversion = )\"[^\"]*\"/\\1\"$VERSION\"/g" ${root_dirpath}/backend/parsers/windmill-parser-wasm/Cargo.lock
cd ${root_dirpath}/frontend && npm i --package-lock-only --ignore-scripts
# The CLI installs this package on every `bun install`, which would otherwise rewrite the
# lockfile's version and leave a dirty tree.
cd ${root_dirpath}/windmill-yaml-validator && npm i --package-lock-only --ignore-scripts
+1 -1
View File
@@ -1,5 +1,5 @@
# Codex output format
- Read the review context file whose absolute path is given at the end of these instructions; it holds the PR metadata and the diff commands.
- Read `./.github/codex/pr-review-context.md` for PR metadata and the diff commands.
- Return a markdown PR comment starting with `## Codex Review`.
- Tag each finding with a severity (P0 / P1 / P2), file path, and line number when known confidently.
+1 -1
View File
@@ -1,6 +1,6 @@
# Pi output format
- Read the review context file whose absolute path is given at the end of these instructions; it holds the PR metadata and the diff (or the git commands to produce it).
- Read `./.github/pi/pr-review-context.md` for PR metadata and the diff commands.
- Return a markdown PR comment starting with `## Pi Review`.
- Tag each finding with a severity (P0 / P1 / P2), file path, and line number when known confidently.
- Output ONLY the final review markdown — no preamble, no thinking, no tool transcripts.
-160
View File
@@ -1,160 +0,0 @@
// Extracts every windmill.dev/docs link referenced in the frontend source and
// verifies none of them 404. Run: `node .github/scripts/check-docs-links.mjs`.
// Used by the check-docs-links GitHub workflow (release / manual trigger only).
import { readdir, readFile } from 'node:fs/promises'
import { join, extname } from 'node:path'
const ROOT = 'frontend/src'
const EXTS = new Set(['.ts', '.js', '.svelte', '.mjs', '.cjs'])
const DOCS_RE = /https?:\/\/(?:www\.)?windmill\.dev\/docs\/[^\s"'`)>\]}]*/g
// `const someBaseUrl = 'https://www.windmill.dev/docs/...'` used later as `${someBaseUrl}/foo`
const BASE_RE = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*['"`](https?:\/\/(?:www\.)?windmill\.dev\/docs\/[^'"`]+)['"`]/g
const CONCURRENCY = 24
const TIMEOUT_MS = 20000
const RETRIES = 2
// Links whose target page is written but not yet deployed on windmill.dev: the app
// link is already the final slug, so a 404 is expected until the docs side ships.
// The value is why the entry exists, for whoever has to judge whether it still should.
const PENDING_DEPLOY = new Map()
async function walk(dir) {
const out = []
for (const entry of await readdir(dir, { withFileTypes: true })) {
const p = join(dir, entry.name)
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === '.svelte-kit') continue
out.push(...(await walk(p)))
} else if (EXTS.has(extname(entry.name))) {
out.push(p)
}
}
return out
}
// url (no fragment) -> Set of source files it appears in
const urls = new Map()
const unresolved = []
function record(url, file) {
const clean = url
.replace(/\\.*$/, '') // cut at an escape sequence embedded in a string literal (e.g. \n)
.replace(/#.*$/, '') // drop anchor fragment — irrelevant to a 404 check
.replace(/[.,;:'")\]]+$/, '')
if (!clean) return
// A `{`/`${` means the URL is built from an unresolved template/interpolation var.
if (clean.includes('{')) {
unresolved.push(`${clean} (${file})`)
return
}
if (!urls.has(clean)) urls.set(clean, new Set())
urls.get(clean).add(file)
}
for (const file of await walk(ROOT)) {
let content = await readFile(file, 'utf8')
// Inline file-local base-url constants so `${base}/page` template literals resolve.
const bases = []
for (const m of content.matchAll(BASE_RE)) bases.push({ name: m[1], value: m[2], decl: m[0] })
for (const { name, value } of bases) {
content = content.replaceAll('${' + name + '}', value)
}
// Blank each base declaration so a prefix-only base (no index page of its own,
// e.g. .../app_configuration_settings) isn't checked as a standalone link.
// A genuinely bare `${base}` usage was already inlined above, so it's still covered.
for (const { decl } of bases) content = content.replace(decl, '')
for (const m of content.matchAll(DOCS_RE)) record(m[0], file)
}
const allUrls = [...urls.keys()].sort()
console.log(`Found ${allUrls.length} distinct docs links across ${ROOT}`)
if (unresolved.length) {
console.log(`\n⚠️ ${unresolved.length} link(s) built from an unrecognized base URL — skipped (register the base const so they can be checked):`)
for (const u of [...new Set(unresolved)].sort()) console.log(` ${u}`)
}
async function check(url) {
for (let attempt = 0; attempt <= RETRIES; attempt++) {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS)
try {
let res = await fetch(url, {
method: 'HEAD',
redirect: 'follow',
signal: ctrl.signal,
headers: { 'user-agent': 'windmill-docs-link-check' }
})
// Some hosts reject HEAD — fall back to GET.
if (res.status === 405 || res.status === 501) {
res = await fetch(url, {
method: 'GET',
redirect: 'follow',
signal: ctrl.signal,
headers: { 'user-agent': 'windmill-docs-link-check' }
})
}
clearTimeout(timer)
return { url, status: res.status, ok: res.status < 400 }
} catch (err) {
clearTimeout(timer)
if (attempt === RETRIES) return { url, status: 0, ok: false, error: String(err?.message || err) }
await new Promise((r) => setTimeout(r, 500 * (attempt + 1)))
}
}
}
// Simple concurrency pool.
const results = []
let idx = 0
async function worker() {
while (idx < allUrls.length) {
const url = allUrls[idx++]
results.push(await check(url))
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, worker))
// An entry claims one thing — the page is not published yet — and 404 is the only
// answer that means it. A timeout, 403 or 5xx on the same URL is a real fault, and
// suppressing it would also read as "still waiting" and defer the staleness check.
const isPendingDeploy = (r) => PENDING_DEPLOY.has(r.url) && r.status === 404
const pending = results.filter((r) => PENDING_DEPLOY.has(r.url))
const waiting = results.filter(isPendingDeploy)
if (waiting.length) {
console.log(`\n${waiting.length} link(s) waiting on a docs deploy:`)
for (const p of waiting.sort((a, b) => a.url.localeCompare(b.url))) {
console.log(` ${p.url}\n ${PENDING_DEPLOY.get(p.url)} — not live yet (${p.status})`)
}
}
// An entry that outlived its reason exempts a URL from the check forever, so a stale
// one has to fail the job: a line in a green log is not read at release time.
const stale = [
...pending.filter((p) => p.ok).map((p) => [p.url, 'the page is live']),
...[...PENDING_DEPLOY.keys()].filter((u) => !urls.has(u)).map((u) => [u, 'nothing references it'])
]
const failures = results.filter((r) => !r.ok && !isPendingDeploy(r))
if (failures.length === 0 && stale.length === 0) {
console.log(`\n✅ No broken docs links (${allUrls.length} checked).`)
process.exit(0)
}
if (failures.length) {
console.log(`\n${failures.length} broken docs link(s):`)
for (const f of failures.sort((a, b) => a.url.localeCompare(b.url))) {
console.log(`\n ${f.url}`)
console.log(` status: ${f.error ? `error (${f.error})` : f.status}`)
for (const file of urls.get(f.url)) console.log(`${file}`)
}
}
if (stale.length) {
console.log(`\n${stale.length} PENDING_DEPLOY entr(ies) to delete from this script:`)
for (const [url, why] of stale.sort((a, b) => a[0].localeCompare(b[0]))) {
console.log(`\n ${url}\n ${why}`)
}
}
process.exit(1)
+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache-workspaces: backend
toolchain: 1.97.0
toolchain: 1.93.0
- uses: oven-sh/setup-bun@v2
with:
+3 -11
View File
@@ -69,7 +69,7 @@ jobs:
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache-workspaces: backend
toolchain: 1.97.0
toolchain: 1.93.0
- uses: oven-sh/setup-bun@v2
with:
@@ -77,11 +77,9 @@ jobs:
- uses: actions/setup-node@v4
with:
# Node 24 ships npm 11, which frontend/package-lock.json is authored
# with; npm 10 rejects it ("Missing: picomatch@4.0.5 from lock file").
# Node must also stay >= 22.19 for the frontend's undici 8.x, which the
# Node 22.19+ is required by the frontend's undici 8.x, which the
# Vitest bridge loads; Node 20 fails with markAsUncloneable.
node-version: "24"
node-version: "22"
# CE build used only as the AI proxy (login, workspace, provider resource,
# /ai/proxy). No worker execution or MCP needed — global tools/drafts run
@@ -118,12 +116,6 @@ jobs:
npm ci
npm run generate-backend-client
- name: Run harness unit tests
working-directory: ./ai_evals
run: |
bun install
bun test adapters/
- name: Run global AI evals
timeout-minutes: 20
working-directory: ./ai_evals
+4 -4
View File
@@ -23,7 +23,7 @@ jobs:
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache: false
toolchain: 1.97.0
toolchain: 1.93.0
- name: cargo check
working-directory: ./backend
timeout-minutes: 16
@@ -44,7 +44,7 @@ jobs:
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache: false
toolchain: 1.97.0
toolchain: 1.93.0
- name: cargo check
working-directory: ./backend
timeout-minutes: 16
@@ -81,7 +81,7 @@ jobs:
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache: false
toolchain: 1.97.0
toolchain: 1.93.0
- name: cargo check
working-directory: ./backend
timeout-minutes: 16
@@ -118,7 +118,7 @@ jobs:
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache-workspaces: backend
toolchain: 1.97.0
toolchain: 1.93.0
- name: Fix stale v8 build cache
working-directory: ./backend
run: |
+12 -29
View File
@@ -50,12 +50,7 @@ jobs:
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache-workspaces: backend
toolchain: 1.97.0
# This action defaults RUSTFLAGS to "-D warnings"; unset it so the test
# run is not failed by cross-platform dead-code (cfg(unix)-only helpers
# are unused on Windows). Warning hygiene is enforced on the Linux CI
# and the build_windows_worker_ release build, not this test job.
rustflags: ""
toolchain: 1.93.0
- uses: actions/setup-dotnet@v4
with:
@@ -63,9 +58,7 @@ jobs:
- uses: denoland/setup-deno@v2
with:
# Pin to the Deno version shipped in the runtime image (Dockerfile) so CI
# tests what production runs, instead of floating on the latest v2.x.
deno-version: 2.2.1
deno-version: v2.x
- uses: actions/setup-go@v2
with:
@@ -81,7 +74,7 @@ jobs:
- uses: astral-sh/setup-uv@v6.2.1
with:
version: "0.11.24"
version: "0.9.25"
- uses: shivammathur/setup-php@v2
with:
@@ -179,17 +172,13 @@ jobs:
# binary link spikes several hundred MB of transient I/O. Capping at
# 8 trades ~25% wall time for headroom on the ~75GB runner disk.
CARGO_BUILD_JOBS: 8
# backend/Cargo.toml leaves profile.dev at the default debug = 2 for
# the (large) windmill workspace crates; that debuginfo is emitted
# into every object file and embedded in each test binary, and on
# windows-msvc also spawns the mspdbsrv.exe PDB type server. Across a
# full --all --features build it is the dominant consumer of the
# ~63GB free on the runner disk (LNK1180 / disk-full during linking).
# CI needs no debug info, so drop it entirely for the dev/test
# profiles here. debug = 0 supersedes the previous split-debuginfo=off
# knob (no debuginfo => no .pdb and no LNK1318 type-server limit).
CARGO_PROFILE_DEV_DEBUG: "0"
CARGO_PROFILE_TEST_DEBUG: "0"
# backend/Cargo.toml sets split-debuginfo = "unpacked", which on
# windows-msvc is coerced to "packed": every test-binary link spawns
# the mspdbsrv.exe PDB type server and writes a large .pdb. CI needs
# no debug info, so disable PDB generation for the dev/test profiles
# here (avoids both LNK1318 type-server limit and PDB disk usage).
CARGO_PROFILE_DEV_SPLIT_DEBUGINFO: "off"
CARGO_PROFILE_TEST_SPLIT_DEBUGINFO: "off"
# Tests' poll-time stack frames (deep nested async fn chains in
# debug builds) reach ~1.8MB. 4MB gives ~2x headroom against flaky
# overflows under parallel-test contention.
@@ -208,15 +197,9 @@ jobs:
WMDEBUG_FORCE_V0_WORKSPACE_DEPENDENCIES: 1
WMDEBUG_FORCE_RUNNABLE_SETTINGS_V0: 1
WMDEBUG_FORCE_NO_LEGACY_DEBOUNCING_COMPAT: 1
# Windows ships a worker-only binary, so test the crates a worker runs
# (windmill-worker/-common/-queue) via -p, not `--all`: this skips the
# disk-heavy windmill-api test binaries (LNK1180) and the server-only
# windmill-trigger-* crates (amqp does not build on Windows). Linux CI runs the rest.
run: >
cargo test
--no-fail-fast
-p windmill-worker
-p windmill-common
-p windmill-queue
--features private,enterprise,deno_core,duckdb,python,rust,csharp,php,quickjs,parquet,mcp,scoped_cache,windmill-git-sync/private,windmill-object-store/private,windmill-object-store/enterprise
--features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,csharp,php,quickjs,mcp,run_inline
--all
-- --nocapture --test-threads=10
+4 -40
View File
@@ -50,9 +50,7 @@ jobs:
dotnet-version: "9.0.x"
- uses: denoland/setup-deno@v2
with:
# Pin to the Deno version shipped in the runtime image (Dockerfile) so CI
# tests what production runs, instead of floating on the latest v2.x.
deno-version: 2.2.1
deno-version: v2.x
- uses: actions/setup-go@v2
with:
go-version: 1.21.5
@@ -64,7 +62,7 @@ jobs:
node-version: "20"
- uses: astral-sh/setup-uv@v6.2.1
with:
version: "0.11.24"
version: "0.9.25"
- uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
@@ -90,7 +88,7 @@ jobs:
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache-workspaces: backend
toolchain: 1.97.0
toolchain: 1.93.0
- name: Fix stale v8 build cache
working-directory: ./backend
run: |
@@ -239,47 +237,13 @@ jobs:
- name: cargo test
timeout-minutes: 30
env:
# setup-rust-toolchain exports RUSTFLAGS=-D warnings, and the RUSTFLAGS env
# var fully REPLACES (never merges with) target.*.rustflags in
# backend/.cargo/config.toml. That silently drops the config's
# `-C link-arg=-fuse-ld=mold`, so CI links the many large integration-test
# binaries (v8 + duckdb + every language runtime, statically linked) with the
# default bfd linker. Its peak memory across ~12 parallel links OOM-kills the
# runner mid-link (SIGTERM => exit 143, before any test runs). Re-add the mold
# link arg here so CI links with mold like local dev, keeping -D warnings.
# (config.toml's `linker = "clang"` still applies; env only overrides rustflags.)
RUSTFLAGS: "-D warnings -C link-arg=-fuse-ld=mold"
SQLX_OFFLINE: true
DATABASE_URL: postgres://postgres:changeme@localhost:5432/windmill
DISABLE_EMBEDDING: true
RUST_LOG: "off"
RUST_LOG_STYLE: never
CARGO_NET_GIT_FETCH_WITH_CLI: true
# Cap parallel rustc/link jobs below the 16 available cores. The tail of
# the build links ~128 full-graph test binaries (one per tests/*.rs file
# across the workspace); at high parallelism enough heavy codegen+link
# units (rustc ~2.6GB, mold ~1GB each) overlap to exhaust the 64GB
# runner. Matches backend-test-windows.yml, which already uses 8.
CARGO_BUILD_JOBS: 8
# Incremental compilation is per-run dead weight in CI: rust-cache
# (cache-workspaces above) restores compiled dependency artifacts but
# never persists target/**/incremental, so there is no prior state to
# reuse in a one-shot `cargo test`. It only adds per-crate memory
# overhead and extra disk. Off here (kept on for local dev via
# .cargo/config.toml). Matches backend-test-windows.yml.
CARGO_INCREMENTAL: "0"
# backend/Cargo.toml leaves profile.dev at the default debug = 2 for
# the (large) windmill workspace crates; that debug info is emitted
# into every object file and embedded in each test binary. Across the
# full --all --features build it is the dominant memory/disk consumer
# when mold links the windmill-api-integration-tests binary, tipping
# the runner over (lost runner reported as a canceled step). CI needs
# no debug info, so drop it entirely for the dev/test profiles here.
# (test profile inherits dev, but the workspace crates link in as
# dev-profile deps, so both must be set.) CI-only; local dev builds
# are unaffected.
CARGO_PROFILE_DEV_DEBUG: "0"
CARGO_PROFILE_TEST_DEBUG: "0"
CARGO_BUILD_JOBS: 12
# Tests' poll-time stack frames (deep nested async fn chains in
# debug builds) reach ~1.8MB, leaving very thin headroom on the
# default 2MB thread stack. 4MB gives ~2x buffer against flaky
@@ -5,20 +5,6 @@ env:
name: Build caddy-l4
on:
workflow_dispatch:
push:
branches:
- main
paths:
- docker/DockerfileCaddyL4
- docker/entrypoint-caddy.sh
- docker/caddy-compat-normalize.awk
- docker/caddy-l4.version
- docker/test-caddy-compat.sh
- Caddyfile
# The version check below reads the pin out of docker-compose.yml, so a
# compose-only bump has to trigger this workflow or the check never runs.
- docker-compose.yml
- .github/workflows/build-caddy-l4-image.yml
permissions: write-all
@@ -28,35 +14,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: depot/setup-action@v1
# docker-compose.yml pins an exact tag, and the Caddyfile it must agree
# with lives in the same checkout. Fail the build rather than publish a
# version nothing references, which is how :latest drifted from the
# Caddyfile in the first place.
- name: Resolve and check image version
id: version
run: |
set -euo pipefail
version="$(tr -d '[:space:]' < docker/caddy-l4.version)"
pinned="$(grep -oE 'caddy-l4:[^[:space:]"]+' docker-compose.yml | head -1 | cut -d: -f2-)"
caddy="$(grep -m1 -oE '^FROM caddy:[0-9]+\.[0-9]+\.[0-9]+' docker/DockerfileCaddyL4 | cut -d: -f2)"
if [ "$version" != "$pinned" ]; then
echo "docker/caddy-l4.version is '$version' but docker-compose.yml pins '$pinned'" >&2
echo "Bump both together." >&2
exit 1
fi
# Otherwise a caddy bump that forgets the version file publishes a tag
# that names the wrong caddy.
case "$version" in
"$caddy"-*) ;;
*)
echo "docker/caddy-l4.version is '$version' but the Dockerfile pins caddy '$caddy'" >&2
echo "The version must be <caddy-version>-<revision>." >&2
exit 1
;;
esac
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Docker meta
id: meta-ee-public
uses: docker/metadata-action@v5
@@ -66,22 +23,8 @@ jobs:
tags: |
type=sha
type=ref,event=branch
# Not gated on the default branch: docker-compose.yml pins this tag,
# so it has to be publishable from a branch (workflow_dispatch)
# before the pin merges, or main would reference a tag that does not
# exist yet. The version is immutable, so republishing from main is
# a no-op. Only branch pushes to main and manual dispatch run this
# workflow, so a branch cannot claim the tag by accident.
type=raw,value=${{ steps.version.outputs.version }}
type=raw,value=latest,enable={{is_default_branch}}
# The shim rewrites config a self-hoster never sees, so a silent
# regression here strands them on a restart loop or a dead :80.
- name: Test the legacy-Caddyfile compatibility shim
run: |
docker build -f docker/DockerfileCaddyL4 -t caddy-l4:ci ./docker
docker/test-caddy-compat.sh caddy-l4:ci
- name: Login to registry
uses: docker/login-action@v3
with:
@@ -63,7 +63,6 @@ jobs:
push: true
build-args: |
features=ee_rhel
WM_BUILD_VERSION=${{ github.sha }}
secrets: |
rh_username=${{ secrets.RH_USERNAME }}
rh_password=${{ secrets.RH_PASSWORD }}
@@ -65,7 +65,6 @@ jobs:
push: true
build-args: |
features=ee_rhel
WM_BUILD_VERSION=${{ github.sha }}
secrets: |
rh_username=${{ secrets.RH_USERNAME }}
rh_password=${{ secrets.RH_PASSWORD }}
@@ -83,7 +82,6 @@ jobs:
push: true
build-args: |
features=ee_rhel
WM_BUILD_VERSION=${{ github.sha }}
secrets: |
rh_username=${{ secrets.RH_USERNAME }}
rh_password=${{ secrets.RH_PASSWORD }}
+2 -6
View File
@@ -33,7 +33,7 @@ jobs:
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache-workspaces: backend
toolchain: 1.97.0
toolchain: 1.93.0
- name: Substitute EE code
shell: bash
@@ -45,12 +45,8 @@ jobs:
env:
RUSTFLAGS: "-D warnings"
run: |
cd backend
# Stub the openapi specs to empty: they are compiled in via an ungated
# include_str! but a worker binary never serves them, so this avoids
# embedding ~2.5MB of spec.
mkdir frontend/build && cd backend
New-Item -Path . -Name "windmill-api/openapi-deref.yaml" -ItemType "File" -Force
New-Item -Path . -Name "windmill-api/openapi-deref.json" -ItemType "File" -Force
cargo check --features=ee_windows
- name: Cargo build dynamic libraries windows
-23
View File
@@ -1,23 +0,0 @@
name: Check frontend docs links
on:
push:
tags:
- "v*"
workflow_dispatch:
jobs:
check-docs-links:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
frontend/src
.github/scripts
- uses: actions/setup-node@v4
with:
node-version: "22.x"
- name: Verify docs links are not 404
run: node .github/scripts/check-docs-links.mjs
@@ -0,0 +1,83 @@
name: Check Organization Membership
on:
workflow_call:
inputs:
commenter:
required: false
type: string
default: ''
description: 'The username to check. Auto-detected from the event context if not provided.'
organization:
required: false
type: string
default: 'windmill-labs'
description: 'The organization to check membership for'
trusted_bot:
required: false
type: string
default: 'windmill-internal-app[bot]'
description: 'The trusted bot username to allow'
secrets:
access_token:
required: true
description: 'The access token to use for org membership check'
outputs:
is_member:
description: 'Whether the user is an organization member or trusted bot'
value: ${{ jobs.check-membership.outputs.is_member }}
jobs:
check-membership:
runs-on: ubicloud-standard-2
outputs:
is_member: ${{ steps.check-membership.outputs.is_member }}
steps:
- name: Determine commenter
id: determine-commenter
run: |
COMMENTER="${{ inputs.commenter }}"
if [[ -z "$COMMENTER" ]]; then
if [[ "${{ github.event_name }}" == "issue_comment" || \
"${{ github.event_name }}" == "pull_request_review_comment" ]]; then
COMMENTER="${{ github.event.comment.user.login }}"
elif [[ "${{ github.event_name }}" == "pull_request_review" ]]; then
COMMENTER="${{ github.event.review.user.login }}"
else
COMMENTER="${{ github.event.issue.user.login }}"
fi
fi
echo "commenter=$COMMENTER" >> $GITHUB_OUTPUT
- name: Check organization membership
id: check-membership
env:
ORG_ACCESS_TOKEN: ${{ secrets.access_token }}
COMMENTER: ${{ steps.determine-commenter.outputs.commenter }}
ORG: ${{ inputs.organization }}
TRUSTED_BOT: ${{ inputs.trusted_bot }}
run: |
# 1. Allow the trusted bot straight away
if [[ "$COMMENTER" == "$TRUSTED_BOT" ]]; then
echo "is_member=true" >> $GITHUB_OUTPUT
exit 0
fi
# 2. Disallow other bots
if [[ "${COMMENTER}" =~ \[bot\]$ ]]; then
echo "is_member=false" >> $GITHUB_OUTPUT
exit 0
fi
# 3. Otherwise check if the user is a member of the organization
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: token $ORG_ACCESS_TOKEN" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/orgs/$ORG/members/$COMMENTER")
if [ "$STATUS" -eq 204 ]; then
echo "is_member=true" >> $GITHUB_OUTPUT
else
echo "is_member=false" >> $GITHUB_OUTPUT
fi
-74
View File
@@ -1,74 +0,0 @@
name: Check Write Access
# Authorizes a user to trigger privileged command workflows (/review, /ai, /plan,
# /updatesqlx, ...). The webhook author_association reports PRIVATE org members as
# CONTRIBUTOR/NONE (only public members show as MEMBER), so command jobs can't gate on
# it alone. This mints the internal GitHub App token — which can see private members —
# and confirms the user is a member or has write access to the repo. The app token is
# minted fresh per run, so unlike the old ORG_ACCESS_TOKEN PAT it never expires.
on:
workflow_call:
inputs:
username:
required: true
type: string
description: 'The user whose access to verify'
trusted_bot:
required: false
type: string
default: 'windmill-internal-app[bot]'
description: 'A bot login that is always authorized'
outputs:
authorized:
description: 'true if the user is the trusted bot, an org member, or has repo write access'
value: ${{ jobs.check.outputs.authorized }}
jobs:
check:
runs-on: ubuntu-latest
outputs:
authorized: ${{ steps.check.outputs.authorized }}
steps:
# This check is purely additive: callers OR it with author_association, so it must
# never fail the job. Failing here would block every dependent reviewer job through
# `needs`, turning an unconfigured or misconfigured app into a total review outage
# rather than a fallback to the author_association path.
- name: Mint internal app token
id: app
if: vars.INTERNAL_APP_ID != ''
continue-on-error: true
uses: actions/create-github-app-token@v2
with:
app-id: ${{ vars.INTERNAL_APP_ID }}
private-key: ${{ secrets.INTERNAL_APP_KEY }}
owner: ${{ github.repository_owner }}
- name: Resolve authorization
id: check
env:
# Without the app token, the default token still resolves public members and
# repo collaborators; private members simply fall through to author_association.
GH_TOKEN: ${{ steps.app.outputs.token || github.token }}
USERNAME: ${{ inputs.username }}
TRUSTED_BOT: ${{ inputs.trusted_bot }}
REPO: ${{ github.repository }}
run: |
if [ "$USERNAME" = "$TRUSTED_BOT" ]; then
echo "authorized=true" >> "$GITHUB_OUTPUT"
exit 0
fi
ORG="${REPO%%/*}"
# Org membership resolves private members too (204 = member, 404 = not).
if gh api "orgs/$ORG/members/$USERNAME" --silent 2>/dev/null; then
echo "authorized=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Fallback: effective repo permission (also covers outside collaborators).
PERM=$(gh api "repos/$REPO/collaborators/$USERNAME/permission" --jq '.permission' 2>/dev/null || echo none)
if [ "$PERM" = "admin" ] || [ "$PERM" = "write" ]; then
echo "authorized=true" >> "$GITHUB_OUTPUT"
else
echo "authorized=false" >> "$GITHUB_OUTPUT"
echo "$USERNAME is neither the trusted bot, an org member, nor a repo writer."
fi
+7 -11
View File
@@ -11,24 +11,20 @@ on:
types: [submitted]
jobs:
# author_association misses private org members; check-access resolves them via the
# internal app token. Both are OR'd below so public members still pass instantly.
check-access:
check-membership:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '/plan')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '/plan')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '/plan')) ||
(github.event_name == 'issues' && contains(github.event.issue.body, '/plan'))
uses: ./.github/workflows/check-write-access.yml
with:
username: ${{ github.event.comment.user.login || github.event.review.user.login || github.event.issue.user.login }}
secrets: inherit
uses: ./.github/workflows/check-org-membership.yml
secrets:
access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
claude-plan-action:
needs: [check-access]
needs: check-membership
if: |
needs.check-access.outputs.authorized == 'true' ||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association || github.event.review.author_association || github.event.issue.author_association)
needs.check-membership.outputs.is_member == 'true'
runs-on: ubicloud-standard-4
timeout-minutes: 20
permissions:
@@ -49,7 +45,7 @@ jobs:
allowed_bots: 'windmill-internal-app[bot]'
trigger_phrase: '/plan'
claude_args: |
--model claude-opus-5
--model claude-opus-4-8
--system-prompt "# Claude Planning Mode
You are operating in PLANNING MODE ONLY. Your role is to create detailed, structured plans without making any code changes.
+7 -49
View File
@@ -11,24 +11,20 @@ on:
types: [submitted]
jobs:
# author_association misses private org members; check-access resolves them via the
# internal app token. Both are OR'd below so public members still pass instantly.
check-access:
check-membership:
if: |
(github.event_name == 'issue_comment' && startsWith(github.event.comment.body, '/ai') && !startsWith(github.event.comment.body, '/ai-fast')) ||
(github.event_name == 'pull_request_review_comment' && startsWith(github.event.comment.body, '/ai') && !startsWith(github.event.comment.body, '/ai-fast')) ||
(github.event_name == 'pull_request_review' && startsWith(github.event.review.body, '/ai') && !startsWith(github.event.review.body, '/ai-fast')) ||
(github.event_name == 'issues' && startsWith(github.event.issue.body, '/ai') && !startsWith(github.event.issue.body, '/ai-fast'))
uses: ./.github/workflows/check-write-access.yml
with:
username: ${{ github.event.comment.user.login || github.event.review.user.login || github.event.issue.user.login }}
secrets: inherit
uses: ./.github/workflows/check-org-membership.yml
secrets:
access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
claude-code-action:
needs: [check-access]
needs: check-membership
if: |
needs.check-access.outputs.authorized == 'true' ||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association || github.event.review.author_association || github.event.issue.author_association)
needs.check-membership.outputs.is_member == 'true'
runs-on: ubicloud-standard-8
permissions:
contents: write
@@ -41,44 +37,6 @@ jobs:
with:
fetch-depth: 1
# Make the EE source (the *_ee.rs files in the companion repo) available so the
# reviewer can see EE-only code (e.g. windmill-queue/src/jobs_ee.rs), not just the
# CE surface. The EE ref is read from the PR head's backend/ee-repo-ref.txt (via the
# API, so it reflects the PR's EE pin regardless of which ref is checked out here).
- name: Check EE access
id: ee
env:
EE_TOKEN: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}
run: |
if [ -z "$EE_TOKEN" ] || [ -z "$PR_NUMBER" ]; then
echo "available=false" >> "$GITHUB_OUTPUT"
exit 0
fi
HEAD_SHA=$(gh api "repos/${{ github.repository }}/pulls/$PR_NUMBER" --jq .head.sha)
REF=$(gh api "repos/${{ github.repository }}/contents/backend/ee-repo-ref.txt?ref=$HEAD_SHA" --jq .content | base64 -d | tr -d '[:space:]')
if [ -z "$REF" ]; then
echo "available=false" >> "$GITHUB_OUTPUT"
else
echo "available=true" >> "$GITHUB_OUTPUT"
echo "ee_repo_ref=$REF" >> "$GITHUB_OUTPUT"
fi
- name: Checkout EE repository
if: steps.ee.outputs.available == 'true'
uses: actions/checkout@v4
with:
repository: windmill-labs/windmill-ee-private
path: ./windmill-ee-private
ref: ${{ steps.ee.outputs.ee_repo_ref }}
token: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
fetch-depth: 1
- name: Substitute EE code
if: steps.ee.outputs.available == 'true'
run: ./backend/substitute_ee_code.sh --copy --dir ./windmill-ee-private
- name: Run Claude PR Action
uses: anthropics/claude-code-action@v1
with:
@@ -93,4 +51,4 @@ jobs:
}
claude_args: |
--allowedTools "Bash,WebFetch,WebSearch"
--model claude-opus-5
--model claude-opus-4-8
-2
View File
@@ -6,14 +6,12 @@ on:
branches: [main]
paths:
- "cli/**"
- "windmill-yaml-validator/**"
- "backend/migrations/**"
- ".github/workflows/cli-tests.yml"
pull_request:
branches: [main]
paths:
- "cli/**"
- "windmill-yaml-validator/**"
- "backend/migrations/**"
- ".github/workflows/cli-tests.yml"
+35 -135
View File
@@ -32,19 +32,27 @@ concurrency:
cancel-in-progress: true
jobs:
check-membership:
if: github.event_name == 'pull_request'
uses: ./.github/workflows/check-org-membership.yml
with:
commenter: ${{ github.event.pull_request.user.login }}
secrets:
access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
codex-review:
needs: check-membership
runs-on: ubicloud-standard-2
timeout-minutes: 30
# A non-fork PR (head.repo.fork == false) can only be opened by someone with push
# access to this repo, so fork==false already enforces write access. Do NOT re-add
# an author_association gate: the pull_request webhook payload reports private org
# members as CONTRIBUTOR/NONE (only public members show as MEMBER), which silently
# skips auto-review for every private member.
if: |
github.event_name == 'workflow_call' ||
always() &&
(
github.event.pull_request.draft == false &&
github.event.pull_request.head.repo.fork == false
needs.check-membership.result == 'skipped' ||
(needs.check-membership.result == 'success' && needs.check-membership.outputs.is_member == 'true')
) &&
(
github.event_name == 'workflow_call' ||
(github.event.pull_request.draft == false && github.event.pull_request.head.repo.fork == false)
)
permissions:
contents: read
@@ -82,7 +90,6 @@ jobs:
EVENT_BODY: ${{ github.event.pull_request.body }}
EVENT_FORK: ${{ github.event.pull_request.head.repo.fork }}
EVENT_AUTHOR: ${{ github.event.pull_request.user.login }}
EVENT_ACTION: ${{ github.event.action }}
run: |
if [ -n "$INPUT_PR_NUMBER" ]; then
PR_JSON=$(gh pr view "$INPUT_PR_NUMBER" --repo "${{ github.repository }}" \
@@ -105,68 +112,24 @@ jobs:
IS_FORK="$EVENT_FORK"
PR_AUTHOR="$EVENT_AUTHOR"
fi
# Fork PRs run untrusted code with secrets present, so the automatic
# pull_request trigger never reviews them. A non-empty INPUT_PR_NUMBER
# means we arrived via workflow_call (a maintainer /codex comment gated
# by check-write-access), so allow forks only on that path.
if [ "$IS_FORK" = "true" ] && [ -z "$INPUT_PR_NUMBER" ]; then
echo "Skipping Codex review for fork PR (automatic trigger)."
if [ "$IS_FORK" = "true" ]; then
echo "Skipping Codex review for fork PR."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# An agent-driven PR flips to ready only after a clean /review round on
# a draft, marked by an author comment naming the head SHA (pr skill,
# "Review rounds"). Re-reviewing that same head on ready_for_review is
# redundant. The marker alone is author attestation, so also require
# reviewer evidence: a Codex review (posted by github-actions[bot], not
# forgeable by the author) that predates the marker and carries a
# non-blocking verdict. Comment-triggered and synchronize runs never
# skip. Keep the three copies of this check in sync (pr-ready-review /
# codex-pr-review / pi-pr-review); a shared local action would need the
# repo checked out before the check, which the fork paths here
# deliberately avoid.
if [ "$EVENT_ACTION" = "ready_for_review" ] && [ -z "$INPUT_PR_NUMBER" ]; then
# Fetch failures fail open (no skip): an API hiccup must run the
# review, never skip it or fail the job.
COMMENTS=$(gh api "repos/${{ github.repository }}/issues/$PR_NUMBER/comments?per_page=100" --paginate | jq -s '[.[][]]') || COMMENTS='[]'
MARKER_TIME=$(jq -r --arg author "$PR_AUTHOR" --arg marker "✅ Review round clean @ $HEAD_SHA" \
'[.[] | select(.user.login == $author) | select(.body | contains($marker)) | .created_at] | min // empty' <<<"$COMMENTS")
CODEX_VERDICT=''
if [ -n "$MARKER_TIME" ]; then
# Only Codex evidence that predates the marker counts: the ready-
# triggered Codex run itself posts after the flip and must not
# vouch for a sibling reviewer's skip.
CODEX_VERDICT=$(jq -r --arg mt "$MARKER_TIME" \
'[.[] | select(.user.login == "github-actions[bot]") | select(.body | contains("## Codex Review")) | select(.created_at < $mt)] | last | .body // ""' <<<"$COMMENTS" \
| grep -m1 -oE '(Good to merge|Mergeable, but should ideally address nits|Should address issues before merging)' || true)
fi
if [ -n "$MARKER_TIME" ] && [ -n "$CODEX_VERDICT" ] && [ "$CODEX_VERDICT" != "Should address issues before merging" ]; then
echo "Clean review round marker found for $HEAD_SHA with pre-marker non-blocking Codex verdict; skipping redundant review."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
fi
# PR title/body are attacker-controlled free text. Use an unguessable
# per-run delimiter so a fork can't embed a fixed heredoc terminator to
# inject extra outputs — e.g. is_fork=false (last-write-wins), which
# would re-enable the EE checkout and trusted-path settings for forks.
RAND=$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')
TITLE_EOF="TITLE_EOF_${RAND}"
BODY_EOF="BODY_EOF_${RAND}"
{
echo "skip=false"
echo "is_fork=$IS_FORK"
echo "pr_number=$PR_NUMBER"
echo "base_ref=$BASE_REF"
echo "base_sha=$BASE_SHA"
echo "head_sha=$HEAD_SHA"
echo "pr_author=$PR_AUTHOR"
echo "title<<$TITLE_EOF"
echo 'title<<PR_TITLE_EOF'
printf '%s\n' "$PR_TITLE"
echo "$TITLE_EOF"
echo "body<<$BODY_EOF"
echo 'PR_TITLE_EOF'
echo 'body<<PR_BODY_EOF'
printf '%s\n' "$PR_BODY"
echo "$BODY_EOF"
echo 'PR_BODY_EOF'
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
@@ -175,17 +138,9 @@ jobs:
with:
ref: refs/pull/${{ steps.pr.outputs.pr_number }}/merge
fetch-depth: 1
# Don't persist github.token in .git/config: the review agent can read
# the checkout, and on the fork path that token (issue/PR write) would
# otherwise be exfiltratable. All later git ops target the public origin
# and need no auth; EE checkout and gh use their own explicit tokens.
persist-credentials: false
# Never expose the EE private-repo token to untrusted fork code. Skipping
# this step leaves steps.ee.outputs.available empty, so the EE checkout and
# substitution steps below are skipped too.
- name: Check EE access
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' && steps.pr.outputs.is_fork != 'true'
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
id: ee
env:
EE_TOKEN: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
@@ -219,7 +174,7 @@ jobs:
- name: Install Codex CLI
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
run: npm install --global @openai/codex@0.144.1
run: npm install --global @openai/codex@0.128.0
- name: Configure Codex auth
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
@@ -259,12 +214,9 @@ jobs:
REPO: ${{ github.repository }}
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
run: |
# Write outside the checkout: on the fork path the merge tree is
# attacker-controlled, and a committed symlink at this path would
# redirect the write.
gh api "repos/$REPO/issues/$PR_NUMBER/comments?per_page=100" \
--jq '[.[] | {user: .user.login, created_at: .created_at, body: (.body | .[:4000])}] | sort_by(.created_at) | .[-20:]' \
> "$RUNNER_TEMP/prior-comments.json" || echo "[]" > "$RUNNER_TEMP/prior-comments.json"
> prior-comments.json || echo "[]" > prior-comments.json
- name: Write Codex review context
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
@@ -278,9 +230,9 @@ jobs:
PR_AUTHOR: ${{ steps.pr.outputs.pr_author }}
EXTRA_PROMPT: ${{ inputs.extra_prompt }}
run: |
mkdir -p .github/codex
node <<'NODE'
const fs = require('fs');
const tmp = process.env.RUNNER_TEMP;
const lines = [
`Repository: ${process.env.PR_REPOSITORY}`,
`PR number: ${process.env.PR_NUMBER}`,
@@ -310,9 +262,9 @@ jobs:
if (process.env.EXTRA_PROMPT && process.env.EXTRA_PROMPT.trim()) {
lines.push('', 'Additional reviewer instructions:', process.env.EXTRA_PROMPT.trim());
}
if (fs.existsSync(`${tmp}/prior-comments.json`)) {
if (fs.existsSync('prior-comments.json')) {
try {
const comments = JSON.parse(fs.readFileSync(`${tmp}/prior-comments.json`, 'utf8'));
const comments = JSON.parse(fs.readFileSync('prior-comments.json', 'utf8'));
if (Array.isArray(comments) && comments.length > 0) {
lines.push(
'',
@@ -327,39 +279,19 @@ jobs:
}
} catch (_) {}
}
fs.writeFileSync(`${tmp}/pr-review-context.md`, `${lines.join('\n')}\n`);
fs.writeFileSync('.github/codex/pr-review-context.md', `${lines.join('\n')}\n`);
NODE
- name: Run Codex review
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
env:
PR_IS_FORK: ${{ steps.pr.outputs.is_fork }}
PR_BASE_REF: ${{ steps.pr.outputs.base_ref }}
run: |
if [ "$PR_IS_FORK" = "true" ]; then
# Fork code is untrusted. Read the review policy/prompt from the base
# ref (git show) rather than the attacker-controlled merge checkout,
# so a malicious fork can't rewrite the reviewer's own instructions,
# and run in a network-disabled sandbox to block secret exfiltration.
git show "origin/$PR_BASE_REF:REVIEW.md" > /tmp/codex-prompt.md
git show "origin/$PR_BASE_REF:.github/codex/pr-review.prompt.md" >> /tmp/codex-prompt.md
SANDBOX_MODE=workspace-write
else
cat REVIEW.md .github/codex/pr-review.prompt.md > /tmp/codex-prompt.md
SANDBOX_MODE=danger-full-access
fi
# The context file lives in RUNNER_TEMP (outside the attacker-controlled
# checkout); tell the agent its absolute path.
printf '\nReview context file (absolute path): %s\n' "$RUNNER_TEMP/pr-review-context.md" >> /tmp/codex-prompt.md
# Write the final message outside the checkout too: a fork could commit
# codex-final-message.md as a symlink and redirect this write to overwrite
# e.g. a GitHub Action's index.js, which then runs with our credentials.
cat REVIEW.md .github/codex/pr-review.prompt.md > /tmp/codex-prompt.md
codex exec \
-C "$GITHUB_WORKSPACE" \
-m gpt-5.6-sol \
-m gpt-5.5 \
-c 'model_reasoning_effort="xhigh"' \
-s "$SANDBOX_MODE" \
-o "$RUNNER_TEMP/codex-final-message.md" \
-s danger-full-access \
-o codex-final-message.md \
- < /tmp/codex-prompt.md
- name: Post Codex review comment
@@ -367,52 +299,20 @@ jobs:
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }}
GH_JOB_TOKEN: ${{ github.token }}
with:
github-token: ${{ github.token }}
script: |
const fs = require('fs');
const path = `${process.env.RUNNER_TEMP}/codex-final-message.md`;
const path = `${process.env.GITHUB_WORKSPACE}/codex-final-message.md`;
if (!fs.existsSync(path)) {
core.info('Codex did not produce a final message; skipping PR comment.');
return;
}
let body = fs.readFileSync(path, 'utf8').trim();
const body = fs.readFileSync(path, 'utf8').trim();
if (!body) {
core.info('Codex final message was empty; skipping PR comment.');
return;
}
// Defense-in-depth for fork reviews: the model call needs the provider
// credential in the env, and the posted comment bypasses Actions log
// masking. Strip any credential (API key, raw auth JSON, nested
// tokens) that leaked into the review text before posting.
const secrets = [];
const addSecret = (v, min) => {
if (typeof v === 'string' && v.length >= min) secrets.push(v);
};
addSecret(process.env.OPENAI_API_KEY, 8);
addSecret(process.env.CODEX_AUTH_JSON, 8);
addSecret(process.env.GH_JOB_TOKEN, 8);
if (process.env.CODEX_AUTH_JSON) {
try {
const collect = (o) => {
if (typeof o === 'string') addSecret(o, 20);
else if (Array.isArray(o)) o.forEach(collect);
else if (o && typeof o === 'object') Object.values(o).forEach(collect);
};
collect(JSON.parse(process.env.CODEX_AUTH_JSON));
} catch (_) {}
}
for (const s of [...new Set(secrets)].sort((a, b) => b.length - a.length)) {
body = body.split(s).join('[REDACTED]');
}
body = body.trim();
if (!body) {
core.info('Codex final message was empty after redaction; skipping PR comment.');
return;
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
-1
View File
@@ -68,7 +68,6 @@ jobs:
push: true
build-args: |
features=ce_rpi
WM_BUILD_VERSION=${{ github.sha }}
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev
${{ steps.meta-public.outputs.tags }}
-3
View File
@@ -93,7 +93,6 @@ jobs:
push: true
build-args: |
features=ce
WM_BUILD_VERSION=${{ github.sha }}
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEV_SHA }}
${{ steps.meta-public.outputs.tags }}
@@ -156,7 +155,6 @@ jobs:
push: true
build-args: |
features=ee
WM_BUILD_VERSION=${{ github.sha }}
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }}
${{ steps.meta-ee-public.outputs.tags }}
@@ -256,7 +254,6 @@ jobs:
target: debuginfo
build-args: |
features=ee
WM_BUILD_VERSION=${{ github.sha }}
outputs: type=local,dest=./debuginfo
- name: Rename debug file with corresponding architecture
-3
View File
@@ -23,8 +23,5 @@ jobs:
cache-dependency-path: "frontend/package-lock.json"
- name: "npm check"
timeout-minutes: 5
env:
# svelte-check peaks past node's ~4GB default ceiling on this runner and aborts.
NODE_OPTIONS: --max-old-space-size=8192
run: cd frontend && npm ci && npm run generate-backend-client && npm run
check
+20 -30
View File
@@ -5,22 +5,21 @@ on:
types: [created]
jobs:
# /command comments can come from anyone; author_association misses private org
# members, so check-access resolves them via the internal app token. Runs once and is
# OR'd into each job's guard (public members still pass on author_association alone).
check-access:
if: github.event.issue.pull_request != null && startsWith(github.event.comment.body, '/')
uses: ./.github/workflows/check-write-access.yml
with:
username: ${{ github.event.comment.user.login }}
secrets: inherit
check-membership:
if: >-
github.event.issue.pull_request && (
startsWith(github.event.comment.body, '/updatesqlx') ||
startsWith(github.event.comment.body, '/demo') ||
startsWith(github.event.comment.body, '/eeref') ||
startsWith(github.event.comment.body, '/docs')
)
uses: ./.github/workflows/check-org-membership.yml
secrets:
access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
update-sqlx:
needs: [check-access]
if: >-
github.event.issue.pull_request &&
(contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) || needs.check-access.outputs.authorized == 'true') &&
startsWith(github.event.comment.body, '/updatesqlx')
needs: check-membership
if: needs.check-membership.outputs.is_member == 'true' && startsWith(github.event.comment.body, '/updatesqlx')
runs-on: ubicloud-standard-8
permissions:
contents: write
@@ -80,7 +79,7 @@ jobs:
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache-workspaces: backend
toolchain: 1.97.0
toolchain: 1.93.0
- name: Install xmlsec and gssapi build-time deps
run: |
@@ -148,11 +147,8 @@ jobs:
})
demo:
needs: [check-access]
if: >-
github.event.issue.pull_request &&
(contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) || needs.check-access.outputs.authorized == 'true') &&
startsWith(github.event.comment.body, '/demo')
needs: check-membership
if: needs.check-membership.outputs.is_member == 'true' && startsWith(github.event.comment.body, '/demo')
runs-on: ubicloud-standard-2
permissions:
contents: read
@@ -231,11 +227,8 @@ jobs:
fi
update-ee-ref:
needs: [check-access]
if: >-
github.event.issue.pull_request &&
(contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) || needs.check-access.outputs.authorized == 'true') &&
startsWith(github.event.comment.body, '/eeref')
needs: check-membership
if: needs.check-membership.outputs.is_member == 'true' && startsWith(github.event.comment.body, '/eeref')
runs-on: ubicloud-standard-2
permissions:
contents: write
@@ -320,11 +313,8 @@ jobs:
})
update-docs:
needs: [check-access]
if: >-
github.event.issue.pull_request &&
(contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) || needs.check-access.outputs.authorized == 'true') &&
startsWith(github.event.comment.body, '/docs')
needs: check-membership
if: needs.check-membership.outputs.is_member == 'true' && startsWith(github.event.comment.body, '/docs')
runs-on: ubicloud-standard-2
permissions:
contents: read
+3 -14
View File
@@ -9,10 +9,6 @@ on:
- "backend/windmill-api-integration-tests/tests/git_sync*"
- "backend/ee-repo-ref.txt"
- "backend/windmill-common/src/workspaces.rs"
- "backend/windmill-worker/src/result_processor.rs"
- "backend/windmill-api-workspaces/**"
- "cli/src/commands/sync/**"
- "cli/src/utils/git.ts"
- "integration_tests/test/git_sync_test.py"
- ".github/workflows/git-sync-test.yml"
pull_request:
@@ -22,10 +18,6 @@ on:
- "backend/windmill-api-integration-tests/tests/git_sync*"
- "backend/ee-repo-ref.txt"
- "backend/windmill-common/src/workspaces.rs"
- "backend/windmill-worker/src/result_processor.rs"
- "backend/windmill-api-workspaces/**"
- "cli/src/commands/sync/**"
- "cli/src/utils/git.ts"
- "integration_tests/test/git_sync_test.py"
- ".github/workflows/git-sync-test.yml"
@@ -58,8 +50,8 @@ jobs:
echo "Changed files:"
echo "$CHANGED_FILES"
# Direct git sync file changes — always relevant.
if echo "$CHANGED_FILES" | grep -qE '^(backend/windmill-git-sync/|backend/windmill-worker/src/result_processor\.rs|backend/windmill-api-workspaces/|backend/windmill-api-integration-tests/tests/git_sync|backend/windmill-common/src/workspaces\.rs|cli/src/commands/sync/|cli/src/utils/git\.ts|integration_tests/test/git_sync|\.github/workflows/git-sync-test\.yml)'; then
# Direct git sync file changes — always relevant
if echo "$CHANGED_FILES" | grep -qE '^(backend/windmill-git-sync/|backend/windmill-api-integration-tests/tests/git_sync|backend/windmill-common/src/workspaces\.rs|integration_tests/test/git_sync|\.github/workflows/git-sync-test\.yml)'; then
echo "should_run=true" >> "$GITHUB_OUTPUT"
echo "Relevant: direct git sync file changes"
exit 0
@@ -129,7 +121,7 @@ jobs:
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache-workspaces: backend
toolchain: 1.97.0
toolchain: 1.93.0
- uses: oven-sh/setup-bun@v2
with:
@@ -188,9 +180,6 @@ jobs:
DENO_PATH: deno
BUN_PATH: bun
NODE_BIN_PATH: node
# The auto-pull poller's SSRF guard rejects localhost git remotes;
# the tests' Gitea runs on localhost.
ALLOW_LOCAL_GIT_REMOTES: "true"
run: |
./target/debug/windmill &
echo "Waiting for Windmill to be ready..."
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
go build
- name: Pushes to another repository
id: push_directory
uses: cpina/github-action-push-to-another-repository@55306faa4ed53b815ae49e564af8cfb359d32ae2 # v1.7.3
uses: cpina/github-action-push-to-another-repository@devel
env:
API_TOKEN_GITHUB: ${{ secrets.DENO_PAT }}
with:
+34 -151
View File
@@ -30,19 +30,27 @@ concurrency:
cancel-in-progress: true
jobs:
check-membership:
if: github.event_name == 'pull_request'
uses: ./.github/workflows/check-org-membership.yml
with:
commenter: ${{ github.event.pull_request.user.login }}
secrets:
access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
pi-review:
needs: check-membership
runs-on: ubicloud-standard-2
timeout-minutes: 30
# A non-fork PR (head.repo.fork == false) can only be opened by someone with push
# access to this repo, so fork==false already enforces write access. Do NOT re-add
# an author_association gate: the pull_request webhook payload reports private org
# members as CONTRIBUTOR/NONE (only public members show as MEMBER), which silently
# skips auto-review for every private member.
if: |
github.event_name == 'workflow_call' ||
always() &&
(
github.event.pull_request.draft == false &&
github.event.pull_request.head.repo.fork == false
needs.check-membership.result == 'skipped' ||
(needs.check-membership.result == 'success' && needs.check-membership.outputs.is_member == 'true')
) &&
(
github.event_name == 'workflow_call' ||
(github.event.pull_request.draft == false && github.event.pull_request.head.repo.fork == false)
)
permissions:
contents: read
@@ -75,7 +83,6 @@ jobs:
EVENT_BODY: ${{ github.event.pull_request.body }}
EVENT_FORK: ${{ github.event.pull_request.head.repo.fork }}
EVENT_AUTHOR: ${{ github.event.pull_request.user.login }}
EVENT_ACTION: ${{ github.event.action }}
run: |
if [ -n "$INPUT_PR_NUMBER" ]; then
PR_JSON=$(gh pr view "$INPUT_PR_NUMBER" --repo "${{ github.repository }}" \
@@ -98,68 +105,24 @@ jobs:
IS_FORK="$EVENT_FORK"
PR_AUTHOR="$EVENT_AUTHOR"
fi
# Fork PRs run untrusted code with secrets present, so the automatic
# pull_request trigger never reviews them. A non-empty INPUT_PR_NUMBER
# means we arrived via workflow_call (a maintainer /pi comment gated by
# check-write-access), so allow forks only on that path.
if [ "$IS_FORK" = "true" ] && [ -z "$INPUT_PR_NUMBER" ]; then
echo "Skipping Pi review for fork PR (automatic trigger)."
if [ "$IS_FORK" = "true" ]; then
echo "Skipping Pi review for fork PR."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# An agent-driven PR flips to ready only after a clean /review round on
# a draft, marked by an author comment naming the head SHA (pr skill,
# "Review rounds"). Re-reviewing that same head on ready_for_review is
# redundant. The marker alone is author attestation, so also require
# reviewer evidence: a Codex review (posted by github-actions[bot], not
# forgeable by the author) that predates the marker and carries a
# non-blocking verdict. Comment-triggered and synchronize runs never
# skip. Keep the three copies of this check in sync (pr-ready-review /
# codex-pr-review / pi-pr-review); a shared local action would need the
# repo checked out before the check, which the fork paths here
# deliberately avoid.
if [ "$EVENT_ACTION" = "ready_for_review" ] && [ -z "$INPUT_PR_NUMBER" ]; then
# Fetch failures fail open (no skip): an API hiccup must run the
# review, never skip it or fail the job.
COMMENTS=$(gh api "repos/${{ github.repository }}/issues/$PR_NUMBER/comments?per_page=100" --paginate | jq -s '[.[][]]') || COMMENTS='[]'
MARKER_TIME=$(jq -r --arg author "$PR_AUTHOR" --arg marker "✅ Review round clean @ $HEAD_SHA" \
'[.[] | select(.user.login == $author) | select(.body | contains($marker)) | .created_at] | min // empty' <<<"$COMMENTS")
CODEX_VERDICT=''
if [ -n "$MARKER_TIME" ]; then
# Only Codex evidence that predates the marker counts: the ready-
# triggered Codex run itself posts after the flip and must not
# vouch for a sibling reviewer's skip.
CODEX_VERDICT=$(jq -r --arg mt "$MARKER_TIME" \
'[.[] | select(.user.login == "github-actions[bot]") | select(.body | contains("## Codex Review")) | select(.created_at < $mt)] | last | .body // ""' <<<"$COMMENTS" \
| grep -m1 -oE '(Good to merge|Mergeable, but should ideally address nits|Should address issues before merging)' || true)
fi
if [ -n "$MARKER_TIME" ] && [ -n "$CODEX_VERDICT" ] && [ "$CODEX_VERDICT" != "Should address issues before merging" ]; then
echo "Clean review round marker found for $HEAD_SHA with pre-marker non-blocking Codex verdict; skipping redundant review."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
fi
# PR title/body are attacker-controlled free text. Use an unguessable
# per-run delimiter so a fork can't embed a fixed heredoc terminator to
# inject extra outputs — e.g. is_fork=false (last-write-wins), which
# would re-enable the EE checkout and trusted-path settings for forks.
RAND=$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')
TITLE_EOF="TITLE_EOF_${RAND}"
BODY_EOF="BODY_EOF_${RAND}"
{
echo "skip=false"
echo "is_fork=$IS_FORK"
echo "pr_number=$PR_NUMBER"
echo "base_ref=$BASE_REF"
echo "base_sha=$BASE_SHA"
echo "head_sha=$HEAD_SHA"
echo "pr_author=$PR_AUTHOR"
echo "title<<$TITLE_EOF"
echo 'title<<PR_TITLE_EOF'
printf '%s\n' "$PR_TITLE"
echo "$TITLE_EOF"
echo "body<<$BODY_EOF"
echo 'PR_TITLE_EOF'
echo 'body<<PR_BODY_EOF'
printf '%s\n' "$PR_BODY"
echo "$BODY_EOF"
echo 'PR_BODY_EOF'
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
@@ -168,17 +131,9 @@ jobs:
with:
ref: refs/pull/${{ steps.pr.outputs.pr_number }}/merge
fetch-depth: 1
# Don't persist github.token in .git/config: the review agent can read
# the checkout, and on the fork path that token (issue/PR write) would
# otherwise be exfiltratable. All later git ops target the public origin
# and need no auth; EE checkout and gh use their own explicit tokens.
persist-credentials: false
# Never expose the EE private-repo token to untrusted fork code. Skipping
# this step leaves steps.ee.outputs.available empty, so the EE checkout and
# substitution steps below are skipped too.
- name: Check EE access
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' && steps.pr.outputs.is_fork != 'true'
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
id: ee
env:
EE_TOKEN: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
@@ -231,12 +186,9 @@ jobs:
REPO: ${{ github.repository }}
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
run: |
# Write outside the checkout: on the fork path the merge tree is
# attacker-controlled, and a committed symlink at this path would
# redirect the write.
gh api "repos/$REPO/issues/$PR_NUMBER/comments?per_page=100" \
--jq '[.[] | {user: .user.login, created_at: .created_at, body: (.body | .[:4000])}] | sort_by(.created_at) | .[-20:]' \
> "$RUNNER_TEMP/prior-comments.json" || echo "[]" > "$RUNNER_TEMP/prior-comments.json"
> prior-comments.json || echo "[]" > prior-comments.json
- name: Write Pi review context
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
@@ -250,9 +202,9 @@ jobs:
PR_AUTHOR: ${{ steps.pr.outputs.pr_author }}
EXTRA_PROMPT: ${{ inputs.extra_prompt }}
run: |
mkdir -p .github/pi
node <<'NODE'
const fs = require('fs');
const tmp = process.env.RUNNER_TEMP;
const lines = [
`Repository: ${process.env.PR_REPOSITORY}`,
`PR number: ${process.env.PR_NUMBER}`,
@@ -282,9 +234,9 @@ jobs:
if (process.env.EXTRA_PROMPT && process.env.EXTRA_PROMPT.trim()) {
lines.push('', 'Additional reviewer instructions:', process.env.EXTRA_PROMPT.trim());
}
if (fs.existsSync(`${tmp}/prior-comments.json`)) {
if (fs.existsSync('prior-comments.json')) {
try {
const comments = JSON.parse(fs.readFileSync(`${tmp}/prior-comments.json`, 'utf8'));
const comments = JSON.parse(fs.readFileSync('prior-comments.json', 'utf8'));
if (Array.isArray(comments) && comments.length > 0) {
lines.push(
'',
@@ -299,7 +251,7 @@ jobs:
}
} catch (_) {}
}
fs.writeFileSync(`${tmp}/pr-review-context.md`, `${lines.join('\n')}\n`);
fs.writeFileSync('.github/pi/pr-review-context.md', `${lines.join('\n')}\n`);
NODE
- name: Run Pi review
@@ -307,69 +259,16 @@ jobs:
env:
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
PI_SKIP_VERSION_CHECK: '1'
PR_IS_FORK: ${{ steps.pr.outputs.is_fork }}
PR_BASE_REF: ${{ steps.pr.outputs.base_ref }}
PR_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
run: |
set -o pipefail
PI_HARDEN_FLAGS=()
# Keep generated files (final message, events, context) outside the
# checkout: on the fork path a committed symlink at any of these paths
# would redirect our write and could overwrite an action's code that
# then runs with our credentials. RUNNER_TEMP is outside the checkout.
OUT_DIR="$RUNNER_TEMP"
CTX="$RUNNER_TEMP/pr-review-context.md"
if [ "$PR_IS_FORK" = "true" ]; then
# Fork code is untrusted. Read the review policy/prompt from the base
# ref (git show) rather than the attacker-controlled merge checkout,
# so a malicious fork can't rewrite the reviewer's own instructions,
# and drop the bash tool so the agent has no shell to exfiltrate with.
git show "origin/$PR_BASE_REF:REVIEW.md" > /tmp/pi-prompt.md
git show "origin/$PR_BASE_REF:.github/pi/pr-review.prompt.md" >> /tmp/pi-prompt.md
PI_TOOLS=read,grep,find,ls
# The agent has no shell, so pre-compute the diff (base...head SHAs are
# trusted) into the context file it reads. It may still read fork files
# by absolute path for extra context — reads are safe.
{
echo ""
echo "## Pre-computed review diff (base...head)"
echo "You have no shell. The full diff is below. The repository checkout"
echo "is at $GITHUB_WORKSPACE — you may read files there by absolute path."
echo '```diff'
git -C "$GITHUB_WORKSPACE" diff --unified=0 "$PR_BASE_SHA...$PR_HEAD_SHA"
echo '```'
} >> "$CTX"
# Pi resolves ALL project config from <cwd>/.pi (settings/packages,
# extensions, skills, themes, prompts, SYSTEM.md); inside the fork
# checkout a fork could inject any to run code or rewrite our system
# prompt. Discovery is cwd-based, so run from a fresh empty dir.
PI_WORKDIR=$(mktemp -d)
cd "$PI_WORKDIR"
# Belt-and-suspenders on top of the isolated cwd: refuse discovery of
# extensions/skills/templates/themes/context-files, and PI_OFFLINE=1 to
# block any startup network op or package install. PI_OFFLINE gates only
# startup network ops, not the provider inference call.
PI_HARDEN_FLAGS=(--no-extensions --no-skills --no-prompt-templates --no-themes --no-context-files)
export PI_OFFLINE=1
else
cat REVIEW.md .github/pi/pr-review.prompt.md > /tmp/pi-prompt.md
PI_TOOLS=read,grep,find,ls,bash
fi
# The context file lives in RUNNER_TEMP (outside the checkout); tell the
# agent its absolute path.
printf '\nReview context file (absolute path): %s\n' "$CTX" >> /tmp/pi-prompt.md
cat REVIEW.md .github/pi/pr-review.prompt.md > /tmp/pi-prompt.md
pi -p \
--provider deepseek \
--model deepseek-v4-pro \
--tools "$PI_TOOLS" \
"${PI_HARDEN_FLAGS[@]}" \
--tools read,grep,find,ls,bash \
--mode json \
< /tmp/pi-prompt.md \
| tee "$OUT_DIR/pi-events.jsonl" \
| tee pi-events.jsonl \
| jq -rc --unbuffered '
if .type == "agent_start" then "🤖 pi agent started"
elif .type == "turn_start" then "── turn ──"
@@ -397,43 +296,27 @@ jobs:
| map(select(.role == "assistant"))
| last
| (.content[]? | select(.type == "text") | .text)
' "$OUT_DIR/pi-events.jsonl" > "$OUT_DIR/pi-final-message.md"
' pi-events.jsonl > pi-final-message.md
- name: Post Pi review comment
if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
GH_JOB_TOKEN: ${{ github.token }}
with:
github-token: ${{ github.token }}
script: |
const fs = require('fs');
const path = `${process.env.RUNNER_TEMP}/pi-final-message.md`;
const path = `${process.env.GITHUB_WORKSPACE}/pi-final-message.md`;
if (!fs.existsSync(path)) {
core.info('Pi did not produce a final message; skipping PR comment.');
return;
}
let body = fs.readFileSync(path, 'utf8').trim();
const body = fs.readFileSync(path, 'utf8').trim();
if (!body) {
core.info('Pi final message was empty; skipping PR comment.');
return;
}
// Defense-in-depth for fork reviews: the model call needs the provider
// credential in the environment (readable via /proc/self/environ), and
// the posted comment is an exfiltration channel that bypasses GitHub
// Actions log masking. Strip the credential if it leaked into the text.
for (const s of [process.env.DEEPSEEK_API_KEY, process.env.GH_JOB_TOKEN]) {
if (typeof s === 'string' && s.length >= 8) {
body = body.split(s).join('[REDACTED]');
}
}
body = body.trim();
if (!body) {
core.info('Pi final message was empty after redaction; skipping PR comment.');
return;
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
+17 -56
View File
@@ -30,73 +30,38 @@ concurrency:
cancel-in-progress: true
jobs:
check-membership:
if: github.event_name == 'pull_request'
uses: ./.github/workflows/check-org-membership.yml
with:
commenter: ${{ github.event.pull_request.user.login }}
secrets:
access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
auto-review:
needs: check-membership
runs-on: ubuntu-latest
# A non-fork PR (head.repo.fork == false) can only be opened by someone with push
# access to this repo, so fork==false already enforces write access. Do NOT re-add
# an author_association gate: the pull_request webhook payload reports private org
# members as CONTRIBUTOR/NONE (only public members show as MEMBER), which silently
# skips auto-review for every private member.
if: |
github.event_name == 'workflow_call' ||
always() &&
(
(github.event.pull_request.draft == false || github.event.pull_request.ready_for_review == true) &&
github.event.pull_request.head.repo.fork == false
needs.check-membership.result == 'skipped' ||
(needs.check-membership.result == 'success' && needs.check-membership.outputs.is_member == 'true')
) &&
(
github.event_name == 'workflow_call' ||
(github.event.pull_request.draft == false || github.event.pull_request.ready_for_review == true)
)
permissions:
contents: read
pull-requests: read
id-token: write
steps:
# An agent-driven PR flips to ready only after a clean /review round on a
# draft, marked by an author comment naming the head SHA (pr skill, "Review
# rounds"). Re-reviewing that same head on ready_for_review is redundant.
# The marker alone is author attestation, so also require reviewer evidence:
# a Codex review (posted by github-actions[bot], not forgeable by the author)
# that predates the marker and carries a non-blocking verdict. Comment-
# triggered (workflow_call) and opened runs never skip. Keep the three
# copies of this check in sync (pr-ready-review / codex-pr-review /
# pi-pr-review); a shared local action would need the repo checked out
# before the check, which the codex/pi fork paths deliberately avoid.
- name: Check clean-round marker
id: marker
if: github.event_name == 'pull_request' && github.event.action == 'ready_for_review'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
run: |
# Fetch failures fail open (skip=false): an API hiccup must run the
# review, never skip it or fail the job.
COMMENTS=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments?per_page=100" --paginate | jq -s '[.[][]]') || COMMENTS='[]'
MARKER_TIME=$(jq -r --arg author "$PR_AUTHOR" --arg marker "✅ Review round clean @ $HEAD_SHA" \
'[.[] | select(.user.login == $author) | select(.body | contains($marker)) | .created_at] | min // empty' <<<"$COMMENTS")
CODEX_VERDICT=''
if [ -n "$MARKER_TIME" ]; then
# Only Codex evidence that predates the marker counts: the ready-
# triggered Codex run itself posts after the flip and must not vouch
# for a sibling reviewer's skip.
CODEX_VERDICT=$(jq -r --arg mt "$MARKER_TIME" \
'[.[] | select(.user.login == "github-actions[bot]") | select(.body | contains("## Codex Review")) | select(.created_at < $mt)] | last | .body // ""' <<<"$COMMENTS" \
| grep -m1 -oE '(Good to merge|Mergeable, but should ideally address nits|Should address issues before merging)' || true)
fi
if [ -n "$MARKER_TIME" ] && [ -n "$CODEX_VERDICT" ] && [ "$CODEX_VERDICT" != "Should address issues before merging" ]; then
echo "Clean review round marker found for $HEAD_SHA with pre-marker non-blocking Codex verdict; skipping redundant review."
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Checkout repository
if: steps.marker.outputs.skip != 'true'
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Check EE access
if: steps.marker.outputs.skip != 'true'
id: ee
env:
EE_TOKEN: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
@@ -123,7 +88,6 @@ jobs:
run: ./backend/substitute_ee_code.sh --copy --dir ./windmill-ee-private
- name: Resolve PR number
if: steps.marker.outputs.skip != 'true'
id: resolve
env:
GH_TOKEN: ${{ github.token }}
@@ -143,7 +107,6 @@ jobs:
echo "pr_author=$PR_AUTHOR" >> "$GITHUB_OUTPUT"
- name: Fetch prior PR discussion
if: steps.marker.outputs.skip != 'true'
id: prior
env:
GH_TOKEN: ${{ github.token }}
@@ -162,7 +125,6 @@ jobs:
' prior-comments.json > prior-comments.md
- name: Read review prompt
if: steps.marker.outputs.skip != 'true'
id: review-prompt
env:
EXTRA_PROMPT: ${{ inputs.extra_prompt }}
@@ -186,7 +148,6 @@ jobs:
} >> "$GITHUB_ENV"
- name: Automatic PR Review
if: steps.marker.outputs.skip != 'true'
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
@@ -199,4 +160,4 @@ jobs:
${{ env.REVIEW_PROMPT }}
claude_args: |
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)"
--model claude-opus-5
--model claude-opus-4-8
+16 -203
View File
@@ -42,24 +42,16 @@ jobs:
;;
esac
# author_association misses private org members; check-access resolves them via the
# internal app token. Both are OR'd so public members still pass instantly.
check-access:
needs: [parse]
check-membership:
needs: parse
if: needs.parse.outputs.command != ''
uses: ./.github/workflows/check-write-access.yml
with:
username: ${{ github.event.comment.user.login }}
secrets: inherit
uses: ./.github/workflows/check-org-membership.yml
secrets:
access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
acknowledge:
needs: [parse, check-access]
if: |
needs.parse.outputs.command != '' &&
(
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) ||
needs.check-access.outputs.authorized == 'true'
)
needs: [parse, check-membership]
if: needs.parse.outputs.command != '' && needs.check-membership.outputs.is_member == 'true'
runs-on: ubuntu-latest
permissions:
issues: write
@@ -75,153 +67,11 @@ jobs:
"/repos/$REPO/issues/comments/$COMMENT_ID/reactions" \
-f content=eyes >/dev/null
# Decide, per agent, whether to launch a fresh run, re-run in place, or skip. A push
# already auto-triggers codex/pi (and claude on open) against the PR head. Relaunching
# via this issue_comment path both cancels those in-flight auto runs (shared concurrency
# group) AND lands the new run's status on main — issue_comment runs never attach a
# check to the PR head — leaving the PR showing only a cancelled review. So for every
# command, launch an agent only when nothing covers the head commit; if the head's run
# was cancelled/failed, re-run it in place (a re-run keeps the original pull_request
# event, so its checks re-attach to the PR head); skip when a running or successful run
# already covers it. `/review` applies this to all three agents; `/codex`, `/pi`,
# `/claude` apply the same decision to just their own agent.
plan:
needs: [parse, check-access]
if: |
needs.parse.outputs.command != '' &&
(
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) ||
needs.check-access.outputs.authorized == 'true'
)
runs-on: ubuntu-latest
permissions:
contents: read
actions: write
pull-requests: read
statuses: write
outputs:
head_sha: ${{ steps.plan.outputs.head_sha }}
launch_codex: ${{ steps.plan.outputs.launch_codex }}
launch_pi: ${{ steps.plan.outputs.launch_pi }}
launch_claude: ${{ steps.plan.outputs.launch_claude }}
steps:
- name: Decide per-agent launch vs re-run for the head commit
id: plan
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number }}
COMMAND: ${{ needs.parse.outputs.command }}
run: |
set -euo pipefail
HEAD_SHA=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json headRefOid --jq '.headRefOid')
echo "PR #$PR_NUMBER head: $HEAD_SHA"
echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT"
RUN_URL="$GITHUB_SERVER_URL/$REPO/actions/runs/$GITHUB_RUN_ID"
# A fresh launch runs from this issue_comment workflow (associated with main),
# so it never appears in the PR-head run query below and its own check lands on
# main, not the head. To keep fresh launches idempotent per head, mark the head
# SHA with a `review-launch/<agent>` commit status at launch; the `finalize` job
# resolves it to success/failure. A prior launch's status covering the head lets
# a second comment skip instead of relaunching (which would cancel the first via
# the reviewer's shared concurrency group). All status calls are best-effort — a
# GitHub API hiccup must degrade to a relaunch, never abort the decision.
mark_launch() {
agent="$1"
gh api -X POST "repos/$REPO/statuses/$HEAD_SHA" \
-f state=pending -f "context=review-launch/$agent" -f "target_url=$RUN_URL" \
-f "description=Review launched via /$COMMAND" >/dev/null 2>&1 || true
}
# Returns "covered" if a prior fresh launch (this or an earlier comment run)
# already covers the head: a success status, or a pending status whose launching
# run is still alive. A pending whose run has completed is stale (that run
# crashed before finalize) and does not count.
launch_coverage() {
agent="$1"
st_json=$(gh api "repos/$REPO/commits/$HEAD_SHA/statuses" \
--jq "[.[] | select(.context == \"review-launch/$agent\")] | first // empty" 2>/dev/null || true)
[ -n "$st_json" ] || return 0
state=$(jq -r '.state // empty' <<<"$st_json" 2>/dev/null || true)
[ "$state" = success ] && { echo covered; return 0; }
[ "$state" = pending ] || return 0
target=$(jq -r '.target_url // empty' <<<"$st_json" 2>/dev/null || true)
run_id=$(printf '%s' "$target" | grep -oE '[0-9]+$' || true)
if [ -n "$run_id" ]; then
run_state=$(gh run view "$run_id" --repo "$REPO" --json status --jq '.status' 2>/dev/null || true)
[ "$run_state" = completed ] && return 0 # stale pending -> not covered
fi
echo covered
}
decide() {
wf="$1"; key="$2"; agent="$3"
if [ "$(launch_coverage "$agent")" = covered ]; then
echo "$key: a prior launch already covers $HEAD_SHA (review-launch/$agent) -> skip"
echo "$key=false" >> "$GITHUB_OUTPUT"
return
fi
# `--commit` matches runs whose head SHA is the PR head. Auto reviews run on
# `pull_request` against that SHA; `/review` (issue_comment) runs execute on
# main, so they never match and are not counted as covering the head commit.
runs=$(gh run list --repo "$REPO" --workflow "$wf" --commit "$HEAD_SHA" --limit 40 \
--json databaseId,status,conclusion)
# Healthy = still running, or completed successfully: a review already
# covers this commit, so skip.
healthy=$(jq -r '[.[] | select(.status != "completed" or .conclusion == "success")] | length' <<<"$runs")
if [ "$healthy" -gt 0 ]; then
echo "$key: a running or successful review already covers $HEAD_SHA -> skip"
echo "$key=false" >> "$GITHUB_OUTPUT"
return
fi
# Re-run only genuinely interrupted runs (cancelled/failed/timed out) in
# place, so their checks re-attach to the PR head instead of posting on
# main. A `skipped` run produced no review and would just skip again (it is
# the draft/fork gate), so it does not count — fall through to a fresh launch.
retry_id=$(jq -r '[.[] | select(.status == "completed" and (.conclusion == "cancelled" or .conclusion == "failure" or .conclusion == "timed_out"))] | sort_by(.databaseId) | last | .databaseId // empty' <<<"$runs")
if [ -n "$retry_id" ]; then
if gh run rerun "$retry_id" --repo "$REPO" >/dev/null 2>&1; then
echo "$key: re-ran interrupted run $retry_id (re-attaches to PR head)"
echo "$key=false" >> "$GITHUB_OUTPUT"
return
fi
echo "$key: re-run of $retry_id failed -> fresh launch"
mark_launch "$agent"
echo "$key=true" >> "$GITHUB_OUTPUT"
return
fi
echo "$key: no usable review for $HEAD_SHA -> launch"
mark_launch "$agent"
echo "$key=true" >> "$GITHUB_OUTPUT"
}
# `/review` targets all three agents; `/codex`, `/pi`, `/claude` target only
# their own. A non-targeted agent is left untouched (no launch, no re-run).
decide_if_targeted() {
wf="$1"; key="$2"; agent="$3"
if [ "$COMMAND" = review ] || [ "$COMMAND" = "$agent" ]; then
decide "$wf" "$key" "$agent"
else
echo "$key: /$COMMAND does not target $agent -> skip"
echo "$key=false" >> "$GITHUB_OUTPUT"
fi
}
decide_if_targeted codex-pr-review.yml launch_codex codex
decide_if_targeted pi-pr-review.yml launch_pi pi
decide_if_targeted pr-ready-review.yml launch_claude claude
claude:
needs: [parse, check-access, plan]
needs: [parse, check-membership]
if: |
(
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) ||
needs.check-access.outputs.authorized == 'true'
) &&
needs.plan.outputs.launch_claude == 'true'
needs.check-membership.outputs.is_member == 'true' &&
(needs.parse.outputs.command == 'review' || needs.parse.outputs.command == 'claude')
permissions:
contents: read
pull-requests: read
@@ -236,13 +86,10 @@ jobs:
WINDMILL_EE_PRIVATE_ACCESS: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
codex:
needs: [parse, check-access, plan]
needs: [parse, check-membership]
if: |
(
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) ||
needs.check-access.outputs.authorized == 'true'
) &&
needs.plan.outputs.launch_codex == 'true'
needs.check-membership.outputs.is_member == 'true' &&
(needs.parse.outputs.command == 'review' || needs.parse.outputs.command == 'codex')
permissions:
contents: read
issues: write
@@ -258,13 +105,10 @@ jobs:
WINDMILL_EE_PRIVATE_ACCESS: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
pi:
needs: [parse, check-access, plan]
needs: [parse, check-membership]
if: |
(
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) ||
needs.check-access.outputs.authorized == 'true'
) &&
needs.plan.outputs.launch_pi == 'true'
needs.check-membership.outputs.is_member == 'true' &&
(needs.parse.outputs.command == 'review' || needs.parse.outputs.command == 'pi')
permissions:
contents: read
issues: write
@@ -277,34 +121,3 @@ jobs:
secrets:
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
WINDMILL_EE_PRIVATE_ACCESS: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
# Resolve the `review-launch/<agent>` head statuses that `plan` set to pending, so a
# fresh launch's outcome is visible on the PR head (not just on main) and never lingers
# as a stale pending check. Targets the exact SHA `plan` launched against, so a push
# that moved the head mid-review does not stamp a status on the new head.
finalize:
needs: [plan, claude, codex, pi]
if: always() && needs.plan.result == 'success' && needs.plan.outputs.head_sha != ''
runs-on: ubuntu-latest
permissions:
statuses: write
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
HEAD_SHA: ${{ needs.plan.outputs.head_sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
steps:
- name: Finalize launch statuses on the PR head
run: |
set -uo pipefail
finalize() {
agent="$1"; launched="$2"; result="$3"
[ "$launched" = true ] || return 0
state=$([ "$result" = success ] && echo success || echo failure)
gh api -X POST "repos/$REPO/statuses/$HEAD_SHA" \
-f "state=$state" -f "context=review-launch/$agent" -f "target_url=$RUN_URL" \
-f "description=Review $result" >/dev/null 2>&1 || true
}
finalize codex "${{ needs.plan.outputs.launch_codex }}" "${{ needs.codex.result }}"
finalize pi "${{ needs.plan.outputs.launch_pi }}" "${{ needs.pi.result }}"
finalize claude "${{ needs.plan.outputs.launch_claude }}" "${{ needs.claude.result }}"
+2 -6
View File
@@ -35,7 +35,7 @@ jobs:
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache-workspaces: backend
toolchain: 1.97.0
toolchain: 1.93.0
- name: Substitute EE code
shell: bash
@@ -56,12 +56,8 @@ jobs:
vcpkg.exe integrate install
$env:VCPKGRS_DYNAMIC=1
$env:OPENSSL_DIR="${Env:VCPKG_INSTALLATION_ROOT}\installed\x64-windows-static"
cd backend
# Stub the openapi specs to empty: they are compiled in via an ungated
# include_str! but a worker binary never serves them, so this avoids
# embedding ~2.5MB of spec.
mkdir frontend/build && cd backend
New-Item -Path . -Name "windmill-api/openapi-deref.yaml" -ItemType "File" -Force
New-Item -Path . -Name "windmill-api/openapi-deref.json" -ItemType "File" -Force
cargo build --release --features=ee_windows
- name: Rename binary with corresponding architecture
run: |
@@ -1,52 +0,0 @@
name: Refresh docs snapshot
# The backend embeds a vendored docs snapshot (backend/windmill-api/docs_snapshot/*.gz)
# so in-product docs search works with no runtime egress. This job re-fetches it from
# windmill.dev on a schedule and opens a PR when it changed, keeping the embedded docs
# fresh independently of the release cadence (the binary embeds whatever is on the
# source tree at build time, so a merged refresh rides into the next release build).
on:
schedule:
- cron: "0 6 * * 1" # Mondays 06:00 UTC
workflow_dispatch:
jobs:
refresh:
runs-on: ubicloud
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/create-github-app-token@v2
id: app
with:
app-id: ${{ vars.INTERNAL_APP_ID }}
private-key: ${{ secrets.INTERNAL_APP_KEY }}
- uses: actions/checkout@v4
with:
token: ${{ steps.app.outputs.token }}
- name: Fetch + re-gzip docs snapshot
run: cd backend/windmill-api/docs_snapshot && ./fetch.sh
- name: Sanity-check the fetched corpus
# curl -f in fetch.sh rejects HTTP errors, but not a valid-but-garbage 200
# (truncated file, error page). Guard against embedding a broken snapshot.
run: |
cd backend/windmill-api/docs_snapshot
test "$(wc -c < llms-full.txt.gz)" -gt 100000
test "$(wc -c < llms.txt.gz)" -gt 1000
pages=$(gzip -dc llms-full.txt.gz | grep -c '^Source:' || true)
echo "pages in snapshot: $pages"
test "${pages:-0}" -ge 200
- uses: peter-evans/create-pull-request@v6
with:
token: ${{ steps.app.outputs.token }}
branch: chore/refresh-docs-snapshot
add-paths: backend/windmill-api/docs_snapshot/*.gz
commit-message: "chore: refresh vendored docs snapshot"
title: "chore: refresh vendored docs snapshot"
body: |
Automated refresh of the embedded docs snapshot
(`backend/windmill-api/docs_snapshot/*.gz`) from windmill.dev.
Review the diff for unexpected churn (a bad upstream docs deploy would
show up as a large drop in pages or content) before merging.
+3 -5
View File
@@ -8,10 +8,8 @@ jobs:
name: "Release please"
runs-on: ubicloud
steps:
# Config lives in release-please-config.json / .release-please-manifest.json:
# a `release-type` input instead re-derives the last released version by
# paginating every GitHub release, which on a repo this size is slow enough
# to fail intermittently.
- uses: googleapis/release-please-action@v5
- uses: GoogleCloudPlatform/release-please-action@v3
with:
release-type: simple
package-name: windmill
token: ${{ secrets.PAT_TOKEN }}
-61
View File
@@ -1,61 +0,0 @@
# The python and typescript SDK unit suites, on release tags only: they guard
# what gets published to npm / PyPI / JSR, and a tag is the moment that decides
# it.
#
# This runs alongside the publish workflows rather than ahead of them, so it
# reports a broken SDK rather than holding one back. Gating would mean putting
# the job inside each publish workflow, since Actions cannot express `needs`
# across workflows.
name: SDK Tests
on:
workflow_dispatch:
push:
tags:
- "v*"
jobs:
typescript-client:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
# No build step: these suites are deliberately free of the generated API
# client, so they run against the sources as committed.
- name: Run tests
working-directory: ./typescript-client
run: bun test --timeout 120000 tests/
python-client:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup uv
uses: astral-sh/setup-uv@v5
# The interpreter is named explicitly: on a clean checkout uv picks the
# runner's system python and stops with "not compatible with the locked
# Python requirement" rather than fetching one. Keep in step with
# `requires-python` in uv.lock.
#
# Note this is not the version a worker runs the SDK on — those are 3.12.
# `uv.lock` asks for >=3.14, so pinning lower means regenerating it, which
# is worth doing separately.
- name: Install the interpreter the lockfile requires
run: uv python install 3.14
# `--frozen` so a drifted lockfile fails here rather than quietly
# resolving to something nobody has run.
- name: Run tests
working-directory: ./python-client/wmill
env:
PYTHONPATH: .
run: uv run --frozen --python 3.14 pytest tests/ -q
@@ -1,37 +0,0 @@
name: YAML validator tests
# The schemas behind `wmill lint` are generated from the OpenAPI specs, so a spec change
# can turn a valid synced file into a lint error without touching any validator code.
on:
push:
branches: [main]
paths:
- "windmill-yaml-validator/**"
- "openflow.openapi.yaml"
- "backend/windmill-api/openapi.yaml"
- ".github/workflows/yaml-validator-tests.yml"
pull_request:
paths:
- "windmill-yaml-validator/**"
- "openflow.openapi.yaml"
- "backend/windmill-api/openapi.yaml"
- ".github/workflows/yaml-validator-tests.yml"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
working-directory: windmill-yaml-validator
run: npm ci
- name: Run tests
working-directory: windmill-yaml-validator
run: npm test
-1
View File
@@ -24,7 +24,6 @@ rust-client/Cargo.toml
# Symlinked cache directories (for git worktrees)
backend/target
node_modules/
frontend/node_modules
typescript-client/node_modules
ai_evals/node_modules
+2 -2
View File
@@ -7,12 +7,12 @@
"playwright": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@playwright/mcp@latest", "--browser", "chromium", "--headless", "--output-dir", "/tmp/playwright-mcp-${USER:-shared}"]
"args": ["-y", "@playwright/mcp@latest", "--browser", "chromium", "--headless"]
},
"playwright-headed": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@playwright/mcp@latest", "--browser", "chromium", "--output-dir", "/tmp/playwright-mcp-${USER:-shared}"]
"args": ["-y", "@playwright/mcp@latest", "--browser", "chromium"]
}
}
}
-3
View File
@@ -1,3 +0,0 @@
{
".": "1.783.0"
}
+2 -71
View File
@@ -12,68 +12,24 @@ Open-source platform for internal tools, workflows, API integrations, background
## Documentation
- **Validation**: `docs/validation.md` — what checks to run based on what you changed
- **Unreleased SDK changes**: `docs/wac-sdk-e2e.md` — exercising a client change on a real worker
- **Agent workers**: `docs/agent-worker-e2e.md` — building and running one locally. An agent
reaches the DB only through the API, so `Connection::Http` paths are never taken by a plain
`cargo run`; a normal build cannot start one at all.
- **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow
- **Backend patterns**: use the `rust-backend` skill when writing Rust code
- **Frontend patterns**: use the `svelte-frontend` skill when writing Svelte code. Do NOT edit svelte files unless you have read that skill.
- **Frontend UUIDs**: do not call `crypto.randomUUID()` in frontend code. Import `randomUUID` from `$lib/utils/uuid` instead.
- **Code review**: review the current PR or branch against the shared review policy in `REVIEW.md` (severity triage, public-surface checklist, AGENTS.md compliance, test-coverage assessment). The skill at `.agents/skills/local-review/SKILL.md` orchestrates it. All three CLIs auto-discover the same SKILL — Claude reads `.claude/skills/` (symlinked to the canonical `.agents/skills/` file), Codex and Pi read `.agents/skills/` directly. Invoke with `/local-review` in Claude Code, `$local-review` (or `/skills` selector) in Codex, or `pi --skill local-review` / `/skill:local-review` in Pi. For a Codex-driven pass that mirrors the `codex-pr-review` GitHub action against your unpushed work (committed + uncommitted) before you push, use `/local-review-codex` (`.agents/skills/local-review-codex/`) — same `REVIEW.md` policy, `gpt-5.6-sol`, `xhigh` reasoning; requires the `codex` CLI >= 0.144.1.
- **Code review**: review the current PR or branch against the shared review policy in `REVIEW.md` (severity triage, public-surface checklist, AGENTS.md compliance, test-coverage assessment). The skill at `.agents/skills/local-review/SKILL.md` orchestrates it. All three CLIs auto-discover the same SKILL — Claude reads `.claude/skills/` (symlinked to the canonical `.agents/skills/` file), Codex and Pi read `.agents/skills/` directly. Invoke with `/local-review` in Claude Code, `$local-review` (or `/skills` selector) in Codex, or `pi --skill local-review` / `/skill:local-review` in Pi.
- **Domain guides**: `.claude/skills/native-trigger/` and `frontend/tutorial-system-guide.mdc`
- **Brand/UI guidelines**: `frontend/brand-guidelines.md`
- **Domain vocabulary**: `CONTEXT.md` — the words this codebase uses for its own concepts (step, step setting, trigger step, …). Name things the way it does.
- **CLI commands**: when adding/modifying/removing a command, subcommand, option, or description in `cli/src/commands/`, run `python system_prompts/generate.py` to refresh `system_prompts/auto-generated/` and `cli/src/guidance/skills.gen.ts`. The CLI docs the agents use to operate `wmill` are derived from the source — stale generated files give agents the wrong flags.
- **Session recorder**: `frontend/src/lib/components/recording/` is also the recorder `wmill app dev --recording` serves, vendored into the CLI as `cli/src/commands/app/devRecorderBundle.gen.ts`. After changing `rawAppSnapshot.ts` or `rawAppRecording.svelte.ts`, run `bun run gen:dev-recorder` from `cli/` (`cli/test/dev_recorder_bundle_unit.test.ts` fails otherwise).
## Dev Environment
> **In a git worktree, the ports and database below are NOT the ones to use.** Each
> worktree gets its own backend port, frontend port and Postgres database, so the
> defaults in this section apply only to a plain single checkout. **Discover the real
> values before running anything** — see "Per-worktree ports and database" below.
- **Backend**: `cargo run` from `backend/` (API at http://localhost:8000)
- **DuckDB local jobs**: before running DuckDB scripts locally, build the FFI shared library with `cd backend/windmill-duckdb-ffi-internal && ./build_dev.sh`. Re-run it after clean builds or when `backend/target/debug/libwindmill_duckdb_ffi_internal.*` is missing. The bundled DuckDB compile (~2min) is cached in a per-user dir shared across worktrees, so a fresh worktree reuses it and the build is near-instant.
- **Data pipelines (DuckLake) from source**: a plain `cargo run` (even `--features quickjs`) advertises a `duckdb` worker tag but **cannot** execute DuckDB scripts and has **no** working S3 proxy (DuckLake writes 404). Build CE DuckLake with `cargo run --features quickjs,duckdb,parquet,private` (add `,python` for Python scripts, `,enterprise,license` for EE) **and** build the FFI (bullet above). See `backend/CLAUDE.md` → "Running data pipelines (DuckLake) from source" for the exact feature sets and the two feature-gate gotchas.
- **Frontend**: `REMOTE=http://localhost:8000 npm run dev` from `frontend/` (port 3000+)
- **DB**: `psql postgres://postgres:changeme@localhost:5432/windmill`
- **Login**: `admin@windmill.dev` / `changeme`
- **Instance settings**: navigate to `/#superadmin-settings`
- **Migrations**: use `cargo sqlx migrate add -r <name>` from `backend/` to create new migrations (never generate timestamps manually)
### Per-worktree ports and database
A worktree's `.env` / `.env.local` (repo root) and `backend/.env` hold its own
`DATABASE_URL` and `PORT`; the database is typically `windmill_<branch_with_underscores>`
(branch `dbt-runtime``windmill_dbt_runtime`). Read them, or discover from what is
already running:
```bash
psql postgres://postgres:changeme@localhost:5432/postgres -tAc \
"select datname from pg_database where datname like 'windmill%'" | grep "$(git branch --show-current | tr - _)"
# the port the frontend actually proxies to (REMOTE of this worktree's vite):
for p in $(pgrep -f vite); do case "$(readlink /proc/$p/cwd)" in *"$(basename "$(git rev-parse --show-toplevel)")"*)
tr '\0' '\n' < /proc/$p/environ | grep -E '^REMOTE=|^PORT=';; esac; done
```
Getting these wrong is not a cheap mistake:
- **`DATABASE_URL` pointed at another worktree's database silently destroys the sqlx
cache.** `cargo run` and `cargo sqlx prepare` both compile `sqlx::query!` against the
**live** database, so the wrong one fails with `relation "<your_new_table>" does not
exist` — and `prepare` deletes the whole `.sqlx/` directory *before* it fails, leaving
it gutted. Always `cp -r backend/.sqlx <tmp>/sqlx_backup` first (see the `update-sqlx`
skill).
- **The frontend proxies to its own worktree's backend port, not 8000.** Starting a
backend on the wrong port leaves the UI up but every API call 502s, which reads like an
application bug rather than a misconfiguration.
- **Kill backends by pid scoped to this worktree's cwd** (`readlink /proc/<pid>/cwd`),
never `pkill -f target/debug/windmill` — that kills every sibling worktree's backend.
Beware that a `pgrep -f "<pattern>"` in a shell whose own command line contains
`<pattern>` matches the shell itself.
## Verifying Frontend Changes
After modifying frontend code, drive the running dev server with the **Playwright MCP** to verify the change in a real browser — don't claim a UI change works without exercising it.
@@ -92,34 +48,10 @@ Typical flow:
5. `mcp__playwright__browser_take_screenshot` for visual confirmation
6. `mcp__playwright__browser_console_messages` / `browser_network_requests` to surface errors
Write screenshots to an absolute path under `/tmp` (the MCP servers already do; standalone
Playwright scripts must be told): moving a PNG out of the checkout afterwards needs a `mv` the
permission hooks always prompt on. Same reason to run `rm`/`mv`/`cp` as one plain command per Bash
call: those hooks defer on `&&`, `;`, redirects, quotes and `$VAR`.
**Attach the screenshots to the PR.** For any change under `frontend/`, embed screenshots of the affected UI in the PR body — the `pr` skill requires this and carries the upload recipe.
If you cannot exercise a UI change (no dev server, etc.), say so explicitly rather than claiming success.
## Verifying Backend Changes
`cargo check` and the unit tests do not exercise a worker code path. **If you changed how
a job runs — an executor, `handle_child`, anything spawning or reading from a
subprocess — run an actual job of that kind** and confirm it completed, then say so.
Whole classes of defect compile and unit-test clean:
- **Stack overflow from a large buffer in an async block.** An array declared across an
`.await` is baked into the future's state; once that future is boxed a few layers deep
by the job poller, two 16 KB arrays abort the worker *process* (`thread
'tokio-runtime-worker' has overflowed its stack`). Heap-allocate read buffers
(`vec![0u8; N]`, not `[0u8; N]`).
- Deadlocks from draining only one of a child's pipes, missed cancellation or timeout
propagation, and anything depending on the real engine's output format.
A crash like this takes down every job on that worker, not just yours, so check the
backend log after the run rather than only the job's own status. If you cannot run one,
say which path went unexercised instead of implying it was verified.
## Banned Patterns
### `$bindable(default_value)` on optional props
@@ -177,6 +109,5 @@ $NAV --root backend callees "X" # what does X call?
- Search for existing code to reuse before writing new code
- Follow established patterns in the codebase
- Keep changes focused — don't refactor beyond what's asked
- **Ship only the tests the PR needs.** A committed test must pin behavior a future change could plausibly break, and be the smallest setup that exercises the new logic. While developing, write as many exhaustive tests and do as much manual testing as you need to convince yourself the change works — then remove that scaffolding before marking the PR ready, keeping only the essential regression guard(s). A test that merely re-exercises pre-existing behavior, or needs elaborate fixtures to assert something trivial, is scaffolding: delete it. If nothing meaningful is left to guard, ship no test rather than a ceremonial one.
- **Comments record constraints, not narration.** Write a comment only for what the code can't show: why a non-obvious approach is required, what breaks if it's "simplified" away. State each invariant once, at the place where someone would break it, in ≤4 lines. Don't describe what the next line does, don't repeat the same rationale at multiple sites, and don't address the PR reviewer (justifying a change belongs in the PR description, not the code). Reference nothing ephemeral — no numbered steps from your dev flow, no "the poller / the test does X" scaffolding, no transient state that won't exist for the next reader; keep only the essential, durable rationale. Describe the code as it is, never its drafting history: "we no longer do X", "unchanged behavior", "instead of the previous approach" are meaningless to a reader who never saw the earlier iteration — before finishing, reread your comments as if the current state is the only state that ever existed.
- **Comments record constraints, not narration.** Write a comment only for what the code can't show: why a non-obvious approach is required, what breaks if it's "simplified" away. State each invariant once, at the place where someone would break it, in ≤4 lines. Don't describe what the next line does, don't repeat the same rationale at multiple sites, and don't address the PR reviewer (justifying a change belongs in the PR description, not the code). Describe the code as it is, never its drafting history: "we no longer do X", "unchanged behavior", "instead of the previous approach" are meaningless to a reader who never saw the earlier iteration — before finishing, reread your comments as if the current state is the only state that ever existed.
- **Never attribute work to a specific customer, account, or "requested by a customer" in repo-tracked content** (PR descriptions, commit messages, code comments, docs). Describe changes by their technical motivation instead.
-1108
View File
File diff suppressed because it is too large Load Diff
-38
View File
@@ -1,38 +0,0 @@
# Windmill
Open-source platform for internal tools, workflows, API integrations, background jobs and UIs. This file pins the vocabulary that is specific to Windmill's domain, so that code, docs and reviews name the same thing the same way.
## Language
### Flows
**Step**:
One node of a flow — the unit a user selects in the graph and configures in the right-hand panel. Typed as `FlowModule` in code.
_Avoid_: module (ambiguous with the architectural sense), node, action
**Step setting**:
A per-step runtime option stored on the step itself: retries, error handling, timeout, concurrency limit, priority, cache, debounce, early stop, skip, suspend, sleep, lifetime. Distinct from the step's inputs and its code. The panel that edits them is the **run settings** tab; a single setting is still a step setting.
_Avoid_: advanced setting, step config, flow option
**Configured**:
Said of a step setting whose config object is present on the step. Deliberately not the same as "would change the runtime's behaviour" — a setting can be configured and still be a no-op (`sleep` of `0`). Every surface that answers "is this setting on?" answers it this way.
_Avoid_: enabled, active, effective
**Trigger step**:
The first step of a polling flow. It runs on a schedule and returns the items found since its last run; an empty return means there is nothing to process and the flow stops early, marked skipped rather than failed.
_Avoid_: poll script, trigger node, schedule step
**Default predicate**:
The `stop_after_if` expression seeded onto a trigger step at creation, encoding what "nothing new" looks like. One value, owned in one place, shared by every path that creates a trigger step.
**Connect**:
Arming an input so that the next property picked fills it. A property can be picked from the prop picker or, when the panel is docked beside the graph, by clicking a step node's output. At most one input is armed per panel, so a pick always has exactly one destination.
_Avoid_: link, bind, plug (the icon is a plug; the action is connecting)
**Step input**:
One argument of a step, edited in the step's input form. Its prop picker is a pane beside the form, always visible, so previous results can be browsed without connecting.
_Avoid_: argument field, param
**Expression input**:
Any other place a property can be picked into: the loop iterator, skip and early-stop predicates, the retry condition, a branch predicate, timeout. Its prop picker opens in a popover from the connect button rather than taking a pane.
_Avoid_: JS field, code input
+5 -14
View File
@@ -1,27 +1,18 @@
{
layer4 {
:25 {
route {
proxy {
upstream windmill_server:2525
}
proxy {
to windmill_server:2525
}
}
}
}
{$BASE_URL} {
# Default to all interfaces (IPv4 + IPv6) when ADDRESS is unset. A bare
# `bind {$ADDRESS}` with an empty value makes Caddy >= 2.9 drop this whole
# site, silently disabling the HTTP proxy while the :25 layer4 listener stays up.
bind {$ADDRESS:0.0.0.0 ::}
bind {$ADDRESS}
# Extra services: LSP, Multiplayer, Debugger (windmill_extra gateway).
# reverse_proxy only reads its first argument as a matcher, so listing
# several paths inline turns the rest into upstream addresses. The paths
# have to go through a named matcher.
@extra path /ws/* /ws_mp/* /ws_debug/*
reverse_proxy @extra http://windmill_extra:3000
# Extra services: LSP, Multiplayer, Debugger (windmill_extra gateway)
reverse_proxy /ws/* /ws_mp/* /ws_debug/* http://windmill_extra:3000
# Search indexer, Enterprise Edition (windmill_indexer:8002)
# reverse_proxy /api/srch/* http://windmill_indexer:8002
+23 -27
View File
@@ -1,7 +1,7 @@
ARG DEBIAN_IMAGE=debian:trixie-slim
ARG RUST_IMAGE=rust:1.97-slim-trixie
ARG DEBIAN_IMAGE=debian:bookworm-slim
ARG RUST_IMAGE=rust:1.93-slim-bookworm
FROM debian:trixie-slim AS nsjail
FROM debian:bookworm-slim AS nsjail
WORKDIR /nsjail
@@ -9,12 +9,12 @@ RUN apt-get -y update \
&& apt-get install -y \
bison=2:3.8.* \
flex=2.6.* \
g++=4:14.2.* \
gcc=4:14.2.* \
git=1:2.47.* \
g++=4:12.2.* \
gcc=4:12.2.* \
git=1:2.39.* \
libprotobuf-dev=3.21.* \
libnl-route-3-dev=3.7.* \
make=4.4.* \
make=4.3-4.1 \
pkg-config=1.8.* \
protobuf-compiler=3.21.*
@@ -44,7 +44,7 @@ FROM rust_base AS windmill_duckdb_ffi_internal_builder
WORKDIR /windmill-duckdb-ffi-internal
RUN apt-get update && apt-get install -y clang=1:19.0* libclang-dev=1:19.0* cmake=3.31.* && \
RUN apt-get update && apt-get install -y clang=1:14.0-55.* libclang-dev=1:14.0-55.* cmake=3.25.* && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
@@ -79,8 +79,6 @@ COPY /python-client/docs/ /frontend/static/pydocs/
RUN npm run generate-backend-client
ENV NODE_OPTIONS "--max-old-space-size=8192"
ARG VITE_BASE_URL ""
# Must be declared for the build-arg to reach the bundle. See frontend/svelte.config.js.
ARG WM_BUILD_VERSION=""
# Read more about macro in docker/dev.nu
# -- MACRO-SPREAD-WASM-PARSER-DEV-ONLY -- #
RUN npm run build
@@ -100,7 +98,7 @@ ARG features=""
COPY --from=planner /windmill/recipe.json recipe.json
RUN apt-get update && apt-get install -y libxml2-dev=2.12.* libxmlsec1-dev=1.2.* libkrb5-dev libsasl2-dev libcurl4-openssl-dev clang=1:19.0* libclang-dev=1:19.0* cmake=3.31.* && \
RUN apt-get update && apt-get install -y libxml2-dev=2.9.* libxmlsec1-dev=1.2.* libkrb5-dev libsasl2-dev libcurl4-openssl-dev clang=1:14.0-55.* libclang-dev=1:14.0-55.* cmake=3.25.* && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
@@ -137,8 +135,9 @@ FROM ${DEBIAN_IMAGE}
ARG TARGETPLATFORM
ARG POWERSHELL_VERSION=7.5.0
ARG KUBECTL_VERSION=1.36.2
ARG HELM_VERSION=3.21.2
ARG POWERSHELL_DEB_VERSION=7.5.0-1
ARG KUBECTL_VERSION=1.28.7
ARG HELM_VERSION=3.14.3
# NOTE: If changing, also change go version in workspace dependencies template at WorkspaceDependenciesEditor.svelte
ARG GO_VERSION=1.26.0
ARG APP=/usr/src/app
@@ -164,15 +163,14 @@ ENV PATH /usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH
RUN apt-get update \
&& apt-get upgrade -y \
&& apt-get install -y --no-install-recommends netbase tzdata ca-certificates wget curl jq unzip build-essential unixodbc xmlsec1 tini gnupg libargon2-1 \
&& apt-get install -y --no-install-recommends netbase tzdata ca-certificates wget curl jq unzip build-essential unixodbc xmlsec1 software-properties-common tini gnupg lsb-release \
&& if echo "$features" | grep -q "ee"; then apt-get install -y --no-install-recommends libsasl2-modules-gssapi-mit krb5-user; fi \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Install latest PostgreSQL client (pg_dump) from official PostgreSQL apt repository
RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgresql-archive-keyring.gpg \
&& echo "deb [signed-by=/usr/share/keyrings/postgresql-archive-keyring.gpg] https://apt.postgresql.org/pub/repos/apt $(. /etc/os-release; echo "$VERSION_CODENAME")-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
&& echo "deb [signed-by=/usr/share/keyrings/postgresql-archive-keyring.gpg] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends postgresql-client \
&& apt-get clean \
@@ -185,14 +183,12 @@ RUN if [ "$WITH_GIT" = "true" ]; then \
&& rm -rf /var/lib/apt/lists/*; \
else echo 'Building the image without git'; fi;
# PowerShell ships as a tarball: the upstream .deb depends on libicu<=74 which no longer exists in trixie
RUN if [ "$WITH_POWERSHELL" = "true" ]; then \
case "$TARGETPLATFORM" in \
"linux/amd64") pwsh_arch=x64 ;; \
"linux/arm64") pwsh_arch=arm64 ;; \
*) pwsh_arch="" ;; \
esac; \
if [ -n "$pwsh_arch" ]; then apt-get update -y && apt install libicu76 -y && wget -O powershell.tar.gz "https://github.com/PowerShell/PowerShell/releases/download/v${POWERSHELL_VERSION}/powershell-${POWERSHELL_VERSION}-linux-${pwsh_arch}.tar.gz" && apt-get clean \
if [ "$TARGETPLATFORM" = "linux/amd64" ]; then apt-get update -y && apt install libicu-dev -y && wget -O 'pwsh.deb' "https://github.com/PowerShell/PowerShell/releases/download/v${POWERSHELL_VERSION}/powershell_${POWERSHELL_DEB_VERSION}.deb_amd64.deb" && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && \
dpkg --install 'pwsh.deb' && \
rm 'pwsh.deb'; \
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then apt-get update -y && apt install libicu-dev -y && wget -O powershell.tar.gz "https://github.com/PowerShell/PowerShell/releases/download/v${POWERSHELL_VERSION}/powershell-${POWERSHELL_VERSION}-linux-arm64.tar.gz" && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && \
mkdir -p /opt/microsoft/powershell/7 && \
tar zxf powershell.tar.gz -C /opt/microsoft/powershell/7 && \
@@ -237,7 +233,7 @@ ENV PATH="${PATH}:/usr/local/go/bin"
ENV GO_PATH=/usr/local/go/bin/go
# Install UV
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.11.24/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv
RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.25/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv
# Preinstall python runtimes to temp build location (will copy with world-writable perms later)
# --compile-bytecode precompiles the stdlib to .pyc so jobs don't recompile it on every run
@@ -296,7 +292,7 @@ RUN bun install -g windmill-cli \
RUN curl -fsSL https://claude.ai/install.sh | bash \
&& cp /root/.local/share/claude/versions/* /usr/bin/claude
COPY --from=php:8.3.30-cli-trixie /usr/local/bin/php /usr/bin/php
COPY --from=php:8.3.30-cli-bookworm /usr/local/bin/php /usr/bin/php
COPY --from=composer:2.9.5 /usr/bin/composer /usr/bin/composer
# add the docker client to call docker from a worker if enabled
@@ -307,13 +303,13 @@ ENV CARGO_HOME="/tmp/windmill/cache/cargo"
ENV LD_LIBRARY_PATH="."
# nsjail runtime deps and binary
RUN apt-get update && apt-get install -y --no-install-recommends libprotobuf32t64 libnl-route-3-200 libnl-3-200 \
RUN apt-get update && apt-get install -y --no-install-recommends libprotobuf32 libnl-route-3-200 libnl-3-200 \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --from=nsjail /nsjail/nsjail /bin/nsjail
# crane: pulls + flattens images for the sandboxed container runtime (`# sandbox <image>`).
# Single static binary — no daemon/store/root needed. See docs/docker-v2-runtime.md.
ARG CRANE_VERSION=v0.21.7
ARG CRANE_VERSION=v0.20.6
RUN arch="$(dpkg --print-architecture)"; \
case "$arch" in amd64) crane_arch=x86_64 ;; arm64) crane_arch=arm64 ;; *) echo >&2 "error: unsupported arch '$arch' for crane"; exit 1 ;; esac; \
wget -O /tmp/crane.tgz "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Linux_${crane_arch}.tar.gz" \
+204 -10
View File
@@ -1,14 +1,208 @@
# AI Evals
# AI Evals Authoring Guide
Black-box benchmark cases for the Windmill AI generation modes (`flow`, `app`,
`script`, `cli`, `global`).
This folder contains black-box benchmark cases for:
**Authoring and running cases is documented in the `ai-evals` skill** — load it
before adding/changing a case or running a benchmark. Claude Code reads
`.claude/skills/ai-evals/SKILL.md`; Codex and Pi read
`.agents/skills/ai-evals/SKILL.md` (same canonical file). Invoke with `/ai-evals` in
Claude Code, `$ai-evals` in Codex, or `pi --skill ai-evals`.
- `flow`
- `app`
- `script`
- `cli`
- `global`
For AI chat / copilot changes that these evals measure, see the `ai-chat` skill.
The goal is to test the current production prompts and guidance with realistic user requests, not to test one exact implementation shape.
The full case format, fields, and fixture details remain in `ai_evals/README.md`.
## Core rules
1. Write prompts like a real user request.
2. Prefer behavior, inputs, constraints, and outcomes over internal implementation details.
3. Keep deterministic validation narrow and hard.
4. Put semantic expectations in `judgeChecklist`.
5. Use `expected` fixtures only when exact structure really matters.
## Prompt writing
Prompts should sound like something a user would naturally ask.
Good:
- "Create a flow that routes support requests based on customer tier."
- "Add a reset button that sets the counter back to 0."
- "Create a flow that reuses the existing greeting script instead of duplicating the logic."
Bad:
- "Use `branchone` with 3 branches and a default branch."
- "Create a `rawscript` step with this exact topology."
- "This is a benchmark harness."
Do not write prompts as if the user knows Windmill internals unless the case is explicitly testing a power-user workflow.
## Flow-specific rules
This is the main principle you asked for:
- flow prompts should read like requests from a user who does not know the product internals
- the user should ask for behavior, not for `branchone`, `branchall`, `rawscript`, `preprocessor_module`, `failure_module`, exact graph topology, or other internal constructs
That means:
- creation cases should describe the business behavior and expected result
- modification cases may mention existing step names, because the user can see the current flow
- only mention special Windmill constructs when the case is explicitly about those constructs
Examples:
- acceptable creation prompt:
"Create a purchase approval flow that pauses for approval and asks the approver for a comment."
- avoid:
"Create a suspend step with one required event and a resume form."
For flow cases, do not fail a case just because the model chose a different valid topology.
## App-specific rules
App prompts should focus on user-visible behavior:
- what the UI should let the user do
- what should persist
- what backend behavior is needed
Avoid prompting in terms of React structure, component names, or implementation unless the case is specifically about editing an existing app.
## CLI-specific rules
CLI prompts can be more explicit about paths and file names because real CLI users often do specify them.
Still, avoid benchmark phrasing. The prompt should read like a repo task, not a harness instruction.
When relevant, ask the assistant to tell the user which `wmill` commands to run next. That is part of the benchmarked behavior.
## Global-specific rules
Global prompts should exercise workspace-level drafting behavior:
- inspecting existing scripts, flows, apps, schedules, triggers, resources, and variables when relevant
- writing AI drafts rather than saving or deploying by default
- producing coherent multi-artifact changes when the request crosses artifact boundaries
Keep deterministic validation focused on the draft contract: required draft type/path, required content snippets, forbidden draft paths, and forbidden mutating tools such as deploy/delete unless the case explicitly asks for them.
Datatable cases should set `skipJudge: true` and validate through tool-use
(`requiredToolsUsed` / `forbiddenToolsUsed`) and SQL-argument assertions
(`toolCallArgs` with `stringIncludesAnyOf`, e.g. `['select']`, `['create table']`,
`['update', 'insert into']`). Two reasons the judge is unreliable here:
- `list_datatables`, `get_datatable_table_schema`, and `exec_datatable_sql`
produce no drafts, and the global judge only sees the drafts artifact — it
scores a no-draft conversational answer as empty (same as the
`askUserQuestion` cases).
- Even a case that *does* produce a draft (a script reading the data table via
`wmill.datatable()` at runtime) is mis-judged: the judge has no datatable SDK
reference and penalizes correct `wmill.datatable()` usage as wrong. Verify the
SDK call deterministically instead — `requiredDrafts.valueIncludes: ['wmill.datatable(']`
plus forbidding `exec_datatable_sql` (keeping chat-time SQL distinct from
runtime SDK use).
`stringIncludesAnyOf` is existential over calls (at least one matching call), so a
mutation case still passes when the model mixes its UPDATE/INSERT with
verification SELECTs. The in-memory engine (`datatableSqlEngine.ts`) is stateful
within a case — writes persist, so a model that re-queries to verify its
CREATE/UPDATE sees the change and does not loop. But the engine is best-effort
(SELECT returns all rows of the referenced/first table with no WHERE/projection),
so still never assert specific returned row values. Seed data via
`workspace.datatables` in the `initial` fixture (see README).
## Deterministic validation
Use deterministic validation only for hard failures such as:
- missing required files
- unexpected extra files when the prompt says not to create them
- syntax errors
- unresolved flow refs
- missing required special modules or suspend config
- obvious artifact corruption
Do not use deterministic validation to enforce one preferred implementation for broad creation tasks.
Examples of bad hard checks:
- exact step topology for a creation flow
- exact branch structure when the prompt only asked for routing behavior
- exact input shape when multiple reasonable shapes are acceptable
## Judge checklist
Every non-trivial case should have a `judgeChecklist`.
The checklist should capture:
- the user-visible behavior that must be present
- important constraints
- key completion criteria
The checklist should not duplicate low-level implementation details unless they are truly required by the task.
Good checklist items:
- "the flow calculates the order total with 8% tax"
- "the app persists recipes appropriately for a raw Windmill app"
- "the flow reuses the existing workspace script instead of rewriting the logic"
Bad checklist items:
- "uses `branchone`"
- "contains a `rawscript` node"
## When to use `expected`
Use `expected` fixtures when the case is structure-sensitive, for example:
- exact file creation
- exact script content
- modification cases where a specific file must change in a specific way
- cases where preserving an existing structure is part of the requirement
Do not use a full `expected` artifact as the semantic oracle for broad creation tasks when multiple valid outputs should pass.
## When to use `initial`
Use `initial` when the benchmark is about:
- editing an existing artifact
- reusing existing workspace assets
- preserving existing behavior while adding a change
If the case is greenfield, prefer no `initial`.
## Case design ladder
Prefer suites that get gradually harder:
1. trivial create case
2. realistic create case
3. reuse-existing-assets case
4. modification case
5. refactor case
6. edge-case or niche product behavior
The last cases in a suite should cover unusual or product-specific behavior.
## Anti-patterns
Avoid these:
- benchmark framing in prompts
- over-specified internal topology for creation tasks
- judge checklists that just restate implementation details
- deterministic validation that encodes one preferred solution
- fixtures that are so minimal or brittle that they create false negatives
## Before adding a case
Ask:
1. Would a real user plausibly write this prompt?
2. If the model solves it in a different valid way, would the case still pass?
3. Are the hard deterministic checks only catching objectively broken output?
4. Does the `judgeChecklist` describe the real success criteria?
5. If this case fails, will the reason be understandable from the saved artifacts?
@@ -1,22 +0,0 @@
import { describe, expect, it } from "bun:test";
import { createEvalArtifactHelpers } from "./evalArtifactStore";
// A hand-written stand-in for SessionArtifactsStore (bun has no IndexedDB), so nothing
// makes it follow that class. A method missing from it surfaces as a tool throwing
// part-way through an eval run, which reads as a model failure rather than a harness one.
describe("eval artifact store", () => {
it("exposes every method the artifact tools call", () => {
const { helpers } = createEvalArtifactHelpers();
for (const method of [
"create",
"get",
"update",
"remove",
"listForSession",
"listVersions",
"getVersion",
]) {
expect(typeof (helpers.artifacts as any)[method]).toBe("function");
}
});
});
@@ -1,93 +0,0 @@
// SessionArtifactsStore can't run here (bun has no IndexedDB, nor the compiled $state runes),
// so mirror only the shape the artifact tools call, not its scoping or race handling.
export const EVAL_SESSION_ID = "eval-session";
export function createEvalArtifactHelpers() {
const items = new Map<string, Record<string, any>>();
// Snapshots per artifact id, oldest first — the version tools read history from here.
const history = new Map<string, Array<Record<string, any>>>();
let seq = 0;
const snapshotOf = (
artifact: Record<string, any>,
version: number,
note?: string,
) => ({
key: `${artifact.id}:${version}`,
artifactId: artifact.id,
version,
name: artifact.name,
content: artifact.content,
savedAt: artifact.updatedAt,
note,
});
const store = {
create: async (sessionId: string, input: Record<string, any>) => {
const now = seq++;
const artifact = {
id: `eval-artifact-${now}`,
sessionId,
chatId: input.chatId,
kind: input.kind ?? "md",
name: input.name,
content: input.content,
createdAt: now,
updatedAt: now,
version: 1,
};
items.set(artifact.id, artifact);
history.set(artifact.id, [snapshotOf(artifact, 1)]);
return artifact;
},
get: async (id: string) => items.get(id),
update: async (
id: string,
input: Record<string, any>,
opts?: { sessionId?: string },
) => {
const existing = items.get(id);
if (!existing) return undefined;
if (
opts?.sessionId !== undefined &&
existing.sessionId !== opts.sessionId
)
return undefined;
// Only a content change earns a version, as in SessionArtifactsStore.
const contentChanged =
input.content !== undefined && input.content !== existing.content;
const version = (existing.version ?? 1) + (contentChanged ? 1 : 0);
const updated = {
...existing,
name: input.name ?? existing.name,
content: input.content ?? existing.content,
updatedAt: seq++,
version,
};
items.set(id, updated);
if (contentChanged) {
history.set(id, [
...(history.get(id) ?? []),
snapshotOf(updated, version, input.note),
]);
}
return updated;
},
remove: async (id: string) => {
items.delete(id);
history.delete(id);
},
listForSession: async (sessionId: string) =>
[...items.values()].filter((a) => a.sessionId === sessionId),
listVersions: async (id: string) =>
[...(history.get(id) ?? [])].sort((a, b) => b.version - a.version),
getVersion: async (id: string, version: number) =>
(history.get(id) ?? []).find((v) => v.version === version),
};
return {
helpers: {
artifacts: store,
sessionId: EVAL_SESSION_ID,
getChatId: () => "eval-chat",
openArtifact: () => {},
},
snapshot: () => [...items.values()],
};
}
@@ -3,7 +3,7 @@ import { tmpdir } from "os";
import { join } from "path";
import type { AIProvider } from "$lib/gen/types.gen";
import {
globalToolsFor,
globalTools,
prepareGlobalSystemMessage,
prepareGlobalUserMessage,
} from "../../../../../frontend/src/lib/components/copilot/chat/global/core";
@@ -14,7 +14,6 @@ import {
} from "../../../../../frontend/src/lib/components/copilot/chat/global/userDraftAdapter";
import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
import { UserDraft } from "../../../../../frontend/src/lib/userDraft.svelte";
import { createEvalArtifactHelpers } from "./evalArtifactStore";
import type { ModeRunContext } from "../../../../core/types";
import type { GlobalDraftState } from "../../../../core/validators";
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
@@ -81,8 +80,6 @@ export interface GlobalEvalOptions {
workspaceFixtures?: BenchmarkWorkspaceRunnables;
liveEditorDrafts?: GlobalLiveEditorDraftFixture[];
user?: GlobalUserFixture;
// Emulate a session chat (preview tools + session prompt); default false = standalone baseline.
sessionChat?: boolean;
model?: string;
maxIterations?: number;
provider?: AIProvider;
@@ -101,10 +98,7 @@ export async function runGlobalEval(
(await mkdtemp(join(tmpdir(), "wmill-frontend-global-benchmark-")));
clearGlobalDrafts(workspaceRoot);
registerBenchmarkWorkspaceRunnables(
workspaceRoot,
options.workspaceFixtures ?? {},
);
registerBenchmarkWorkspaceRunnables(workspaceRoot, options.workspaceFixtures ?? {});
seedLiveEditorDrafts(workspaceRoot, options.liveEditorDrafts ?? []);
try {
@@ -113,25 +107,18 @@ export async function runGlobalEval(
process.env[DISABLE_ACTIVE_EDITOR_CONTEXT_ENV] !== "1";
// Pass the seeded identity straight to the prompt builder rather than mutating
// the process-global `userStore`, so concurrent cases never race on it.
const evalArtifacts = createEvalArtifactHelpers();
const rawResult = await runEval({
userPrompt,
systemMessage: prepareGlobalSystemMessage(undefined, {
user: options.user,
previewTools: options.sessionChat ?? false,
}),
systemMessage: prepareGlobalSystemMessage(undefined, { user: options.user }),
userMessage: prepareGlobalUserMessage(
userPrompt,
[],
injectActiveEditorContext ? { workspace: workspaceRoot } : {},
),
tools: getGlobalEvalTools(options.sessionChat ?? false),
helpers: evalArtifacts.helpers,
tools: getGlobalEvalTools(),
helpers: {},
apiKey,
getOutput: async () => ({
...(await collectGlobalDraftState(workspaceRoot)),
artifacts: evalArtifacts.snapshot(),
}),
getOutput: () => collectGlobalDraftState(workspaceRoot),
onAssistantMessageStart: options.runContext?.onAssistantMessageStart,
onAssistantToken: options.runContext?.onAssistantChunk,
onAssistantMessageEnd: options.runContext?.onAssistantMessageEnd,
@@ -226,15 +213,10 @@ function clearLiveEditorDrafts(
}
}
// Gate session-preview tools on sessionChat, as production's globalToolsFor does.
function getGlobalEvalTools(sessionChat: boolean): ProductionTool<{}>[] {
function getGlobalEvalTools(): ProductionTool<{}>[] {
const disableSearchApp = process.env[DISABLE_SEARCH_APP_ENV] === "1";
return (
globalToolsFor({ sessionPreview: sessionChat }) as ProductionTool<{}>[]
)
.filter(
(tool) => !(disableSearchApp && tool.def.function.name === "search_app"),
)
return (globalTools as ProductionTool<{}>[])
.filter((tool) => !(disableSearchApp && tool.def.function.name === "search_app"))
.map((tool) => {
if (!MUTATING_GLOBAL_TOOLS.has(tool.def.function.name)) {
return tool;
+13 -452
View File
@@ -10,9 +10,7 @@ import type {
import type {
DataTableTables,
DataTableTableSchema,
EndpointTool,
GetDraftForUserResponse,
GetOwnDraftResponse,
ListDraftsResponse,
ScriptLang,
UpdateDraftResponse,
@@ -92,13 +90,6 @@ export function resetBenchmarkMockBackend(): void {
benchmarkDrafts.clear()
}
// Stand-in for FolderService.createFolder so the global create_folder tool runs in
// memory instead of mutating the real backend. Folders aren't otherwise modelled
// (no folder-listing in evals), so this just echoes the created name.
export function createBenchmarkFolder(_workspace: string, name: string): string {
return name
}
export function registerBenchmarkWorkspace(workspace: string): void {
benchmarkWorkspaces.add(workspace)
}
@@ -296,28 +287,21 @@ export function getBenchmarkJobLogs(workspace: string, jobId: string): string {
/**
* In-memory stand-in for the per-user draft backend (`DraftService`). The global
* AI chat now persists and reads drafts through the backend DB instead of an
* in-tab `UserDraft` cell, so the eval mocks the draft endpoints it exercises
* (`updateDraft` / `getOwnDraft` / `getDraftForUser` / `listDrafts`) and keeps the
* in-tab `UserDraft` cell, so the eval mocks the three draft endpoints it
* exercises (`updateDraft` / `getDraftForUser` / `listDrafts`) and keeps the
* saved values here, keyed by workspace + draft kind + storage path. Mirrors the
* semantics of the production unit test's mock in
* `frontend/src/lib/components/copilot/chat/global/core.test.ts`.
*/
const benchmarkDrafts = new Map<
string,
{ workspace: string; kind: UserDraftItemKind; path: string; value: unknown; createdAt: string }
{ workspace: string; kind: UserDraftItemKind; path: string; value: unknown }
>()
// Counter-based timestamps: deterministic run-to-run (same event order → same
// values) but MONOTONIC per update, because production bumps a draft row's
// created_at on every upsert and the diff snapshot cache keys patch reuse on
// it — a fixed timestamp would serve stale patches after an edit. No eval
// simulates a concurrent writer, so every save is accepted and the conflict
// branch is never taken.
let benchmarkDraftClock = 0
function nextBenchmarkDraftTimestamp(): string {
benchmarkDraftClock += 1
return new Date(benchmarkDraftClock * 1000).toISOString()
}
// Fixed timestamp so artifacts stay deterministic. No eval simulates a
// concurrent writer, so every save is accepted and the conflict branch is
// never taken — the syncer just records this as its `last_sync` baseline.
const BENCHMARK_DRAFT_TIMESTAMP = '1970-01-01T00:00:00.000Z'
function benchmarkDraftKey(workspace: string, kind: string, path: string): string {
return `${workspace}::${kind}::${path}`
@@ -348,8 +332,7 @@ export function seedBenchmarkDraft(
workspace,
kind,
path,
value,
createdAt: nextBenchmarkDraftTimestamp()
value
})
}
@@ -362,7 +345,6 @@ export function updateBenchmarkDraft(input: {
}): UpdateDraftResponse {
const key = benchmarkDraftKey(input.workspace, input.kind, input.path)
const value = input.requestBody?.value
const createdAt = nextBenchmarkDraftTimestamp()
if (value == null) {
benchmarkDrafts.delete(key)
} else {
@@ -370,11 +352,10 @@ export function updateBenchmarkDraft(input: {
workspace: input.workspace,
kind: input.kind,
path: input.path,
value,
createdAt
value
})
}
return { status: 'saved', current_timestamp: createdAt }
return { status: 'saved', current_timestamp: BENCHMARK_DRAFT_TIMESTAMP }
}
/** Mirror `DraftService.getDraftForUser`: 404-shaped throw when absent so the
@@ -388,32 +369,7 @@ export function getBenchmarkDraftForUser(input: {
if (!entry) {
throw Object.assign(new Error(`no draft for "${input.path}"`), { status: 404 })
}
return { value: entry.value, created_at: entry.createdAt }
}
/** Mirror `DraftService.getOwnDraft`: `null` (200) when absent — unlike
* `getDraftForUser`, absence is not an error on this route. */
export function getBenchmarkOwnDraft(input: {
workspace: string
kind: UserDraftItemKind
path: string
}): GetOwnDraftResponse {
const entry = benchmarkDrafts.get(benchmarkDraftKey(input.workspace, input.kind, input.path))
if (!entry) {
return null
}
return { value: entry.value, created_at: entry.createdAt }
}
/** Whether a deployed benchmark item exists for a draft row's kind+path —
* drives `draft_only`, which production computes against the deployed tables. */
function benchmarkDeployedExists(workspace: string, kind: UserDraftItemKind, path: string): boolean {
if (kind === 'script') return Boolean(getBenchmarkScriptByPath(workspace, path))
if (kind === 'flow') return Boolean(getBenchmarkFlowByPath(workspace, path))
if (kind === 'app' || kind === 'raw_app') return Boolean(getBenchmarkAppByPath(workspace, path))
// Drawer kinds (variables/resources/schedules/triggers) have no deployed
// benchmark stores today.
return false
return { value: entry.value, created_at: BENCHMARK_DRAFT_TIMESTAMP }
}
/** Mirror `DraftService.listDrafts`: metadata rows (no value) for a workspace. */
@@ -424,9 +380,9 @@ export function listBenchmarkDrafts(workspace: string): ListDraftsResponse {
kind: entry.kind,
path: entry.path,
summary: (entry.value as { summary?: string } | null)?.summary,
draft_only: !benchmarkDeployedExists(workspace, entry.kind, entry.path),
draft_only: true,
legacy_draft: false,
created_at: entry.createdAt
created_at: BENCHMARK_DRAFT_TIMESTAMP
}))
}
@@ -561,35 +517,6 @@ export function runBenchmarkScriptPreview(input: {
})
}
export function runBenchmarkScriptByPath(input: {
workspace: string
path: string
args?: Record<string, unknown>
}): string {
const script = getBenchmarkScriptByPath(input.workspace, input.path)
return createBenchmarkCompletedJob({
workspace: input.workspace,
jobKind: 'script',
success: script !== null,
scriptPath: input.path,
args: input.args,
result:
script !== null
? {
path: input.path,
args: input.args ?? {},
mocked: true
}
: {
error: `Script "${input.path}" not found in benchmark workspace`
},
logs:
script !== null
? 'Mock benchmark script run completed successfully.'
: `Script "${input.path}" not found in benchmark workspace.`
})
}
export function runBenchmarkFlowByPath(input: {
workspace: string
path: string
@@ -745,369 +672,3 @@ function buildBenchmarkApp(app: BenchmarkWorkspaceApp): AppWithLastVersion {
raw_app: true
}
}
// ============= API endpoint catalog (McpService.listMcpTools + raw fetch) =============
// The global chat's API catalog tools list endpoints via McpService and execute
// them with a plain relative fetch('/api/...'), which has no meaning in the
// vitest environment. A representative slice of the real catalog is served here,
// and `handleBenchmarkApiFetch` answers the executed calls.
const BENCHMARK_MCP_TOOLS: EndpointTool[] = [
{
name: 'listWorkers',
description: 'List workers',
instructions: 'List all workers with their last ping and job counts.',
path: '/workers/list',
method: 'GET',
query_params_schema: {
type: 'object',
properties: { page: { type: 'integer' }, per_page: { type: 'integer' } }
}
},
{
name: 'listQueue',
description: 'List queued jobs',
instructions: '',
path: '/w/{workspace}/jobs/queue/list',
method: 'GET',
path_params_schema: {
type: 'object',
properties: { workspace: { type: 'string' } },
required: ['workspace']
}
},
{
name: 'getJob',
description: 'get job',
instructions: '',
path: '/w/{workspace}/jobs_u/get/{id}',
method: 'GET',
path_params_schema: {
type: 'object',
properties: { workspace: { type: 'string' }, id: { type: 'string', format: 'uuid' } },
required: ['workspace', 'id']
},
query_params_schema: {
type: 'object',
properties: {
no_logs: { type: 'boolean' },
no_code: { type: 'boolean' },
approval_token: { type: 'string' }
},
required: []
}
},
{
name: 'runScriptByPath',
description: 'Run the deployed version of a script by path',
instructions: '',
path: '/w/{workspace}/jobs/run/p/{path}',
method: 'POST',
path_params_schema: {
type: 'object',
properties: { workspace: { type: 'string' }, path: { type: 'string' } },
required: ['workspace', 'path']
},
body_schema: { type: 'object', properties: {} }
},
{
name: 'runFlowByPath',
description: 'Run the deployed version of a flow by path',
instructions: '',
path: '/w/{workspace}/jobs/run/f/{path}',
method: 'POST',
path_params_schema: {
type: 'object',
properties: { workspace: { type: 'string' }, path: { type: 'string' } },
required: ['workspace', 'path']
},
body_schema: { type: 'object', properties: {} }
},
// Draft-covered endpoints, present so steering cases exercise the guard the
// way production does (hidden from search, refused at call time).
{
name: 'getScriptByPath',
description: 'Get a script by path',
instructions: '',
path: '/w/{workspace}/scripts/get/p/{path}',
method: 'GET'
},
{
name: 'createFlow',
description: 'Create a flow',
instructions: '',
path: '/w/{workspace}/flows/create',
method: 'POST'
},
{
name: 'deleteSchedule',
description: 'Delete a schedule',
instructions: '',
path: '/w/{workspace}/schedules/delete/{path}',
method: 'DELETE'
},
{
name: 'getVariable',
description: 'Get a variable',
instructions: '',
path: '/w/{workspace}/variables/get/{path}',
method: 'GET'
}
]
export function listBenchmarkMcpTools(): EndpointTool[] {
return BENCHMARK_MCP_TOOLS
}
/** A stand-in Windmill hub. `search_hub_scripts` and a `hub/` read go out over
* relative `/api/...` fetches, which have no origin here, so without these the
* hub tools throw and no case can exercise hub reuse. Serving fixtures rather
* than the live hub also keeps assertions on script content stable as the real
* hub republishes new versions. */
const BENCHMARK_HUB_SCRIPTS = [
{
version_id: 22235,
app: 'holded',
summary: 'Send Document',
terms: 'holded invoice document send email mail',
language: 'bun',
content: `//native
type Holded = {
apiKey: string;
};
/**
* Send Document
* Send a specific document by email.
*/
export async function main(
auth: Holded,
docType: string,
documentId: string,
body: {
mailTemplateId?: string;
emails: string;
subject?: string;
message?: string;
docIds?: string;
},
) {
const url = new URL(
\`https://api.holded.com/api/invoicing/v1/documents/\${docType}/\${documentId}/send\`,
);
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
key: auth.apiKey,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const text = await response.text();
throw new Error(\`\${response.status} \${text}\`);
}
return await response.json();
}
`,
schema: {
type: 'object',
required: ['auth', 'docType', 'documentId', 'body'],
properties: {
auth: { type: 'object', format: 'resource-holded' },
docType: { type: 'string' },
documentId: { type: 'string' },
body: { type: 'object' }
}
}
},
{
version_id: 28294,
app: 'discord',
summary: 'Send a message to Discord using Webhook',
terms: 'discord webhook message send chat channel',
language: 'bunnative',
content: `//native
type DiscordWebhook = {
webhook_url: string;
};
export async function main(discord_webhook: DiscordWebhook, message: string) {
const response = await fetch(\`\${discord_webhook.webhook_url}?wait=true\`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: message }),
});
if (!response.ok) {
throw new Error(\`\${response.status} \${await response.text()}\`);
}
return await response.json();
}
`,
schema: {
type: 'object',
required: ['discord_webhook', 'message'],
properties: {
discord_webhook: { type: 'object', format: 'resource-discord_webhook' },
message: { type: 'string' }
}
}
}
]
/** Naive whole-word overlap — enough to rank a handful of fixtures for a natural
* query without pulling an embedding model into the benchmark. Every frontend eval
* shares this handler, so the bar to match is deliberately high: naming the
* integration, or overlapping on three meaningful words. A looser bar answers
* "send a Slack message" with the Discord fixture, handing an unrelated case a
* plausible-looking wrong integration. */
function searchBenchmarkHubScripts(text: string) {
const tokens = new Set(
text
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((token) => token.length > 2)
)
return BENCHMARK_HUB_SCRIPTS.map((script) => {
const words = new Set(
`${script.app} ${script.summary} ${script.terms}`.toLowerCase().split(/[^a-z0-9]+/)
)
const score = [...tokens].filter((token) => words.has(token)).length
return { script, score, namesApp: tokens.has(script.app) }
})
.filter((entry) => entry.namesApp || entry.score >= 3)
.sort((a, b) => b.score - a.score)
.map(({ script }, index) => ({
ask_id: script.version_id,
id: script.version_id,
version_id: script.version_id,
summary: script.summary,
app: script.app,
kind: 'script',
score: 1 - index * 0.01
}))
}
/** The hub keys a script by its version id; the app and slug segments that
* follow are descriptive, so match on the id exactly as the real hub does. */
function getBenchmarkHubScript(path: string) {
const versionId = Number(path.replace(/^\/api\/scripts\/hub\/get_full\/hub\//, '').split('/')[0])
return BENCHMARK_HUB_SCRIPTS.find((script) => script.version_id === versionId)
}
const BENCHMARK_WORKERS = [
{
worker: 'wk-benchmark-1',
worker_instance: 'benchmark-host',
last_ping: 2,
started_at: BENCHMARK_TIMESTAMP,
jobs_executed: 42,
custom_tags: null,
worker_group: 'default',
wm_version: 'benchmark'
},
{
worker: 'wk-benchmark-2',
worker_instance: 'benchmark-host',
last_ping: 5,
started_at: BENCHMARK_TIMESTAMP,
jobs_executed: 17,
custom_tags: null,
worker_group: 'default',
wm_version: 'benchmark'
}
]
const BENCHMARK_JOB_GET_PATH = /^\/api\/w\/([^/]+)\/jobs_u\/get\/([^/]+)$/
const BENCHMARK_RUN_BY_PATH = /^\/api\/w\/([^/]+)\/jobs\/run\/(p|f)\/([^/]+)$/
/** `executeEndpoint` sends a JSON string; anything else means no args were supplied. */
function parseBenchmarkRequestBody(
body: BodyInit | null | undefined
): Record<string, unknown> | undefined {
if (typeof body !== 'string') {
return undefined
}
try {
const parsed = JSON.parse(body)
return typeof parsed === 'object' && parsed !== null
? (parsed as Record<string, unknown>)
: undefined
} catch {
return undefined
}
}
/** True when `handleBenchmarkApiFetch` has an answer for this `/api/...` url.
* Any other relative fetch must keep its normal (non-benchmark) behavior —
* intercepting it with a synthetic 404 sends the model into retry loops. */
export function hasBenchmarkApiHandler(url: string): boolean {
const path = url.split('?')[0]
return (
path === '/api/workers/list' ||
BENCHMARK_JOB_GET_PATH.test(path) ||
BENCHMARK_RUN_BY_PATH.test(path) ||
/^\/api\/w\/[^/]+\/jobs\/queue\/list$/.test(path) ||
path === '/api/embeddings/query_hub_scripts' ||
path.startsWith('/api/scripts/hub/get_full/')
)
}
/** Answer a relative `/api/...` fetch — from the API catalog executor, or from the
* chat's hub tools. */
export function handleBenchmarkApiFetch(url: string, init?: RequestInit): Response {
const path = url.split('?')[0]
if (path === '/api/workers/list') {
return Response.json(BENCHMARK_WORKERS)
}
if (/^\/api\/w\/[^/]+\/jobs\/queue\/list$/.test(path)) {
return Response.json([])
}
const jobGet = BENCHMARK_JOB_GET_PATH.exec(path)
if (jobGet) {
const id = decodeURIComponent(jobGet[2])
const job = getBenchmarkCompletedJob(decodeURIComponent(jobGet[1]), id)
if (!job) {
return Response.json({ error: `Job not found for "${id}"` }, { status: 404 })
}
// The real endpoint lets a caller drop the bulky fields. Ignoring that here would
// size the model's context off a payload it explicitly asked to shrink.
const query = new URLSearchParams(url.split('?')[1] ?? '')
if (query.get('no_logs') === 'true') {
delete job.logs
}
if (query.get('no_code') === 'true') {
delete job.raw_code
}
return Response.json(job)
}
const runByPath = BENCHMARK_RUN_BY_PATH.exec(path)
if (runByPath) {
const workspace = decodeURIComponent(runByPath[1])
const runnablePath = decodeURIComponent(runByPath[3])
const args = parseBenchmarkRequestBody(init?.body)
// The real endpoint answers with the bare job id as text, not JSON.
return new Response(
runByPath[2] === 'f'
? runBenchmarkFlowByPath({ workspace, path: runnablePath, args })
: runBenchmarkScriptByPath({ workspace, path: runnablePath, args })
)
}
if (path === '/api/embeddings/query_hub_scripts') {
const text = new URLSearchParams(url.split('?')[1] ?? '').get('text') ?? ''
return Response.json(searchBenchmarkHubScripts(text))
}
if (path.startsWith('/api/scripts/hub/get_full/')) {
const script = getBenchmarkHubScript(path)
if (!script) {
return Response.json({ error: 'hub script not found' }, { status: 404 })
}
return Response.json({
content: script.content,
language: script.language,
schema: script.schema,
summary: script.summary
})
}
return Response.json({ error: `no benchmark handler for ${path}` }, { status: 404 })
}
@@ -1,83 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import {
createBenchmarkCompletedJob,
getBenchmarkCompletedJob,
handleBenchmarkApiFetch,
hasBenchmarkApiHandler,
listBenchmarkMcpTools,
resetBenchmarkMockBackend,
registerBenchmarkWorkspaceRunnables
} from './mockBackend'
const WORKSPACE = 'benchmark-api-ws'
// A catalog entry with no fetch handler is a dead end: the catalog executor builds a
// relative `/api/...` url, the stub declines it, and node's fetch throws on the relative
// url instead of returning a result the model can act on. Mutating entries are reachable
// too — the eval runners define no `requestConfirmation`, so `call_api_endpoint` executes
// unconfirmed.
describe('benchmark API catalog', () => {
beforeEach(() => resetBenchmarkMockBackend())
afterEach(() => resetBenchmarkMockBackend())
it('answers every endpoint it advertises', () => {
const unanswered = listBenchmarkMcpTools()
.map((tool) =>
`/api${tool.path.replace('{workspace}', WORKSPACE)}`.replace(/\{[^}]+\}/g, 'x')
)
.filter((url) => !hasBenchmarkApiHandler(url))
// The draft-covered entries are refused by name before any fetch, so they are
// advertised without a handler on purpose.
expect(unanswered).toEqual([
`/api/w/${WORKSPACE}/scripts/get/p/x`,
`/api/w/${WORKSPACE}/flows/create`,
`/api/w/${WORKSPACE}/schedules/delete/x`,
`/api/w/${WORKSPACE}/variables/get/x`
])
})
it('runs a deployed script by path, the way call_api_endpoint reaches it', async () => {
registerBenchmarkWorkspaceRunnables(WORKSPACE, {
scripts: [
{
path: 'f/evals/greet',
summary: 'Greet',
language: 'bun',
content: 'export async function main() {}'
}
]
})
const res = handleBenchmarkApiFetch(
`/api/w/${WORKSPACE}/jobs/run/p/${encodeURIComponent('f/evals/greet')}`,
{ method: 'POST', body: JSON.stringify({ name: 'ada' }) }
)
expect(res.status).toBe(200)
const job = getBenchmarkCompletedJob(WORKSPACE, (await res.text()).trim())
expect(job).toMatchObject({ success: true, args: { name: 'ada' } })
})
it('serves a recorded job so a model can check the run it just started', async () => {
const id = createBenchmarkCompletedJob({
workspace: WORKSPACE,
jobKind: 'preview',
result: 'Hello, World!'
})
const res = handleBenchmarkApiFetch(`/api/w/${WORKSPACE}/jobs_u/get/${id}`)
expect(res.status).toBe(200)
expect(await res.json()).toMatchObject({
id,
success: true,
result: 'Hello, World!'
})
})
it('404s an unknown job id instead of letting the fetch fall through', () => {
expect(hasBenchmarkApiHandler(`/api/w/${WORKSPACE}/jobs_u/get/missing`)).toBe(true)
expect(handleBenchmarkApiFetch(`/api/w/${WORKSPACE}/jobs_u/get/missing`).status).toBe(404)
})
})
@@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import {
clearBenchmarkDrafts,
getBenchmarkDraftForUser,
getBenchmarkOwnDraft,
listBenchmarkDrafts,
resetBenchmarkMockBackend,
seedBenchmarkDraft,
@@ -56,27 +55,6 @@ describe('mockBackend drafts', () => {
expect(() => getBenchmarkDraftForUser({ workspace: WORKSPACE, kind: 'variable', path: 'f/evals/token' })).toThrow()
})
it('returns null from getOwnDraft when no draft exists', () => {
expect(
getBenchmarkOwnDraft({ workspace: WORKSPACE, kind: 'trigger_schedule', path: 'u/evals/missing' })
).toBeNull()
})
// The global chat hydrates drawer-kind drafts (schedule/trigger/resource/variable)
// through getOwnDraft — getDraftForUser rejects those kinds as private.
it('hydrates a saved drawer-kind draft through getOwnDraft', () => {
const value = { path: 'u/evals/nightly', schedule: '0 0 9 * * *' }
updateBenchmarkDraft({
workspace: WORKSPACE,
kind: 'trigger_schedule',
path: 'u/evals/nightly',
requestBody: { value }
})
expect(
getBenchmarkOwnDraft({ workspace: WORKSPACE, kind: 'trigger_schedule', path: 'u/evals/nightly' })?.value
).toEqual(value)
})
it('throws a 404-shaped error when no draft exists', () => {
try {
getBenchmarkDraftForUser({ workspace: WORKSPACE, kind: 'script', path: 'f/evals/missing' })
+10 -103
View File
@@ -3,20 +3,6 @@ import { expect, it, vi } from 'vitest'
import { mkdir, writeFile } from 'fs/promises'
// @ts-ignore - Node.js path
import { dirname, resolve } from 'path'
import { handleBenchmarkApiFetch, hasBenchmarkApiHandler } from './mockBackend'
// The API catalog executor issues relative fetch('/api/...') calls, which have
// no meaning in the vitest environment — serve the ones the benchmark handles.
// Every other relative fetch keeps its normal behavior (it fails the same way
// it does without this stub) so unrelated tools see an unchanged environment.
const ORIGINAL_FETCH = globalThis.fetch
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
const url = typeof input === 'string' ? input : ((input as Request | URL | null)?.url ?? '')
if (typeof url === 'string' && hasBenchmarkApiHandler(url)) {
return handleBenchmarkApiFetch(url, init)
}
return ORIGINAL_FETCH(input as Parameters<typeof fetch>[0], init)
}) as typeof fetch
vi.mock('monaco-editor', () => ({
editor: {},
@@ -54,7 +40,6 @@ vi.mock('$lib/gen', async () => {
getBenchmarkDraftForUser,
getBenchmarkFlowByPath,
getBenchmarkJobLogs,
getBenchmarkOwnDraft,
getBenchmarkScriptByHash,
getBenchmarkScriptByPath,
hasBenchmarkWorkspace,
@@ -64,15 +49,13 @@ vi.mock('$lib/gen', async () => {
listBenchmarkFlows,
listBenchmarkJobs,
listBenchmarkScripts,
createBenchmarkFolder,
createBenchmarkHttpTrigger,
createBenchmarkSchedule,
previewBenchmarkSchedule,
runBenchmarkDatatableSql,
runBenchmarkFlowByPath,
runBenchmarkScriptPreview,
updateBenchmarkDraft,
listBenchmarkMcpTools
updateBenchmarkDraft
} = await import('./mockBackend')
function wrapService<T extends object>(target: T, overrides: Record<string, unknown>): T {
@@ -102,21 +85,11 @@ vi.mock('$lib/gen', async () => {
hasBenchmarkWorkspace(data.workspace)
? getBenchmarkDraftForUser(data)
: actual.DraftService.getDraftForUser(data),
getOwnDraft: async (data: { workspace: string; kind: any; path: string }) =>
hasBenchmarkWorkspace(data.workspace)
? getBenchmarkOwnDraft(data)
: actual.DraftService.getOwnDraft(data),
listDrafts: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace)
? listBenchmarkDrafts(data.workspace)
: actual.DraftService.listDrafts(data)
}),
FolderService: wrapService(actual.FolderService, {
createFolder: async (data: { workspace: string; requestBody: { name: string } }) =>
hasBenchmarkWorkspace(data.workspace)
? createBenchmarkFolder(data.workspace, data.requestBody.name)
: actual.FolderService.createFolder(data)
}),
ScriptService: wrapService(actual.ScriptService, {
listScripts: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace)
@@ -126,32 +99,13 @@ vi.mock('$lib/gen', async () => {
hasBenchmarkWorkspace(data.workspace)
? Boolean(getBenchmarkScriptByPath(data.workspace, data.path))
: actual.ScriptService.existsScriptByPath(data),
getScriptByPath: async (data: { workspace: string; path: string; getDraft?: boolean }) => {
getScriptByPath: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
const script = getBenchmarkScriptByPath(data.workspace, data.path)
// `getDraft` mirrors production's overlay: the row plus the caller's
// draft and a `no_deployed` marker (draft-only item). The diff tool
// reads through this shape — without it every draft looks absent.
const draft = data.getDraft
? getBenchmarkOwnDraft({ workspace: data.workspace, kind: 'script', path: data.path })
: null
if (!script) {
if (data.getDraft && draft) {
return {
...(draft.value as Record<string, unknown>),
path: data.path,
draft: draft.value,
no_deployed: true
}
}
throw Object.assign(
new Error(`Script "${data.path}" not found in benchmark workspace`),
{ status: 404 }
)
throw new Error(`Script "${data.path}" not found in benchmark workspace`)
}
return data.getDraft
? { ...script, draft: draft?.value ?? undefined, no_deployed: false }
: script
return script
}
return actual.ScriptService.getScriptByPath(data)
},
@@ -185,30 +139,13 @@ vi.mock('$lib/gen', async () => {
hasBenchmarkWorkspace(data.workspace)
? Boolean(getBenchmarkFlowByPath(data.workspace, data.path))
: actual.FlowService.existsFlowByPath(data),
getFlowByPath: async (data: { workspace: string; path: string; getDraft?: boolean }) => {
getFlowByPath: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
const flow = getBenchmarkFlowByPath(data.workspace, data.path)
// Mirror production's `getDraft` overlay (see getScriptByPath above).
const draft = data.getDraft
? getBenchmarkOwnDraft({ workspace: data.workspace, kind: 'flow', path: data.path })
: null
if (!flow) {
if (data.getDraft && draft) {
return {
...(draft.value as Record<string, unknown>),
path: data.path,
draft: draft.value,
no_deployed: true
}
}
throw Object.assign(
new Error(`Flow "${data.path}" not found in benchmark workspace`),
{ status: 404 }
)
throw new Error(`Flow "${data.path}" not found in benchmark workspace`)
}
return data.getDraft
? { ...flow, draft: draft?.value ?? undefined, no_deployed: false }
: flow
return flow
}
return actual.FlowService.getFlowByPath(data)
},
@@ -350,12 +287,6 @@ vi.mock('$lib/gen', async () => {
queryResourceTypes: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.ResourceService.queryResourceTypes(data)
}),
McpService: wrapService(actual.McpService, {
listMcpTools: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace)
? listBenchmarkMcpTools()
: actual.McpService.listMcpTools(data)
}),
VariableService: wrapService(actual.VariableService, {
existsVariable: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace) ? false : actual.VariableService.existsVariable(data),
@@ -377,37 +308,13 @@ vi.mock('$lib/gen', async () => {
hasBenchmarkWorkspace(data.workspace)
? (listBenchmarkApps(data.workspace) ?? [])
: actual.AppService.listApps(data),
getAppByPath: async (data: {
workspace: string
path: string
getDraft?: boolean
rawApp?: boolean
}) => {
getAppByPath: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
const app = getBenchmarkAppByPath(data.workspace, data.path)
// Mirror production's `getDraft` overlay (see getScriptByPath above).
// Benchmark app drafts live under the raw_app kind.
const draft = data.getDraft
? getBenchmarkOwnDraft({ workspace: data.workspace, kind: 'raw_app', path: data.path })
: null
if (!app) {
if (data.getDraft && draft) {
return {
...(draft.value as Record<string, unknown>),
path: data.path,
raw_app: true,
draft: draft.value,
no_deployed: true
}
}
throw Object.assign(
new Error(`App "${data.path}" not found in benchmark workspace`),
{ status: 404 }
)
throw new Error(`App "${data.path}" not found in benchmark workspace`)
}
return data.getDraft
? { ...app, draft: draft?.value ?? undefined, no_deployed: false }
: app
return app
}
return actual.AppService.getAppByPath(data)
}
+7 -88
View File
@@ -305,19 +305,23 @@
type: rawscript
moduleRules:
- id: count_until_target
hasStopAfterIf: true
hasStopAfterAllItersIf: false
exactImmediateChildStepIds:
- increment_counter
immediateChildStepTypes:
- id: increment_counter
type: rawscript
moduleFieldRules:
- id: count_until_target
path: stop_after_if.expr
equals: result >= flow_input.target
judgeChecklist:
- "the input schema includes a number field named `target`"
- "the top-level while loop step is named `count_until_target`"
- "`count_until_target` contains a single increment step named `increment_counter`"
- "the loop stops when the counter reaches `target` via a `stop_after_if` on the loop module or on `increment_counter` — both placements are valid per-iteration breaks in Windmill. Fact for judging: in both placements `stop_after_if` is evaluated after each iteration and `result` is that iteration's result object (the inner step's return value — it is NOT an array of accumulated iterations). Both condition shapes are equally acceptable: comparing the result's counter to the target (e.g. `result.counter >= flow_input.target`) or checking a boolean the step returns (e.g. `result.done === true`). Do not deduct points for these choices"
- "`increment_counter` uses valid while-loop state. A counter derived from the iteration index (`flow_input.iter.index` or `flow_input.iter.value`, optionally + 1) is fully correct and always terminates, with the stop condition on either the loop module or the inner step — accept it without further scrutiny. Carrying state via `results.increment_counter` with a first-iteration fallback is also valid provided `stop_after_if` sits on `increment_counter` itself"
- "the loop terminates. Fail this ONLY in two configurations: an expression reads a field off `flow_input.iter.value` (it is a plain number, so e.g. `flow_input.iter.value.counter` never advances), or the single-step body reads `results.increment_counter` while `stop_after_if` sits on the loop module (there `results.increment_counter` is null every iteration). Otherwise pass it — do not invent additional termination concerns"
- "`count_until_target` uses module-level `stop_after_if` to stop when the counter reaches `target`"
- "`increment_counter` uses `flow_input.iter.value` or an equivalent loop-state expression and falls back to `0` on the first iteration"
- "`return_final_counter` returns the final counter value"
- id: flow-test11-preprocessor-and-failure-handler
@@ -355,8 +359,6 @@
- request_approval
- finalize_purchase
topLevelStepTypes:
- id: request_approval
type: [rawscript, script]
- id: finalize_purchase
type: rawscript
schemaRequiredPaths:
@@ -371,7 +373,6 @@
judgeChecklist:
- "the flow includes an approval step named `request_approval`"
- "`request_approval` pauses the flow and asks the approver for a comment"
- "`request_approval` is a real script step that generates approval/resume URLs (e.g. via `getResumeUrls`) so approvers receive an actionable link, not a no-op passthrough (identity) step"
- one approval is enough to continue
- "the flow includes a final step named `finalize_purchase`"
- "`finalize_purchase` returns an approved status object after approval"
@@ -476,85 +477,3 @@
judgeChecklist:
- "the flow includes a final top-level step named `webhook_response`"
- "`webhook_response` returns `ok: true` and the order summary"
- id: flow-test17-implicit-schedule-intent
prompt: |-
I want this order processing flow to run on its own every morning at 07:30 UTC.
Set that up for me. Do not ask me for the flow path.
initial: ai_evals/fixtures/frontend/flow/initial/scheduled_order_flow.json
toolExpect:
requiredToolsUsed:
- create_schedule
toolCallArgs:
- tool: create_schedule
field: path
stringStartsWithAnyOf:
- f/
- u/
stringMustNotStartWithAnyOf:
- schedules/
- tool: create_schedule
field: schedule
stringIncludesAnyOf:
- 30 7
- tool: create_schedule
field: timezone
stringIncludesAnyOf:
- UTC
skipJudge: true
judgeChecklist:
- "a schedule is created for the flow that runs daily at 07:30 UTC"
- id: flow-test18-implicit-http-trigger-intent
prompt: |-
I need to be able to kick off this order processing flow by sending it an HTTP POST
from an external system, with no authentication. Use route path `ai-evals/order-processing-implicit`.
Do not ask me for the flow path.
initial: ai_evals/fixtures/frontend/flow/initial/scheduled_order_flow.json
toolExpect:
requiredToolsUsed:
- create_trigger
toolCallArgs:
- tool: create_trigger
field: path
stringStartsWithAnyOf:
- f/
- u/
stringMustNotStartWithAnyOf:
- schedules/
- tool: create_trigger
field: kind
stringStartsWithAnyOf:
- http
- tool: create_trigger
field: config.http_method
stringIncludesAnyOf:
- post
- tool: create_trigger
field: config.authentication_method
stringIncludesAnyOf:
- none
- tool: create_trigger
field: config.route_path
stringIncludesAnyOf:
- ai-evals/order-processing-implicit
skipJudge: true
judgeChecklist:
- "an HTTP trigger is created for the flow that accepts unauthenticated POST requests"
- id: flow-test19-implicit-email-trigger-intent
prompt: |-
Make this order processing flow run automatically whenever an email is received.
Do not ask me for the flow path.
initial: ai_evals/fixtures/frontend/flow/initial/scheduled_order_flow.json
toolExpect:
requiredToolsUsed:
- create_trigger
toolCallArgs:
- tool: create_trigger
field: kind
stringStartsWithAnyOf:
- email
skipJudge: true
judgeChecklist:
- "an email trigger (kind email) is created, or the user is told how to enable email triggering on the instance"
+1 -790
View File
@@ -3,7 +3,6 @@
Create a draft Bun script at `f/evals/global/greet_user`.
It should take a string `name` input and return `Hello, ${name}!`.
Leave it as an AI draft only; do not deploy or save it.
initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json
runtime:
maxTurns: 10
validate:
@@ -567,40 +566,6 @@
- passes dry_run as true
- leaves only the schedule draft for review
- id: global-test29-schedule-with-retry-and-error-handler
prompt: |-
The workspace already has a report digest helper.
Schedule it every morning at 6am UTC with `dry_run` on, and have it retry twice
if it fails. Run it on our `nightly` worker group.
Draft only, I'll review before deploying.
initial: ai_evals/fixtures/frontend/global/initial/report_digest_script.json
runtime:
maxTurns: 8
validate:
draftCountExactly: 1
requiredDrafts:
- type: schedule
pathIncludes:
- digest
valueIncludes:
- f/evals/global/send_report_digest
- attempts
- nightly
toolExpect:
requiredToolsUsed:
- write_schedule
- get_schedule_schema
forbiddenToolsUsed:
- write_script
- write_flow
- deploy_workspace_item
judgeChecklist:
- finds the existing report digest helper rather than creating a new script or flow
- creates one schedule draft for that helper running daily around 06:00 UTC
- configures a retry policy with two attempts
- routes the schedule to the nightly worker tag
- leaves the schedule as a draft without deploying
- id: global-test18-human-slack-resource-with-secret
prompt: |-
I'm preparing Slack notifications for eval failures.
@@ -906,272 +871,6 @@
- fetches the logs for the requested job id
- explains the failure from the returned logs (connection refused to the upstream API)
# --- Page navigation (open_page) ---
# The assistant should take the user to a Windmill page (Runs/Schedules) with the
# right filters via open_page, rather than describing where to click or dumping the
# data. No draft is produced, so the global judge is skipped and we validate the
# tool call and its arguments.
- id: global-openpage1-runs-failed-of-script
prompt: |-
Take me to the failed runs of the script at f/evals/global/greet_user so I can see what's going wrong.
runtime:
maxTurns: 6
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- open_page
forbiddenToolsUsed:
- write_script
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
- tool: open_page
field: page
stringIncludesAnyOf:
- runs
- tool: open_page
field: status
stringIncludesAnyOf:
- failure
- tool: open_page
field: path
stringIncludesAnyOf:
- f/evals/global/greet_user
skipJudge: true
judgeChecklist:
- opens the Runs page filtered to the failed runs of f/evals/global/greet_user
- applies both the failure status and the script path as filters
- does not write, deploy, or delete anything
- id: global-openpage2-runs-of-schedule
prompt: |-
Open the runs page filtered to the jobs triggered by the schedule f/evals/global/nightly_digest.
runtime:
maxTurns: 6
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- open_page
forbiddenToolsUsed:
- write_script
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
- tool: open_page
field: page
stringIncludesAnyOf:
- runs
- tool: open_page
field: schedule_path
stringIncludesAnyOf:
- f/evals/global/nightly_digest
skipJudge: true
judgeChecklist:
- opens the Runs page filtered to jobs triggered by the f/evals/global/nightly_digest schedule
- passes the schedule path as the filter
- does not write, deploy, or delete anything
- id: global-openpage3-open-schedule
prompt: |-
Open the schedule f/evals/global/nightly_digest so I can review and edit it.
runtime:
maxTurns: 6
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- open_page
forbiddenToolsUsed:
- write_schedule
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
- tool: open_page
field: page
stringIncludesAnyOf:
- schedules
- tool: open_page
field: open
stringIncludesAnyOf:
- f/evals/global/nightly_digest
skipJudge: true
judgeChecklist:
- opens the Schedules page and targets the f/evals/global/nightly_digest schedule for editing
- passes the schedule path so the editor opens on it
- does not write, deploy, or delete anything
- id: global-openpage4-workspace-settings-tab
prompt: |-
Take me to the Git sync configuration for this workspace.
runtime:
maxTurns: 6
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- open_page
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
- tool: open_page
field: page
stringIncludesAnyOf:
- workspace_settings
- tool: open_page
field: tab
stringIncludesAnyOf:
- git_sync
skipJudge: true
judgeChecklist:
- opens the Workspace settings page on the git_sync tab
- does not write, deploy, or delete anything
- id: global-openpage5-audit-logs-user
prompt: |-
Open the audit logs filtered to actions performed by the user admin.
runtime:
maxTurns: 6
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- open_page
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
- tool: open_page
field: page
stringIncludesAnyOf:
- audit_logs
- tool: open_page
field: username
stringIncludesAnyOf:
- admin
skipJudge: true
judgeChecklist:
- opens the Audit logs page filtered to the admin user
- does not write, deploy, or delete anything
- id: global-openpage6-triggers-kind
prompt: |-
Take me to the Kafka triggers for this workspace.
runtime:
maxTurns: 6
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- open_page
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
- tool: open_page
field: page
stringIncludesAnyOf:
- triggers
- tool: open_page
field: trigger_kind
stringIncludesAnyOf:
- kafka
skipJudge: true
judgeChecklist:
- opens the Kafka triggers page
- does not write, deploy, or delete anything
- id: global-openpage7-compare-review
prompt: |-
Create a TypeScript script draft at f/evals/global/compare_review_demo that returns the string "ok" (no need to test it), then open the review page so I can look over the pending change and deploy it myself.
runtime:
maxTurns: 8
validate:
draftCountExactly: 1
toolExpect:
requiredToolsUsed:
- write_script
- open_page
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
- tool: open_page
field: page
stringIncludesAnyOf:
- compare
# The eval chat is untracked (no modified-items mask), so the model must scope
# the review by passing the item it changed explicitly — an omitted mask would
# preselect every pending change in the workspace.
- tool: open_page
field: items
stringIncludesAnyOf:
- f/evals/global/compare_review_demo
skipJudge: true
judgeChecklist:
- creates the script draft, then opens the Compare & Deploy review page instead of deploying itself
- preselects only the created script on the review page
- does not deploy or delete anything
- id: global-closepage1-close-runs-tab
prompt: |-
You just opened the runs page for me in the side panel. Close that tab, I'm done looking at it.
runtime:
maxTurns: 6
sessionChat: true
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- close_page
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
- tool: close_page
field: match
stringIncludesAnyOf:
- runs
skipJudge: true
judgeChecklist:
- closes the runs preview tab in the side panel
- does not write, deploy, or delete anything
# --- Artifact version history ---
# Every content change to an artifact is snapshotted, and update_artifact requires a
# change_note that the user reads in the version picker. A blank note makes the history
# unreadable, so pin that the model fills it on every edit.
- id: global-artifact-note-on-each-edit
prompt: |-
Write up a short rollout plan for me as a doc I can come back to, covering a staged
rollout in three phases. Then add a rollback section to it, and after that tighten
the wording of phase 2.
runtime:
maxTurns: 12
sessionChat: true
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- create_artifact
- update_artifact
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
# The note is what the version picker shows; a blank one makes history unreadable.
- tool: update_artifact
field: change_note
nonEmpty: true
skipJudge: true
judgeChecklist:
- creates one artifact and revises it rather than creating a second artifact
- each revision carries a short description of what changed
# --- Documentation search (search_docs) ---
# Pure product-knowledge questions: the assistant should consult the docs via
# search_docs and answer conversationally, not draft or mutate anything. No
@@ -1479,7 +1178,7 @@
when a new hire joins. Keep it as a draft.
initial: ai_evals/fixtures/frontend/global/initial/user_admin_folders.json
runtime:
maxTurns: 8
maxTurns: 6
validate:
draftCountExactly: 0
toolExpect:
@@ -1522,491 +1221,3 @@
- drafts a flow that returns the current date as an ISO string
- places it in team_a (writable by this non-admin user) and not team_b (read-only)
- leaves the result as a draft only
- id: global-test-pipeline-create-node
prompt: |-
Set up the first step of a data pipeline at `f/evals/global/orders_ingest`.
On a schedule, it should pull raw orders and land them in a managed DuckLake
table so later steps can build on it. Keep it as an AI draft only — don't
deploy or save it.
initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json
runtime:
maxTurns: 10
validate:
draftCountExactly: 1
requiredDrafts:
- type: script
path: f/evals/global/orders_ingest
valueIncludes:
- pipeline
forbiddenDrafts:
- type: flow
pathStartsWith: f/evals/global/
toolExpect:
requiredToolsUsed:
- write_script
forbiddenToolsUsed:
- write_flow
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
# A pipeline node is DECLARATIVE: triggers are declared by `-- on <ref>`
# annotations (the trigger row is created separately) and a `-- materialize`
# output is a MANAGED write where the body is a bare SELECT that the runtime
# wraps in the create/replace. Do not expect a separate trigger config or a
# hand-written CREATE TABLE / INSERT — those would be wrong for a materialize node.
- builds a data pipeline node as a script (not a flow)
- marks the script as a pipeline member with the pipeline annotation in the script's comment syntax (`-- pipeline` for a DuckDB/SQL node, not `// pipeline`)
- declares the schedule trigger with the `-- on schedule` annotation comment (this annotation is the correct and complete way a pipeline node binds a schedule; no separate trigger configuration is expected)
- declares the managed DuckLake output with `-- materialize ducklake://<table>` and writes the body as a bare SELECT (materialize is a managed write, so the node correctly does NOT hand-write its own CREATE TABLE / INSERT)
- leaves the result as an AI draft and does not deploy or save it
- id: global-test-pipeline-two-node-chain
prompt: |-
Build a small data pipeline in the `f/evals/global` folder: one step that
ingests orders into a DuckLake table, and a second step that reads that table
and writes a daily order-count rollup table. Wire the second step to run off
the first step's output. Keep everything as drafts — don't deploy.
initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json
runtime:
maxTurns: 14
validate:
draftCountAtLeast: 2
requiredDrafts:
- type: script
pathStartsWith: f/evals/global/
valueIncludes:
- pipeline
- ducklake
forbiddenDrafts:
- type: flow
pathStartsWith: f/evals/global/
toolExpect:
requiredToolsUsed:
- write_script
forbiddenToolsUsed:
- write_flow
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
# Pipeline nodes are declarative: `-- on <ref>` binds inputs/triggers and
# `-- materialize ducklake://<table>` is a managed write whose body is a bare
# SELECT. Do not expect hand-written CREATE TABLE / INSERT on a materialize node.
- creates two data pipeline nodes as scripts (not a flow) in f/evals/global
- both scripts carry the pipeline annotation in their comment syntax (`-- pipeline` for DuckDB/SQL nodes, not `// pipeline`)
- the first ingests orders into a DuckLake table (a `-- materialize ducklake://<table>` output with a bare SELECT body is correct; no hand-written CREATE TABLE / INSERT is expected)
- the second reads that same table via `-- on ducklake://<that-table>` and materializes a daily rollup table, wiring it to the first step's output asset
- leaves both as AI drafts without deploying
- id: global-test-pipeline-complex-incremental
prompt: |-
Build a data pipeline in the `f/evals/global` folder for our web shop's
orders. It has three steps:
1. On a schedule, ingest the raw order CSVs under `s3://raw/orders/` into a
managed DuckLake table.
2. An incremental daily rollup: read that raw orders table and, on each run,
append just the current day's order count and total revenue into a second
DuckLake table. It should process one day at a time, not rebuild the whole
table every run.
3. A final step that reads the daily rollup table and exports the latest data
as a Parquet file to `s3://reports/` for the BI team.
Wire each step to the previous step's output so they form one pipeline. Keep
everything as AI drafts — don't deploy or save.
initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json
runtime:
maxTurns: 18
validate:
draftCountAtLeast: 3
requiredDrafts:
- type: script
pathStartsWith: f/evals/global/
valueIncludes:
- pipeline
- ducklake
forbiddenDrafts:
- type: flow
pathStartsWith: f/evals/global/
toolExpect:
requiredToolsUsed:
- write_script
forbiddenToolsUsed:
- write_flow
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
# Pipeline nodes are declarative: `-- on <ref>` binds inputs/triggers, and a
# DuckLake `-- materialize` output is a managed write whose body is a bare SELECT
# (the runtime performs the create/replace/append/merge). Do not expect a
# separate trigger config or hand-written CREATE TABLE / INSERT on a
# materialize node. S3/Parquet output is NOT materialize: the body writes it.
- builds the pipeline as three independent scripts (not a flow) in f/evals/global
- every node carries the pipeline annotation in its own comment syntax (`-- pipeline` for DuckDB/SQL nodes, not `// pipeline`)
- step 1 binds a schedule with `-- on schedule` and declares a managed DuckLake output with `-- materialize ducklake://<table>` and a bare SELECT body (no separate trigger config or hand-written CREATE TABLE is expected)
- "step 2 is incremental: each run adds only that day's rows to a second DuckLake table rather than rebuilding the whole table every run (e.g. an `append` or `key=<col>` merge materialize mode, not a full replace). Selecting the day via the `-- partitioned daily` + `{partition}` / `wm_partition(...)` idiom is the idiomatic form, but an equivalent current-day filter also satisfies this; a full-refresh/replace of the whole table does not"
- step 2 reads the same DuckLake table step 1 writes (via `-- on ducklake://<that-table>`), wiring it to step 1's output asset
- step 3 reads the daily rollup table and exports it as a Parquet file to S3
- does not misuse `-- materialize` for the S3 Parquet export (materialize is DuckLake-only; the S3 output is written by the script body, e.g. a DuckDB COPY or an SDK write)
- leaves all three nodes as AI drafts without deploying or saving
- id: global-path5-create-folder-then-draft
prompt: |-
Create a new shared folder called "analytics" for our data work, then draft a
script in it that returns the current timestamp as an ISO string. Keep the
script as a draft.
initial: ai_evals/fixtures/frontend/global/initial/user_admin_empty.json
runtime:
maxTurns: 10
validate:
draftCountExactly: 1
requiredDrafts:
- type: script
pathStartsWith: f/analytics/
toolExpect:
requiredToolsUsed:
- create_folder
- write_script
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
skipJudge: true
judgeChecklist:
- creates a new shared folder named "analytics" via create_folder
- drafts a script placed in that folder (f/analytics/...) returning an ISO timestamp
- leaves the script as a draft only
- id: global-artifact-plan-create
prompt: |-
I'm about to build a customer onboarding flow, but first I want a short written plan I can review and iterate on before any code.
Draft a markdown plan with a title, a one-sentence summary, and three or four bullet steps.
Keep it as something I can reopen and revise later — don't build the flow itself yet.
initial: ai_evals/fixtures/frontend/global/initial/user_admin_empty.json
runtime:
maxTurns: 6
sessionChat: true
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- create_artifact
forbiddenToolsUsed:
- write_flow
- write_script
- deploy_workspace_item
judgeChecklist:
- saves the plan as a markdown artifact via create_artifact rather than only replying inline
- the artifact content has a title, a one-line summary, and three or four bullet steps for onboarding
- does not create a flow or script draft yet
- id: global-npm1-script-search-package
prompt: |-
Find a good npm package for parsing RSS/Atom feeds and use it to create a draft Bun script
at `f/evals/global/fetch_feed`. The script should take a string `url` input and return the
feed title along with the titles of the 5 most recent items. Tell me which package you
picked and link its documentation. Leave it as an AI draft only; do not deploy.
initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json
runtime:
maxTurns: 12
validate:
draftCountExactly: 1
requiredDrafts:
- type: script
path: f/evals/global/fetch_feed
language: bun
valueIncludes:
- url
toolExpect:
requiredToolsUsed:
- search_npm_packages
- write_script
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
- searches npm for a feed-parsing package before writing the code
- the draft Bun script imports the chosen npm package rather than hand-rolling an XML/RSS parser
- the script takes a url input and returns the feed title plus the recent item titles
- the reply names the chosen package and links its documentation
- the result stays as an AI draft and is not deployed
- id: global-dbschema1-postgres-resource-tables
prompt: |-
I have a postgres resource at f/data/reports_pg in this workspace.
What tables does that database have?
initial: ai_evals/fixtures/frontend/global/initial/user_admin_empty.json
runtime:
maxTurns: 12
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- get_db_schema
forbiddenToolsUsed:
- write_script
- write_resource
- test_run_script
- deploy_workspace_item
toolCallArgs:
- tool: get_db_schema
field: resourcePath
stringIncludesAnyOf:
- f/data/reports_pg
skipJudge: true
judgeChecklist:
- fetches the schema through get_db_schema with the resource path f/data/reports_pg
- when the lookup fails, tells the user instead of inventing table names
- does not write scripts or resources to answer a read-only question
# --- API catalog (search_api_endpoints / call_api_get / call_api_endpoint) ---
# The harness serves the catalog and the executed calls itself (mock
# listMcpTools + benchmark fetch handlers in adapters/frontend), so these cases
# do not require an mcp-enabled eval backend.
- id: global-test30-api-catalog-workers
prompt: |-
Are any workers online right now? Give me a quick summary of the workers and when they last pinged.
initial: ai_evals/fixtures/frontend/global/initial/user_admin_empty.json
runtime:
maxTurns: 8
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- search_api_endpoints
- call_api_get
forbiddenToolsUsed:
- call_api_endpoint
- write_script
- deploy_workspace_item
toolCallArgs:
- tool: call_api_get
field: name
stringIncludesAnyOf:
- listWorkers
# Read-only workspace inspection produces no draft; validate via tool use.
skipJudge: true
judgeChecklist:
- discovers the workers endpoint through the API catalog instead of guessing or fabricating
- reports worker status from the returned data
- id: global-test31-draft-test-run-not-deployed
prompt: |-
Update `f/evals/global/format_greeting` so the provided name is uppercased in the greeting, then run it with name "ada" to check it works.
Keep it as an AI draft only; do not deploy or save it.
initial: ai_evals/fixtures/frontend/global/initial/format_greeting_script.json
runtime:
maxTurns: 10
validate:
draftCountExactly: 1
requiredDrafts:
- type: script
path: f/evals/global/format_greeting
language: bun
valueIncludes:
- toUpperCase
toolExpect:
requiredToolsUsed:
- test_run_script
forbiddenToolsUsed:
- call_api_endpoint
- deploy_workspace_item
- delete_workspace_item
# The judge only sees the drafts artifact and cannot observe runs, so it always
# docks the prompt's "run it" requirement — validate deterministically instead:
# draft content via valueIncludes, the test run via toolExpect (test_run_script
# required, call_api_endpoint forbidden).
skipJudge: true
judgeChecklist:
- creates an AI draft of f/evals/global/format_greeting with the name uppercased in the greeting
- does not deploy or save the draft
- id: global-test32-delete-via-dedicated-tool
prompt: |-
Delete the deployed script `f/evals/global/send_report_digest` from the workspace — we don't use it anymore.
initial: ai_evals/fixtures/frontend/global/initial/report_digest_script.json
runtime:
maxTurns: 6
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- delete_workspace_item
forbiddenToolsUsed:
- call_api_endpoint
- search_api_endpoints
- write_script
# Deletion produces no draft; validate via tool use.
skipJudge: true
judgeChecklist:
- deletes the deployed script via delete_workspace_item rather than a raw API endpoint
- id: global-undo-created-draft
prompt: |-
Create a draft Postgres resource at `u/admin/scratch_db` for host db.example.com port 5432, database `orders`, user `app`, and tell me what fields it ended up with.
Once you've shown me that, delete it from the workspace again — I only wanted to see the shape of it.
initial: ai_evals/fixtures/frontend/global/initial/user_admin_empty.json
runtime:
maxTurns: 10
validate:
draftCountExactly: 0
toolExpect:
requiredToolsUsed:
- write_resource
- discard_local_draft
forbiddenToolsUsed:
- delete_workspace_item
- deploy_workspace_item
# Undoing a draft leaves no draft behind; validate via tool use.
skipJudge: true
judgeChecklist:
- undoes its own never-deployed resource with discard_local_draft rather than delete_workspace_item
- id: global-draft-diff-report
prompt: |-
Update the existing workspace script at `f/evals/global/format_greeting` so the returned message ends with an exclamation mark, keeping everything else the same.
Then show me exactly what your draft changes compared to the deployed version.
Leave the result as an AI draft only; do not deploy or save it.
initial: ai_evals/fixtures/frontend/global/initial/format_greeting_script.json
runtime:
maxTurns: 8
validate:
draftCountExactly: 1
requiredDrafts:
- type: script
path: f/evals/global/format_greeting
language: bun
valueIncludes:
- "!"
toolExpect:
requiredToolsUsed:
- diff
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
- tool: diff
field: path
stringIncludesAnyOf:
- f/evals/global/format_greeting
judgeChecklist:
- creates an AI draft for the existing f/evals/global/format_greeting script with the exclamation-mark change
- the draft changes only the returned message's punctuation — summary, language, path, and the rest of the code are untouched
- does not deploy or save the draft to the workspace
- id: global-resource-manual-credentials
prompt: |-
Set up a resource for our production Postgres database at `f/evals/global/prod_db` (host db.internal.example.com, port 5432, database `orders`, user `app`).
I don't want to paste the password into this chat — prepare everything so I can enter it myself.
Leave it as an AI draft only; do not deploy or save it.
initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json
runtime:
maxTurns: 12
validate:
draftCountAtLeast: 1
requiredDrafts:
- type: resource
path: f/evals/global/prod_db
valueIncludes:
- db.internal.example.com
- orders
toolExpect:
requiredToolsUsed:
- write_resource
- open_page
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
# The model may land the user in the resource's drawer or in the drawer of
# the secret variable it created for the password — both are correct.
- tool: open_page
field: page
stringIncludesAnyOf:
- resources
- variables
- tool: open_page
field: open
stringIncludesAnyOf:
- prod_db
- password
judgeChecklist:
- creates a postgres resource draft at f/evals/global/prod_db with the provided host, port, database, and user
- the password is left for the user to provide (empty, a placeholder, or a secret variable reference) — no invented password value presented as real
- does not deploy or save anything to the workspace
- id: global-test29-email-trigger-draft
prompt: |-
Set up a draft auto-reply job.
Create a Bun script at `f/evals/global/email_pong` that returns the string "pong".
Then set it up so it runs whenever an email is received at the inbox `pong`.
Leave everything as AI drafts only; do not deploy or save anything to the workspace.
runtime:
maxTurns: 10
validate:
requiredDrafts:
- type: script
path: f/evals/global/email_pong
language: bun
valueIncludes:
- pong
toolExpect:
requiredToolsUsed:
- write_script
- write_trigger
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
# "Runs when an email is received" must resolve to the native email trigger kind,
# never a faked HTTP webhook. Assert the recorded tool-call kind (not the draft):
# it holds even on a CE backend where email trigger routes (smtp+private) 404.
- tool: write_trigger
field: kind
stringIncludesAnyOf:
- email
skipJudge: true
# --- Windmill Hub reuse (search_hub_scripts + read_workspace_item on a hub/ path) ---
# Holded's API is obscure enough that a model writing from memory cannot reproduce
# its endpoint and `key` auth header — so the draft's fidelity to the published
# script is what proves the hub content was actually fetched, not guessed.
- id: global-hub1-reuse-hub-script
prompt: |-
I want to email one of my Holded invoices to a customer from Windmill.
There is already a script for that on the Windmill hub — reuse it instead of writing
your own, and save it as a draft script at `f/evals/global/holded_send_document`.
Leave it as an AI draft; do not deploy it.
initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json
runtime:
maxTurns: 12
validate:
draftCountExactly: 1
requiredDrafts:
- type: script
path: f/evals/global/holded_send_document
language: bun
valueIncludes:
- api.holded.com/api/invoicing/v1/documents
# `mailTemplateId` is an optional field of the published script's body
# that a model writing from memory does not invent, so it is what
# separates reusing the hub script from re-deriving one that merely
# hits the same endpoint.
- mailTemplateId
toolExpect:
requiredToolsUsed:
- search_hub_scripts
- read_workspace_item
- write_script
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
- tool: read_workspace_item
field: path
stringIncludesAnyOf:
- hub/
judgeChecklist:
- the draft sends an existing Holded document by email rather than creating one
- the request targets Holded's document send endpoint, not an invented URL
- authentication uses Holded's own key header rather than a bearer token
- the document type, document id, and recipient emails are inputs to the script
- the result stays an AI draft and is not deployed
-3
View File
@@ -212,9 +212,6 @@ describe("loadCases", () => {
},
],
});
expect(caseEntry?.initialPath).toContain(
"ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json"
);
expect(caseEntry?.toolExpect).toMatchObject({
requiredToolsUsed: ["write_script"],
forbiddenToolsUsed: ["deploy_workspace_item", "delete_workspace_item"],
+1 -9
View File
@@ -31,8 +31,6 @@ export interface EvalCaseRuntimeSpec {
maxTurns?: number;
backendPreview?: EvalCaseRuntimeBackendPreview;
appContext?: EvalCaseRuntimeAppContextSpec;
// Global mode: run as a session chat (preview tools + session prompt) vs the standalone chat.
sessionChat?: boolean;
}
export interface FlowValidationSpec {
@@ -49,7 +47,7 @@ export interface FlowValidationSpec {
}>;
topLevelStepTypes?: Array<{
id: string;
type: string | string[];
type: string;
}>;
moduleRules?: Array<{
id: string;
@@ -166,12 +164,6 @@ export interface ToolCallArgumentRule {
* tool — e.g. SQL where a mutation is mixed with verification SELECTs.
*/
stringIncludesAnyOf?: string[];
/**
* Universal over calls: every recorded call to `tool` must carry `field` as a
* non-blank string. Use for a required argument whose value is free text, where
* the point is that the model filled it in at all rather than what it said.
*/
nonEmpty?: boolean;
}
export interface ToolValidationSpec {
-80
View File
@@ -174,86 +174,6 @@ describe("validateToolExpectations", () => {
expect(checks.every((check) => check.passed)).toBe(true);
});
it("fails nonEmpty when any call left the field blank", () => {
const checks = validateToolExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 2,
toolsUsed: ["update_artifact"],
toolCallDetails: [
{ name: "update_artifact", arguments: { change_note: "Added a rollback section" } },
// A whitespace-only note is as unreadable in the picker as a missing one.
{ name: "update_artifact", arguments: { change_note: " " } },
],
skillsInvoked: [],
},
toolExpect: {
toolCallArgs: [{ tool: "update_artifact", field: "change_note", nonEmpty: true }],
},
});
const nonEmptyCheck = checks.find((c) => c.name.includes("is filled in on every call"));
expect(nonEmptyCheck?.passed).toBe(false);
expect(nonEmptyCheck?.details).toContain("blank on 1 of 2");
});
it("passes nonEmpty when every call filled the field", () => {
const checks = validateToolExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["update_artifact"],
toolCallDetails: [
{ name: "update_artifact", arguments: { change_note: "Tightened phase 2" } },
],
skillsInvoked: [],
},
toolExpect: {
toolCallArgs: [{ tool: "update_artifact", field: "change_note", nonEmpty: true }],
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("accepts a stringIncludesAnyOf substring inside an array-valued field", () => {
const checks = validateToolExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["open_page"],
toolCallDetails: [
{
name: "open_page",
arguments: {
page: "compare",
items: ["script:f/evals/global/compare_review_demo"],
},
},
],
skillsInvoked: [],
},
toolExpect: {
requiredToolsUsed: ["open_page"],
toolCallArgs: [
{
tool: "open_page",
field: "items",
stringIncludesAnyOf: ["f/evals/global/compare_review_demo"],
},
],
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("accepts stringIncludesAnyOf when only one of several calls matches", () => {
// Existential: a mutation mixed with verification SELECTs still passes.
const checks = validateToolExpectations({
+5 -27
View File
@@ -233,34 +233,15 @@ export function validateToolExpectations(input: {
);
}
if (rule.nonEmpty) {
const blankValues = values.filter(
(value) => typeof value !== "string" || value.trim().length === 0
);
checks.push(
check(
`${rule.tool}.${rule.field} is filled in on every call`,
blankValues.length === 0,
`blank on ${blankValues.length} of ${values.length} call(s); values: ${summarizeToolValues(values)}`
)
);
}
if (rule.stringIncludesAnyOf && rule.stringIncludesAnyOf.length > 0) {
// Existential: at least one call must contain one of the substrings.
// Other calls to the same tool may do anything — this suits SQL, where a
// model mixes the requested statement (e.g. an UPDATE) with verification
// SELECTs that would otherwise fail an "all calls" check.
const needles = rule.stringIncludesAnyOf.map((needle) => needle.toLowerCase());
// Array-valued fields (e.g. open_page.items) match on any element.
const haystacks = (value: unknown): string[] =>
typeof value === "string"
? [value]
: Array.isArray(value)
? value.filter((v): v is string => typeof v === "string")
: [];
const hasMatch = values.some((value) =>
haystacks(value).some((hay) => needles.some((needle) => hay.toLowerCase().includes(needle)))
const hasMatch = values.some(
(value) =>
typeof value === "string" && needles.some((needle) => value.toLowerCase().includes(needle))
);
checks.push(
check(
@@ -1397,14 +1378,11 @@ function validateFlowRequirements(
continue;
}
const allowedTypes = Array.isArray(requiredStep.type)
? requiredStep.type
: [requiredStep.type];
checks.push(
check(
`${requiredStep.id} type matches required`,
allowedTypes.includes(getModuleType(module) ?? ""),
`expected ${allowedTypes.join(" or ")}, got ${getModuleType(module) ?? "(missing)"}`
getModuleType(module) === requiredStep.type,
`expected ${requiredStep.type}, got ${getModuleType(module) ?? "(missing)"}`
)
);
}
@@ -0,0 +1,47 @@
{
"value": {
"modules": [
{
"id": "count_until_target",
"value": {
"type": "whileloopflow",
"skip_failures": false,
"modules": [
{
"id": "increment_counter",
"value": {
"type": "rawscript",
"language": "bun"
}
}
]
},
"stop_after_if": {
"expr": "result >= flow_input.target",
"skip_if_stopped": false
}
},
{
"id": "return_final_counter",
"value": {
"type": "rawscript"
}
}
]
},
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"target": {
"type": "number"
}
},
"required": [
"target"
],
"order": [
"target"
]
}
}
@@ -1,8 +0,0 @@
{
"user": {
"username": "admin",
"is_admin": true,
"folders": ["evals"],
"folders_read": ["evals"]
}
}
-1
View File
@@ -41,7 +41,6 @@ export function createGlobalModeRunner(
workspaceFixtures: initial?.workspace,
liveEditorDrafts: initial?.liveEditorDrafts,
user: initial?.user,
sessionChat: context.evalCase?.runtime?.sessionChat,
maxIterations: context.evalCase?.runtime?.maxTurns,
provider: modelConfig.provider,
model: modelConfig.model,
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, edited_at, on_behalf_of, on_behalf_of_email)\n VALUES ('test-workspace', 'u/test-user/obo_flow', '', '', $1, 'test-user', NOW(), 'u/test-user-2', 'test2@windmill.dev')",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb"
]
},
"nullable": []
},
"hash": "00a7fccde8bc2075642ba02f4015d752ba7d67966ed03c0bf3414c842c049b3a"
}
@@ -1,24 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path FROM flow WHERE on_behalf_of = $1 AND NOT path LIKE $2 AND workspace_id = $3 AND NOT archived",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "015b63b1fbdf95fc76138fcf0aed03ac8948cfcf071c7f5df574c5f7003545cd"
}
@@ -1,34 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE materialized_asset_schema\n SET snapshot_id = $5, job_id = $6, captured_at = now()\n WHERE workspace_id = $1 AND asset_kind = $2 AND asset_path = $3\n AND version = $4",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
{
"Custom": {
"name": "asset_kind",
"kind": {
"Enum": [
"s3object",
"resource",
"variable",
"ducklake",
"datatable",
"volume",
"dbt"
]
}
}
},
"Text",
"Int8",
"Int8",
"Uuid"
]
},
"nullable": []
},
"hash": "01732ca02b1888145c48c4e51e5b5829657224a743af9c0b2d5a140ad70e13dd"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT args->'__git_sync_auto_pull' FROM v2_job WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "020cc73e39782e7be50a146373096bd4378988af6447feeafd243262c246a9f5"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE flow SET on_behalf_of = $1 WHERE on_behalf_of = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "024fa8a99a7967a56cd5ac9486ef728f2de0800eeb877fd29aca2fba3029aa55"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_diff_full_scan SET source_workspace_id = $1 WHERE source_workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "02bdda30f56376073333e45fdf67a874a0cc1a26ca295b0e9b4a6f26e2e83db7"
}
@@ -1,38 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO amqp_trigger (\n amqp_resource_path,\n queue_name,\n exchange,\n options,\n workspace_id,\n path,\n script_path,\n is_flow,\n permissioned_as,\n mode,\n edited_by,\n error_handler_path,\n error_handler_args,\n retry\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14\n )",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Jsonb",
"Jsonb",
"Varchar",
"Varchar",
"Varchar",
"Bool",
"Varchar",
{
"Custom": {
"name": "trigger_mode",
"kind": {
"Enum": [
"enabled",
"disabled",
"suspended"
]
}
}
},
"Varchar",
"Varchar",
"Jsonb",
"Jsonb"
]
},
"nullable": []
},
"hash": "02e39bb9957d3ccfe3d573994cd281a22467010d2c4a0611c8ae02742336f433"
}
@@ -0,0 +1,38 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM asset\n WHERE (workspace_id, path, kind) IN (\n SELECT workspace_id, path, kind FROM (\n SELECT a.workspace_id, a.path, a.kind, a.usage_kind, ROW_NUMBER() OVER (\n PARTITION BY a.workspace_id, a.path, a.kind\n ORDER BY a.created_at DESC\n ) as rn,\n limits.max_n\n FROM asset a\n INNER JOIN (\n SELECT * FROM UNNEST(\n $1::varchar[], \n $2::varchar[], \n $3::asset_kind[],\n $4::int[]\n ) AS t(workspace_id, path, kind, max_n)\n ) limits\n ON a.workspace_id = limits.workspace_id \n AND a.path = limits.path \n AND a.kind = limits.kind\n WHERE a.usage_kind = 'job'\n ) ranked\n WHERE rn > max_n\n )",
"describe": {
"columns": [],
"parameters": {
"Left": [
"VarcharArray",
"VarcharArray",
{
"Custom": {
"name": "asset_kind[]",
"kind": {
"Array": {
"Custom": {
"name": "asset_kind",
"kind": {
"Enum": [
"s3object",
"resource",
"variable",
"ducklake",
"datatable",
"volume"
]
}
}
}
}
}
},
"Int4Array"
]
},
"nullable": []
},
"hash": "02e526146f3584cd599dec708e1be48db3b0cd1c74adbfa2e4039377daa016f0"
}
@@ -1,17 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO dbt_graph_snapshot (workspace_id, script_path, script_hash, job_id, digest)\n VALUES ($1, $2, $3, $4, 'd2')",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Int8",
"Uuid"
]
},
"nullable": []
},
"hash": "03238f44f8d4db3ed406f853b5afd945796a2657e58cf1c5086d971e652803d5"
}
@@ -1,12 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM feature_usage WHERE day < CURRENT_DATE - 60",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "0390d9e4fae597aabdeef940aa5b5014e1c889ba9181ee3331f128c56c9d22de"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE postgres_trigger SET permissioned_as = $1 WHERE permissioned_as = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "03fdb7d0d4d95a98fdbdcf7dd1a9289119b9ce719d6a6c2975e96d7785819fc9"
}
@@ -1,29 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT created_by, runnable_path FROM v2_job\n WHERE id = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "runnable_path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
false,
true
]
},
"hash": "04896f435eee78f5ac604f5a57c8d35a313fc321df788e1c701e11f60da46480"
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "WITH RECURSIVE tree AS (\n SELECT id, is_dev_workspace, dev_workspace_label, deleted, 0 AS depth\n FROM workspace WHERE id = $1\n UNION ALL\n SELECT w.id, w.is_dev_workspace, w.dev_workspace_label, w.deleted,\n tree.depth + 1\n FROM workspace w JOIN tree ON w.parent_workspace_id = tree.id\n WHERE tree.depth < 20\n )\n SELECT id AS \"id!\", dev_workspace_label FROM tree\n WHERE depth > 0 AND is_dev_workspace AND NOT deleted",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "dev_workspace_label",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null,
null
]
},
"hash": "051a18ab1720ffff792a843be93e8c01515be10b2e40292d904d20e7d53bf289"
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id as \"workspace_id!\", MIN(completed_at) as oldest\n FROM v2_job_completed\n WHERE workspace_id = ANY($1::text[])\n GROUP BY workspace_id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "oldest",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"TextArray"
]
},
"nullable": [
false,
null
]
},
"hash": "056231b872a080a257c034489127e253678631e091b9e4223842648a4475dbbf"
}
@@ -1,41 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT kind, path, script_path, is_flow FROM (\n SELECT 'schedule' AS kind, path, script_path, is_flow FROM schedule\n WHERE workspace_id = $1\n AND script_path IS NOT NULL\n UNION ALL\n SELECT 'email', path, script_path, is_flow FROM email_trigger\n WHERE workspace_id = $1\n UNION ALL\n SELECT 'kafka', path, script_path, is_flow FROM kafka_trigger\n WHERE workspace_id = $1\n UNION ALL\n SELECT 'mqtt', path, script_path, is_flow FROM mqtt_trigger\n WHERE workspace_id = $1\n UNION ALL\n SELECT 'amqp', path, script_path, is_flow FROM amqp_trigger\n WHERE workspace_id = $1\n UNION ALL\n SELECT 'nats', path, script_path, is_flow FROM nats_trigger\n WHERE workspace_id = $1\n UNION ALL\n SELECT 'postgres', path, script_path, is_flow FROM postgres_trigger\n WHERE workspace_id = $1\n UNION ALL\n SELECT 'sqs', path, script_path, is_flow FROM sqs_trigger\n WHERE workspace_id = $1\n UNION ALL\n SELECT 'gcp', path, script_path, is_flow FROM gcp_trigger\n WHERE workspace_id = $1\n ) t\n WHERE ($2::text IS NULL OR script_path LIKE $2)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "kind",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "script_path",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "is_flow",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null,
null,
null,
null
]
},
"hash": "05843e117361d6b7cd9da652596d3be7a970d602d3809396d9253d7e5dc88152"
}
@@ -1,12 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE usr SET email = 'group-ops@windmill.dev' WHERE email = 'test2@windmill.dev'",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "058df18b148867dbb8bcf9e10c485d276b5af4b2b972fb335e41077a029c3196"
}
@@ -1,20 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT SUM(pg_database_size(datname))::BIGINT AS \"v!\" FROM pg_database",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "v!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "074dd26f6427f4ff97e92c35163ad042144e656adcca56a4936a2eb196d3f48c"
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT parent_workspace_id, is_dev_workspace FROM workspace WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "parent_workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "is_dev_workspace",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true,
false
]
},
"hash": "079e1dab42a783a1e5e4cba5faa854b4b727b1fab89e42f2efaf97c63dad6384"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM job_resolution WHERE workspace_id = $1 AND job_id = ANY($2)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"UuidArray"
]
},
"nullable": []
},
"hash": "07bcd445061f34a5d370398ee56d98e0e8f42e9e4fd70f00ee5f120fc3b04ed9"
}
@@ -35,9 +35,7 @@
"ci_test",
"github",
"azure",
"asset",
"freshness",
"amqp"
"asset"
]
}
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET slack_email = $1 WHERE slack_email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "099067cf6c08642b205074771ef23d7ab950015d70f7065f5645f1e76d6a25ee"
}
@@ -1,17 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM dbt_edge WHERE workspace_id = $1 AND script_path = $2\n AND script_hash = $3 AND job_id = $4",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Uuid"
]
},
"nullable": []
},
"hash": "0b3754e11677390837ec7ab00ba5443dc634b8628e90cce879952c8c4319a6a6"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork FROM workspace_diff\n WHERE source_workspace_id = $1 AND fork_workspace_id = $2\n AND NOT EXISTS (\n SELECT 1 FROM ws_specific ws\n WHERE ws.path = workspace_diff.path\n AND ws.item_kind = workspace_diff.kind\n AND ws.workspace_id IN (workspace_diff.source_workspace_id, workspace_diff.fork_workspace_id)\n )",
"query": "SELECT path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork FROM workspace_diff\n WHERE source_workspace_id = $1 AND fork_workspace_id = $2",
"describe": {
"columns": [
{
@@ -55,5 +55,5 @@
true
]
},
"hash": "2ec9f88ad80d192a2066764222fdfed7c553de4df8e876ff8589738dea93d0cf"
"hash": "0b8e5fe95f4a2855678ca041b50405b698a368626da42dd9f4ce9d0681d016a1"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT pg_advisory_xact_lock(hashtext('dev_workspace_pairing:' || $1))",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "pg_advisory_xact_lock",
"type_info": "Void"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "0b9088064d2a61fd9df91269ec95cab2539a701109727f997a3e5a0f4b0f1f22"
}

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